1use std::collections::HashMap;
2
3use reqwest::header::CONTENT_TYPE;
4use serde_json::{Map, Number, Value as JsonValue};
5use uuid::Uuid;
6
7use crate::dataverse::entity::{
8 Entity, EntityReference, OptionSetValueCollection, Value as DataverseValue,
9};
10use crate::dataverse::requestparameters::RequestParameters;
11
12const HEADER_SEPARATOR: &str = "\r\n\r\n";
13
14#[derive(Debug, Clone, Default)]
15pub struct ExecuteMultipleSettings {
16 pub continue_on_error: bool,
18 pub return_responses: bool,
20}
21
22#[derive(Debug, Clone, Default)]
24pub struct ExecuteMultipleRequest {
25 pub settings: ExecuteMultipleSettings,
27 pub requests: Vec<OrganizationRequest>,
29}
30
31#[derive(Debug, Clone, Default)]
33pub struct ExecuteMultipleResponse {
34 pub responses: Vec<ExecuteMultipleResponseItem>,
36}
37
38#[derive(Debug, Clone)]
40pub struct ExecuteMultipleResponseItem {
41 pub request_index: usize,
43 pub response: Option<OrganizationResponse>,
45 pub fault: Option<OrganizationServiceFault>,
47}
48
49#[derive(Debug, Clone)]
51pub struct OrganizationServiceFault {
52 pub status_code: u16,
54 pub code: Option<String>,
56 pub message: String,
58 pub raw_body: Option<String>,
60}
61
62#[derive(Debug, Clone)]
64pub enum OrganizationRequest {
65 Create(CreateRequest),
67 Update(UpdateRequest),
69 Delete(DeleteRequest),
71}
72
73#[derive(Debug, Clone)]
75pub enum OrganizationResponse {
76 Create(CreateResponse),
78 Update(UpdateResponse),
80 Delete(DeleteResponse),
82}
83
84#[derive(Debug, Clone)]
86pub struct CreateRequest {
87 pub target: Entity,
89 pub parameters: RequestParameters,
91}
92
93#[derive(Debug, Clone)]
95pub struct CreateResponse {
96 pub id: Option<Uuid>,
98}
99
100#[derive(Debug, Clone)]
102pub struct UpdateRequest {
103 pub target: Entity,
105 pub parameters: RequestParameters,
107}
108
109#[derive(Debug, Clone, Default)]
111pub struct UpdateResponse;
112
113#[derive(Debug, Clone)]
115pub struct DeleteRequest {
116 pub target: EntityReference,
118 pub parameters: RequestParameters,
120}
121
122#[derive(Debug, Clone, Default)]
124pub struct DeleteResponse;
125
126#[derive(Debug, Clone)]
127pub(crate) struct PreparedBatchRequest {
128 pub(crate) method: &'static str,
129 pub(crate) path: String,
130 pub(crate) body: Option<String>,
131 pub(crate) parameters: RequestParameters,
132}
133
134#[derive(Debug, Clone)]
135pub(crate) struct PreparedBatchItem {
136 pub(crate) prepared_request: PreparedBatchRequest,
137}
138
139#[derive(Debug, Clone)]
140pub(crate) struct ParsedBatchPart {
141 pub(crate) status_code: u16,
142 pub(crate) headers: HashMap<String, String>,
143 pub(crate) body: Option<String>,
144}
145
146impl CreateRequest {
147 pub fn new(target: Entity) -> Self {
149 Self {
150 target,
151 parameters: RequestParameters::default(),
152 }
153 }
154}
155
156impl UpdateRequest {
157 pub fn new(target: Entity) -> Self {
159 Self {
160 target,
161 parameters: RequestParameters::default(),
162 }
163 }
164}
165
166impl DeleteRequest {
167 pub fn new(target: EntityReference) -> Self {
169 Self {
170 target,
171 parameters: RequestParameters::default(),
172 }
173 }
174}
175
176impl OrganizationRequest {
177 pub(crate) fn success_response(&self, headers: &HashMap<String, String>) -> OrganizationResponse {
178 match self {
179 OrganizationRequest::Create(_) => OrganizationResponse::Create(CreateResponse {
180 id: entity_id_from_headers(headers),
181 }),
182 OrganizationRequest::Update(_) => OrganizationResponse::Update(UpdateResponse),
183 OrganizationRequest::Delete(_) => OrganizationResponse::Delete(DeleteResponse),
184 }
185 }
186}
187
188pub(crate) fn entity_to_write_body(
189 entity: &Entity,
190 entity_set_name_by_logical_name: &HashMap<String, String>,
191) -> Result<String, String> {
192 let mut body = Map::new();
193
194 for (attribute, value) in &entity.attributes {
195 match value {
196 DataverseValue::EntityReference(reference) => {
197 let entity_set_name = entity_set_name_by_logical_name
198 .get(&reference.logical_name.to_ascii_lowercase())
199 .ok_or_else(|| {
200 format!(
201 "Entity set metadata not found for referenced entity '{}'",
202 reference.logical_name
203 )
204 })?;
205
206 body.insert(
207 format!("{attribute}@odata.bind"),
208 JsonValue::String(format!(
209 "{entity_set_name}({})",
210 reference.id.as_hyphenated()
211 )),
212 );
213 }
214 other => {
215 body.insert(attribute.clone(), value_to_json(other)?);
216 }
217 }
218 }
219
220 serde_json::to_string(&body).map_err(|e| format!("Failed to serialize request body: {e}"))
221}
222
223pub(crate) fn parse_batch_response_parts(
224 content_type: Option<&str>,
225 response_text: &str,
226) -> Result<Vec<ParsedBatchPart>, String> {
227 let boundary = extract_boundary(
228 content_type.ok_or_else(|| "Batch response missing Content-Type header".to_string())?,
229 )?;
230 parse_multipart_parts(response_text, &boundary)
231}
232
233fn value_to_json(value: &DataverseValue) -> Result<JsonValue, String> {
234 match value {
235 DataverseValue::Int(value) => Ok(JsonValue::Number(Number::from(*value))),
236 DataverseValue::Float(value) => Number::from_f64(*value)
237 .map(JsonValue::Number)
238 .ok_or_else(|| format!("Cannot serialize non-finite float value: {value}")),
239 DataverseValue::Decimal(value) => json_number_from_string(&value.to_string()),
240 DataverseValue::String(value) => Ok(JsonValue::String(value.clone())),
241 DataverseValue::Boolean(value) => Ok(JsonValue::Bool(*value)),
242 DataverseValue::DateTime(value) => Ok(JsonValue::String(value.to_rfc3339())),
243 DataverseValue::Guid(value) => Ok(JsonValue::String(value.as_hyphenated().to_string())),
244 DataverseValue::Money(value) => json_number_from_string(&value.value.to_string()),
245 DataverseValue::OptionSetValue(value) => Ok(JsonValue::Number(Number::from(value.value))),
246 DataverseValue::OptionSetValueCollection(OptionSetValueCollection { values }) => Ok(
247 JsonValue::String(
248 values
249 .iter()
250 .map(i32::to_string)
251 .collect::<Vec<String>>()
252 .join(","),
253 ),
254 ),
255 DataverseValue::Null => Ok(JsonValue::Null),
256 DataverseValue::EntityReference(_) => unreachable!("entity references are handled separately"),
257 }
258}
259
260fn json_number_from_string(value: &str) -> Result<JsonValue, String> {
261 serde_json::from_str::<JsonValue>(value)
262 .map_err(|e| format!("Failed to serialize numeric value '{value}': {e}"))
263}
264
265fn extract_boundary(content_type: &str) -> Result<String, String> {
266 content_type
267 .split(';')
268 .map(str::trim)
269 .find_map(|segment| segment.strip_prefix("boundary="))
270 .map(|value| value.trim_matches('"').to_string())
271 .ok_or_else(|| format!("Batch response missing boundary in Content-Type: {content_type}"))
272}
273
274fn parse_multipart_parts(payload: &str, boundary: &str) -> Result<Vec<ParsedBatchPart>, String> {
275 let marker = format!("--{boundary}");
276 let terminator = format!("--{boundary}--");
277 let mut parts = Vec::new();
278
279 for section in payload.split(&marker).skip(1) {
280 let trimmed = section.trim();
281 if trimmed.is_empty() || trimmed == "--" || trimmed == terminator {
282 continue;
283 }
284
285 if trimmed.starts_with("--") {
286 continue;
287 }
288
289 let normalized = trimmed.trim_matches('\r').trim_matches('\n');
290 let Some((part_headers, part_body)) = normalized.split_once(HEADER_SEPARATOR) else {
291 continue;
292 };
293
294 let headers = parse_headers(part_headers);
295 let content_type = headers
296 .get(&CONTENT_TYPE.as_str().to_ascii_lowercase())
297 .cloned()
298 .unwrap_or_default();
299
300 if content_type.starts_with("multipart/mixed") {
301 let nested_parts = parse_multipart_parts(part_body, &extract_boundary(&content_type)?)?;
302 parts.extend(nested_parts);
303 continue;
304 }
305
306 if !content_type.starts_with("application/http") {
307 continue;
308 }
309
310 parts.push(parse_application_http_part(part_body)?);
311 }
312
313 Ok(parts)
314}
315
316fn parse_application_http_part(content: &str) -> Result<ParsedBatchPart, String> {
317 let normalized = content.trim_matches('\r').trim_matches('\n');
318 let (raw_headers, body) = normalized
319 .split_once(HEADER_SEPARATOR)
320 .map(|(headers, body)| (headers, Some(body.to_string())))
321 .unwrap_or((normalized, None));
322
323 let mut lines = raw_headers.lines();
324 let status_line = lines
325 .next()
326 .ok_or_else(|| "Batch response item missing HTTP status line".to_string())?;
327
328 let status_code = status_line
329 .split_whitespace()
330 .nth(1)
331 .ok_or_else(|| format!("Invalid batch response status line: {status_line}"))?
332 .parse::<u16>()
333 .map_err(|e| format!("Invalid batch response status code: {e}"))?;
334
335 let headers = parse_headers(&lines.collect::<Vec<&str>>().join("\r\n"));
336
337 Ok(ParsedBatchPart {
338 status_code,
339 headers,
340 body: body.and_then(|body| {
341 let trimmed = body.trim_matches('\r').trim_matches('\n').trim();
342 if trimmed.is_empty() {
343 None
344 } else {
345 Some(trimmed.to_string())
346 }
347 }),
348 })
349}
350
351fn parse_headers(raw_headers: &str) -> HashMap<String, String> {
352 raw_headers
353 .lines()
354 .filter_map(|line| {
355 let (name, value) = line.split_once(':')?;
356 Some((name.trim().to_ascii_lowercase(), value.trim().to_string()))
357 })
358 .collect()
359}
360
361fn entity_id_from_headers(headers: &HashMap<String, String>) -> Option<Uuid> {
362 ["odata-entityid", "location"]
363 .into_iter()
364 .filter_map(|name| headers.get(name))
365 .find_map(|value| parse_uuid_from_uri(value))
366}
367
368fn parse_uuid_from_uri(value: &str) -> Option<Uuid> {
369 let start = value.rfind('(')? + 1;
370 let end = value.rfind(')')?;
371 Uuid::parse_str(value[start..end].trim_matches('{').trim_matches('}')).ok()
372}
373
374pub(crate) fn parse_fault(part: &ParsedBatchPart) -> OrganizationServiceFault {
375 let (code, message) = part
376 .body
377 .as_deref()
378 .and_then(parse_fault_json)
379 .unwrap_or((None, format!("Dataverse batch item failed with HTTP {}", part.status_code)));
380
381 OrganizationServiceFault {
382 status_code: part.status_code,
383 code,
384 message,
385 raw_body: part.body.clone(),
386 }
387}
388
389fn parse_fault_json(body: &str) -> Option<(Option<String>, String)> {
390 let json: JsonValue = serde_json::from_str(body).ok()?;
391 let error = json.get("error")?;
392 let message = error.get("message")?.as_str()?.to_string();
393 let code = error
394 .get("code")
395 .and_then(|value| value.as_str())
396 .map(|value| value.to_string());
397 Some((code, message))
398}
399
400#[cfg(test)]
401mod tests {
402 use std::collections::HashMap;
403
404 use rust_decimal::Decimal;
405 use uuid::Uuid;
406
407 use super::{
408 CreateRequest, OrganizationRequest, entity_to_write_body, parse_batch_response_parts,
409 parse_fault,
410 };
411 use crate::dataverse::entity::{Entity, EntityReference, Money, Value};
412
413 #[test]
414 fn parses_flat_batch_response_parts() {
415 let payload = concat!(
416 "--batchresponse_123\r\n",
417 "Content-Type: application/http\r\n",
418 "Content-Transfer-Encoding: binary\r\n",
419 "\r\n",
420 "HTTP/1.1 204 No Content\r\n",
421 "OData-EntityId: https://example.crm.dynamics.com/api/data/v9.2/accounts(aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee)\r\n",
422 "\r\n",
423 "--batchresponse_123\r\n",
424 "Content-Type: application/http\r\n",
425 "Content-Transfer-Encoding: binary\r\n",
426 "\r\n",
427 "HTTP/1.1 400 Bad Request\r\n",
428 "Content-Type: application/json\r\n",
429 "\r\n",
430 "{\"error\":{\"code\":\"0x1\",\"message\":\"Bad data\"}}\r\n",
431 "--batchresponse_123--\r\n"
432 );
433
434 let parts = parse_batch_response_parts(
435 Some("multipart/mixed; boundary=batchresponse_123"),
436 payload,
437 )
438 .expect("should parse multipart response");
439
440 assert_eq!(parts.len(), 2);
441 assert_eq!(parts[0].status_code, 204);
442 assert_eq!(parts[1].status_code, 400);
443
444 let fault = parse_fault(&parts[1]);
445 assert_eq!(fault.code.as_deref(), Some("0x1"));
446 assert_eq!(fault.message, "Bad data");
447 }
448
449 #[test]
450 fn serializes_entity_reference_as_odata_bind() {
451 let mut entity = Entity::new(Uuid::new_v4(), "contact", None);
452 entity.attributes.insert(
453 "parentcustomerid".to_string(),
454 Value::EntityReference(EntityReference {
455 id: Uuid::parse_str("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").expect("uuid"),
456 logical_name: "account".to_string(),
457 name: None,
458 }),
459 );
460
461 let body = entity_to_write_body(
462 &entity,
463 &HashMap::from([("account".to_string(), "accounts".to_string())]),
464 )
465 .expect("should serialize");
466
467 assert!(body.contains("\"parentcustomerid@odata.bind\":\"accounts(aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee)\""));
468 }
469
470 #[test]
471 fn create_request_constructor_uses_default_parameters() {
472 let request = CreateRequest::new(Entity::new(Uuid::new_v4(), "account", None));
473
474 assert!(request.parameters.headers().is_empty());
475 }
476
477 #[test]
478 fn create_success_response_reads_entity_id_header() {
479 let request = OrganizationRequest::Create(CreateRequest::new(Entity::new(
480 Uuid::new_v4(),
481 "account",
482 None,
483 )));
484
485 let response = request.success_response(&HashMap::from([(
486 "odata-entityid".to_string(),
487 "https://example.crm.dynamics.com/api/data/v9.2/accounts(aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee)"
488 .to_string(),
489 )]));
490
491 match response {
492 super::OrganizationResponse::Create(created) => {
493 assert_eq!(
494 created.id,
495 Some(Uuid::parse_str("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").expect("uuid"))
496 );
497 }
498 _ => panic!("expected create response"),
499 }
500 }
501
502 #[test]
503 fn serializes_decimal_like_values_without_quotes() {
504 let mut entity = Entity::new(Uuid::new_v4(), "invoice", None);
505 entity.attributes.insert(
506 "totalamount".to_string(),
507 Value::Money(Money {
508 value: Decimal::new(12345, 2),
509 }),
510 );
511
512 let body = entity_to_write_body(&entity, &HashMap::new()).expect("should serialize");
513
514 assert!(body.contains("\"totalamount\":123.45"));
515 }
516}