Skip to main content

powerplatform_dataverse_client/dataverse/
batch.rs

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    /// Continue processing later requests when an earlier request fails.
17    pub continue_on_error: bool,
18    /// Include per-request success payloads in the batch response when Dataverse provides them.
19    pub return_responses: bool,
20}
21
22/// Batch request payload for Dataverse `ExecuteMultiple`-style operations.
23#[derive(Debug, Clone, Default)]
24pub struct ExecuteMultipleRequest {
25    /// Batch execution behavior flags.
26    pub settings: ExecuteMultipleSettings,
27    /// Individual create, update, or delete operations to include in the batch.
28    pub requests: Vec<OrganizationRequest>,
29}
30
31/// Batch response returned from Dataverse after executing multiple operations.
32#[derive(Debug, Clone, Default)]
33pub struct ExecuteMultipleResponse {
34    /// Per-request outcomes in the same order as the submitted requests.
35    pub responses: Vec<ExecuteMultipleResponseItem>,
36}
37
38/// Result for a single request within an `ExecuteMultipleResponse`.
39#[derive(Debug, Clone)]
40pub struct ExecuteMultipleResponseItem {
41    /// Zero-based index of the original request in the submitted batch.
42    pub request_index: usize,
43    /// Successful response payload, when `return_responses` was enabled and the request succeeded.
44    pub response: Option<OrganizationResponse>,
45    /// Fault details for failed requests.
46    pub fault: Option<OrganizationServiceFault>,
47}
48
49/// Error payload for a failed Dataverse batch item.
50#[derive(Debug, Clone)]
51pub struct OrganizationServiceFault {
52    /// HTTP status code returned for the failed item.
53    pub status_code: u16,
54    /// Dataverse fault code when present in the response body.
55    pub code: Option<String>,
56    /// Human-readable fault message.
57    pub message: String,
58    /// Raw HTTP body for callers that need the original Dataverse payload.
59    pub raw_body: Option<String>,
60}
61
62/// Supported Dataverse operations for a batch request.
63#[derive(Debug, Clone)]
64pub enum OrganizationRequest {
65    /// Create a new Dataverse row.
66    Create(CreateRequest),
67    /// Update an existing Dataverse row.
68    Update(UpdateRequest),
69    /// Delete an existing Dataverse row.
70    Delete(DeleteRequest),
71}
72
73/// Successful payload for a single `OrganizationRequest`.
74#[derive(Debug, Clone)]
75pub enum OrganizationResponse {
76    /// Create response containing the created row id when Dataverse returns it.
77    Create(CreateResponse),
78    /// Update response placeholder for successful updates.
79    Update(UpdateResponse),
80    /// Delete response placeholder for successful deletes.
81    Delete(DeleteResponse),
82}
83
84/// Create operation inside a batch request.
85#[derive(Debug, Clone)]
86pub struct CreateRequest {
87    /// Entity payload to create.
88    pub target: Entity,
89    /// Optional Dataverse headers that affect plugin/business logic execution.
90    pub parameters: RequestParameters,
91}
92
93/// Success payload for a batch create request.
94#[derive(Debug, Clone)]
95pub struct CreateResponse {
96    /// Created row id extracted from Dataverse response headers when available.
97    pub id: Option<Uuid>,
98}
99
100/// Update operation inside a batch request.
101#[derive(Debug, Clone)]
102pub struct UpdateRequest {
103    /// Entity payload to update. The entity id must already be populated.
104    pub target: Entity,
105    /// Optional Dataverse headers that affect plugin/business logic execution.
106    pub parameters: RequestParameters,
107}
108
109/// Success payload for a batch update request.
110#[derive(Debug, Clone, Default)]
111pub struct UpdateResponse;
112
113/// Delete operation inside a batch request.
114#[derive(Debug, Clone)]
115pub struct DeleteRequest {
116    /// Entity reference to delete.
117    pub target: EntityReference,
118    /// Optional Dataverse headers that affect plugin/business logic execution.
119    pub parameters: RequestParameters,
120}
121
122/// Success payload for a batch delete request.
123#[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    /// Create a batch create request with default request parameters.
148    pub fn new(target: Entity) -> Self {
149        Self {
150            target,
151            parameters: RequestParameters::default(),
152        }
153    }
154}
155
156impl UpdateRequest {
157    /// Create a batch update request with default request parameters.
158    pub fn new(target: Entity) -> Self {
159        Self {
160            target,
161            parameters: RequestParameters::default(),
162        }
163    }
164}
165
166impl DeleteRequest {
167    /// Create a batch delete request with default request parameters.
168    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}