1use 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
12#[serde(tag = "type", content = "config", rename_all = "snake_case")]
13pub enum XmlAuth {
14 None,
16 Bearer { token: String },
18 Basic { username: String, password: String },
20 Custom { headers: HashMap<String, String> },
22}
23
24fn default_true() -> bool {
25 true
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
33pub enum SoapVersion {
34 #[default]
37 #[serde(rename = "1.1")]
38 Soap11,
39 #[serde(rename = "1.2")]
42 Soap12,
43}
44
45impl SoapVersion {
46 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
63pub struct SoapConfig {
64 #[serde(default)]
66 pub version: SoapVersion,
67 pub action: Option<String>,
71 pub body_inner: Option<String>,
75 #[serde(default)]
79 pub namespaces: HashMap<String, String>,
80 #[serde(default = "default_true")]
85 pub path_relative_to_body: bool,
86 #[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 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 .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 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 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
158#[serde(tag = "type")]
159pub enum XmlPagination {
160 PageNumber {
162 param_name: String,
163 start_page: usize,
164 page_size: Option<usize>,
165 page_size_param: Option<String>,
166 },
167 Offset {
169 offset_param: String,
170 limit_param: String,
171 limit: usize,
172 },
173 BodyCursor {
180 next_token_path: String,
182 next_body: String,
185 },
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
190pub struct XmlStreamConfig {
191 pub base_url: String,
193 pub path: String,
195 #[serde(with = "crate::serde_helpers::http_method")]
197 #[schemars(with = "String")]
198 pub method: reqwest::Method,
199 pub auth: AuthSpec<XmlAuth>,
202 #[serde(skip, default)]
204 pub headers: HeaderMap,
205 pub body: Option<String>,
208 #[serde(default)]
213 pub soap: Option<SoapConfig>,
214 pub records_element_path: Option<String>,
217 pub pagination: Option<XmlPagination>,
219 pub max_pages: Option<usize>,
221 pub query_params: std::collections::HashMap<String, String>,
223 #[serde(default, skip_serializing_if = "Vec::is_empty")]
228 pub decode: Vec<DecodeStep>,
229 #[serde(default = "default_batch_size")]
239 pub batch_size: usize,
240 #[serde(default)]
244 pub tls: Option<TlsClientConfig>,
245}
246
247fn default_batch_size() -> usize {
248 DEFAULT_BATCH_SIZE
249}
250
251impl XmlStreamConfig {
252 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 pub fn decode(mut self, steps: Vec<DecodeStep>) -> Self {
274 self.decode = steps;
275 self
276 }
277
278 pub fn tls(mut self, tls: TlsClientConfig) -> Self {
281 self.tls = Some(tls);
282 self
283 }
284
285 pub fn method(mut self, method: reqwest::Method) -> Self {
287 self.method = method;
288 self
289 }
290
291 pub fn auth(mut self, auth: XmlAuth) -> Self {
293 self.auth = AuthSpec::Inline(auth);
294 self
295 }
296
297 pub fn headers(mut self, headers: HeaderMap) -> Self {
299 self.headers = headers;
300 self
301 }
302
303 pub fn body(mut self, body: impl Into<String>) -> Self {
305 self.body = Some(body.into());
306 self
307 }
308
309 pub fn with_soap(mut self, soap: SoapConfig) -> Self {
311 self.soap = Some(soap);
312 self
313 }
314
315 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 pub fn pagination(mut self, pagination: XmlPagination) -> Self {
323 self.pagination = Some(pagination);
324 self
325 }
326
327 pub fn max_pages(mut self, max: usize) -> Self {
329 self.max_pages = Some(max);
330 self
331 }
332
333 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 pub fn with_batch_size(mut self, batch_size: usize) -> Self {
345 self.batch_size = batch_size;
346 self
347 }
348
349 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 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 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 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 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 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}