use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use axum::{
extract::{DefaultBodyLimit, MatchedPath, Query, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::post,
Router,
};
use bytes::Bytes;
use chrono::Utc;
use tokio::sync::Mutex;
use crate::dispatch::{self, DispatchError, DispatchTable};
use crate::envelope::{
detect_soap_version, parse_envelope, response_content_type, serialize_envelope,
};
use crate::fault::SoapFault;
use crate::handler::SoapHandler;
use crate::qname::QName;
use crate::wsdl::definitions::SoapVersion;
use crate::wsdl::resolver::{
resolve_wsdl, rewrite_wsdl_address, rewrite_wsdl_address_for_service, WsdlLoader,
};
use crate::wssec::{nonce_cache::RotatingNonceCache, username_token::validate_username_token};
use crate::xsd::types::TypeRegistry;
const DEFAULT_NONCE_CACHE_HALF_WINDOW_SECS: u64 = 150;
const DEFAULT_TIMESTAMP_TOLERANCE_SECS: i64 = 300;
const DEFAULT_MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
type AuthFn = Option<Arc<dyn Fn(&str) -> Option<String> + Send + Sync + 'static>>;
pub struct ServerBuilder {
wsdl_bytes: Option<Vec<u8>>,
wsdl_path: Option<std::path::PathBuf>,
custom_loader: Option<Arc<dyn WsdlLoader>>,
handlers: HashMap<String, Arc<dyn SoapHandler>>,
default_handler: Option<Arc<dyn SoapHandler>>,
auth_fn: AuthFn,
auth_bypass: HashSet<String>,
mount_path: String,
timestamp_tolerance_secs: i64,
nonce_cache_half_window_secs: u64,
max_body_bytes: usize,
}
impl ServerBuilder {
fn new() -> Self {
Self {
wsdl_bytes: None,
wsdl_path: None,
custom_loader: None,
handlers: HashMap::new(),
default_handler: None,
auth_fn: None,
auth_bypass: HashSet::new(),
mount_path: "/soap".to_string(),
timestamp_tolerance_secs: DEFAULT_TIMESTAMP_TOLERANCE_SECS,
nonce_cache_half_window_secs: DEFAULT_NONCE_CACHE_HALF_WINDOW_SECS,
max_body_bytes: DEFAULT_MAX_BODY_BYTES,
}
}
pub fn from_wsdl_file(path: impl Into<std::path::PathBuf>) -> Self {
let mut builder = Self::new();
builder.wsdl_path = Some(path.into());
builder
}
pub fn from_wsdl_bytes(bytes: impl Into<Vec<u8>>) -> Self {
let mut builder = Self::new();
builder.wsdl_bytes = Some(bytes.into());
builder
}
pub fn from_wsdl_bytes_with_loader(
bytes: impl Into<Vec<u8>>,
loader: impl WsdlLoader + 'static,
) -> Self {
let mut builder = Self::new();
builder.wsdl_bytes = Some(bytes.into());
builder.custom_loader = Some(Arc::new(loader));
builder
}
pub fn handler(mut self, operation: impl Into<String>, handler: impl SoapHandler) -> Self {
self.handlers.insert(operation.into(), Arc::new(handler));
self
}
pub fn default_handler(mut self, handler: impl SoapHandler) -> Self {
self.default_handler = Some(Arc::new(handler));
self
}
pub fn auth<F>(mut self, f: F) -> Self
where
F: Fn(&str) -> Option<String> + Send + Sync + 'static,
{
self.auth_fn = Some(Arc::new(f));
self
}
pub fn auth_bypass<I, S>(mut self, ops: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
for op in ops {
self.auth_bypass.insert(op.into());
}
self
}
pub fn path(mut self, path: impl Into<String>) -> Self {
self.mount_path = path.into();
self
}
pub fn timestamp_tolerance_secs(mut self, secs: i64) -> Self {
self.timestamp_tolerance_secs = secs;
self
}
pub fn max_body_bytes(mut self, bytes: usize) -> Self {
self.max_body_bytes = bytes;
self
}
pub fn build(self) -> Result<SoapService, BuildError> {
let (wsdl_bytes, wsdl_file_path) = match (self.wsdl_bytes, self.wsdl_path) {
(Some(bytes), _) => (bytes, None),
(None, Some(path)) => {
let bytes = std::fs::read(&path).map_err(|e| BuildError::WsdlIo(e.to_string()))?;
(bytes, Some(path))
}
(None, None) => return Err(BuildError::MissingWsdl),
};
let mut visited = HashSet::new();
let resolved = if let Some(ref loader) = self.custom_loader {
resolve_wsdl(&wsdl_bytes, loader.as_ref(), &mut visited)
.map_err(|e| BuildError::WsdlParse(e.to_string()))?
} else if let Some(ref path) = wsdl_file_path {
let base_dir = path
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.to_path_buf();
let loader = FileWsdlLoader { base_dir };
resolve_wsdl(&wsdl_bytes, &loader, &mut visited)
.map_err(|e| BuildError::WsdlParse(e.to_string()))?
} else {
let loader = NoOpLoader;
resolve_wsdl(&wsdl_bytes, &loader, &mut visited)
.map_err(|e| BuildError::WsdlParse(e.to_string()))?
};
let service_names: Vec<String> = resolved.definition.services.keys().cloned().collect();
let is_multi_service = service_names.len() > 1;
let (dispatch_table, service_tables, service_path_names) = if is_multi_service {
let mut service_tables: HashMap<String, Arc<DispatchTable>> = HashMap::new();
let mut service_path_names: HashMap<String, String> = HashMap::new();
let mut all_ops_in_all_services: HashSet<String> = HashSet::new();
for svc_name in &service_names {
let svc = resolved
.definition
.services
.get(svc_name)
.ok_or_else(|| BuildError::UnknownService(svc_name.clone()))?;
for port in &svc.ports {
let binding_local = &port.binding.local_name;
if let Some(binding) = resolved.definition.bindings.get(binding_local) {
for binding_op in &binding.operations {
all_ops_in_all_services.insert(binding_op.name.clone());
}
}
}
}
for handler_name in self.handlers.keys() {
if !all_ops_in_all_services.contains(handler_name) {
return Err(BuildError::UnknownOperation(handler_name.clone()));
}
}
for svc_name in &service_names {
let svc = resolved
.definition
.services
.get(svc_name)
.ok_or_else(|| BuildError::UnknownService(svc_name.clone()))?;
let mut svc_op_names: Vec<String> = Vec::new();
for port in &svc.ports {
let binding_local = &port.binding.local_name;
if let Some(binding) = resolved.definition.bindings.get(binding_local) {
for binding_op in &binding.operations {
svc_op_names.push(binding_op.name.clone());
}
}
}
let mut svc_handlers: HashMap<String, Arc<dyn SoapHandler>> = HashMap::new();
for op_name in &svc_op_names {
if let Some(h) = self.handlers.get(op_name) {
svc_handlers.insert(op_name.clone(), h.clone());
}
}
let table = dispatch::build_dispatch_table_for_service(
svc_name,
&resolved,
svc_handlers,
&self.auth_bypass,
self.default_handler.clone(),
)
.map_err(|e| match e {
DispatchError::UnregisteredOperation(op) => {
BuildError::UnregisteredOperation(op)
}
DispatchError::UnknownOperation(op) => BuildError::UnknownOperation(op),
DispatchError::UnresolvableInputType { op, element, type_ref } => {
BuildError::WsdlParse(format!(
"Operation '{op}' input element '{element}' references unresolvable type '{type_ref}'"
))
}
})?;
let path = svc
.ports
.first()
.map(|p| extract_path_from_url(&p.address))
.unwrap_or_else(|| format!("/{}", svc_name.to_lowercase()));
service_path_names.insert(path.clone(), svc_name.clone());
service_tables.insert(path, Arc::new(table));
}
let first_table = service_tables
.values()
.next()
.cloned()
.unwrap_or_else(|| Arc::new(DispatchTable::empty()));
(first_table, service_tables, service_path_names)
} else {
let table = dispatch::build_dispatch_table(
&resolved,
self.handlers,
&self.auth_bypass,
self.default_handler,
)
.map_err(|e| match e {
DispatchError::UnregisteredOperation(op) => BuildError::UnregisteredOperation(op),
DispatchError::UnknownOperation(op) => BuildError::UnknownOperation(op),
DispatchError::UnresolvableInputType { op, element, type_ref } => {
BuildError::WsdlParse(format!(
"Operation '{op}' input element '{element}' references unresolvable type '{type_ref}'"
))
}
})?;
(Arc::new(table), HashMap::new(), HashMap::new())
};
let type_registry = Arc::new(resolved.type_registry);
Ok(SoapService {
dispatch_table,
service_tables,
service_path_names,
type_registry,
wsdl_raw: Arc::new(wsdl_bytes),
auth_fn: self.auth_fn,
nonce_cache: Arc::new(Mutex::new(RotatingNonceCache::new(
self.nonce_cache_half_window_secs,
))),
timestamp_tolerance_secs: self.timestamp_tolerance_secs,
mount_path: self.mount_path,
max_body_bytes: self.max_body_bytes,
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum BuildError {
#[error("No WSDL source provided — call from_wsdl_bytes() or from_wsdl_file()")]
MissingWsdl,
#[error("Failed to read WSDL file: {0}")]
WsdlIo(String),
#[error("Failed to parse or resolve WSDL: {0}")]
WsdlParse(String),
#[error("WSDL operation '{0}' has no registered handler")]
UnregisteredOperation(String),
#[error("Registered handler '{0}' has no matching WSDL operation")]
UnknownOperation(String),
#[error("WSDL service '{0}' not found in resolved definition")]
UnknownService(String),
}
struct NoOpLoader;
impl WsdlLoader for NoOpLoader {
fn load(&self, location: &str) -> Result<Vec<u8>, crate::wsdl::parser::WsdlError> {
Err(crate::wsdl::parser::WsdlError::MalformedXml(format!(
"External WSDL import '{location}' not supported in embedded mode"
)))
}
}
pub struct FileWsdlLoader {
base_dir: std::path::PathBuf,
}
impl WsdlLoader for FileWsdlLoader {
fn load(&self, location: &str) -> Result<Vec<u8>, crate::wsdl::parser::WsdlError> {
let raw_path = self.base_dir.join(location);
let path = normalize_path(&raw_path);
std::fs::read(&path).map_err(|e| {
crate::wsdl::parser::WsdlError::MalformedXml(format!(
"Failed to load WSDL import '{location}' from '{}': {e}",
path.display()
))
})
}
}
fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
use std::path::Component;
let mut normalized = std::path::PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
normalized.pop();
}
Component::CurDir => {}
other => {
normalized.push(other);
}
}
}
normalized
}
fn extract_path_from_url(url: &str) -> String {
if let Some(after_scheme) = url.split_once("://").map(|x| x.1) {
if let Some(slash_pos) = after_scheme.find('/') {
return after_scheme[slash_pos..].to_string();
}
return "/".to_string();
}
if url.starts_with('/') {
url.to_string()
} else {
format!("/{url}")
}
}
pub struct SoapService {
dispatch_table: Arc<DispatchTable>,
service_tables: HashMap<String, Arc<DispatchTable>>,
service_path_names: HashMap<String, String>,
type_registry: Arc<TypeRegistry>,
wsdl_raw: Arc<Vec<u8>>,
auth_fn: AuthFn,
nonce_cache: Arc<Mutex<RotatingNonceCache>>,
timestamp_tolerance_secs: i64,
mount_path: String,
max_body_bytes: usize,
}
impl std::fmt::Debug for SoapService {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SoapService")
.field("mount_path", &self.mount_path)
.finish_non_exhaustive()
}
}
impl SoapService {
pub fn into_router(self) -> Router {
let max_body = self.max_body_bytes;
if !self.service_tables.is_empty() {
let state = Arc::new(self);
let mut router = Router::new();
let routes: Vec<(String, Arc<DispatchTable>, String)> = state
.service_tables
.iter()
.map(|(path, table)| {
let svc_name = state
.service_path_names
.get(path)
.cloned()
.unwrap_or_default();
(path.clone(), table.clone(), svc_name)
})
.collect();
for (path, table, service_name) in routes {
let route_state = SoapServiceRoute {
svc: state.clone(),
table,
service_name,
};
router = router.route(
&path,
post(soap_post_handler_for_route)
.get(wsdl_get_handler_for_route)
.with_state(route_state),
);
}
router.layer(DefaultBodyLimit::max(max_body))
} else {
let mount_path = self.mount_path.clone();
let state = Arc::new(self);
Router::new()
.route(&mount_path, post(soap_post_handler).get(wsdl_get_handler))
.with_state(state)
.layer(DefaultBodyLimit::max(max_body))
}
}
}
#[derive(Clone)]
struct SoapServiceRoute {
svc: Arc<SoapService>,
table: Arc<DispatchTable>,
service_name: String,
}
fn fault_response(fault: SoapFault, version: crate::wsdl::definitions::SoapVersion) -> Response {
let bytes = fault.to_xml_bytes_versioned(&version);
let ct = response_content_type(&version);
(
StatusCode::INTERNAL_SERVER_ERROR,
[("Content-Type", ct)],
bytes,
)
.into_response()
}
fn extract_body_qname(body_bytes: &[u8]) -> Result<QName, SoapFault> {
use quick_xml::events::Event;
use quick_xml::NsReader;
let mut reader = NsReader::from_reader(body_bytes);
reader.config_mut().trim_text(true);
loop {
match reader
.read_resolved_event()
.map_err(|e| SoapFault::sender(format!("XML parse error in body: {e}")))?
{
(_, Event::Eof) => {
return Err(SoapFault::sender("Empty SOAP Body element"));
}
(resolved_ns, Event::Start(e)) | (resolved_ns, Event::Empty(e)) => {
let local = std::str::from_utf8(e.local_name().as_ref())
.map_err(|e| SoapFault::sender(format!("Invalid UTF-8 in element name: {e}")))?
.to_string();
let ns = match resolved_ns {
quick_xml::name::ResolveResult::Bound(ns) => std::str::from_utf8(ns.0)
.map_err(|e| SoapFault::sender(format!("Invalid UTF-8 in namespace: {e}")))?
.to_string(),
_ => String::new(),
};
if ns.is_empty() {
return Ok(QName::local(&local));
} else {
return Ok(QName::new(&ns, &local));
}
}
_ => {}
}
}
}
const WSSE_NS: &str =
"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd";
fn find_security_header(header_children: &[Bytes]) -> Option<&Bytes> {
use quick_xml::events::Event;
use quick_xml::NsReader;
for child in header_children {
let mut reader = NsReader::from_reader(child.as_ref());
reader.config_mut().trim_text(true);
loop {
match reader.read_resolved_event() {
Ok((resolved_ns, Event::Start(e))) | Ok((resolved_ns, Event::Empty(e))) => {
let local = e.local_name();
let local_str = std::str::from_utf8(local.as_ref()).unwrap_or("");
if local_str == "Security" {
if let quick_xml::name::ResolveResult::Bound(ns) = resolved_ns {
if std::str::from_utf8(ns.0).unwrap_or("") == WSSE_NS {
return Some(child);
}
}
}
break; }
Ok((_, Event::Eof)) | Err(_) => break,
_ => {}
}
}
}
None
}
async fn soap_post_handler(
State(svc): State<Arc<SoapService>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let soap_version = match detect_soap_version(content_type) {
Ok(v) => v,
Err(fault) => return fault_response(fault, crate::wsdl::definitions::SoapVersion::Soap12),
};
let envelope = match parse_envelope(&body) {
Ok(e) => e,
Err(fault) => return fault_response(fault, soap_version),
};
let body_qname = match extract_body_qname(&envelope.body_element) {
Ok(q) => q,
Err(fault) => return fault_response(fault, envelope.soap_version.clone()),
};
let soap_action = headers
.get("soapaction")
.or_else(|| headers.get("SOAPAction"))
.and_then(|v| v.to_str().ok())
.map(|s| s.trim_matches('"'));
let entry = match dispatch::route(&svc.dispatch_table, &body_qname, soap_action) {
Ok(e) => e,
Err(fault) => return fault_response(fault, envelope.soap_version.clone()),
};
if entry.auth_required && svc.auth_fn.is_some() {
match find_security_header(&envelope.header_children) {
None => {
return fault_response(
SoapFault::sender("WS-Security header required but not provided"),
envelope.soap_version.clone(),
);
}
Some(security_bytes) => {
let auth_fn = match &svc.auth_fn {
Some(f) => f.clone(),
None => {
return fault_response(
SoapFault::sender(
"Authentication required but no credential store configured",
),
envelope.soap_version.clone(),
);
}
};
let mut nonce_cache = svc.nonce_cache.lock().await;
let now = Utc::now();
if let Err(fault) = validate_username_token(
security_bytes,
auth_fn.as_ref(),
&mut nonce_cache,
svc.timestamp_tolerance_secs,
now,
) {
return fault_response(fault, envelope.soap_version.clone());
}
}
}
}
if let Err(fault) = dispatch::validate_request(
&envelope.body_element,
&svc.type_registry,
entry.validation_type.as_ref(),
) {
return fault_response(fault, envelope.soap_version.clone());
}
let response_body = match entry
.handler
.handle_with_headers(envelope.body_element, &envelope.header_children)
.await
{
Ok(bytes) => bytes,
Err(fault) => return fault_response(fault, envelope.soap_version.clone()),
};
let response_version = envelope.soap_version.clone();
let envelope_bytes = serialize_envelope(response_body, response_version.clone());
let content_type_value = response_content_type(&response_version);
(
StatusCode::OK,
[("Content-Type", content_type_value)],
envelope_bytes,
)
.into_response()
}
async fn soap_post_handler_for_route(
State(route_state): State<SoapServiceRoute>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let svc = &route_state.svc;
let table = &route_state.table;
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let soap_version = match detect_soap_version(content_type) {
Ok(v) => v,
Err(fault) => return fault_response(fault, SoapVersion::Soap12),
};
let envelope = match parse_envelope(&body) {
Ok(e) => e,
Err(fault) => return fault_response(fault, soap_version),
};
let body_qname = match extract_body_qname(&envelope.body_element) {
Ok(q) => q,
Err(fault) => return fault_response(fault, envelope.soap_version.clone()),
};
let soap_action = headers
.get("soapaction")
.or_else(|| headers.get("SOAPAction"))
.and_then(|v| v.to_str().ok())
.map(|s| s.trim_matches('"'));
let entry = match dispatch::route(table, &body_qname, soap_action) {
Ok(e) => e,
Err(fault) => return fault_response(fault, envelope.soap_version.clone()),
};
if entry.auth_required && svc.auth_fn.is_some() {
match find_security_header(&envelope.header_children) {
None => {
return fault_response(
SoapFault::sender("WS-Security header required but not provided"),
envelope.soap_version.clone(),
);
}
Some(security_bytes) => {
let auth_fn = match &svc.auth_fn {
Some(f) => f.clone(),
None => {
return fault_response(
SoapFault::sender(
"Authentication required but no credential store configured",
),
envelope.soap_version.clone(),
);
}
};
let mut nonce_cache = svc.nonce_cache.lock().await;
let now = Utc::now();
if let Err(fault) = validate_username_token(
security_bytes,
auth_fn.as_ref(),
&mut nonce_cache,
svc.timestamp_tolerance_secs,
now,
) {
return fault_response(fault, envelope.soap_version.clone());
}
}
}
}
if let Err(fault) = dispatch::validate_request(
&envelope.body_element,
&svc.type_registry,
entry.validation_type.as_ref(),
) {
return fault_response(fault, envelope.soap_version.clone());
}
let response_body = match entry
.handler
.handle_with_headers(envelope.body_element, &envelope.header_children)
.await
{
Ok(bytes) => bytes,
Err(fault) => return fault_response(fault, envelope.soap_version.clone()),
};
let response_version = envelope.soap_version.clone();
let envelope_bytes = serialize_envelope(response_body, response_version.clone());
let content_type_value = response_content_type(&response_version);
(
StatusCode::OK,
[("Content-Type", content_type_value)],
envelope_bytes,
)
.into_response()
}
#[derive(serde::Deserialize)]
struct WsdlQuery {
wsdl: Option<String>,
}
async fn wsdl_get_handler(
matched_path: Option<MatchedPath>,
State(svc): State<Arc<SoapService>>,
Query(params): Query<WsdlQuery>,
headers: HeaderMap,
) -> Response {
if params.wsdl.is_none() {
return StatusCode::NOT_FOUND.into_response();
}
let host = headers
.get("x-forwarded-host")
.or_else(|| headers.get("host"))
.and_then(|v| v.to_str().ok())
.unwrap_or("localhost");
let path = matched_path
.as_ref()
.map(|mp| mp.as_str())
.unwrap_or(&svc.mount_path);
let server_url = format!("http://{host}{path}");
let rewritten = rewrite_wsdl_address(&svc.wsdl_raw, &server_url);
(
StatusCode::OK,
[("Content-Type", "text/xml; charset=utf-8")],
rewritten,
)
.into_response()
}
async fn wsdl_get_handler_for_route(
matched_path: Option<MatchedPath>,
State(route_state): State<SoapServiceRoute>,
Query(params): Query<WsdlQuery>,
headers: HeaderMap,
) -> Response {
if params.wsdl.is_none() {
return StatusCode::NOT_FOUND.into_response();
}
let svc = &route_state.svc;
let host = headers
.get("x-forwarded-host")
.or_else(|| headers.get("host"))
.and_then(|v| v.to_str().ok())
.unwrap_or("localhost");
let path = matched_path
.as_ref()
.map(|mp| mp.as_str())
.unwrap_or(&svc.mount_path);
let server_url = format!("http://{host}{path}");
let rewritten =
rewrite_wsdl_address_for_service(&svc.wsdl_raw, &server_url, &route_state.service_name);
(
StatusCode::OK,
[("Content-Type", "text/xml; charset=utf-8")],
rewritten,
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fault::SoapFault;
use crate::handler::FnHandler;
use bytes::Bytes;
const MINIMAL_WSDL: &[u8] = br#"<?xml version="1.0" encoding="utf-8"?>
<wsdl:definitions
xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/"
xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap12/"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:tns="http://example.com/test"
targetNamespace="http://example.com/test">
<wsdl:types>
<xs:schema targetNamespace="http://example.com/test" elementFormDefault="qualified">
<xs:element name="Ping">
<xs:complexType><xs:sequence/></xs:complexType>
</xs:element>
<xs:element name="PingResponse">
<xs:complexType><xs:sequence/></xs:complexType>
</xs:element>
</xs:schema>
</wsdl:types>
<wsdl:message name="PingRequest">
<wsdl:part name="parameters" element="tns:Ping"/>
</wsdl:message>
<wsdl:message name="PingResponse">
<wsdl:part name="parameters" element="tns:PingResponse"/>
</wsdl:message>
<wsdl:portType name="TestPortType">
<wsdl:operation name="Ping">
<wsdl:input message="tns:PingRequest"/>
<wsdl:output message="tns:PingResponse"/>
</wsdl:operation>
</wsdl:portType>
<wsdl:binding name="TestBinding" type="tns:TestPortType">
<soap:binding style="document" transport="http://schemas.xmlsoap.org/soap/http"/>
<wsdl:operation name="Ping">
<soap:operation soapAction="http://example.com/test/Ping"/>
<wsdl:input><soap:body use="literal"/></wsdl:input>
<wsdl:output><soap:body use="literal"/></wsdl:output>
</wsdl:operation>
</wsdl:binding>
<wsdl:service name="TestService">
<wsdl:port name="TestPort" binding="tns:TestBinding">
<soap:address location="http://localhost/soap"/>
</wsdl:port>
</wsdl:service>
</wsdl:definitions>"#;
#[test]
fn server_builder_builds_without_panic() {
let svc = ServerBuilder::from_wsdl_bytes(MINIMAL_WSDL)
.handler(
"Ping",
FnHandler::new(|_body: Bytes| async move {
Ok::<Bytes, SoapFault>(Bytes::from_static(b"<PingResponse/>"))
}),
)
.auth_bypass(["Ping"])
.build();
assert!(svc.is_ok(), "build should succeed: {:?}", svc.err());
}
#[test]
fn server_builder_into_router_returns_router() {
let svc = ServerBuilder::from_wsdl_bytes(MINIMAL_WSDL)
.handler(
"Ping",
FnHandler::new(|_body: Bytes| async move {
Ok::<Bytes, SoapFault>(Bytes::from_static(b"<PingResponse/>"))
}),
)
.auth_bypass(["Ping"])
.build()
.unwrap();
let _router = svc.into_router();
}
#[test]
fn server_builder_fails_with_unregistered_operation() {
let result = ServerBuilder::from_wsdl_bytes(MINIMAL_WSDL).build();
assert!(result.is_err());
match result.unwrap_err() {
BuildError::UnregisteredOperation(op) => assert_eq!(op, "Ping"),
other => panic!("Expected UnregisteredOperation, got: {other:?}"),
}
}
#[test]
fn server_builder_fails_with_unknown_handler_name() {
let result = ServerBuilder::from_wsdl_bytes(MINIMAL_WSDL)
.handler(
"Ping",
FnHandler::new(|_body: Bytes| async move {
Ok::<Bytes, SoapFault>(Bytes::from_static(b"<PingResponse/>"))
}),
)
.handler(
"NonExistentOp",
FnHandler::new(|_body: Bytes| async move {
Ok::<Bytes, SoapFault>(Bytes::from_static(b"<resp/>"))
}),
)
.auth_bypass(["Ping"])
.build();
assert!(result.is_err());
match result.unwrap_err() {
BuildError::UnknownOperation(op) => assert_eq!(op, "NonExistentOp"),
other => panic!("Expected UnknownOperation, got: {other:?}"),
}
}
#[test]
fn fault_response_soap12_content_type() {
use crate::wsdl::definitions::SoapVersion;
let fault = SoapFault::sender("test");
let response = fault_response(fault, SoapVersion::Soap12);
let ct = response.headers().get("content-type").unwrap();
assert_eq!(ct.to_str().unwrap(), "application/soap+xml; charset=utf-8");
}
#[test]
fn fault_response_soap11_content_type() {
use crate::wsdl::definitions::SoapVersion;
let fault = SoapFault::sender("test");
let response = fault_response(fault, SoapVersion::Soap11);
let ct = response.headers().get("content-type").unwrap();
assert_eq!(ct.to_str().unwrap(), "text/xml; charset=utf-8");
}
#[test]
fn extract_body_qname_parses_namespaced_element() {
let bytes = b"<tns:Ping xmlns:tns=\"http://example.com/test\"/>";
let qname = extract_body_qname(bytes).unwrap();
assert_eq!(qname.local_name, "Ping");
assert_eq!(qname.namespace.as_deref(), Some("http://example.com/test"));
}
#[test]
fn extract_body_qname_parses_unnamespaced_element() {
let bytes = b"<Ping/>";
let qname = extract_body_qname(bytes).unwrap();
assert_eq!(qname.local_name, "Ping");
assert_eq!(qname.namespace, None);
}
#[test]
fn find_security_header_matches_wsse_namespace() {
let wsse_header = Bytes::from_static(
br#"<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"><wsse:UsernameToken/></wsse:Security>"#,
);
let headers = [wsse_header.clone()];
let result = find_security_header(&headers);
assert!(
result.is_some(),
"Expected wsse:Security header to be found"
);
assert_eq!(result.unwrap(), &wsse_header);
}
#[test]
fn find_security_header_ignores_non_wsse_security_element() {
let fake_security = Bytes::from_static(
br#"<ns:Security xmlns:ns="http://example.com/other">some content</ns:Security>"#,
);
let headers = [fake_security];
let result = find_security_header(&headers);
assert!(
result.is_none(),
"Security element in non-WSSE namespace should NOT be selected"
);
}
#[test]
fn find_security_header_ignores_element_containing_security_substring() {
let unrelated = Bytes::from_static(
br#"<ns:Header xmlns:ns="http://example.com/other">Security policy here</ns:Header>"#,
);
let headers = [unrelated];
let result = find_security_header(&headers);
assert!(
result.is_none(),
"Header containing 'Security' substring but wrong QName should NOT be selected"
);
}
#[test]
fn find_security_header_returns_first_valid_wsse_header() {
let fake_security = Bytes::from_static(
br#"<ns:Security xmlns:ns="http://example.com/other">content</ns:Security>"#,
);
let real_security = Bytes::from_static(
br#"<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"/>"#,
);
let headers = [fake_security, real_security.clone()];
let result = find_security_header(&headers);
assert!(result.is_some());
assert_eq!(result.unwrap(), &real_security);
}
#[test]
fn server_builder_max_body_bytes_sets_field() {
let svc = ServerBuilder::from_wsdl_bytes(MINIMAL_WSDL)
.handler(
"Ping",
FnHandler::new(|_body: Bytes| async move {
Ok::<Bytes, SoapFault>(Bytes::from_static(b"<PingResponse/>"))
}),
)
.auth_bypass(["Ping"])
.max_body_bytes(512 * 1024) .build()
.expect("build should succeed");
assert_eq!(svc.max_body_bytes, 512 * 1024);
let _router = svc.into_router();
}
#[test]
fn server_builder_default_max_body_bytes_is_2mib() {
let svc = ServerBuilder::from_wsdl_bytes(MINIMAL_WSDL)
.handler(
"Ping",
FnHandler::new(|_body: Bytes| async move {
Ok::<Bytes, SoapFault>(Bytes::from_static(b"<PingResponse/>"))
}),
)
.auth_bypass(["Ping"])
.build()
.expect("build should succeed");
assert_eq!(svc.max_body_bytes, 2 * 1024 * 1024);
}
}