powerplatform_dataverse_client/dataverse/
parse.rs1use std::collections::HashMap;
2use std::str::FromStr;
3
4use chrono::{DateTime, Utc};
5use log::warn;
6use rust_decimal::Decimal;
7use serde_json::Value;
8
9use crate::dataverse::entity::Value::{
10 Boolean, DateTime as DateTimeValue, Decimal as DecimalValue,
11 EntityReference as EntityRefValue, Float, Guid as GuidValue, Int, Money as MoneyValue, Null,
12 OptionSetValue as OptionSetSingle, OptionSetValueCollection as OptionSetMany, String,
13};
14use crate::dataverse::entity::{
15 Attribute, Entity, EntityReference, Money, OptionSetValue, OptionSetValueCollection,
16 Value as RowValue,
17};
18use crate::dataverse::entityattribute::EntityAttribute;
19use uuid::Uuid;
20
21const FORMATTED_VALUE_SUFFIX: &str = "@OData.Community.Display.V1.FormattedValue";
22
23pub(crate) fn parse_more_records(json: &Value) -> bool {
25 match json.get("@Microsoft.Dynamics.CRM.morerecords") {
26 Some(Value::Bool(value)) => *value,
27 Some(Value::String(value)) => value.eq_ignore_ascii_case("true"),
28 _ => false,
29 }
30}
31
32pub(crate) fn extract_paging_cookie(json: &Value) -> Option<std::string::String> {
34 let cookie_element = json
35 .get("@Microsoft.Dynamics.CRM.fetchxmlpagingcookie")
36 .and_then(|value| value.as_str())?;
37 let key = "pagingcookie=\"";
38 let start = cookie_element.find(key)? + key.len();
39 let end = cookie_element[start..].find('"')? + start;
40 let encoded = &cookie_element[start..end];
41 let decoded_once = urlencoding::decode(encoded).ok()?.into_owned();
42 let decoded_twice = urlencoding::decode(&decoded_once).ok()?.into_owned();
43 Some(decoded_twice)
44}
45
46pub(crate) fn parse_entities_from_response(
48 json: &Value,
49 entity_set: &str,
50 primary_id_attribute: Option<&str>,
51 entity_attributes: Option<&HashMap<std::string::String, EntityAttribute>>,
52) -> Result<Vec<Entity>, std::string::String> {
53 let response_object = json
54 .as_object()
55 .ok_or_else(|| "Invalid response from Dataverse".to_string())?;
56
57 let response_array = response_object
58 .get("value")
59 .ok_or_else(|| "Invalid response from Dataverse".to_string())?
60 .as_array()
61 .ok_or_else(|| "Invalid response from Dataverse".to_string())?;
62
63 let mut entities: Vec<Entity> = vec![];
64 let logical_name = infer_logical_name(entity_set);
65 let primary_id_key = primary_id_attribute
66 .map(|value| value.to_string())
67 .unwrap_or_else(|| format!("{}id", logical_name));
68
69 for record_value in response_array {
70 let record = record_value
71 .as_object()
72 .ok_or_else(|| "Invalid response from Dataverse".to_string())?;
73
74 let id_value = record
78 .get(&primary_id_key)
79 .and_then(|value| value.as_str())
80 .ok_or_else(|| {
81 format!(
82 "Primary id '{}' not found for entity set '{}'",
83 primary_id_key, entity_set
84 )
85 })?;
86 let id =
87 Uuid::parse_str(id_value).map_err(|_| "Invalid response from Dataverse".to_string())?;
88
89 let name = record
90 .get("name")
91 .and_then(|value| value.as_str())
92 .map(|value| value.to_string());
93
94 let mut entity = Entity::new(id, &logical_name, name);
95
96 let mut lookup_keys: Vec<(std::string::String, std::string::String)> = Vec::new();
97
98 for (key, value) in record {
99 if key.contains('@') {
100 continue;
101 }
102
103 if let Some(base) = lookup_base_attribute(key) {
104 let id = value
105 .as_str()
106 .map(|value| value.to_string())
107 .unwrap_or_default();
108 lookup_keys.push((key.to_string(), base.clone()));
109
110 if id.is_empty() {
111 entity.attributes.insert(base, Null);
112 }
113 continue;
114 }
115
116 let implemented = add_attribute(
117 &mut entity.attributes,
118 key,
119 value,
120 entity_attributes.and_then(|attributes| {
121 attributes.get(&normalize_attribute_name(key))
122 }),
123 )
124 .map_err(|_| "Invalid response from Dataverse".to_string())?;
125
126 if !implemented {
127 warn!("Unsupported Dataverse key: {}", key);
128 }
129 }
130
131 for (raw_key, base) in lookup_keys {
132 if entity.attributes.contains_key(&base) {
133 continue;
134 }
135
136 let Some(id_value) = record.get(&raw_key).and_then(|value| value.as_str()) else {
137 entity.attributes.insert(base, Null);
138 continue;
139 };
140
141 let logical_key = format!("{raw_key}@Microsoft.Dynamics.CRM.lookuplogicalname");
142 let formatted_key = format!("{raw_key}@OData.Community.Display.V1.FormattedValue");
143
144 let logical_name = record
145 .get(&logical_key)
146 .and_then(|value| value.as_str())
147 .map(|value| value.to_string());
148
149 let name = record
150 .get(&formatted_key)
151 .and_then(|value| value.as_str())
152 .map(|value| value.to_string());
153
154 if let Some(name) = name.as_ref() {
155 entity
156 .attributes
157 .insert(format!("{base}name"), String(name.clone()));
158 }
159
160 if let Some(logical_name) = logical_name {
161 let id = Uuid::parse_str(id_value)
162 .map_err(|_| "Invalid response from Dataverse".to_string())?;
163 entity.attributes.insert(
164 base,
165 EntityRefValue(EntityReference {
166 id,
167 logical_name,
168 name,
169 }),
170 );
171 } else {
172 warn!("Lookup logical name missing for key: {}", raw_key);
173 entity.attributes.insert(base, String(id_value.to_string()));
174 }
175 }
176
177 apply_lookup_attribute_annotations(&mut entity.attributes, record);
178 apply_formatted_value_names(&mut entity.attributes, record);
179
180 entities.push(entity);
181 }
182
183 Ok(entities)
184}
185
186pub(crate) fn parse_record_count_from_response(json: &Value) -> Result<usize, std::string::String> {
188 let response_object = json
189 .as_object()
190 .ok_or_else(|| "Invalid response from Dataverse".to_string())?;
191
192 let response_array = response_object
193 .get("value")
194 .ok_or_else(|| "Invalid response from Dataverse".to_string())?
195 .as_array()
196 .ok_or_else(|| "Invalid response from Dataverse".to_string())?;
197
198 Ok(response_array.len())
199}
200
201fn add_attribute(
203 attributes: &mut HashMap<Attribute, RowValue>,
204 key: &str,
205 value: &Value,
206 attribute: Option<&EntityAttribute>,
207) -> Result<bool, std::string::String> {
208 if value.is_null() {
209 attributes.insert(key.to_string(), Null);
210 return Ok(true);
211 }
212
213 if let Some(attribute_type) = attribute_type_key(attribute) {
214 if let Some(parsed) = parse_typed_attribute_value(value, attribute_type)? {
215 attributes.insert(key.to_string(), parsed);
216 return Ok(true);
217 }
218 }
219
220 if value.is_i64() {
221 let i = value
222 .as_i64()
223 .ok_or(format!("Unable to parse dataverse value: {:?}", value))?;
224 attributes.insert(key.to_string(), Int(i));
225 return Ok(true);
226 }
227
228 if value.is_u64() {
229 let i = value
230 .as_u64()
231 .ok_or(format!("Unable to parse dataverse value: {:?}", value))?;
232 if let Ok(as_i64) = i64::try_from(i) {
233 attributes.insert(key.to_string(), Int(as_i64));
234 } else {
235 attributes.insert(key.to_string(), Float(i as f64));
236 }
237 return Ok(true);
238 }
239
240 if value.is_f64() {
241 let f = value
242 .as_f64()
243 .ok_or(format!("Unable to parse dataverse value: {:?}", value))?;
244 attributes.insert(key.to_string(), Float(f));
245 return Ok(true);
246 }
247
248 if value.is_string() {
249 let s = value
250 .as_str()
251 .ok_or(format!("Unable to parse dataverse value: {:?}", value))?;
252 attributes.insert(key.to_string(), String(s.to_string()));
253 return Ok(true);
254 }
255
256 if value.is_boolean() {
257 let b = value
258 .as_bool()
259 .ok_or(format!("Unable to parse dataverse value: {:?}", value))?;
260 attributes.insert(key.to_string(), Boolean(b));
261 return Ok(true);
262 }
263
264 Ok(true)
265}
266
267fn parse_typed_attribute_value(
268 value: &Value,
269 attribute_type: &str,
270) -> Result<Option<RowValue>, std::string::String> {
271 match attribute_type {
272 "BigInt" | "BigIntType" => Ok(parse_i64_value(value).map(Int)),
273 "Boolean" | "BooleanType" => Ok(parse_bool_value(value).map(Boolean)),
274 "DateTime" | "DateTimeType" => Ok(parse_datetime_value(value).map(DateTimeValue)),
275 "Decimal" | "DecimalType" => Ok(parse_decimal_value(value).map(DecimalValue)),
276 "Double" | "DoubleType" => Ok(parse_f64_value(value).map(Float)),
277 "Integer" | "IntegerType" => Ok(parse_i64_value(value).map(Int)),
278 "Guid" | "Uniqueidentifier" | "UniqueidentifierType" => {
279 Ok(parse_guid_value(value).map(GuidValue))
280 }
281 "Money" | "MoneyType" => Ok(parse_decimal_value(value).map(|value| {
282 MoneyValue(Money { value })
283 })),
284 "Picklist" | "PicklistType" | "State" | "StateType" | "Status" | "StatusType" => {
285 Ok(parse_i32_value(value).map(|value| {
286 OptionSetSingle(OptionSetValue { value, name: None })
287 }))
288 }
289 "MultiSelectPicklist" | "MultiSelectPicklistType" => {
290 Ok(parse_multi_select_value(value).map(|values| {
291 OptionSetMany(OptionSetValueCollection { values })
292 }))
293 }
294 "Customer"
295 | "CustomerType"
296 | "Lookup"
297 | "LookupType"
298 | "Owner"
299 | "OwnerType"
300 | "PartyList"
301 | "PartyListType" => Ok(None),
302 "String"
303 | "StringType"
304 | "Memo"
305 | "MemoType"
306 | "EntityName"
307 | "EntityNameType"
308 | "Image"
309 | "ImageType"
310 | "File"
311 | "FileType" => Ok(value.as_str().map(|value| String(value.to_string()))),
312 _ => Ok(None),
313 }
314}
315
316fn attribute_type_key(attribute: Option<&EntityAttribute>) -> Option<&str> {
317 attribute
318 .and_then(|attribute| {
319 attribute
320 .attribute_type_name
321 .as_ref()
322 .and_then(|value| value.value.as_deref())
323 .or(attribute.attribute_type.as_deref())
324 })
325}
326
327fn normalize_attribute_name(value: &str) -> std::string::String {
328 value.to_ascii_lowercase()
329}
330
331fn parse_i64_value(value: &Value) -> Option<i64> {
332 value
333 .as_i64()
334 .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok()))
335}
336
337fn parse_i32_value(value: &Value) -> Option<i32> {
338 parse_i64_value(value).and_then(|value| i32::try_from(value).ok())
339}
340
341fn parse_f64_value(value: &Value) -> Option<f64> {
342 value
343 .as_f64()
344 .or_else(|| value.as_i64().map(|value| value as f64))
345 .or_else(|| value.as_u64().map(|value| value as f64))
346}
347
348fn parse_bool_value(value: &Value) -> Option<bool> {
349 value.as_bool()
350}
351
352fn parse_decimal_value(value: &Value) -> Option<Decimal> {
353 match value {
354 Value::Number(number) => Decimal::from_str(&number.to_string()).ok(),
355 Value::String(value) => Decimal::from_str(value).ok(),
356 _ => None,
357 }
358}
359
360fn parse_datetime_value(value: &Value) -> Option<DateTime<Utc>> {
361 value
362 .as_str()
363 .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
364 .map(|value| value.with_timezone(&Utc))
365}
366
367fn parse_guid_value(value: &Value) -> Option<Uuid> {
368 value
369 .as_str()
370 .and_then(|value| Uuid::parse_str(value).ok())
371}
372
373fn parse_multi_select_value(value: &Value) -> Option<Vec<i32>> {
374 match value {
375 Value::String(value) => {
376 let parsed: Vec<i32> = value
377 .split(',')
378 .map(str::trim)
379 .filter(|value| !value.is_empty())
380 .filter_map(|value| value.parse::<i32>().ok())
381 .collect();
382 Some(parsed)
383 }
384 Value::Array(values) => Some(
385 values
386 .iter()
387 .filter_map(parse_i32_value)
388 .collect(),
389 ),
390 _ => None,
391 }
392}
393
394fn lookup_base_attribute(key: &str) -> Option<std::string::String> {
395 if !key.starts_with('_') || !key.ends_with("_value") {
396 return None;
397 }
398
399 let trimmed = &key[1..key.len() - "_value".len()];
400 if trimmed.is_empty() {
401 return None;
402 }
403
404 Some(trimmed.to_string())
405}
406
407fn apply_formatted_value_names(
408 attributes: &mut HashMap<Attribute, RowValue>,
409 record: &serde_json::Map<std::string::String, Value>,
410) {
411 if !record.keys().any(|key| key.ends_with(FORMATTED_VALUE_SUFFIX)) {
414 return;
415 }
416
417 for (key, value) in record {
418 let Some(base_key) = key.strip_suffix(FORMATTED_VALUE_SUFFIX) else {
419 continue;
420 };
421
422 let Some(formatted) = value.as_str() else {
423 continue;
424 };
425
426 let Some(attribute) = attributes.get_mut(base_key) else {
427 continue;
428 };
429
430 if let OptionSetSingle(option) = attribute {
431 option.name = Some(formatted.to_string());
432 }
433 }
434}
435
436fn apply_lookup_attribute_annotations(
437 attributes: &mut HashMap<Attribute, RowValue>,
438 record: &serde_json::Map<std::string::String, Value>,
439) {
440 for (key, value) in record {
444 let Some(base_key) = key.strip_suffix("@Microsoft.Dynamics.CRM.lookuplogicalname") else {
445 continue;
446 };
447
448 if base_key.starts_with('_') {
449 continue;
450 }
451
452 let Some(logical_name) = value.as_str() else {
453 continue;
454 };
455
456 let Some(attribute) = attributes.get_mut(base_key) else {
457 continue;
458 };
459
460 let RowValue::String(id_value) = attribute else {
461 continue;
462 };
463
464 let Ok(id) = Uuid::parse_str(id_value) else {
465 continue;
466 };
467
468 let formatted_key = format!("{base_key}{FORMATTED_VALUE_SUFFIX}");
469 let name = record
470 .get(&formatted_key)
471 .and_then(|value| value.as_str())
472 .map(|value| value.to_string());
473
474 *attribute = EntityRefValue(EntityReference {
475 id,
476 logical_name: logical_name.to_string(),
477 name,
478 });
479 }
480}
481
482fn infer_logical_name(entity_set: &str) -> std::string::String {
483 let normalized = entity_set.trim().to_ascii_lowercase();
486
487 if normalized.ends_with("ies") && normalized.len() > 3 {
488 return format!("{}y", &normalized[..normalized.len() - 3]);
489 }
490
491 if ends_with_any(&normalized, &["ses", "xes", "zes", "ches", "shes"]) && normalized.len() > 2 {
492 return normalized[..normalized.len() - 2].to_string();
493 }
494
495 if normalized.ends_with('s')
496 && !normalized.ends_with("ss")
497 && !normalized.ends_with("us")
498 && !normalized.ends_with("is")
499 && normalized.len() > 1
500 {
501 return normalized[..normalized.len() - 1].to_string();
502 }
503
504 normalized
505}
506
507fn ends_with_any(name: &str, suffixes: &[&str]) -> bool {
508 suffixes.iter().any(|suffix| name.ends_with(suffix))
509}
510
511#[cfg(test)]
512mod tests {
513 use std::collections::HashMap;
514
515 use serde_json::json;
516
517 use super::{
518 extract_paging_cookie, infer_logical_name, parse_entities_from_response,
519 parse_more_records, parse_record_count_from_response,
520 };
521 use crate::dataverse::entityattribute::{AttributeTypeName, EntityAttribute};
522
523 #[test]
524 fn parses_more_records_from_bool_and_string_annotations() {
525 assert!(parse_more_records(&json!({
526 "@Microsoft.Dynamics.CRM.morerecords": true
527 })));
528 assert!(parse_more_records(&json!({
529 "@Microsoft.Dynamics.CRM.morerecords": "true"
530 })));
531 assert!(!parse_more_records(&json!({})));
532 }
533
534 #[test]
535 fn extracts_double_encoded_paging_cookie() {
536 let json = json!({
537 "@Microsoft.Dynamics.CRM.fetchxmlpagingcookie":
538 "pagingcookie=\"%253ccookie%2520page%253d%25221%2522%2520%252f%253e\""
539 });
540
541 let cookie = extract_paging_cookie(&json).expect("should extract cookie");
542
543 assert_eq!(cookie, "<cookie page=\"1\" />");
544 }
545
546 #[test]
547 fn parses_entities_and_upgrades_lookup_annotations() {
548 let json = json!({
549 "value": [
550 {
551 "contactid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
552 "fullname": "Ada Lovelace",
553 "statecode": 0,
554 "statecode@OData.Community.Display.V1.FormattedValue": "Active",
555 "primarycontactid": "11111111-2222-3333-4444-555555555555",
556 "primarycontactid@Microsoft.Dynamics.CRM.lookuplogicalname": "contact",
557 "primarycontactid@OData.Community.Display.V1.FormattedValue": "Ada Lovelace"
558 }
559 ]
560 });
561 let entity_attributes = HashMap::from([(
562 "statecode".to_string(),
563 EntityAttribute {
564 logical_name: "statecode".to_string(),
565 schema_name: "StateCode".to_string(),
566 attribute_type: Some("State".to_string()),
567 attribute_type_name: Some(AttributeTypeName {
568 value: Some("StateType".to_string()),
569 }),
570 is_custom_attribute: Some(false),
571 is_valid_odata_attribute: Some(true),
572 is_valid_for_read: Some(true),
573 is_valid_for_update: Some(false),
574 },
575 )]);
576
577 let entities =
578 parse_entities_from_response(
579 &json,
580 "contacts",
581 Some("contactid"),
582 Some(&entity_attributes),
583 )
584 .expect("should parse entities");
585
586 assert_eq!(entities.len(), 1);
587 let entity = &entities[0];
588 assert_eq!(entity.logical_name, "contact");
589 assert!(matches!(
590 entity.attributes.get("statecode"),
591 Some(crate::dataverse::entity::Value::OptionSetValue(option))
592 if option.value == 0 && option.name.as_deref() == Some("Active")
593 ));
594 assert!(matches!(
595 entity.attributes.get("primarycontactid"),
596 Some(crate::dataverse::entity::Value::EntityReference(reference))
597 if reference.logical_name == "contact"
598 && reference.name.as_deref() == Some("Ada Lovelace")
599 ));
600 }
601
602 #[test]
603 fn record_count_uses_value_array_length() {
604 let count = parse_record_count_from_response(&json!({
605 "value": [{}, {}, {}]
606 }))
607 .expect("should count records");
608
609 assert_eq!(count, 3);
610 }
611
612 #[test]
613 fn infer_logical_name_handles_common_entity_set_pluralization() {
614 assert_eq!(infer_logical_name("contacts"), "contact");
615 assert_eq!(infer_logical_name("categories"), "category");
616 assert_eq!(infer_logical_name("boxes"), "box");
617 }
618}