use faucet_core::{AuthSpec, DEFAULT_BATCH_SIZE, FaucetError};
use reqwest::header::HeaderMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", content = "config", rename_all = "snake_case")]
pub enum XmlAuth {
None,
Bearer { token: String },
Basic { username: String, password: String },
Custom { headers: HashMap<String, String> },
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
pub enum SoapVersion {
#[default]
#[serde(rename = "1.1")]
Soap11,
#[serde(rename = "1.2")]
Soap12,
}
impl SoapVersion {
pub fn namespace(self) -> &'static str {
match self {
SoapVersion::Soap11 => "http://schemas.xmlsoap.org/soap/envelope/",
SoapVersion::Soap12 => "http://www.w3.org/2003/05/soap-envelope",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SoapConfig {
#[serde(default)]
pub version: SoapVersion,
pub action: Option<String>,
pub body_inner: Option<String>,
#[serde(default)]
pub namespaces: HashMap<String, String>,
#[serde(default = "default_true")]
pub path_relative_to_body: bool,
#[serde(default = "default_true")]
pub fault_as_error: bool,
}
impl Default for SoapConfig {
fn default() -> Self {
Self {
version: SoapVersion::default(),
action: None,
body_inner: None,
namespaces: HashMap::new(),
path_relative_to_body: true,
fault_as_error: true,
}
}
}
impl SoapConfig {
pub fn build_envelope(&self, body_inner: &str) -> String {
let mut attrs = format!(" xmlns:soap=\"{}\"", self.version.namespace());
let mut prefixes: Vec<(&String, &String)> = self
.namespaces
.iter()
.filter(|(prefix, _)| prefix.as_str() != "soap")
.collect();
prefixes.sort_by(|a, b| a.0.cmp(b.0));
for (prefix, uri) in prefixes {
attrs.push_str(&format!(" xmlns:{prefix}=\"{uri}\""));
}
format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\
<soap:Envelope{attrs}><soap:Body>{body_inner}</soap:Body></soap:Envelope>"
)
}
pub fn content_type(&self) -> String {
match self.version {
SoapVersion::Soap11 => "text/xml; charset=utf-8".to_string(),
SoapVersion::Soap12 => match &self.action {
Some(action) => {
format!("application/soap+xml; charset=utf-8; action=\"{action}\"")
}
None => "application/soap+xml; charset=utf-8".to_string(),
},
}
}
pub fn soap_action_header(&self) -> Option<String> {
match self.version {
SoapVersion::Soap11 => self.action.as_ref().map(|action| format!("\"{action}\"")),
SoapVersion::Soap12 => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type")]
pub enum XmlPagination {
PageNumber {
param_name: String,
start_page: usize,
page_size: Option<usize>,
page_size_param: Option<String>,
},
Offset {
offset_param: String,
limit_param: String,
limit: usize,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct XmlStreamConfig {
pub base_url: String,
pub path: String,
#[serde(with = "crate::serde_helpers::http_method")]
#[schemars(with = "String")]
pub method: reqwest::Method,
pub auth: AuthSpec<XmlAuth>,
#[serde(skip, default)]
pub headers: HeaderMap,
pub body: Option<String>,
#[serde(default)]
pub soap: Option<SoapConfig>,
pub records_element_path: Option<String>,
pub pagination: Option<XmlPagination>,
pub max_pages: Option<usize>,
pub query_params: std::collections::HashMap<String, String>,
#[serde(default = "default_batch_size")]
pub batch_size: usize,
}
fn default_batch_size() -> usize {
DEFAULT_BATCH_SIZE
}
impl XmlStreamConfig {
pub fn new(base_url: impl Into<String>, path: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
path: path.into(),
method: reqwest::Method::GET,
auth: AuthSpec::Inline(XmlAuth::None),
headers: HeaderMap::new(),
body: None,
soap: None,
records_element_path: None,
pagination: None,
max_pages: None,
query_params: std::collections::HashMap::new(),
batch_size: DEFAULT_BATCH_SIZE,
}
}
pub fn method(mut self, method: reqwest::Method) -> Self {
self.method = method;
self
}
pub fn auth(mut self, auth: XmlAuth) -> Self {
self.auth = AuthSpec::Inline(auth);
self
}
pub fn headers(mut self, headers: HeaderMap) -> Self {
self.headers = headers;
self
}
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
pub fn with_soap(mut self, soap: SoapConfig) -> Self {
self.soap = Some(soap);
self
}
pub fn records_element_path(mut self, path: impl Into<String>) -> Self {
self.records_element_path = Some(path.into());
self
}
pub fn pagination(mut self, pagination: XmlPagination) -> Self {
self.pagination = Some(pagination);
self
}
pub fn max_pages(mut self, max: usize) -> Self {
self.max_pages = Some(max);
self
}
pub fn query_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.query_params.insert(key.into(), value.into());
self
}
pub fn with_batch_size(mut self, batch_size: usize) -> Self {
self.batch_size = batch_size;
self
}
pub fn validate(&self) -> Result<(), FaucetError> {
if let Some(soap) = &self.soap {
if self.body.is_some() && soap.body_inner.is_some() {
return Err(FaucetError::Config(
"xml: set either the top-level `body` or `soap.body_inner`, not both \
(ambiguous request body)"
.into(),
));
}
if self.method == reqwest::Method::GET {
return Err(FaucetError::Config(
"xml: a `soap` block requires `method: POST` — SOAP is a POST protocol".into(),
));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config() {
let config = XmlStreamConfig::new("https://api.example.com", "/users");
assert_eq!(config.base_url, "https://api.example.com");
assert_eq!(config.path, "/users");
assert_eq!(config.method, reqwest::Method::GET);
assert!(config.records_element_path.is_none());
}
#[test]
fn soap_config() {
let config = XmlStreamConfig::new("https://api.example.com", "/soap")
.method(reqwest::Method::POST)
.body("<Envelope><Body><GetUsers/></Body></Envelope>")
.records_element_path("Envelope.Body.GetUsersResponse.Users.User");
assert_eq!(config.method, reqwest::Method::POST);
assert!(config.body.is_some());
assert_eq!(
config.records_element_path.unwrap(),
"Envelope.Body.GetUsersResponse.Users.User"
);
}
#[test]
fn batch_size_defaults_to_default_batch_size() {
let config = XmlStreamConfig::new("https://api.example.com", "/users");
assert_eq!(config.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
}
#[test]
fn with_batch_size_overrides_default() {
let config = XmlStreamConfig::new("https://api.example.com", "/users").with_batch_size(500);
assert_eq!(config.batch_size, 500);
}
#[test]
fn batch_size_zero_is_accepted_as_no_batching_sentinel() {
let config = XmlStreamConfig::new("https://api.example.com", "/users").with_batch_size(0);
assert_eq!(config.batch_size, 0);
assert!(faucet_core::validate_batch_size(config.batch_size).is_ok());
}
#[test]
fn batch_size_above_max_is_rejected_by_validate_batch_size() {
let config = XmlStreamConfig::new("https://api.example.com", "/users")
.with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
assert!(faucet_core::validate_batch_size(config.batch_size).is_err());
}
#[test]
fn batch_size_deserializes_from_json() {
let json = r#"{
"base_url": "https://api.example.com",
"path": "/users.xml",
"method": "GET",
"auth": { "type": "none" },
"body": null,
"records_element_path": "root.user",
"pagination": null,
"max_pages": null,
"query_params": {},
"batch_size": 250
}"#;
let config: XmlStreamConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.batch_size, 250);
}
#[test]
fn soap_version_deserializes_from_wire_strings() {
assert_eq!(
serde_json::from_str::<SoapVersion>("\"1.1\"").unwrap(),
SoapVersion::Soap11
);
assert_eq!(
serde_json::from_str::<SoapVersion>("\"1.2\"").unwrap(),
SoapVersion::Soap12
);
assert_eq!(SoapVersion::default(), SoapVersion::Soap11);
}
#[test]
fn soap_version_serializes_to_wire_strings() {
assert_eq!(
serde_json::to_string(&SoapVersion::Soap11).unwrap(),
"\"1.1\""
);
assert_eq!(
serde_json::to_string(&SoapVersion::Soap12).unwrap(),
"\"1.2\""
);
}
#[test]
fn soap_version_namespaces() {
assert_eq!(
SoapVersion::Soap11.namespace(),
"http://schemas.xmlsoap.org/soap/envelope/"
);
assert_eq!(
SoapVersion::Soap12.namespace(),
"http://www.w3.org/2003/05/soap-envelope"
);
}
#[test]
fn soap_config_defaults_are_body_relative_and_fault_as_error() {
let soap = SoapConfig::default();
assert_eq!(soap.version, SoapVersion::Soap11);
assert!(soap.path_relative_to_body);
assert!(soap.fault_as_error);
assert!(soap.action.is_none());
assert!(soap.body_inner.is_none());
}
#[test]
fn soap_config_deserializes_defaults_from_minimal_json() {
let soap: SoapConfig = serde_json::from_str("{}").unwrap();
assert_eq!(soap.version, SoapVersion::Soap11);
assert!(soap.path_relative_to_body);
assert!(soap.fault_as_error);
}
#[test]
fn build_envelope_soap11() {
let soap = SoapConfig {
version: SoapVersion::Soap11,
body_inner: Some("<GetUsers xmlns=\"urn:example\"/>".into()),
..Default::default()
};
let env = soap.build_envelope(soap.body_inner.as_deref().unwrap());
assert!(
env.contains("xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\""),
"got {env}"
);
assert!(env.contains("<soap:Envelope"));
assert!(env.contains("<soap:Body><GetUsers xmlns=\"urn:example\"/></soap:Body>"));
assert!(env.trim_end().ends_with("</soap:Envelope>"));
}
#[test]
fn build_envelope_soap12() {
let soap = SoapConfig {
version: SoapVersion::Soap12,
..Default::default()
};
let env = soap.build_envelope("<Op/>");
assert!(
env.contains("xmlns:soap=\"http://www.w3.org/2003/05/soap-envelope\""),
"got {env}"
);
assert!(env.contains("<soap:Body><Op/></soap:Body>"));
}
#[test]
fn build_envelope_declares_extra_namespaces_sorted() {
let mut namespaces = HashMap::new();
namespaces.insert("b".to_string(), "urn:b".to_string());
namespaces.insert("a".to_string(), "urn:a".to_string());
namespaces.insert("soap".to_string(), "urn:should-be-ignored".to_string());
let soap = SoapConfig {
namespaces,
..Default::default()
};
let env = soap.build_envelope("<Op/>");
let idx_soap = env.find("xmlns:soap=").unwrap();
let idx_a = env.find("xmlns:a=\"urn:a\"").unwrap();
let idx_b = env.find("xmlns:b=\"urn:b\"").unwrap();
assert!(idx_soap < idx_a && idx_a < idx_b, "got {env}");
assert!(!env.contains("urn:should-be-ignored"), "got {env}");
}
#[test]
fn soap11_content_type_and_action_header() {
let soap = SoapConfig {
version: SoapVersion::Soap11,
action: Some("urn:GetUsers".into()),
..Default::default()
};
assert_eq!(soap.content_type(), "text/xml; charset=utf-8");
assert_eq!(
soap.soap_action_header().as_deref(),
Some("\"urn:GetUsers\"")
);
}
#[test]
fn soap11_without_action_has_no_soap_action_header() {
let soap = SoapConfig {
version: SoapVersion::Soap11,
action: None,
..Default::default()
};
assert_eq!(soap.content_type(), "text/xml; charset=utf-8");
assert!(soap.soap_action_header().is_none());
}
#[test]
fn soap12_content_type_carries_action_and_has_no_soap_action_header() {
let soap = SoapConfig {
version: SoapVersion::Soap12,
action: Some("urn:GetUsers".into()),
..Default::default()
};
assert_eq!(
soap.content_type(),
"application/soap+xml; charset=utf-8; action=\"urn:GetUsers\""
);
assert!(
soap.soap_action_header().is_none(),
"SOAP 1.2 never sets a SOAPAction header"
);
}
#[test]
fn soap12_content_type_without_action() {
let soap = SoapConfig {
version: SoapVersion::Soap12,
action: None,
..Default::default()
};
assert_eq!(soap.content_type(), "application/soap+xml; charset=utf-8");
}
#[test]
fn validate_ok_without_soap_block() {
let config = XmlStreamConfig::new("https://api.example.com", "/svc");
assert!(config.validate().is_ok());
}
#[test]
fn validate_rejects_body_and_body_inner_both_set() {
let config = XmlStreamConfig::new("https://api.example.com", "/svc")
.method(reqwest::Method::POST)
.body("<Envelope/>")
.with_soap(SoapConfig {
body_inner: Some("<Op/>".into()),
..Default::default()
});
let err = config.validate().unwrap_err();
assert!(
matches!(&err, FaucetError::Config(m) if m.contains("not both")),
"got {err:?}"
);
}
#[test]
fn validate_rejects_soap_with_get_method() {
let config = XmlStreamConfig::new("https://api.example.com", "/svc")
.with_soap(SoapConfig::default());
let err = config.validate().unwrap_err();
assert!(
matches!(&err, FaucetError::Config(m) if m.contains("POST")),
"got {err:?}"
);
}
#[test]
fn validate_ok_with_soap_and_post() {
let config = XmlStreamConfig::new("https://api.example.com", "/svc")
.method(reqwest::Method::POST)
.with_soap(SoapConfig {
body_inner: Some("<Op/>".into()),
..Default::default()
});
assert!(config.validate().is_ok());
}
#[test]
fn with_soap_sets_the_block() {
let config = XmlStreamConfig::new("https://api.example.com", "/svc")
.method(reqwest::Method::POST)
.with_soap(SoapConfig {
action: Some("urn:Op".into()),
..Default::default()
});
assert_eq!(config.soap.unwrap().action.as_deref(), Some("urn:Op"));
}
#[test]
fn soap_absent_by_default_and_deserializes_from_config_without_soap() {
let json = r#"{
"base_url": "https://api.example.com",
"path": "/users.xml",
"method": "GET",
"auth": { "type": "none" },
"body": null,
"records_element_path": "root.user",
"pagination": null,
"max_pages": null,
"query_params": {}
}"#;
let config: XmlStreamConfig = serde_json::from_str(json).unwrap();
assert!(config.soap.is_none());
}
#[test]
fn batch_size_defaults_when_missing_from_json() {
let json = r#"{
"base_url": "https://api.example.com",
"path": "/users.xml",
"method": "GET",
"auth": { "type": "none" },
"body": null,
"records_element_path": null,
"pagination": null,
"max_pages": null,
"query_params": {}
}"#;
let config: XmlStreamConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
}
}