Skip to main content

faucet_source_xml/
config.rs

1//! XML source configuration.
2
3use crate::decode::DecodeStep;
4use faucet_core::{AuthSpec, DEFAULT_BATCH_SIZE, FaucetError, TlsClientConfig};
5use reqwest::header::HeaderMap;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Authentication for XML API endpoints.
11#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
12#[serde(tag = "type", content = "config", rename_all = "snake_case")]
13pub enum XmlAuth {
14    /// No authentication.
15    None,
16    /// Bearer token.
17    Bearer { token: String },
18    /// Basic authentication.
19    Basic { username: String, password: String },
20    /// Custom headers (e.g. SOAP action headers, API keys).
21    Custom { headers: HashMap<String, String> },
22}
23
24fn default_true() -> bool {
25    true
26}
27
28/// SOAP protocol version. Controls the envelope namespace and the HTTP
29/// header shape used to carry the SOAP action.
30///
31/// Deserializes from the wire strings `"1.1"` / `"1.2"`.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
33pub enum SoapVersion {
34    /// SOAP 1.1 — envelope namespace `http://schemas.xmlsoap.org/soap/envelope/`,
35    /// action carried in a separate `SOAPAction` header.
36    #[default]
37    #[serde(rename = "1.1")]
38    Soap11,
39    /// SOAP 1.2 — envelope namespace `http://www.w3.org/2003/05/soap-envelope`,
40    /// action carried as a `Content-Type` parameter (no `SOAPAction` header).
41    #[serde(rename = "1.2")]
42    Soap12,
43}
44
45impl SoapVersion {
46    /// The SOAP envelope namespace URI for this version.
47    pub fn namespace(self) -> &'static str {
48        match self {
49            SoapVersion::Soap11 => "http://schemas.xmlsoap.org/soap/envelope/",
50            SoapVersion::Soap12 => "http://www.w3.org/2003/05/soap-envelope",
51        }
52    }
53}
54
55/// First-class SOAP ergonomics for the XML source.
56///
57/// This is **sugar** over the existing XML-over-HTTP request/response path —
58/// not a WSDL client. When present, the source assembles a SOAP envelope for
59/// the request body, injects the version-appropriate headers, and (by default)
60/// resolves [`XmlStreamConfig::records_element_path`] relative to
61/// `Envelope.Body` and surfaces SOAP `<Fault>` responses as errors.
62#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
63pub struct SoapConfig {
64    /// SOAP protocol version (default `1.1`).
65    #[serde(default)]
66    pub version: SoapVersion,
67    /// The SOAP action. For 1.1 it becomes the `SOAPAction` header; for 1.2 it
68    /// is carried as the `action` parameter of the `Content-Type` header.
69    /// Optional — omit for actionless operations.
70    pub action: Option<String>,
71    /// The XML fragment placed inside `<soap:Body>` — typically the operation
72    /// element, e.g. `<GetUsers xmlns="urn:example"/>`. Mutually exclusive with
73    /// the top-level [`XmlStreamConfig::body`].
74    pub body_inner: Option<String>,
75    /// Extra namespace declarations (prefix → URI) added to the envelope
76    /// element. The `soap` prefix is reserved for the envelope namespace and
77    /// any entry using it is ignored.
78    #[serde(default)]
79    pub namespaces: HashMap<String, String>,
80    /// When `true` (default), [`XmlStreamConfig::records_element_path`] is
81    /// resolved relative to `Envelope.Body` — i.e. `Envelope.Body.` is
82    /// auto-prepended, so you write `GetUsersResponse.Users.User`. Set `false`
83    /// to supply the fully-qualified path from the document root.
84    #[serde(default = "default_true")]
85    pub path_relative_to_body: bool,
86    /// When `true` (default), a SOAP `<Fault>` in the response raises
87    /// [`FaucetError::Source`]. When `false`,
88    /// a fault yields zero records (logged once).
89    #[serde(default = "default_true")]
90    pub fault_as_error: bool,
91}
92
93impl Default for SoapConfig {
94    fn default() -> Self {
95        Self {
96            version: SoapVersion::default(),
97            action: None,
98            body_inner: None,
99            namespaces: HashMap::new(),
100            path_relative_to_body: true,
101            fault_as_error: true,
102        }
103    }
104}
105
106impl SoapConfig {
107    /// Assemble the SOAP request envelope wrapping `body_inner` inside
108    /// `<soap:Body>`, declaring the version namespace plus any user-declared
109    /// prefixes on the envelope element.
110    ///
111    /// Prefixes are emitted in a deterministic (sorted) order so the assembled
112    /// body is stable across runs.
113    pub fn build_envelope(&self, body_inner: &str) -> String {
114        let mut attrs = format!(" xmlns:soap=\"{}\"", self.version.namespace());
115        let mut prefixes: Vec<(&String, &String)> = self
116            .namespaces
117            .iter()
118            // The `soap` prefix is reserved for the envelope namespace.
119            .filter(|(prefix, _)| prefix.as_str() != "soap")
120            .collect();
121        prefixes.sort_by(|a, b| a.0.cmp(b.0));
122        for (prefix, uri) in prefixes {
123            attrs.push_str(&format!(" xmlns:{prefix}=\"{uri}\""));
124        }
125        format!(
126            "<?xml version=\"1.0\" encoding=\"utf-8\"?>\
127             <soap:Envelope{attrs}><soap:Body>{body_inner}</soap:Body></soap:Envelope>"
128        )
129    }
130
131    /// The `Content-Type` header value for a request of this SOAP version.
132    ///
133    /// For 1.2 the action (when set) is carried as a `Content-Type` parameter.
134    pub fn content_type(&self) -> String {
135        match self.version {
136            SoapVersion::Soap11 => "text/xml; charset=utf-8".to_string(),
137            SoapVersion::Soap12 => match &self.action {
138                Some(action) => {
139                    format!("application/soap+xml; charset=utf-8; action=\"{action}\"")
140                }
141                None => "application/soap+xml; charset=utf-8".to_string(),
142            },
143        }
144    }
145
146    /// The `SOAPAction` header value (quoted) for SOAP 1.1. Always `None` for
147    /// SOAP 1.2, which carries the action inside `Content-Type` instead.
148    pub fn soap_action_header(&self) -> Option<String> {
149        match self.version {
150            SoapVersion::Soap11 => self.action.as_ref().map(|action| format!("\"{action}\"")),
151            SoapVersion::Soap12 => None,
152        }
153    }
154}
155
156/// Pagination configuration for XML APIs.
157#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
158#[serde(tag = "type")]
159pub enum XmlPagination {
160    /// Page-number pagination with a query parameter.
161    PageNumber {
162        param_name: String,
163        start_page: usize,
164        page_size: Option<usize>,
165        page_size_param: Option<String>,
166    },
167    /// Offset/limit pagination.
168    Offset {
169        offset_param: String,
170        limit_param: String,
171        limit: usize,
172    },
173    /// Body-cursor pagination (#544): read a continuation token from the
174    /// response via a dot-path (namespace-insensitive, trailing-match), and on
175    /// each subsequent request replace the request body with `next_body` (with
176    /// `${next_token}` substituted). Stops when the token is absent/empty or
177    /// repeats (loop guard), honouring `max_pages`. For stateful XML/SOAP APIs
178    /// that page with a `readMore`/`resultId` handle (e.g. Sage Intacct).
179    BodyCursor {
180        /// Dot-path to the continuation-token element in the response.
181        next_token_path: String,
182        /// Request-body template for pages after the first; `${next_token}` is
183        /// substituted with the captured token.
184        next_body: String,
185    },
186}
187
188/// Configuration for the XML source.
189#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
190pub struct XmlStreamConfig {
191    /// Base URL of the API.
192    pub base_url: String,
193    /// Request path (appended to base_url).
194    pub path: String,
195    /// HTTP method (GET or POST for SOAP).
196    #[serde(with = "crate::serde_helpers::http_method")]
197    #[schemars(with = "String")]
198    pub method: reqwest::Method,
199    /// Authentication: either inline (`{ type, config }`) or a `{ ref: <name> }`
200    /// pointer to a shared provider in the CLI's top-level `auth:` catalog.
201    pub auth: AuthSpec<XmlAuth>,
202    /// Additional request headers.
203    #[serde(skip, default)]
204    pub headers: HeaderMap,
205    /// Optional request body (e.g. a raw SOAP envelope). Mutually exclusive
206    /// with [`SoapConfig::body_inner`] when a [`soap`](Self::soap) block is set.
207    pub body: Option<String>,
208    /// Optional first-class SOAP ergonomics block. When present, the source
209    /// assembles the SOAP envelope, injects the version-appropriate headers,
210    /// and (by default) resolves `records_element_path` relative to
211    /// `Envelope.Body`. Sugar over the raw `body` path — see [`SoapConfig`].
212    #[serde(default)]
213    pub soap: Option<SoapConfig>,
214    /// Dot-separated path to the repeating element in the XML response
215    /// (e.g. `"Envelope.Body.GetUsersResponse.Users.User"`).
216    pub records_element_path: Option<String>,
217    /// Pagination configuration.
218    pub pagination: Option<XmlPagination>,
219    /// Maximum number of pages to fetch.
220    pub max_pages: Option<usize>,
221    /// Query parameters to include in every request.
222    pub query_params: std::collections::HashMap<String, String>,
223    /// Response-decode pipeline (#540): a declarative chain applied to the raw
224    /// response body before record extraction (`extract` an element's text →
225    /// `base64`/`gunzip`/`unzip` → `parse` csv/xlsx/xml/json). When non-empty,
226    /// records come from the decoded output instead of `records_element_path`.
227    #[serde(default, skip_serializing_if = "Vec::is_empty")]
228    pub decode: Vec<DecodeStep>,
229    /// Records per emitted [`StreamPage`](faucet_core::StreamPage). The
230    /// event-driven XML parser accumulates matched subtrees into a buffer
231    /// and yields whenever the buffer reaches this size. Defaults to
232    /// [`DEFAULT_BATCH_SIZE`].
233    ///
234    /// `batch_size = 0` is the "no batching" sentinel: the document is
235    /// drained end-to-end and the entire result set is emitted in a single
236    /// page. Useful for small lookup payloads or for sinks (e.g. SQL `COPY`,
237    /// BigQuery load jobs) that prefer one large request to many small ones.
238    #[serde(default = "default_batch_size")]
239    pub batch_size: usize,
240    /// Optional client-certificate (mutual TLS) config. When set, the source
241    /// presents a client certificate on every request (data + inline auth token
242    /// request). Requires the crate's `mtls` feature.
243    #[serde(default)]
244    pub tls: Option<TlsClientConfig>,
245}
246
247fn default_batch_size() -> usize {
248    DEFAULT_BATCH_SIZE
249}
250
251impl XmlStreamConfig {
252    /// Create a new config with required fields.
253    pub fn new(base_url: impl Into<String>, path: impl Into<String>) -> Self {
254        Self {
255            base_url: base_url.into(),
256            path: path.into(),
257            method: reqwest::Method::GET,
258            auth: AuthSpec::Inline(XmlAuth::None),
259            headers: HeaderMap::new(),
260            body: None,
261            soap: None,
262            records_element_path: None,
263            pagination: None,
264            max_pages: None,
265            query_params: std::collections::HashMap::new(),
266            decode: Vec::new(),
267            batch_size: DEFAULT_BATCH_SIZE,
268            tls: None,
269        }
270    }
271
272    /// Set the response-decode pipeline (#540).
273    pub fn decode(mut self, steps: Vec<DecodeStep>) -> Self {
274        self.decode = steps;
275        self
276    }
277
278    /// Attach a mutual-TLS client identity (requires the `mtls` feature at build
279    /// time; otherwise [`XmlStream::try_new`](crate::XmlStream::try_new) errors).
280    pub fn tls(mut self, tls: TlsClientConfig) -> Self {
281        self.tls = Some(tls);
282        self
283    }
284
285    /// Set the HTTP method (default: GET).
286    pub fn method(mut self, method: reqwest::Method) -> Self {
287        self.method = method;
288        self
289    }
290
291    /// Set the authentication method.
292    pub fn auth(mut self, auth: XmlAuth) -> Self {
293        self.auth = AuthSpec::Inline(auth);
294        self
295    }
296
297    /// Set additional headers.
298    pub fn headers(mut self, headers: HeaderMap) -> Self {
299        self.headers = headers;
300        self
301    }
302
303    /// Set a raw SOAP or XML request body.
304    pub fn body(mut self, body: impl Into<String>) -> Self {
305        self.body = Some(body.into());
306        self
307    }
308
309    /// Attach a first-class [`SoapConfig`] ergonomics block.
310    pub fn with_soap(mut self, soap: SoapConfig) -> Self {
311        self.soap = Some(soap);
312        self
313    }
314
315    /// Set the dot-separated path to the repeating element.
316    pub fn records_element_path(mut self, path: impl Into<String>) -> Self {
317        self.records_element_path = Some(path.into());
318        self
319    }
320
321    /// Set pagination configuration.
322    pub fn pagination(mut self, pagination: XmlPagination) -> Self {
323        self.pagination = Some(pagination);
324        self
325    }
326
327    /// Set the maximum number of pages.
328    pub fn max_pages(mut self, max: usize) -> Self {
329        self.max_pages = Some(max);
330        self
331    }
332
333    /// Add a query parameter.
334    pub fn query_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
335        self.query_params.insert(key.into(), value.into());
336        self
337    }
338
339    /// Set the per-page record count for
340    /// [`Source::stream_pages`](faucet_core::Source::stream_pages).
341    ///
342    /// Pass `0` to opt out of batching — the entire document is drained and
343    /// emitted in a single [`StreamPage`](faucet_core::StreamPage).
344    pub fn with_batch_size(mut self, batch_size: usize) -> Self {
345        self.batch_size = batch_size;
346        self
347    }
348
349    /// Validate the configuration, surfacing SOAP-specific conflicts as
350    /// [`FaucetError::Config`] before any request is made.
351    ///
352    /// Rules:
353    /// - both the top-level [`body`](Self::body) and [`SoapConfig::body_inner`]
354    ///   set is ambiguous;
355    /// - a [`soap`](Self::soap) block requires `method: POST` (SOAP is a POST
356    ///   protocol).
357    ///
358    /// A no-op (always `Ok`) when no `soap` block is present, so non-SOAP
359    /// configs are unaffected.
360    pub fn validate(&self) -> Result<(), FaucetError> {
361        if let Some(soap) = &self.soap {
362            if self.body.is_some() && soap.body_inner.is_some() {
363                return Err(FaucetError::Config(
364                    "xml: set either the top-level `body` or `soap.body_inner`, not both \
365                     (ambiguous request body)"
366                        .into(),
367                ));
368            }
369            if self.method == reqwest::Method::GET {
370                return Err(FaucetError::Config(
371                    "xml: a `soap` block requires `method: POST` — SOAP is a POST protocol".into(),
372                ));
373            }
374        }
375        if let Some(tls) = &self.tls {
376            tls.validate()?;
377        }
378        Ok(())
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn default_config() {
388        let config = XmlStreamConfig::new("https://api.example.com", "/users");
389        assert_eq!(config.base_url, "https://api.example.com");
390        assert_eq!(config.path, "/users");
391        assert_eq!(config.method, reqwest::Method::GET);
392        assert!(config.records_element_path.is_none());
393    }
394
395    #[test]
396    fn soap_config() {
397        let config = XmlStreamConfig::new("https://api.example.com", "/soap")
398            .method(reqwest::Method::POST)
399            .body("<Envelope><Body><GetUsers/></Body></Envelope>")
400            .records_element_path("Envelope.Body.GetUsersResponse.Users.User");
401        assert_eq!(config.method, reqwest::Method::POST);
402        assert!(config.body.is_some());
403        assert_eq!(
404            config.records_element_path.unwrap(),
405            "Envelope.Body.GetUsersResponse.Users.User"
406        );
407    }
408
409    #[test]
410    fn batch_size_defaults_to_default_batch_size() {
411        let config = XmlStreamConfig::new("https://api.example.com", "/users");
412        assert_eq!(config.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
413    }
414
415    #[test]
416    fn with_batch_size_overrides_default() {
417        let config = XmlStreamConfig::new("https://api.example.com", "/users").with_batch_size(500);
418        assert_eq!(config.batch_size, 500);
419    }
420
421    #[test]
422    fn batch_size_zero_is_accepted_as_no_batching_sentinel() {
423        let config = XmlStreamConfig::new("https://api.example.com", "/users").with_batch_size(0);
424        assert_eq!(config.batch_size, 0);
425        assert!(faucet_core::validate_batch_size(config.batch_size).is_ok());
426    }
427
428    #[test]
429    fn batch_size_above_max_is_rejected_by_validate_batch_size() {
430        let config = XmlStreamConfig::new("https://api.example.com", "/users")
431            .with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
432        assert!(faucet_core::validate_batch_size(config.batch_size).is_err());
433    }
434
435    #[test]
436    fn batch_size_deserializes_from_json() {
437        let json = r#"{
438            "base_url": "https://api.example.com",
439            "path": "/users.xml",
440            "method": "GET",
441            "auth": { "type": "none" },
442            "body": null,
443            "records_element_path": "root.user",
444            "pagination": null,
445            "max_pages": null,
446            "query_params": {},
447            "batch_size": 250
448        }"#;
449        let config: XmlStreamConfig = serde_json::from_str(json).unwrap();
450        assert_eq!(config.batch_size, 250);
451    }
452
453    #[test]
454    fn soap_version_deserializes_from_wire_strings() {
455        assert_eq!(
456            serde_json::from_str::<SoapVersion>("\"1.1\"").unwrap(),
457            SoapVersion::Soap11
458        );
459        assert_eq!(
460            serde_json::from_str::<SoapVersion>("\"1.2\"").unwrap(),
461            SoapVersion::Soap12
462        );
463        assert_eq!(SoapVersion::default(), SoapVersion::Soap11);
464    }
465
466    #[test]
467    fn soap_version_serializes_to_wire_strings() {
468        assert_eq!(
469            serde_json::to_string(&SoapVersion::Soap11).unwrap(),
470            "\"1.1\""
471        );
472        assert_eq!(
473            serde_json::to_string(&SoapVersion::Soap12).unwrap(),
474            "\"1.2\""
475        );
476    }
477
478    #[test]
479    fn soap_version_namespaces() {
480        assert_eq!(
481            SoapVersion::Soap11.namespace(),
482            "http://schemas.xmlsoap.org/soap/envelope/"
483        );
484        assert_eq!(
485            SoapVersion::Soap12.namespace(),
486            "http://www.w3.org/2003/05/soap-envelope"
487        );
488    }
489
490    #[test]
491    fn soap_config_defaults_are_body_relative_and_fault_as_error() {
492        let soap = SoapConfig::default();
493        assert_eq!(soap.version, SoapVersion::Soap11);
494        assert!(soap.path_relative_to_body);
495        assert!(soap.fault_as_error);
496        assert!(soap.action.is_none());
497        assert!(soap.body_inner.is_none());
498    }
499
500    #[test]
501    fn soap_config_deserializes_defaults_from_minimal_json() {
502        let soap: SoapConfig = serde_json::from_str("{}").unwrap();
503        assert_eq!(soap.version, SoapVersion::Soap11);
504        assert!(soap.path_relative_to_body);
505        assert!(soap.fault_as_error);
506    }
507
508    #[test]
509    fn build_envelope_soap11() {
510        let soap = SoapConfig {
511            version: SoapVersion::Soap11,
512            body_inner: Some("<GetUsers xmlns=\"urn:example\"/>".into()),
513            ..Default::default()
514        };
515        let env = soap.build_envelope(soap.body_inner.as_deref().unwrap());
516        assert!(
517            env.contains("xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\""),
518            "got {env}"
519        );
520        assert!(env.contains("<soap:Envelope"));
521        assert!(env.contains("<soap:Body><GetUsers xmlns=\"urn:example\"/></soap:Body>"));
522        assert!(env.trim_end().ends_with("</soap:Envelope>"));
523    }
524
525    #[test]
526    fn build_envelope_soap12() {
527        let soap = SoapConfig {
528            version: SoapVersion::Soap12,
529            ..Default::default()
530        };
531        let env = soap.build_envelope("<Op/>");
532        assert!(
533            env.contains("xmlns:soap=\"http://www.w3.org/2003/05/soap-envelope\""),
534            "got {env}"
535        );
536        assert!(env.contains("<soap:Body><Op/></soap:Body>"));
537    }
538
539    #[test]
540    fn build_envelope_declares_extra_namespaces_sorted() {
541        let mut namespaces = HashMap::new();
542        namespaces.insert("b".to_string(), "urn:b".to_string());
543        namespaces.insert("a".to_string(), "urn:a".to_string());
544        // A `soap` prefix is reserved and must be dropped.
545        namespaces.insert("soap".to_string(), "urn:should-be-ignored".to_string());
546        let soap = SoapConfig {
547            namespaces,
548            ..Default::default()
549        };
550        let env = soap.build_envelope("<Op/>");
551        // Deterministic sorted order: soap (envelope), then a, then b.
552        let idx_soap = env.find("xmlns:soap=").unwrap();
553        let idx_a = env.find("xmlns:a=\"urn:a\"").unwrap();
554        let idx_b = env.find("xmlns:b=\"urn:b\"").unwrap();
555        assert!(idx_soap < idx_a && idx_a < idx_b, "got {env}");
556        assert!(!env.contains("urn:should-be-ignored"), "got {env}");
557    }
558
559    #[test]
560    fn soap11_content_type_and_action_header() {
561        let soap = SoapConfig {
562            version: SoapVersion::Soap11,
563            action: Some("urn:GetUsers".into()),
564            ..Default::default()
565        };
566        assert_eq!(soap.content_type(), "text/xml; charset=utf-8");
567        assert_eq!(
568            soap.soap_action_header().as_deref(),
569            Some("\"urn:GetUsers\"")
570        );
571    }
572
573    #[test]
574    fn soap11_without_action_has_no_soap_action_header() {
575        let soap = SoapConfig {
576            version: SoapVersion::Soap11,
577            action: None,
578            ..Default::default()
579        };
580        assert_eq!(soap.content_type(), "text/xml; charset=utf-8");
581        assert!(soap.soap_action_header().is_none());
582    }
583
584    #[test]
585    fn soap12_content_type_carries_action_and_has_no_soap_action_header() {
586        let soap = SoapConfig {
587            version: SoapVersion::Soap12,
588            action: Some("urn:GetUsers".into()),
589            ..Default::default()
590        };
591        assert_eq!(
592            soap.content_type(),
593            "application/soap+xml; charset=utf-8; action=\"urn:GetUsers\""
594        );
595        assert!(
596            soap.soap_action_header().is_none(),
597            "SOAP 1.2 never sets a SOAPAction header"
598        );
599    }
600
601    #[test]
602    fn soap12_content_type_without_action() {
603        let soap = SoapConfig {
604            version: SoapVersion::Soap12,
605            action: None,
606            ..Default::default()
607        };
608        assert_eq!(soap.content_type(), "application/soap+xml; charset=utf-8");
609    }
610
611    #[test]
612    fn validate_ok_without_soap_block() {
613        let config = XmlStreamConfig::new("https://api.example.com", "/svc");
614        assert!(config.validate().is_ok());
615    }
616
617    #[test]
618    fn validate_rejects_body_and_body_inner_both_set() {
619        let config = XmlStreamConfig::new("https://api.example.com", "/svc")
620            .method(reqwest::Method::POST)
621            .body("<Envelope/>")
622            .with_soap(SoapConfig {
623                body_inner: Some("<Op/>".into()),
624                ..Default::default()
625            });
626        let err = config.validate().unwrap_err();
627        assert!(
628            matches!(&err, FaucetError::Config(m) if m.contains("not both")),
629            "got {err:?}"
630        );
631    }
632
633    #[test]
634    fn validate_rejects_soap_with_get_method() {
635        // `new` defaults method to GET; a soap block requires POST.
636        let config = XmlStreamConfig::new("https://api.example.com", "/svc")
637            .with_soap(SoapConfig::default());
638        let err = config.validate().unwrap_err();
639        assert!(
640            matches!(&err, FaucetError::Config(m) if m.contains("POST")),
641            "got {err:?}"
642        );
643    }
644
645    #[test]
646    fn validate_ok_with_soap_and_post() {
647        let config = XmlStreamConfig::new("https://api.example.com", "/svc")
648            .method(reqwest::Method::POST)
649            .with_soap(SoapConfig {
650                body_inner: Some("<Op/>".into()),
651                ..Default::default()
652            });
653        assert!(config.validate().is_ok());
654    }
655
656    #[test]
657    fn with_soap_sets_the_block() {
658        let config = XmlStreamConfig::new("https://api.example.com", "/svc")
659            .method(reqwest::Method::POST)
660            .with_soap(SoapConfig {
661                action: Some("urn:Op".into()),
662                ..Default::default()
663            });
664        assert_eq!(config.soap.unwrap().action.as_deref(), Some("urn:Op"));
665    }
666
667    #[test]
668    fn soap_absent_by_default_and_deserializes_from_config_without_soap() {
669        // Backward-compat: a config JSON with no `soap` key deserializes to
670        // `soap: None`, leaving every legacy field untouched.
671        let json = r#"{
672            "base_url": "https://api.example.com",
673            "path": "/users.xml",
674            "method": "GET",
675            "auth": { "type": "none" },
676            "body": null,
677            "records_element_path": "root.user",
678            "pagination": null,
679            "max_pages": null,
680            "query_params": {}
681        }"#;
682        let config: XmlStreamConfig = serde_json::from_str(json).unwrap();
683        assert!(config.soap.is_none());
684    }
685
686    #[test]
687    fn batch_size_defaults_when_missing_from_json() {
688        // The `#[serde(default = "default_batch_size")]` attribute is the
689        // user-facing contract — older configs without `batch_size` must
690        // continue to deserialize and adopt the library default.
691        let json = r#"{
692            "base_url": "https://api.example.com",
693            "path": "/users.xml",
694            "method": "GET",
695            "auth": { "type": "none" },
696            "body": null,
697            "records_element_path": null,
698            "pagination": null,
699            "max_pages": null,
700            "query_params": {}
701        }"#;
702        let config: XmlStreamConfig = serde_json::from_str(json).unwrap();
703        assert_eq!(config.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
704    }
705}