use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ODataDeltaResponse {
#[serde(rename = "@odata.context", default)]
pub context: Option<String>,
#[serde(default)]
pub value: Vec<Value>,
#[serde(rename = "@odata.deltaLink", default)]
pub delta_link: Option<String>,
#[serde(rename = "@odata.nextLink", default)]
pub next_link: Option<String>,
}
#[derive(Debug, Clone)]
pub enum DataverseChange {
NewOrUpdated {
id: String,
entity_name: String,
attributes: serde_json::Map<String, Value>,
},
Deleted {
id: String,
entity_name: String,
},
}
pub fn extract_entity_id(
record: &serde_json::Map<String, Value>,
entity_name: &str,
) -> Option<String> {
let id_field = format!("{entity_name}id");
record
.get(&id_field)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
pub fn is_deleted_entity(record: &Value) -> bool {
record
.get("@odata.context")
.and_then(|v| v.as_str())
.is_some_and(|ctx| ctx.contains("$deletedEntity"))
}
pub fn parse_delta_changes(
response: &ODataDeltaResponse,
entity_name: &str,
) -> Vec<DataverseChange> {
let mut changes = Vec::new();
for record in &response.value {
if is_deleted_entity(record) {
if let Some(id) = record.get("id").and_then(|v| v.as_str()) {
changes.push(DataverseChange::Deleted {
id: id.to_string(),
entity_name: entity_name.to_string(),
});
}
} else if let Some(obj) = record.as_object() {
if let Some(id) = extract_entity_id(obj, entity_name) {
let attributes: serde_json::Map<String, Value> = obj
.iter()
.filter(|(key, _)| !key.starts_with('@') && !key.contains('@'))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
changes.push(DataverseChange::NewOrUpdated {
id,
entity_name: entity_name.to_string(),
attributes,
});
}
}
}
changes
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_delta_response() {
let json = r#"{
"@odata.context": "https://myorg.crm.dynamics.com/api/data/v9.2/$metadata#accounts",
"@odata.deltaLink": "https://myorg.crm.dynamics.com/api/data/v9.2/accounts?$deltatoken=12345",
"value": [
{
"@odata.etag": "W/\"12345\"",
"accountid": "60c4e274-0d87-e711-80e5-00155db19e6d",
"name": "Contoso Ltd",
"revenue": 1000000.0
}
]
}"#;
let response: ODataDeltaResponse = serde_json::from_str(json).expect("should parse");
assert_eq!(response.value.len(), 1);
assert!(response.delta_link.is_some());
assert!(response.next_link.is_none());
let changes = parse_delta_changes(&response, "account");
assert_eq!(changes.len(), 1);
match &changes[0] {
DataverseChange::NewOrUpdated {
id,
entity_name,
attributes,
} => {
assert_eq!(id, "60c4e274-0d87-e711-80e5-00155db19e6d");
assert_eq!(entity_name, "account");
assert_eq!(
attributes.get("name").and_then(|v| v.as_str()),
Some("Contoso Ltd")
);
assert!(!attributes.contains_key("@odata.etag"));
}
_ => panic!("Expected NewOrUpdated"),
}
}
#[test]
fn test_parse_delta_with_deletes() {
let json = r#"{
"@odata.context": "https://myorg.crm.dynamics.com/api/data/v9.2/$metadata#accounts",
"@odata.deltaLink": "https://myorg.crm.dynamics.com/api/data/v9.2/accounts?$deltatoken=12346",
"value": [
{
"@odata.etag": "W/\"99999\"",
"accountid": "aaaa-bbbb",
"name": "Updated Corp",
"revenue": 2000000.0
},
{
"@odata.context": "https://myorg.crm.dynamics.com/api/data/v9.2/$metadata#accounts/$deletedEntity",
"id": "cccc-dddd",
"reason": "deleted"
}
]
}"#;
let response: ODataDeltaResponse = serde_json::from_str(json).expect("should parse");
let changes = parse_delta_changes(&response, "account");
assert_eq!(changes.len(), 2);
match &changes[0] {
DataverseChange::NewOrUpdated { id, .. } => assert_eq!(id, "aaaa-bbbb"),
_ => panic!("Expected NewOrUpdated"),
}
match &changes[1] {
DataverseChange::Deleted { id, entity_name } => {
assert_eq!(id, "cccc-dddd");
assert_eq!(entity_name, "account");
}
_ => panic!("Expected Deleted"),
}
}
#[test]
fn test_is_deleted_entity() {
let deleted = serde_json::json!({
"@odata.context": "https://test.crm.dynamics.com/api/data/v9.2/$metadata#accounts/$deletedEntity",
"id": "123",
"reason": "deleted"
});
assert!(is_deleted_entity(&deleted));
let normal = serde_json::json!({
"@odata.etag": "W/\"123\"",
"accountid": "456",
"name": "Test"
});
assert!(!is_deleted_entity(&normal));
}
#[test]
fn test_extract_entity_id() {
let record = serde_json::json!({
"accountid": "60c4e274-0d87-e711-80e5-00155db19e6d",
"name": "Test"
});
let obj = record.as_object().expect("should be object");
assert_eq!(
extract_entity_id(obj, "account"),
Some("60c4e274-0d87-e711-80e5-00155db19e6d".to_string())
);
}
#[test]
fn test_extract_entity_id_missing() {
let record = serde_json::json!({"name": "Test"});
let obj = record.as_object().expect("should be object");
assert_eq!(extract_entity_id(obj, "account"), None);
}
#[test]
fn test_empty_delta_response() {
let json = r#"{
"@odata.context": "https://test.crm.dynamics.com/api/data/v9.2/$metadata#accounts",
"@odata.deltaLink": "https://test.crm.dynamics.com/api/data/v9.2/accounts?$deltatoken=999",
"value": []
}"#;
let response: ODataDeltaResponse = serde_json::from_str(json).expect("should parse");
let changes = parse_delta_changes(&response, "account");
assert!(changes.is_empty());
}
#[test]
fn test_pagination_response() {
let json = r#"{
"@odata.context": "https://test.crm.dynamics.com/api/data/v9.2/$metadata#accounts",
"@odata.nextLink": "https://test.crm.dynamics.com/api/data/v9.2/accounts?$skiptoken=abc",
"value": [
{"accountid": "111", "name": "Page1Record"}
]
}"#;
let response: ODataDeltaResponse = serde_json::from_str(json).expect("should parse");
assert!(response.delta_link.is_none());
assert!(response.next_link.is_some());
}
}