Skip to main content

faucet_source_xml/
stream.rs

1//! XML stream executor.
2
3use crate::config::{XmlAuth, XmlPagination, XmlStreamConfig};
4use crate::convert;
5use async_trait::async_trait;
6use faucet_core::util::{self, DEFAULT_ERROR_BODY_MAX_LEN};
7use faucet_core::{AuthSpec, Credential, FaucetError, SharedAuthProvider};
8use faucet_core::{Stream, StreamPage};
9use reqwest::Client;
10use serde_json::Value;
11use std::collections::HashMap;
12use std::pin::Pin;
13use std::time::Duration;
14
15/// Content fingerprint of a fetched page, used as a pagination loop guard: a
16/// server that ignores the page/offset parameter (or clamps to the last page)
17/// returns the same non-empty page on every request, which would otherwise loop
18/// forever. Stopping when two consecutive pages fingerprint identically mirrors
19/// the REST source's body-fingerprint guard (audit #146 H4/H5).
20fn page_fingerprint(records: &[Value]) -> u64 {
21    use std::hash::{Hash, Hasher};
22    // `serde_json::Value` is not `Hash`; hash its canonical string form.
23    let mut hasher = std::collections::hash_map::DefaultHasher::new();
24    records.len().hash(&mut hasher);
25    for r in records {
26        r.to_string().hash(&mut hasher);
27    }
28    hasher.finish()
29}
30
31/// Retries on transient (5xx / connection) failures before giving up.
32const RETRY_MAX_ATTEMPTS: u32 = 3;
33/// Base exponential-backoff delay between retries.
34const RETRY_BASE_BACKOFF: Duration = Duration::from_millis(500);
35
36/// A configured XML API source that handles pagination and extraction.
37pub struct XmlStream {
38    config: XmlStreamConfig,
39    client: Client,
40    /// Optional shared auth provider. When present it takes precedence over
41    /// inline auth, so several sources can share one token with single-flight
42    /// refresh. Used by the CLI to resolve `auth: { ref }`, and by library
43    /// callers who construct one provider and inject it into many sources.
44    auth_provider: Option<SharedAuthProvider>,
45    /// Retry policy for transient request failures. Defaulted in `new()` to
46    /// reproduce the legacy `RETRY_MAX_ATTEMPTS` / `RETRY_BASE_BACKOFF`
47    /// constants; overridable via [`with_retry_policy`](Self::with_retry_policy).
48    retry_policy: faucet_core::RetryPolicy,
49}
50
51/// Map a [`Credential`] from a shared provider onto the XML [`XmlAuth`]
52/// representation so the existing header-application path can be reused.
53fn credential_to_auth(cred: Credential) -> XmlAuth {
54    match cred {
55        Credential::Bearer(token) => XmlAuth::Bearer { token },
56        Credential::Token(token) => XmlAuth::Custom {
57            headers: std::iter::once(("Authorization".to_string(), token)).collect(),
58        },
59        Credential::Basic { username, password } => XmlAuth::Basic { username, password },
60        Credential::Header { name, value } => XmlAuth::Custom {
61            headers: std::iter::once((name, value)).collect(),
62        },
63    }
64}
65
66impl XmlStream {
67    /// Create a new XML stream from the given configuration.
68    pub fn new(config: XmlStreamConfig) -> Self {
69        Self {
70            config,
71            client: Client::new(),
72            auth_provider: None,
73            // Reproduce the legacy `execute_with_retry(RETRY_MAX_ATTEMPTS,
74            // RETRY_BASE_BACKOFF, …)` behavior exactly: `max_retries` is
75            // retries-after-first, so `max_attempts = RETRY_MAX_ATTEMPTS + 1`.
76            retry_policy: faucet_core::RetryPolicy {
77                max_attempts: RETRY_MAX_ATTEMPTS + 1,
78                backoff: faucet_core::BackoffKind::Exponential,
79                base: RETRY_BASE_BACKOFF,
80                max: Duration::from_secs(60),
81                jitter: true,
82                retry_on: faucet_core::RetryClassSet::default(),
83            },
84        }
85    }
86
87    /// Attach a custom [`RetryPolicy`](faucet_core::RetryPolicy) for transient
88    /// request failures, replacing the default derived from
89    /// `RETRY_MAX_ATTEMPTS` / `RETRY_BASE_BACKOFF`. Used by the CLI to inject a
90    /// pipeline-level `resilience:` policy into the source.
91    pub fn with_retry_policy(mut self, policy: faucet_core::RetryPolicy) -> Self {
92        self.retry_policy = policy;
93        self
94    }
95
96    /// Attach a shared [`AuthProvider`](faucet_core::AuthProvider). When set,
97    /// the provider supplies the credential for every request (taking precedence
98    /// over inline auth), so several sources can share one token with
99    /// single-flight refresh. Used by the CLI to resolve `auth: { ref }`, and by
100    /// library callers who construct one provider and inject it into many
101    /// sources.
102    pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
103        self.auth_provider = Some(provider);
104        self
105    }
106
107    /// The effective record element path after applying SOAP ergonomics.
108    ///
109    /// When a `soap:` block is present with `path_relative_to_body` (the
110    /// default), the configured `records_element_path` is resolved relative to
111    /// the SOAP body — `Envelope.Body.` is prepended so the user writes
112    /// `GetUsersResponse.Users.User`. Otherwise the configured path is used
113    /// verbatim (the non-SOAP behavior).
114    fn effective_records_path(&self) -> Option<String> {
115        match (&self.config.soap, &self.config.records_element_path) {
116            (Some(soap), Some(path)) if soap.path_relative_to_body => {
117                Some(format!("Envelope.Body.{path}"))
118            }
119            (_, path) => path.clone(),
120        }
121    }
122
123    /// Eagerly convert one HTTP page of XML to JSON and extract its records,
124    /// applying SOAP fault handling when a `soap:` block is present.
125    ///
126    /// When `soap` is absent this reproduces the legacy eager path exactly
127    /// (`xml_to_json` + `extract_at_path`), so non-SOAP behavior is unchanged.
128    /// When `soap` is present it additionally detects a SOAP `<Fault>` under
129    /// `Envelope.Body`: with `fault_as_error` it raises
130    /// [`FaucetError::Source`]; otherwise it emits zero records and logs the
131    /// fault once (tracked via `fault_logged`).
132    fn extract_records_eager(
133        &self,
134        xml_text: &str,
135        fault_logged: &mut bool,
136    ) -> Result<Vec<Value>, FaucetError> {
137        let doc = convert::xml_to_json(xml_text)?;
138
139        if let Some(soap) = &self.config.soap
140            && let Some(message) = convert::detect_soap_fault(&doc)
141        {
142            if soap.fault_as_error {
143                return Err(FaucetError::Source(format!("SOAP fault: {message}")));
144            }
145            if !*fault_logged {
146                tracing::warn!(
147                    fault = %message,
148                    "SOAP fault in response; emitting zero records (fault_as_error=false)"
149                );
150                *fault_logged = true;
151            }
152            return Ok(Vec::new());
153        }
154
155        let records = match self.effective_records_path() {
156            Some(path) => convert::extract_at_path(&doc, &path),
157            None => vec![doc],
158        };
159        Ok(records)
160    }
161
162    /// Fetch all records across all pages.
163    pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
164        self.fetch_all_with_context(&HashMap::new()).await
165    }
166
167    /// Fetch all records, substituting parent context into path, query_params, and body.
168    async fn fetch_all_with_context(
169        &self,
170        context: &HashMap<String, serde_json::Value>,
171    ) -> Result<Vec<Value>, FaucetError> {
172        self.config.validate()?;
173
174        let mut all_records = Vec::new();
175        let mut pages_fetched = 0usize;
176        let mut offset = 0usize;
177        let mut page_number = None;
178        let mut prev_fingerprint: Option<u64> = None;
179        let mut fault_logged = false;
180
181        // Initialize pagination state.
182        if let Some(XmlPagination::PageNumber { start_page, .. }) = &self.config.pagination {
183            page_number = Some(*start_page);
184        }
185
186        loop {
187            if let Some(max) = self.config.max_pages
188                && pages_fetched >= max
189            {
190                tracing::warn!("max pages ({max}) reached");
191                break;
192            }
193
194            let mut params = self.config.query_params.clone();
195            self.apply_pagination_params(&mut params, page_number, offset);
196
197            let xml_text = self.execute_request(&params, context).await?;
198            let records = self.extract_records_eager(&xml_text, &mut fault_logged)?;
199
200            let record_count = records.len();
201            let fingerprint = page_fingerprint(&records);
202            pages_fetched += 1;
203
204            // Loop guard: a server that ignores the page/offset parameter (or
205            // clamps to the last page) returns the same non-empty page forever.
206            // Stop when two consecutive pages are identical (audit #146 H4/H5) —
207            // and do it BEFORE appending, so the duplicate page's records are
208            // never emitted to the sink a second time (audit #321 M4).
209            if record_count > 0 && prev_fingerprint == Some(fingerprint) {
210                tracing::warn!(
211                    "XML pagination returned an identical page; stopping to avoid an infinite loop"
212                );
213                break;
214            }
215            prev_fingerprint = Some(fingerprint);
216            all_records.extend(records);
217
218            // Advance pagination or stop.
219            match &self.config.pagination {
220                Some(XmlPagination::PageNumber { page_size, .. }) => {
221                    if record_count == 0 {
222                        break;
223                    }
224                    // Stop if page_size is set and we got fewer records than the page size.
225                    if let Some(size) = page_size
226                        && record_count < *size
227                    {
228                        break;
229                    }
230                    page_number = page_number.map(|p| p + 1);
231                }
232                Some(XmlPagination::Offset { limit, .. }) => {
233                    if record_count < *limit {
234                        break;
235                    }
236                    offset += record_count;
237                }
238                None => break,
239            }
240        }
241
242        tracing::info!(
243            records = all_records.len(),
244            pages = pages_fetched,
245            "XML fetch complete"
246        );
247        Ok(all_records)
248    }
249
250    fn apply_pagination_params(
251        &self,
252        params: &mut HashMap<String, String>,
253        page_number: Option<usize>,
254        offset: usize,
255    ) {
256        match &self.config.pagination {
257            Some(XmlPagination::PageNumber {
258                param_name,
259                page_size,
260                page_size_param,
261                ..
262            }) => {
263                if let Some(page) = page_number {
264                    params.insert(param_name.clone(), page.to_string());
265                }
266                if let (Some(size), Some(param)) = (page_size, page_size_param) {
267                    params.insert(param.clone(), size.to_string());
268                }
269            }
270            Some(XmlPagination::Offset {
271                offset_param,
272                limit_param,
273                limit,
274            }) => {
275                params.insert(offset_param.clone(), offset.to_string());
276                params.insert(limit_param.clone(), limit.to_string());
277            }
278            None => {}
279        }
280    }
281
282    async fn execute_request(
283        &self,
284        params: &HashMap<String, String>,
285        context: &HashMap<String, serde_json::Value>,
286    ) -> Result<String, FaucetError> {
287        let path = if context.is_empty() {
288            self.config.path.clone()
289        } else {
290            faucet_core::util::substitute_context(&self.config.path, context)
291        };
292
293        let url = format!("{}/{}", self.config.base_url, path.trim_start_matches('/'));
294
295        // Substitute context into query parameter values.
296        let resolved_params: HashMap<String, String> = if context.is_empty() {
297            params.clone()
298        } else {
299            params
300                .iter()
301                .map(|(k, v)| (k.clone(), faucet_core::util::substitute_context(v, context)))
302                .collect()
303        };
304
305        let mut req = self
306            .client
307            .request(self.config.method.clone(), &url)
308            .headers(self.config.headers.clone())
309            .query(&resolved_params);
310
311        // Resolve credentials to concrete auth. A shared auth provider
312        // (from `auth: { ref }` or injected by a library caller) takes
313        // precedence; otherwise inline auth is used.
314        let effective_auth: XmlAuth = if let Some(provider) = &self.auth_provider {
315            credential_to_auth(provider.credential().await?)
316        } else {
317            match &self.config.auth {
318                AuthSpec::Inline(a) => a.clone(),
319                AuthSpec::Reference(r) => {
320                    return Err(FaucetError::Auth(format!(
321                        "auth references provider '{}' but no provider was supplied; \
322                         set one via the CLI `auth:` catalog or `with_auth_provider`",
323                        r.name
324                    )));
325                }
326            }
327        };
328
329        // Apply auth.
330        match &effective_auth {
331            XmlAuth::None => {}
332            XmlAuth::Bearer { token } => {
333                req = req.bearer_auth(token);
334            }
335            XmlAuth::Basic { username, password } => {
336                req = req.basic_auth(username, Some(password));
337            }
338            XmlAuth::Custom { headers } => {
339                let mut hm = reqwest::header::HeaderMap::new();
340                for (name, value) in headers {
341                    let n =
342                        reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
343                            FaucetError::Auth(format!("invalid custom header name {name:?}: {e}"))
344                        })?;
345                    let v = reqwest::header::HeaderValue::from_str(value).map_err(|e| {
346                        FaucetError::Auth(format!("invalid custom header value for {name:?}: {e}"))
347                    })?;
348                    hm.insert(n, v);
349                }
350                req = req.headers(hm);
351            }
352        }
353
354        // Set the request body for POST (SOAP), with context substitution.
355        //
356        // A `soap:` block takes precedence: it assembles the envelope and
357        // injects the version-appropriate headers (Content-Type + SOAPAction).
358        // These headers are set here regardless of the `auth` variant, so real
359        // bearer / basic auth (applied above) is left untouched. Otherwise the
360        // legacy raw-`body` path is used verbatim (byte-for-byte unchanged).
361        if let Some(soap) = &self.config.soap {
362            let inner = soap.body_inner.as_deref().unwrap_or("");
363            let resolved_inner = if context.is_empty() {
364                inner.to_string()
365            } else {
366                faucet_core::util::substitute_context(inner, context)
367            };
368            let envelope = soap.build_envelope(&resolved_inner);
369            req = req
370                .header("Content-Type", soap.content_type())
371                .body(envelope);
372            if let Some(action) = soap.soap_action_header() {
373                req = req.header("SOAPAction", action);
374            }
375        } else if let Some(body) = &self.config.body {
376            let resolved_body = if context.is_empty() {
377                body.clone()
378            } else {
379                faucet_core::util::substitute_context(body, context)
380            };
381            req = req
382                .header("Content-Type", "text/xml; charset=utf-8")
383                .body(resolved_body);
384        }
385
386        // Retry transient failures (5xx / connection resets) with jittered
387        // backoff, matching the REST source's reliability layer (#78/#16).
388        // The request body is a String, so `try_clone` always succeeds.
389        faucet_core::execute_with_policy(&self.retry_policy, None, || {
390            let attempt = req.try_clone();
391            async move {
392                let req = attempt.ok_or_else(|| {
393                    FaucetError::Source("xml: request is not cloneable for retry".into())
394                })?;
395                let resp = req.send().await.map_err(FaucetError::Http)?;
396                let resp = util::check_http_response(resp, DEFAULT_ERROR_BODY_MAX_LEN).await?;
397                resp.text().await.map_err(FaucetError::Http)
398            }
399        })
400        .await
401    }
402}
403
404#[async_trait]
405impl faucet_core::Source for XmlStream {
406    async fn fetch_with_context(
407        &self,
408        context: &std::collections::HashMap<String, serde_json::Value>,
409    ) -> Result<Vec<Value>, FaucetError> {
410        self.fetch_all_with_context(context).await
411    }
412
413    /// Stream records from the XML response without materialising the whole
414    /// document tree. The event-driven parser only builds JSON values for
415    /// elements matching [`XmlStreamConfig::records_element_path`]; other
416    /// elements are observed and discarded, so client-side memory is bounded
417    /// at `O(batch_size * record_size)` regardless of how large the document
418    /// is.
419    ///
420    /// Records are accumulated into a buffer of
421    /// [`XmlStreamConfig::batch_size`] entries and yielded as a
422    /// [`StreamPage`] once the buffer is full. The trailing partial buffer
423    /// (if any) is emitted after the parser hits EOF and all pagination
424    /// rounds drain.
425    ///
426    /// The trait-level `batch_size` argument is intentionally ignored in
427    /// favour of the config field — the config is the user-facing knob the
428    /// README documents, and routing the pipeline-supplied hint through it
429    /// would silently override an explicit config value. `batch_size = 0`
430    /// drains every page into a single emitted page.
431    ///
432    /// Bookmarks are always `None` — the XML source has no
433    /// incremental-replication mode today; pagination only walks the
434    /// API's own page-number / offset cursor.
435    fn stream_pages<'a>(
436        &'a self,
437        context: &'a HashMap<String, Value>,
438        _batch_size: usize,
439    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
440        let batch_size = self.config.batch_size;
441        let owned_context = context.clone();
442
443        Box::pin(async_stream::try_stream! {
444            self.config.validate()?;
445
446            let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
447            let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
448            let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
449            let mut total = 0usize;
450            let mut pages_fetched = 0usize;
451            let mut offset = 0usize;
452            let mut page_number = None;
453            let mut prev_fingerprint: Option<u64> = None;
454            let mut fault_logged = false;
455
456            if let Some(XmlPagination::PageNumber { start_page, .. }) =
457                &self.config.pagination
458            {
459                page_number = Some(*start_page);
460            }
461
462            loop {
463                if let Some(max) = self.config.max_pages
464                    && pages_fetched >= max
465                {
466                    tracing::warn!("max pages ({max}) reached");
467                    break;
468                }
469
470                let mut params = self.config.query_params.clone();
471                self.apply_pagination_params(&mut params, page_number, offset);
472
473                let xml_text = self.execute_request(&params, &owned_context).await?;
474
475                // Event-driven extraction: only the matched subtree is
476                // ever materialised. The closure pushes into the local
477                // buffer; once it crosses `chunk`, the surrounding loop
478                // can flush, but we can't `yield` from inside the closure,
479                // so we collect this HTTP page's records into a scratch
480                // Vec and then iterate them after.
481                //
482                // A `soap:` block routes through the eager converter so the
483                // SOAP `<Fault>` check and `Envelope.Body.`-relative path
484                // resolution apply (SOAP responses are small — the bounded-
485                // memory streaming path is reserved for the non-SOAP case,
486                // which stays byte-for-byte unchanged).
487                let mut page_records: Vec<Value> = Vec::new();
488                if self.config.soap.is_some() {
489                    page_records = self.extract_records_eager(&xml_text, &mut fault_logged)?;
490                } else {
491                    convert::stream_extract(
492                        &xml_text,
493                        self.config.records_element_path.as_deref(),
494                        |rec| page_records.push(rec),
495                    )?;
496                }
497
498                let record_count = page_records.len();
499                let fingerprint = page_fingerprint(&page_records);
500                pages_fetched += 1;
501
502                // Loop guard: stop when two consecutive pages are identical — a
503                // server ignoring the page/offset parameter (or clamping to the
504                // last page) returns the same non-empty page forever (#146 H4/H5).
505                // Check BEFORE buffering/yielding so the duplicate page's records
506                // are not emitted to the sink a second time (audit #321 M4).
507                if record_count > 0 && prev_fingerprint == Some(fingerprint) {
508                    tracing::warn!(
509                        "XML pagination returned an identical page; stopping to avoid an infinite loop"
510                    );
511                    break;
512                }
513                prev_fingerprint = Some(fingerprint);
514
515                for rec in page_records.drain(..) {
516                    buffer.push(rec);
517                    if buffer.len() >= chunk {
518                        let flush = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
519                        total += flush.len();
520                        yield StreamPage { records: flush, bookmark: None };
521                    }
522                }
523
524                // Advance pagination using the same rules as
525                // `fetch_all_with_context`.
526                match &self.config.pagination {
527                    Some(XmlPagination::PageNumber { page_size, .. }) => {
528                        if record_count == 0 {
529                            break;
530                        }
531                        if let Some(size) = page_size
532                            && record_count < *size
533                        {
534                            break;
535                        }
536                        page_number = page_number.map(|p| p + 1);
537                    }
538                    Some(XmlPagination::Offset { limit, .. }) => {
539                        if record_count < *limit {
540                            break;
541                        }
542                        offset += record_count;
543                    }
544                    None => break,
545                }
546            }
547
548            if !buffer.is_empty() {
549                total += buffer.len();
550                yield StreamPage { records: buffer, bookmark: None };
551            }
552
553            tracing::info!(
554                records = total,
555                pages = pages_fetched,
556                batch_size,
557                "XML source stream complete",
558            );
559        })
560    }
561
562    fn config_schema(&self) -> serde_json::Value {
563        serde_json::to_value(faucet_core::schema_for!(XmlStreamConfig))
564            .expect("schema serialization")
565    }
566
567    fn dataset_uri(&self) -> String {
568        format!(
569            "{}{}",
570            faucet_core::redact_uri_credentials(&self.config.base_url),
571            self.config.path
572        )
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use crate::config::{SoapConfig, SoapVersion};
580    use faucet_core::Source;
581
582    fn soap_response(records: &str) -> String {
583        format!(
584            "<Envelope xmlns=\"http://schemas.xmlsoap.org/soap/envelope/\"><Body>\
585             <GetUsersResponse><Users>{records}</Users></GetUsersResponse></Body></Envelope>"
586        )
587    }
588
589    #[test]
590    fn effective_path_prepends_envelope_body_by_default() {
591        let source = XmlStream::new(
592            XmlStreamConfig::new("https://s", "/svc")
593                .method(reqwest::Method::POST)
594                .records_element_path("GetUsersResponse.Users.User")
595                .with_soap(SoapConfig {
596                    body_inner: Some("<Op/>".into()),
597                    ..Default::default()
598                }),
599        );
600        assert_eq!(
601            source.effective_records_path().as_deref(),
602            Some("Envelope.Body.GetUsersResponse.Users.User")
603        );
604    }
605
606    #[test]
607    fn effective_path_absolute_override_when_not_relative() {
608        let source = XmlStream::new(
609            XmlStreamConfig::new("https://s", "/svc")
610                .method(reqwest::Method::POST)
611                .records_element_path("Envelope.Body.GetUsersResponse.Users.User")
612                .with_soap(SoapConfig {
613                    body_inner: Some("<Op/>".into()),
614                    path_relative_to_body: false,
615                    ..Default::default()
616                }),
617        );
618        assert_eq!(
619            source.effective_records_path().as_deref(),
620            Some("Envelope.Body.GetUsersResponse.Users.User")
621        );
622    }
623
624    #[test]
625    fn effective_path_unchanged_without_soap() {
626        let source = XmlStream::new(
627            XmlStreamConfig::new("https://s", "/svc").records_element_path("root.item"),
628        );
629        assert_eq!(
630            source.effective_records_path().as_deref(),
631            Some("root.item")
632        );
633    }
634
635    #[test]
636    fn extract_records_eager_resolves_relative_soap_path() {
637        let source = XmlStream::new(
638            XmlStreamConfig::new("https://s", "/svc")
639                .method(reqwest::Method::POST)
640                .records_element_path("GetUsersResponse.Users.User")
641                .with_soap(SoapConfig {
642                    body_inner: Some("<Op/>".into()),
643                    ..Default::default()
644                }),
645        );
646        let xml = soap_response("<User><Name>Alice</Name></User><User><Name>Bob</Name></User>");
647        let mut logged = false;
648        let records = source.extract_records_eager(&xml, &mut logged).unwrap();
649        assert_eq!(records.len(), 2);
650        assert_eq!(records[0]["Name"], "Alice");
651        assert_eq!(records[1]["Name"], "Bob");
652    }
653
654    #[test]
655    fn extract_records_eager_fault_as_error_raises_source_error() {
656        let source = XmlStream::new(
657            XmlStreamConfig::new("https://s", "/svc")
658                .method(reqwest::Method::POST)
659                .records_element_path("GetUsersResponse.Users.User")
660                .with_soap(SoapConfig {
661                    body_inner: Some("<Op/>".into()),
662                    ..Default::default()
663                }),
664        );
665        let xml = r#"<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/"><Body>
666            <Fault><faultcode>Server</faultcode><faultstring>kaboom</faultstring></Fault>
667        </Body></Envelope>"#;
668        let mut logged = false;
669        let err = source.extract_records_eager(xml, &mut logged).unwrap_err();
670        assert!(
671            matches!(&err, FaucetError::Source(m) if m.contains("SOAP fault") && m.contains("kaboom")),
672            "got {err:?}"
673        );
674    }
675
676    #[test]
677    fn extract_records_eager_fault_not_error_yields_zero_records() {
678        let source = XmlStream::new(
679            XmlStreamConfig::new("https://s", "/svc")
680                .method(reqwest::Method::POST)
681                .records_element_path("GetUsersResponse.Users.User")
682                .with_soap(SoapConfig {
683                    body_inner: Some("<Op/>".into()),
684                    fault_as_error: false,
685                    ..Default::default()
686                }),
687        );
688        let xml = r#"<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/"><Body>
689            <Fault><faultstring>ignored</faultstring></Fault>
690        </Body></Envelope>"#;
691        let mut logged = false;
692        let records = source.extract_records_eager(xml, &mut logged).unwrap();
693        assert!(records.is_empty());
694        assert!(logged, "fault should be recorded as logged");
695    }
696
697    #[test]
698    fn extract_records_eager_non_soap_matches_legacy_eager_path() {
699        // Regression: with no soap block, extraction is byte-for-byte the
700        // legacy xml_to_json + extract_at_path behavior.
701        let source = XmlStream::new(
702            XmlStreamConfig::new("https://s", "/svc").records_element_path("root.item"),
703        );
704        let xml = "<root><item><id>1</id></item><item><id>2</id></item></root>";
705        let mut logged = false;
706        let records = source.extract_records_eager(xml, &mut logged).unwrap();
707        let legacy = convert::extract_at_path(&convert::xml_to_json(xml).unwrap(), "root.item");
708        assert_eq!(records, legacy);
709        assert_eq!(records.len(), 2);
710    }
711
712    #[tokio::test]
713    async fn fetch_all_rejects_invalid_soap_config() {
714        // A soap block with the default GET method fails validation before any
715        // request is attempted.
716        let source = XmlStream::new(
717            XmlStreamConfig::new("https://s", "/svc").with_soap(SoapConfig::default()),
718        );
719        let err = source.fetch_all().await.unwrap_err();
720        assert!(matches!(&err, FaucetError::Config(_)), "got {err:?}");
721    }
722
723    #[test]
724    fn soap12_content_type_used_for_envelope() {
725        // Sanity: a 1.2 soap block produces the 1.2 content type.
726        let soap = SoapConfig {
727            version: SoapVersion::Soap12,
728            action: Some("urn:Op".into()),
729            ..Default::default()
730        };
731        assert!(soap.content_type().starts_with("application/soap+xml"));
732    }
733
734    #[test]
735    fn dataset_uri_combines_base_and_path() {
736        let source = XmlStream::new(XmlStreamConfig::new(
737            "https://soap.example.com",
738            "/api/v1/service",
739        ));
740        assert_eq!(
741            source.dataset_uri(),
742            "https://soap.example.com/api/v1/service"
743        );
744    }
745
746    #[test]
747    fn dataset_uri_redacts_credentials() {
748        let source = XmlStream::new(XmlStreamConfig::new(
749            "https://user:pass@soap.example.com",
750            "/svc",
751        ));
752        assert_eq!(source.dataset_uri(), "https://soap.example.com/svc");
753    }
754
755    #[test]
756    fn default_retry_policy_reproduces_legacy_constants() {
757        let source = XmlStream::new(XmlStreamConfig::new("https://soap.example.com", "/svc"));
758        assert_eq!(source.retry_policy.max_attempts, RETRY_MAX_ATTEMPTS + 1);
759        assert_eq!(source.retry_policy.base, RETRY_BASE_BACKOFF);
760    }
761
762    #[test]
763    fn with_retry_policy_overrides_the_default() {
764        let policy = faucet_core::RetryPolicy {
765            max_attempts: 9,
766            base: Duration::from_secs(7),
767            ..faucet_core::RetryPolicy::default()
768        };
769        let source = XmlStream::new(XmlStreamConfig::new("https://soap.example.com", "/svc"))
770            .with_retry_policy(policy);
771        assert_eq!(source.retry_policy.max_attempts, 9);
772        assert_eq!(source.retry_policy.base, Duration::from_secs(7));
773    }
774}