Skip to main content

faucet_source_xml/
config.rs

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