drasi_source_dataverse/
types.rs1use serde::{Deserialize, Serialize};
33use serde_json::Value;
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ODataDeltaResponse {
42 #[serde(rename = "@odata.context", default)]
44 pub context: Option<String>,
45
46 #[serde(default)]
50 pub value: Vec<Value>,
51
52 #[serde(rename = "@odata.deltaLink", default)]
55 pub delta_link: Option<String>,
56
57 #[serde(rename = "@odata.nextLink", default)]
60 pub next_link: Option<String>,
61}
62
63#[derive(Debug, Clone)]
68pub enum DataverseChange {
69 NewOrUpdated {
72 id: String,
74 entity_name: String,
76 attributes: serde_json::Map<String, Value>,
78 },
79 Deleted {
82 id: String,
84 entity_name: String,
86 },
87}
88
89pub fn extract_entity_id(
99 record: &serde_json::Map<String, Value>,
100 entity_name: &str,
101) -> Option<String> {
102 let id_field = format!("{entity_name}id");
103 record
104 .get(&id_field)
105 .and_then(|v| v.as_str())
106 .map(|s| s.to_string())
107}
108
109pub fn is_deleted_entity(record: &Value) -> bool {
116 record
117 .get("@odata.context")
118 .and_then(|v| v.as_str())
119 .is_some_and(|ctx| ctx.contains("$deletedEntity"))
120}
121
122pub fn parse_delta_changes(
133 response: &ODataDeltaResponse,
134 entity_name: &str,
135) -> Vec<DataverseChange> {
136 let mut changes = Vec::new();
137
138 for record in &response.value {
139 if is_deleted_entity(record) {
140 if let Some(id) = record.get("id").and_then(|v| v.as_str()) {
142 changes.push(DataverseChange::Deleted {
143 id: id.to_string(),
144 entity_name: entity_name.to_string(),
145 });
146 }
147 } else if let Some(obj) = record.as_object() {
148 if let Some(id) = extract_entity_id(obj, entity_name) {
150 let attributes: serde_json::Map<String, Value> = obj
155 .iter()
156 .filter(|(key, _)| !key.starts_with('@') && !key.contains('@'))
157 .map(|(k, v)| (k.clone(), v.clone()))
158 .collect();
159
160 changes.push(DataverseChange::NewOrUpdated {
161 id,
162 entity_name: entity_name.to_string(),
163 attributes,
164 });
165 }
166 }
167 }
168
169 changes
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn test_parse_delta_response() {
178 let json = r#"{
179 "@odata.context": "https://myorg.crm.dynamics.com/api/data/v9.2/$metadata#accounts",
180 "@odata.deltaLink": "https://myorg.crm.dynamics.com/api/data/v9.2/accounts?$deltatoken=12345",
181 "value": [
182 {
183 "@odata.etag": "W/\"12345\"",
184 "accountid": "60c4e274-0d87-e711-80e5-00155db19e6d",
185 "name": "Contoso Ltd",
186 "revenue": 1000000.0
187 }
188 ]
189 }"#;
190
191 let response: ODataDeltaResponse = serde_json::from_str(json).expect("should parse");
192 assert_eq!(response.value.len(), 1);
193 assert!(response.delta_link.is_some());
194 assert!(response.next_link.is_none());
195
196 let changes = parse_delta_changes(&response, "account");
197 assert_eq!(changes.len(), 1);
198 match &changes[0] {
199 DataverseChange::NewOrUpdated {
200 id,
201 entity_name,
202 attributes,
203 } => {
204 assert_eq!(id, "60c4e274-0d87-e711-80e5-00155db19e6d");
205 assert_eq!(entity_name, "account");
206 assert_eq!(
207 attributes.get("name").and_then(|v| v.as_str()),
208 Some("Contoso Ltd")
209 );
210 assert!(!attributes.contains_key("@odata.etag"));
212 }
213 _ => panic!("Expected NewOrUpdated"),
214 }
215 }
216
217 #[test]
218 fn test_parse_delta_with_deletes() {
219 let json = r#"{
220 "@odata.context": "https://myorg.crm.dynamics.com/api/data/v9.2/$metadata#accounts",
221 "@odata.deltaLink": "https://myorg.crm.dynamics.com/api/data/v9.2/accounts?$deltatoken=12346",
222 "value": [
223 {
224 "@odata.etag": "W/\"99999\"",
225 "accountid": "aaaa-bbbb",
226 "name": "Updated Corp",
227 "revenue": 2000000.0
228 },
229 {
230 "@odata.context": "https://myorg.crm.dynamics.com/api/data/v9.2/$metadata#accounts/$deletedEntity",
231 "id": "cccc-dddd",
232 "reason": "deleted"
233 }
234 ]
235 }"#;
236
237 let response: ODataDeltaResponse = serde_json::from_str(json).expect("should parse");
238 let changes = parse_delta_changes(&response, "account");
239 assert_eq!(changes.len(), 2);
240
241 match &changes[0] {
242 DataverseChange::NewOrUpdated { id, .. } => assert_eq!(id, "aaaa-bbbb"),
243 _ => panic!("Expected NewOrUpdated"),
244 }
245 match &changes[1] {
246 DataverseChange::Deleted { id, entity_name } => {
247 assert_eq!(id, "cccc-dddd");
248 assert_eq!(entity_name, "account");
249 }
250 _ => panic!("Expected Deleted"),
251 }
252 }
253
254 #[test]
255 fn test_is_deleted_entity() {
256 let deleted = serde_json::json!({
257 "@odata.context": "https://test.crm.dynamics.com/api/data/v9.2/$metadata#accounts/$deletedEntity",
258 "id": "123",
259 "reason": "deleted"
260 });
261 assert!(is_deleted_entity(&deleted));
262
263 let normal = serde_json::json!({
264 "@odata.etag": "W/\"123\"",
265 "accountid": "456",
266 "name": "Test"
267 });
268 assert!(!is_deleted_entity(&normal));
269 }
270
271 #[test]
272 fn test_extract_entity_id() {
273 let record = serde_json::json!({
274 "accountid": "60c4e274-0d87-e711-80e5-00155db19e6d",
275 "name": "Test"
276 });
277 let obj = record.as_object().expect("should be object");
278 assert_eq!(
279 extract_entity_id(obj, "account"),
280 Some("60c4e274-0d87-e711-80e5-00155db19e6d".to_string())
281 );
282 }
283
284 #[test]
285 fn test_extract_entity_id_missing() {
286 let record = serde_json::json!({"name": "Test"});
287 let obj = record.as_object().expect("should be object");
288 assert_eq!(extract_entity_id(obj, "account"), None);
289 }
290
291 #[test]
292 fn test_empty_delta_response() {
293 let json = r#"{
294 "@odata.context": "https://test.crm.dynamics.com/api/data/v9.2/$metadata#accounts",
295 "@odata.deltaLink": "https://test.crm.dynamics.com/api/data/v9.2/accounts?$deltatoken=999",
296 "value": []
297 }"#;
298
299 let response: ODataDeltaResponse = serde_json::from_str(json).expect("should parse");
300 let changes = parse_delta_changes(&response, "account");
301 assert!(changes.is_empty());
302 }
303
304 #[test]
305 fn test_pagination_response() {
306 let json = r#"{
307 "@odata.context": "https://test.crm.dynamics.com/api/data/v9.2/$metadata#accounts",
308 "@odata.nextLink": "https://test.crm.dynamics.com/api/data/v9.2/accounts?$skiptoken=abc",
309 "value": [
310 {"accountid": "111", "name": "Page1Record"}
311 ]
312 }"#;
313
314 let response: ODataDeltaResponse = serde_json::from_str(json).expect("should parse");
315 assert!(response.delta_link.is_none());
316 assert!(response.next_link.is_some());
317 }
318}