1use faucet_core::{AuthSpec, DEFAULT_BATCH_SIZE, FaucetError};
4use reqwest::header::HeaderMap;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
11#[serde(tag = "type", content = "config", rename_all = "snake_case")]
12pub enum XmlAuth {
13 None,
15 Bearer { token: String },
17 Basic { username: String, password: String },
19 Custom { headers: HashMap<String, String> },
21}
22
23fn default_true() -> bool {
24 true
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
32pub enum SoapVersion {
33 #[default]
36 #[serde(rename = "1.1")]
37 Soap11,
38 #[serde(rename = "1.2")]
41 Soap12,
42}
43
44impl SoapVersion {
45 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
62pub struct SoapConfig {
63 #[serde(default)]
65 pub version: SoapVersion,
66 pub action: Option<String>,
70 pub body_inner: Option<String>,
74 #[serde(default)]
78 pub namespaces: HashMap<String, String>,
79 #[serde(default = "default_true")]
84 pub path_relative_to_body: bool,
85 #[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 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 .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 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 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
157#[serde(tag = "type")]
158pub enum XmlPagination {
159 PageNumber {
161 param_name: String,
162 start_page: usize,
163 page_size: Option<usize>,
164 page_size_param: Option<String>,
165 },
166 Offset {
168 offset_param: String,
169 limit_param: String,
170 limit: usize,
171 },
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
176pub struct XmlStreamConfig {
177 pub base_url: String,
179 pub path: String,
181 #[serde(with = "crate::serde_helpers::http_method")]
183 #[schemars(with = "String")]
184 pub method: reqwest::Method,
185 pub auth: AuthSpec<XmlAuth>,
188 #[serde(skip, default)]
190 pub headers: HeaderMap,
191 pub body: Option<String>,
194 #[serde(default)]
199 pub soap: Option<SoapConfig>,
200 pub records_element_path: Option<String>,
203 pub pagination: Option<XmlPagination>,
205 pub max_pages: Option<usize>,
207 pub query_params: std::collections::HashMap<String, String>,
209 #[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 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 pub fn method(mut self, method: reqwest::Method) -> Self {
247 self.method = method;
248 self
249 }
250
251 pub fn auth(mut self, auth: XmlAuth) -> Self {
253 self.auth = AuthSpec::Inline(auth);
254 self
255 }
256
257 pub fn headers(mut self, headers: HeaderMap) -> Self {
259 self.headers = headers;
260 self
261 }
262
263 pub fn body(mut self, body: impl Into<String>) -> Self {
265 self.body = Some(body.into());
266 self
267 }
268
269 pub fn with_soap(mut self, soap: SoapConfig) -> Self {
271 self.soap = Some(soap);
272 self
273 }
274
275 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 pub fn pagination(mut self, pagination: XmlPagination) -> Self {
283 self.pagination = Some(pagination);
284 self
285 }
286
287 pub fn max_pages(mut self, max: usize) -> Self {
289 self.max_pages = Some(max);
290 self
291 }
292
293 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 pub fn with_batch_size(mut self, batch_size: usize) -> Self {
305 self.batch_size = batch_size;
306 self
307 }
308
309 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 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 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 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 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 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}