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 pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
109 self.fetch_all_with_context(&HashMap::new()).await
110 }
111
112 async fn fetch_all_with_context(
114 &self,
115 context: &HashMap<String, serde_json::Value>,
116 ) -> Result<Vec<Value>, FaucetError> {
117 let mut all_records = Vec::new();
118 let mut pages_fetched = 0usize;
119 let mut offset = 0usize;
120 let mut page_number = None;
121 let mut prev_fingerprint: Option<u64> = None;
122
123 if let Some(XmlPagination::PageNumber { start_page, .. }) = &self.config.pagination {
125 page_number = Some(*start_page);
126 }
127
128 loop {
129 if let Some(max) = self.config.max_pages
130 && pages_fetched >= max
131 {
132 tracing::warn!("max pages ({max}) reached");
133 break;
134 }
135
136 let mut params = self.config.query_params.clone();
137 self.apply_pagination_params(&mut params, page_number, offset);
138
139 let xml_text = self.execute_request(¶ms, context).await?;
140 let json = convert::xml_to_json(&xml_text)?;
141
142 let records = match &self.config.records_element_path {
143 Some(path) => convert::extract_at_path(&json, path),
144 None => vec![json],
145 };
146
147 let record_count = records.len();
148 let fingerprint = page_fingerprint(&records);
149 all_records.extend(records);
150 pages_fetched += 1;
151
152 if record_count > 0 && prev_fingerprint == Some(fingerprint) {
156 tracing::warn!(
157 "XML pagination returned an identical page; stopping to avoid an infinite loop"
158 );
159 break;
160 }
161 prev_fingerprint = Some(fingerprint);
162
163 match &self.config.pagination {
165 Some(XmlPagination::PageNumber { page_size, .. }) => {
166 if record_count == 0 {
167 break;
168 }
169 if let Some(size) = page_size
171 && record_count < *size
172 {
173 break;
174 }
175 page_number = page_number.map(|p| p + 1);
176 }
177 Some(XmlPagination::Offset { limit, .. }) => {
178 if record_count < *limit {
179 break;
180 }
181 offset += record_count;
182 }
183 None => break,
184 }
185 }
186
187 tracing::info!(
188 records = all_records.len(),
189 pages = pages_fetched,
190 "XML fetch complete"
191 );
192 Ok(all_records)
193 }
194
195 fn apply_pagination_params(
196 &self,
197 params: &mut HashMap<String, String>,
198 page_number: Option<usize>,
199 offset: usize,
200 ) {
201 match &self.config.pagination {
202 Some(XmlPagination::PageNumber {
203 param_name,
204 page_size,
205 page_size_param,
206 ..
207 }) => {
208 if let Some(page) = page_number {
209 params.insert(param_name.clone(), page.to_string());
210 }
211 if let (Some(size), Some(param)) = (page_size, page_size_param) {
212 params.insert(param.clone(), size.to_string());
213 }
214 }
215 Some(XmlPagination::Offset {
216 offset_param,
217 limit_param,
218 limit,
219 }) => {
220 params.insert(offset_param.clone(), offset.to_string());
221 params.insert(limit_param.clone(), limit.to_string());
222 }
223 None => {}
224 }
225 }
226
227 async fn execute_request(
228 &self,
229 params: &HashMap<String, String>,
230 context: &HashMap<String, serde_json::Value>,
231 ) -> Result<String, FaucetError> {
232 let path = if context.is_empty() {
233 self.config.path.clone()
234 } else {
235 faucet_core::util::substitute_context(&self.config.path, context)
236 };
237
238 let url = format!("{}/{}", self.config.base_url, path.trim_start_matches('/'));
239
240 let resolved_params: HashMap<String, String> = if context.is_empty() {
242 params.clone()
243 } else {
244 params
245 .iter()
246 .map(|(k, v)| (k.clone(), faucet_core::util::substitute_context(v, context)))
247 .collect()
248 };
249
250 let mut req = self
251 .client
252 .request(self.config.method.clone(), &url)
253 .headers(self.config.headers.clone())
254 .query(&resolved_params);
255
256 let effective_auth: XmlAuth = if let Some(provider) = &self.auth_provider {
260 credential_to_auth(provider.credential().await?)
261 } else {
262 match &self.config.auth {
263 AuthSpec::Inline(a) => a.clone(),
264 AuthSpec::Reference(r) => {
265 return Err(FaucetError::Auth(format!(
266 "auth references provider '{}' but no provider was supplied; \
267 set one via the CLI `auth:` catalog or `with_auth_provider`",
268 r.name
269 )));
270 }
271 }
272 };
273
274 match &effective_auth {
276 XmlAuth::None => {}
277 XmlAuth::Bearer { token } => {
278 req = req.bearer_auth(token);
279 }
280 XmlAuth::Basic { username, password } => {
281 req = req.basic_auth(username, Some(password));
282 }
283 XmlAuth::Custom { headers } => {
284 let mut hm = reqwest::header::HeaderMap::new();
285 for (name, value) in headers {
286 let n =
287 reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
288 FaucetError::Auth(format!("invalid custom header name {name:?}: {e}"))
289 })?;
290 let v = reqwest::header::HeaderValue::from_str(value).map_err(|e| {
291 FaucetError::Auth(format!("invalid custom header value for {name:?}: {e}"))
292 })?;
293 hm.insert(n, v);
294 }
295 req = req.headers(hm);
296 }
297 }
298
299 if let Some(body) = &self.config.body {
301 let resolved_body = if context.is_empty() {
302 body.clone()
303 } else {
304 faucet_core::util::substitute_context(body, context)
305 };
306 req = req
307 .header("Content-Type", "text/xml; charset=utf-8")
308 .body(resolved_body);
309 }
310
311 faucet_core::execute_with_policy(&self.retry_policy, None, || {
315 let attempt = req.try_clone();
316 async move {
317 let req = attempt.ok_or_else(|| {
318 FaucetError::Source("xml: request is not cloneable for retry".into())
319 })?;
320 let resp = req.send().await.map_err(FaucetError::Http)?;
321 let resp = util::check_http_response(resp, DEFAULT_ERROR_BODY_MAX_LEN).await?;
322 resp.text().await.map_err(FaucetError::Http)
323 }
324 })
325 .await
326 }
327}
328
329#[async_trait]
330impl faucet_core::Source for XmlStream {
331 async fn fetch_with_context(
332 &self,
333 context: &std::collections::HashMap<String, serde_json::Value>,
334 ) -> Result<Vec<Value>, FaucetError> {
335 self.fetch_all_with_context(context).await
336 }
337
338 fn stream_pages<'a>(
361 &'a self,
362 context: &'a HashMap<String, Value>,
363 _batch_size: usize,
364 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
365 let batch_size = self.config.batch_size;
366 let owned_context = context.clone();
367
368 Box::pin(async_stream::try_stream! {
369 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
370 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
371 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
372 let mut total = 0usize;
373 let mut pages_fetched = 0usize;
374 let mut offset = 0usize;
375 let mut page_number = None;
376 let mut prev_fingerprint: Option<u64> = None;
377
378 if let Some(XmlPagination::PageNumber { start_page, .. }) =
379 &self.config.pagination
380 {
381 page_number = Some(*start_page);
382 }
383
384 loop {
385 if let Some(max) = self.config.max_pages
386 && pages_fetched >= max
387 {
388 tracing::warn!("max pages ({max}) reached");
389 break;
390 }
391
392 let mut params = self.config.query_params.clone();
393 self.apply_pagination_params(&mut params, page_number, offset);
394
395 let xml_text = self.execute_request(¶ms, &owned_context).await?;
396
397 let mut page_records: Vec<Value> = Vec::new();
404 convert::stream_extract(
405 &xml_text,
406 self.config.records_element_path.as_deref(),
407 |rec| page_records.push(rec),
408 )?;
409
410 let record_count = page_records.len();
411 let fingerprint = page_fingerprint(&page_records);
412
413 for rec in page_records.drain(..) {
414 buffer.push(rec);
415 if buffer.len() >= chunk {
416 let flush = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
417 total += flush.len();
418 yield StreamPage { records: flush, bookmark: None };
419 }
420 }
421 pages_fetched += 1;
422
423 if record_count > 0 && prev_fingerprint == Some(fingerprint) {
427 tracing::warn!(
428 "XML pagination returned an identical page; stopping to avoid an infinite loop"
429 );
430 break;
431 }
432 prev_fingerprint = Some(fingerprint);
433
434 match &self.config.pagination {
437 Some(XmlPagination::PageNumber { page_size, .. }) => {
438 if record_count == 0 {
439 break;
440 }
441 if let Some(size) = page_size
442 && record_count < *size
443 {
444 break;
445 }
446 page_number = page_number.map(|p| p + 1);
447 }
448 Some(XmlPagination::Offset { limit, .. }) => {
449 if record_count < *limit {
450 break;
451 }
452 offset += record_count;
453 }
454 None => break,
455 }
456 }
457
458 if !buffer.is_empty() {
459 total += buffer.len();
460 yield StreamPage { records: buffer, bookmark: None };
461 }
462
463 tracing::info!(
464 records = total,
465 pages = pages_fetched,
466 batch_size,
467 "XML source stream complete",
468 );
469 })
470 }
471
472 fn config_schema(&self) -> serde_json::Value {
473 serde_json::to_value(faucet_core::schema_for!(XmlStreamConfig))
474 .expect("schema serialization")
475 }
476
477 fn dataset_uri(&self) -> String {
478 format!(
479 "{}{}",
480 faucet_core::redact_uri_credentials(&self.config.base_url),
481 self.config.path
482 )
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use faucet_core::Source;
490
491 #[test]
492 fn dataset_uri_combines_base_and_path() {
493 let source = XmlStream::new(XmlStreamConfig::new(
494 "https://soap.example.com",
495 "/api/v1/service",
496 ));
497 assert_eq!(
498 source.dataset_uri(),
499 "https://soap.example.com/api/v1/service"
500 );
501 }
502
503 #[test]
504 fn dataset_uri_redacts_credentials() {
505 let source = XmlStream::new(XmlStreamConfig::new(
506 "https://user:pass@soap.example.com",
507 "/svc",
508 ));
509 assert_eq!(source.dataset_uri(), "https://soap.example.com/svc");
510 }
511
512 #[test]
513 fn default_retry_policy_reproduces_legacy_constants() {
514 let source = XmlStream::new(XmlStreamConfig::new("https://soap.example.com", "/svc"));
515 assert_eq!(source.retry_policy.max_attempts, RETRY_MAX_ATTEMPTS + 1);
516 assert_eq!(source.retry_policy.base, RETRY_BASE_BACKOFF);
517 }
518
519 #[test]
520 fn with_retry_policy_overrides_the_default() {
521 let policy = faucet_core::RetryPolicy {
522 max_attempts: 9,
523 base: Duration::from_secs(7),
524 ..faucet_core::RetryPolicy::default()
525 };
526 let source = XmlStream::new(XmlStreamConfig::new("https://soap.example.com", "/svc"))
527 .with_retry_policy(policy);
528 assert_eq!(source.retry_policy.max_attempts, 9);
529 assert_eq!(source.retry_policy.base, Duration::from_secs(7));
530 }
531}