1use 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
15fn page_fingerprint(records: &[Value]) -> u64 {
21 use std::hash::{Hash, Hasher};
22 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
31const RETRY_MAX_ATTEMPTS: u32 = 3;
33const RETRY_BASE_BACKOFF: Duration = Duration::from_millis(500);
35
36pub struct XmlStream {
38 config: XmlStreamConfig,
39 client: Client,
40 auth_provider: Option<SharedAuthProvider>,
45 retry_policy: faucet_core::RetryPolicy,
49}
50
51fn 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 pub fn new(config: XmlStreamConfig) -> Self {
69 Self {
70 config,
71 client: Client::new(),
72 auth_provider: None,
73 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 pub fn with_retry_policy(mut self, policy: faucet_core::RetryPolicy) -> Self {
92 self.retry_policy = policy;
93 self
94 }
95
96 pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
103 self.auth_provider = Some(provider);
104 self
105 }
106
107 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 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 pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
164 self.fetch_all_with_context(&HashMap::new()).await
165 }
166
167 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 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(¶ms, 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 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 match &self.config.pagination {
220 Some(XmlPagination::PageNumber { page_size, .. }) => {
221 if record_count == 0 {
222 break;
223 }
224 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 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 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 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 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 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 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(¶ms, &owned_context).await?;
474
475 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 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 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 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 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 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}