Skip to main content

soap_server/
server.rs

1// ServerBuilder + SoapService — integration layer composing all components.
2// Produces an axum::Router serving SOAP 1.2 requests.
3
4use std::collections::{HashMap, HashSet};
5use std::sync::Arc;
6
7use axum::{
8    extract::{DefaultBodyLimit, MatchedPath, Query, State},
9    http::{HeaderMap, StatusCode},
10    response::{IntoResponse, Response},
11    routing::post,
12    Router,
13};
14use bytes::Bytes;
15use chrono::Utc;
16use tokio::sync::Mutex;
17
18use crate::dispatch::{self, DispatchError, DispatchTable};
19use crate::envelope::{
20    detect_soap_version, parse_envelope, response_content_type, serialize_envelope,
21};
22use crate::fault::SoapFault;
23use crate::handler::SoapHandler;
24use crate::qname::QName;
25use crate::wsdl::definitions::SoapVersion;
26use crate::wsdl::resolver::{
27    resolve_wsdl, rewrite_wsdl_address, rewrite_wsdl_address_for_service, WsdlLoader,
28};
29use crate::wssec::{nonce_cache::RotatingNonceCache, username_token::validate_username_token};
30use crate::xsd::types::TypeRegistry;
31
32/// Default nonce cache half-window in seconds (150s → 300s total replay window).
33const DEFAULT_NONCE_CACHE_HALF_WINDOW_SECS: u64 = 150;
34/// Default timestamp tolerance in seconds (±300s).
35const DEFAULT_TIMESTAMP_TOLERANCE_SECS: i64 = 300;
36/// Default maximum request body size: 2 MiB.
37const DEFAULT_MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
38
39/// Authentication function type: takes a raw Authorization header value and returns a username if valid.
40type AuthFn = Option<Arc<dyn Fn(&str) -> Option<String> + Send + Sync + 'static>>;
41
42// ── ServerBuilder ─────────────────────────────────────────────────────────────
43
44/// Builder for a SoapService. Accumulates WSDL source, handlers, auth config, and routing.
45pub struct ServerBuilder {
46    wsdl_bytes: Option<Vec<u8>>,
47    wsdl_path: Option<std::path::PathBuf>,
48    custom_loader: Option<Arc<dyn WsdlLoader>>,
49    handlers: HashMap<String, Arc<dyn SoapHandler>>,
50    default_handler: Option<Arc<dyn SoapHandler>>,
51    auth_fn: AuthFn,
52    auth_bypass: HashSet<String>,
53    mount_path: String,
54    timestamp_tolerance_secs: i64,
55    nonce_cache_half_window_secs: u64,
56    max_body_bytes: usize,
57}
58
59impl ServerBuilder {
60    fn new() -> Self {
61        Self {
62            wsdl_bytes: None,
63            wsdl_path: None,
64            custom_loader: None,
65            handlers: HashMap::new(),
66            default_handler: None,
67            auth_fn: None,
68            auth_bypass: HashSet::new(),
69            mount_path: "/soap".to_string(),
70            timestamp_tolerance_secs: DEFAULT_TIMESTAMP_TOLERANCE_SECS,
71            nonce_cache_half_window_secs: DEFAULT_NONCE_CACHE_HALF_WINDOW_SECS,
72            max_body_bytes: DEFAULT_MAX_BODY_BYTES,
73        }
74    }
75
76    /// Load WSDL from the given file path at build time.
77    pub fn from_wsdl_file(path: impl Into<std::path::PathBuf>) -> Self {
78        let mut builder = Self::new();
79        builder.wsdl_path = Some(path.into());
80        builder
81    }
82
83    /// Use the provided WSDL bytes directly (no file I/O).
84    /// For WSDLs with external imports, use `from_wsdl_file` or `from_wsdl_bytes_with_loader`.
85    pub fn from_wsdl_bytes(bytes: impl Into<Vec<u8>>) -> Self {
86        let mut builder = Self::new();
87        builder.wsdl_bytes = Some(bytes.into());
88        builder
89    }
90
91    /// Use the provided WSDL bytes with a custom loader for resolving external imports.
92    /// The loader is invoked for any `wsdl:import` or `xs:import` location strings.
93    pub fn from_wsdl_bytes_with_loader(
94        bytes: impl Into<Vec<u8>>,
95        loader: impl WsdlLoader + 'static,
96    ) -> Self {
97        let mut builder = Self::new();
98        builder.wsdl_bytes = Some(bytes.into());
99        builder.custom_loader = Some(Arc::new(loader));
100        builder
101    }
102
103    /// Register a handler for the named WSDL operation.
104    pub fn handler(mut self, operation: impl Into<String>, handler: impl SoapHandler) -> Self {
105        self.handlers.insert(operation.into(), Arc::new(handler));
106        self
107    }
108
109    /// Register a catch-all handler invoked for any WSDL operation without a specific handler.
110    /// When set, `build()` will not return `UnregisteredOperation` for unhandled operations.
111    pub fn default_handler(mut self, handler: impl SoapHandler) -> Self {
112        self.default_handler = Some(Arc::new(handler));
113        self
114    }
115
116    /// Configure credential lookup, enabling WS-Security enforcement. The closure is called
117    /// with a username and must return the stored password (plaintext) for that user, or
118    /// `None` if the user does not exist.
119    ///
120    /// If `auth` is never called, the server runs **unauthenticated**: WS-Security headers
121    /// are not required or validated on any operation. Calling `auth` enables WS-Security
122    /// enforcement on all non-bypassed operations (see [`auth_bypass`](Self::auth_bypass)).
123    pub fn auth<F>(mut self, f: F) -> Self
124    where
125        F: Fn(&str) -> Option<String> + Send + Sync + 'static,
126    {
127        self.auth_fn = Some(Arc::new(f));
128        self
129    }
130
131    /// Mark the named operations as auth-bypassed (no WS-Security header required).
132    /// Accepts any iterable of string-like values.
133    pub fn auth_bypass<I, S>(mut self, ops: I) -> Self
134    where
135        I: IntoIterator<Item = S>,
136        S: Into<String>,
137    {
138        for op in ops {
139            self.auth_bypass.insert(op.into());
140        }
141        self
142    }
143
144    /// Override the mount path (default: "/soap").
145    pub fn path(mut self, path: impl Into<String>) -> Self {
146        self.mount_path = path.into();
147        self
148    }
149
150    /// Override the WS-Security timestamp tolerance in seconds (default: 300).
151    pub fn timestamp_tolerance_secs(mut self, secs: i64) -> Self {
152        self.timestamp_tolerance_secs = secs;
153        self
154    }
155
156    /// Set the maximum allowed request body size in bytes (default: 2 MiB).
157    ///
158    /// Requests whose body exceeds this limit are rejected before XML parsing
159    /// begins, preventing memory exhaustion from oversized payloads.
160    pub fn max_body_bytes(mut self, bytes: usize) -> Self {
161        self.max_body_bytes = bytes;
162        self
163    }
164
165    /// Build the SoapService, resolving the WSDL and building the dispatch table.
166    /// Returns an error if the WSDL cannot be resolved or the dispatch table is inconsistent.
167    pub fn build(self) -> Result<SoapService, BuildError> {
168        // Step 1: Load WSDL bytes, preserving the file path for loader selection.
169        let (wsdl_bytes, wsdl_file_path) = match (self.wsdl_bytes, self.wsdl_path) {
170            (Some(bytes), _) => (bytes, None),
171            (None, Some(path)) => {
172                let bytes = std::fs::read(&path).map_err(|e| BuildError::WsdlIo(e.to_string()))?;
173                (bytes, Some(path))
174            }
175            (None, None) => return Err(BuildError::MissingWsdl),
176        };
177
178        // Step 2: Resolve WSDL (pass 1 + pass 2).
179        // Loader selection priority:
180        //   1. custom_loader (explicitly provided via from_wsdl_bytes_with_loader)
181        //   2. FileWsdlLoader (when loaded from a file path)
182        //   3. NoOpLoader (embedded bytes — imports unsupported)
183        let mut visited = HashSet::new();
184        let resolved = if let Some(ref loader) = self.custom_loader {
185            resolve_wsdl(&wsdl_bytes, loader.as_ref(), &mut visited)
186                .map_err(|e| BuildError::WsdlParse(e.to_string()))?
187        } else if let Some(ref path) = wsdl_file_path {
188            let base_dir = path
189                .parent()
190                .unwrap_or_else(|| std::path::Path::new("."))
191                .to_path_buf();
192            let loader = FileWsdlLoader { base_dir };
193            resolve_wsdl(&wsdl_bytes, &loader, &mut visited)
194                .map_err(|e| BuildError::WsdlParse(e.to_string()))?
195        } else {
196            let loader = NoOpLoader;
197            resolve_wsdl(&wsdl_bytes, &loader, &mut visited)
198                .map_err(|e| BuildError::WsdlParse(e.to_string()))?
199        };
200
201        // Step 3: Build dispatch table(s).
202        // If WSDL has multiple services, build per-service dispatch tables.
203        // Otherwise, build a single dispatch table for backward compatibility.
204        let service_names: Vec<String> = resolved.definition.services.keys().cloned().collect();
205        let is_multi_service = service_names.len() > 1;
206
207        let (dispatch_table, service_tables, service_path_names) = if is_multi_service {
208            // Multi-service mode: build one table per service, mount at per-service path.
209            // The handlers HashMap is shared across services — each service only uses the
210            // handlers for operations it owns. We clone handlers for each service.
211            let mut service_tables: HashMap<String, Arc<DispatchTable>> = HashMap::new();
212            let mut service_path_names: HashMap<String, String> = HashMap::new();
213            let mut all_ops_in_all_services: HashSet<String> = HashSet::new();
214
215            // First pass: collect which ops belong to each service.
216            for svc_name in &service_names {
217                let svc = resolved
218                    .definition
219                    .services
220                    .get(svc_name)
221                    .ok_or_else(|| BuildError::UnknownService(svc_name.clone()))?;
222                for port in &svc.ports {
223                    let binding_local = &port.binding.local_name;
224                    if let Some(binding) = resolved.definition.bindings.get(binding_local) {
225                        for binding_op in &binding.operations {
226                            all_ops_in_all_services.insert(binding_op.name.clone());
227                        }
228                    }
229                }
230            }
231
232            // Verify no handlers registered for unknown operations.
233            for handler_name in self.handlers.keys() {
234                if !all_ops_in_all_services.contains(handler_name) {
235                    return Err(BuildError::UnknownOperation(handler_name.clone()));
236                }
237            }
238
239            for svc_name in &service_names {
240                let svc = resolved
241                    .definition
242                    .services
243                    .get(svc_name)
244                    .ok_or_else(|| BuildError::UnknownService(svc_name.clone()))?;
245
246                // Collect ops for this service and build handlers subset.
247                let mut svc_op_names: Vec<String> = Vec::new();
248                for port in &svc.ports {
249                    let binding_local = &port.binding.local_name;
250                    if let Some(binding) = resolved.definition.bindings.get(binding_local) {
251                        for binding_op in &binding.operations {
252                            svc_op_names.push(binding_op.name.clone());
253                        }
254                    }
255                }
256
257                // Build handlers for this service only.
258                let mut svc_handlers: HashMap<String, Arc<dyn SoapHandler>> = HashMap::new();
259                for op_name in &svc_op_names {
260                    if let Some(h) = self.handlers.get(op_name) {
261                        svc_handlers.insert(op_name.clone(), h.clone());
262                    }
263                }
264
265                let table = dispatch::build_dispatch_table_for_service(
266                    svc_name,
267                    &resolved,
268                    svc_handlers,
269                    &self.auth_bypass,
270                    self.default_handler.clone(),
271                )
272                .map_err(|e| match e {
273                    DispatchError::UnregisteredOperation(op) => {
274                        BuildError::UnregisteredOperation(op)
275                    }
276                    DispatchError::UnknownOperation(op) => BuildError::UnknownOperation(op),
277                    DispatchError::UnresolvableInputType { op, element, type_ref } => {
278                        BuildError::WsdlParse(format!(
279                            "Operation '{op}' input element '{element}' references unresolvable type '{type_ref}'"
280                        ))
281                    }
282                })?;
283
284                // Derive the route path from the first port's address.
285                let path = svc
286                    .ports
287                    .first()
288                    .map(|p| extract_path_from_url(&p.address))
289                    .unwrap_or_else(|| format!("/{}", svc_name.to_lowercase()));
290
291                service_path_names.insert(path.clone(), svc_name.clone());
292                service_tables.insert(path, Arc::new(table));
293            }
294
295            // Build a combined table for the dispatch_table field (used for single-route fallback).
296            // Use the first service's table as the primary — in multi-service mode,
297            // routing uses service_tables exclusively.
298            let first_table = service_tables
299                .values()
300                .next()
301                .cloned()
302                .unwrap_or_else(|| Arc::new(DispatchTable::empty()));
303
304            (first_table, service_tables, service_path_names)
305        } else {
306            // Single-service mode: build one dispatch table, no per-service tables.
307            let table = dispatch::build_dispatch_table(
308                &resolved,
309                self.handlers,
310                &self.auth_bypass,
311                self.default_handler,
312            )
313            .map_err(|e| match e {
314                DispatchError::UnregisteredOperation(op) => BuildError::UnregisteredOperation(op),
315                DispatchError::UnknownOperation(op) => BuildError::UnknownOperation(op),
316                DispatchError::UnresolvableInputType { op, element, type_ref } => {
317                    BuildError::WsdlParse(format!(
318                        "Operation '{op}' input element '{element}' references unresolvable type '{type_ref}'"
319                    ))
320                }
321            })?;
322            (Arc::new(table), HashMap::new(), HashMap::new())
323        };
324
325        let type_registry = Arc::new(resolved.type_registry);
326
327        Ok(SoapService {
328            dispatch_table,
329            service_tables,
330            service_path_names,
331            type_registry,
332            wsdl_raw: Arc::new(wsdl_bytes),
333            auth_fn: self.auth_fn,
334            nonce_cache: Arc::new(Mutex::new(RotatingNonceCache::new(
335                self.nonce_cache_half_window_secs,
336            ))),
337            timestamp_tolerance_secs: self.timestamp_tolerance_secs,
338            mount_path: self.mount_path,
339            max_body_bytes: self.max_body_bytes,
340        })
341    }
342}
343
344// ── Build errors ──────────────────────────────────────────────────────────────
345
346/// Errors that can occur during ServerBuilder::build().
347#[derive(Debug, thiserror::Error)]
348pub enum BuildError {
349    #[error("No WSDL source provided — call from_wsdl_bytes() or from_wsdl_file()")]
350    MissingWsdl,
351    #[error("Failed to read WSDL file: {0}")]
352    WsdlIo(String),
353    #[error("Failed to parse or resolve WSDL: {0}")]
354    WsdlParse(String),
355    #[error("WSDL operation '{0}' has no registered handler")]
356    UnregisteredOperation(String),
357    #[error("Registered handler '{0}' has no matching WSDL operation")]
358    UnknownOperation(String),
359    #[error("WSDL service '{0}' not found in resolved definition")]
360    UnknownService(String),
361}
362
363// ── WSDL loader (no-op for embedded/self-contained WSDLs) ────────────────────
364
365struct NoOpLoader;
366
367impl WsdlLoader for NoOpLoader {
368    fn load(&self, location: &str) -> Result<Vec<u8>, crate::wsdl::parser::WsdlError> {
369        Err(crate::wsdl::parser::WsdlError::MalformedXml(format!(
370            "External WSDL import '{location}' not supported in embedded mode"
371        )))
372    }
373}
374
375/// A WsdlLoader that resolves import locations relative to a base directory on the filesystem.
376/// Used automatically when ServerBuilder::from_wsdl_file() is called.
377pub struct FileWsdlLoader {
378    base_dir: std::path::PathBuf,
379}
380
381impl WsdlLoader for FileWsdlLoader {
382    fn load(&self, location: &str) -> Result<Vec<u8>, crate::wsdl::parser::WsdlError> {
383        // Resolve the location relative to the base directory, normalizing ".." components.
384        let raw_path = self.base_dir.join(location);
385        let path = normalize_path(&raw_path);
386        std::fs::read(&path).map_err(|e| {
387            crate::wsdl::parser::WsdlError::MalformedXml(format!(
388                "Failed to load WSDL import '{location}' from '{}': {e}",
389                path.display()
390            ))
391        })
392    }
393}
394
395/// Normalize a path by resolving ".." components without requiring the path to exist.
396/// This is needed because std::fs::canonicalize requires the path to exist on disk.
397fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
398    use std::path::Component;
399    let mut normalized = std::path::PathBuf::new();
400    for component in path.components() {
401        match component {
402            Component::ParentDir => {
403                normalized.pop();
404            }
405            Component::CurDir => {}
406            other => {
407                normalized.push(other);
408            }
409        }
410    }
411    normalized
412}
413
414/// Extract the path component from a URL string (e.g., "http://host/soap/ServiceA" → "/soap/ServiceA").
415/// Falls back to "/" if the URL has no path.
416fn extract_path_from_url(url: &str) -> String {
417    if let Some(after_scheme) = url.split_once("://").map(|x| x.1) {
418        if let Some(slash_pos) = after_scheme.find('/') {
419            return after_scheme[slash_pos..].to_string();
420        }
421        return "/".to_string();
422    }
423    if url.starts_with('/') {
424        url.to_string()
425    } else {
426        format!("/{url}")
427    }
428}
429
430// ── SoapService ───────────────────────────────────────────────────────────────
431
432/// A fully configured SOAP service that can be converted into an axum Router.
433pub struct SoapService {
434    dispatch_table: Arc<DispatchTable>,
435    /// Per-service dispatch tables keyed by route path.
436    /// Non-empty when the WSDL has multiple services (multi-service mode).
437    /// Empty in single-service mode (backward-compat).
438    service_tables: HashMap<String, Arc<DispatchTable>>,
439    /// Maps route path → WSDL service name (for per-service WSDL address rewriting).
440    /// Non-empty only in multi-service mode.
441    service_path_names: HashMap<String, String>,
442    type_registry: Arc<TypeRegistry>,
443    wsdl_raw: Arc<Vec<u8>>,
444    auth_fn: AuthFn,
445    nonce_cache: Arc<Mutex<RotatingNonceCache>>,
446    timestamp_tolerance_secs: i64,
447    mount_path: String,
448    max_body_bytes: usize,
449}
450
451impl std::fmt::Debug for SoapService {
452    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
453        f.debug_struct("SoapService")
454            .field("mount_path", &self.mount_path)
455            .finish_non_exhaustive()
456    }
457}
458
459impl SoapService {
460    /// Convert this service into an axum Router with POST (SOAP) and GET (?wsdl) routes.
461    /// The returned Router is composable with Router::merge().
462    ///
463    /// In multi-service mode: registers one POST route per service path (from service_tables).
464    /// In single-service mode: registers a single POST + GET route at mount_path (backward-compat).
465    pub fn into_router(self) -> Router {
466        let max_body = self.max_body_bytes;
467        if !self.service_tables.is_empty() {
468            // Multi-service mode: each service gets its own POST + GET (?wsdl) route.
469            let state = Arc::new(self);
470            let mut router = Router::new();
471            // Collect (path, table, service_name) triples before entering the loop to
472            // avoid holding immutable borrows on `state` while we call `state.clone()`.
473            let routes: Vec<(String, Arc<DispatchTable>, String)> = state
474                .service_tables
475                .iter()
476                .map(|(path, table)| {
477                    let svc_name = state
478                        .service_path_names
479                        .get(path)
480                        .cloned()
481                        .unwrap_or_default();
482                    (path.clone(), table.clone(), svc_name)
483                })
484                .collect();
485            for (path, table, service_name) in routes {
486                let route_state = SoapServiceRoute {
487                    svc: state.clone(),
488                    table,
489                    service_name,
490                };
491                router = router.route(
492                    &path,
493                    post(soap_post_handler_for_route)
494                        .get(wsdl_get_handler_for_route)
495                        .with_state(route_state),
496                );
497            }
498            router.layer(DefaultBodyLimit::max(max_body))
499        } else {
500            // Single-service mode: single route (backward-compat).
501            let mount_path = self.mount_path.clone();
502            let state = Arc::new(self);
503            Router::new()
504                .route(&mount_path, post(soap_post_handler).get(wsdl_get_handler))
505                .with_state(state)
506                .layer(DefaultBodyLimit::max(max_body))
507        }
508    }
509}
510
511/// Thin wrapper for per-service route state in multi-service mode.
512#[derive(Clone)]
513struct SoapServiceRoute {
514    svc: Arc<SoapService>,
515    table: Arc<DispatchTable>,
516    /// The WSDL service name this route belongs to (used for per-service WSDL address rewriting).
517    service_name: String,
518}
519
520// ── Helper: return a 500 SOAP fault response ──────────────────────────────────
521
522fn fault_response(fault: SoapFault, version: crate::wsdl::definitions::SoapVersion) -> Response {
523    let bytes = fault.to_xml_bytes_versioned(&version);
524    let ct = response_content_type(&version);
525    (
526        StatusCode::INTERNAL_SERVER_ERROR,
527        [("Content-Type", ct)],
528        bytes,
529    )
530        .into_response()
531}
532
533// ── Helper: extract QName of the first element in body_element bytes ──────────
534
535fn extract_body_qname(body_bytes: &[u8]) -> Result<QName, SoapFault> {
536    use quick_xml::events::Event;
537    use quick_xml::NsReader;
538
539    let mut reader = NsReader::from_reader(body_bytes);
540    reader.config_mut().trim_text(true);
541
542    loop {
543        match reader
544            .read_resolved_event()
545            .map_err(|e| SoapFault::sender(format!("XML parse error in body: {e}")))?
546        {
547            (_, Event::Eof) => {
548                return Err(SoapFault::sender("Empty SOAP Body element"));
549            }
550            (resolved_ns, Event::Start(e)) | (resolved_ns, Event::Empty(e)) => {
551                let local = std::str::from_utf8(e.local_name().as_ref())
552                    .map_err(|e| SoapFault::sender(format!("Invalid UTF-8 in element name: {e}")))?
553                    .to_string();
554                let ns = match resolved_ns {
555                    quick_xml::name::ResolveResult::Bound(ns) => std::str::from_utf8(ns.0)
556                        .map_err(|e| SoapFault::sender(format!("Invalid UTF-8 in namespace: {e}")))?
557                        .to_string(),
558                    _ => String::new(),
559                };
560                if ns.is_empty() {
561                    return Ok(QName::local(&local));
562                } else {
563                    return Ok(QName::new(&ns, &local));
564                }
565            }
566            _ => {}
567        }
568    }
569}
570
571// ── Helper: find the wsse:Security header bytes from header_children ──────────
572
573/// WS-Security namespace URI.
574const WSSE_NS: &str =
575    "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd";
576
577/// Match a header child by WS-Security QName: local name `Security` in the
578/// WS-Security namespace.  A header element whose root tag is merely named
579/// `Security` in a different namespace is NOT selected.
580fn find_security_header(header_children: &[Bytes]) -> Option<&Bytes> {
581    use quick_xml::events::Event;
582    use quick_xml::NsReader;
583
584    for child in header_children {
585        let mut reader = NsReader::from_reader(child.as_ref());
586        reader.config_mut().trim_text(true);
587        // We only need the first start/empty event to identify the root element.
588        loop {
589            match reader.read_resolved_event() {
590                Ok((resolved_ns, Event::Start(e))) | Ok((resolved_ns, Event::Empty(e))) => {
591                    let local = e.local_name();
592                    let local_str = std::str::from_utf8(local.as_ref()).unwrap_or("");
593                    if local_str == "Security" {
594                        if let quick_xml::name::ResolveResult::Bound(ns) = resolved_ns {
595                            if std::str::from_utf8(ns.0).unwrap_or("") == WSSE_NS {
596                                return Some(child);
597                            }
598                        }
599                    }
600                    break; // Only examine the root element of each child.
601                }
602                Ok((_, Event::Eof)) | Err(_) => break,
603                _ => {}
604            }
605        }
606    }
607    None
608}
609
610// ── axum handler: POST /soap ──────────────────────────────────────────────────
611
612async fn soap_post_handler(
613    State(svc): State<Arc<SoapService>>,
614    headers: HeaderMap,
615    body: Bytes,
616) -> Response {
617    // Step 1: Detect SOAP version from Content-Type.
618    let content_type = headers
619        .get("content-type")
620        .and_then(|v| v.to_str().ok())
621        .unwrap_or("");
622
623    let soap_version = match detect_soap_version(content_type) {
624        Ok(v) => v,
625        Err(fault) => return fault_response(fault, crate::wsdl::definitions::SoapVersion::Soap12),
626    };
627
628    // Step 2: Parse envelope.
629    let envelope = match parse_envelope(&body) {
630        Ok(e) => e,
631        Err(fault) => return fault_response(fault, soap_version),
632    };
633
634    // Step 3: Extract body first-child QName.
635    let body_qname = match extract_body_qname(&envelope.body_element) {
636        Ok(q) => q,
637        Err(fault) => return fault_response(fault, envelope.soap_version.clone()),
638    };
639
640    // Step 4: Route to dispatch entry.
641    let soap_action = headers
642        .get("soapaction")
643        .or_else(|| headers.get("SOAPAction"))
644        .and_then(|v| v.to_str().ok())
645        .map(|s| s.trim_matches('"'));
646
647    let entry = match dispatch::route(&svc.dispatch_table, &body_qname, soap_action) {
648        Ok(e) => e,
649        Err(fault) => return fault_response(fault, envelope.soap_version.clone()),
650    };
651
652    // Step 5: If auth required, validate WS-Security UsernameToken.
653    // Auth is only enforced when a credential validator (`auth_fn`) is configured
654    // via `ServerBuilder::auth`. With no validator the server runs unauthenticated
655    // — see the `auth` builder docs.
656    if entry.auth_required && svc.auth_fn.is_some() {
657        match find_security_header(&envelope.header_children) {
658            None => {
659                return fault_response(
660                    SoapFault::sender("WS-Security header required but not provided"),
661                    envelope.soap_version.clone(),
662                );
663            }
664            Some(security_bytes) => {
665                let auth_fn = match &svc.auth_fn {
666                    Some(f) => f.clone(),
667                    None => {
668                        return fault_response(
669                            SoapFault::sender(
670                                "Authentication required but no credential store configured",
671                            ),
672                            envelope.soap_version.clone(),
673                        );
674                    }
675                };
676                let mut nonce_cache = svc.nonce_cache.lock().await;
677                let now = Utc::now();
678                if let Err(fault) = validate_username_token(
679                    security_bytes,
680                    auth_fn.as_ref(),
681                    &mut nonce_cache,
682                    svc.timestamp_tolerance_secs,
683                    now,
684                ) {
685                    return fault_response(fault, envelope.soap_version.clone());
686                }
687            }
688        }
689    }
690
691    // Step 6: XSD structural validation.
692    if let Err(fault) = dispatch::validate_request(
693        &envelope.body_element,
694        &svc.type_registry,
695        entry.validation_type.as_ref(),
696    ) {
697        return fault_response(fault, envelope.soap_version.clone());
698    }
699
700    // Step 7: Invoke handler (pass header fragments for WS-Addressing/WS-Security use).
701    let response_body = match entry
702        .handler
703        .handle_with_headers(envelope.body_element, &envelope.header_children)
704        .await
705    {
706        Ok(bytes) => bytes,
707        Err(fault) => return fault_response(fault, envelope.soap_version.clone()),
708    };
709
710    // Step 8: Serialize into SOAP envelope.
711    // Use the envelope namespace as the single authoritative SOAP version source for both
712    // serialization and response Content-Type (BLOCK-SS-C04 fix). The Content-Type header
713    // version is a hint; the envelope namespace is what actually identifies the SOAP version.
714    let response_version = envelope.soap_version.clone();
715    let envelope_bytes = serialize_envelope(response_body, response_version.clone());
716    let content_type_value = response_content_type(&response_version);
717
718    (
719        StatusCode::OK,
720        [("Content-Type", content_type_value)],
721        envelope_bytes,
722    )
723        .into_response()
724}
725
726// ── axum handler: POST per-service route (multi-service mode) ─────────────────
727
728async fn soap_post_handler_for_route(
729    State(route_state): State<SoapServiceRoute>,
730    headers: HeaderMap,
731    body: Bytes,
732) -> Response {
733    let svc = &route_state.svc;
734    let table = &route_state.table;
735
736    // Step 1: Detect SOAP version from Content-Type.
737    let content_type = headers
738        .get("content-type")
739        .and_then(|v| v.to_str().ok())
740        .unwrap_or("");
741
742    let soap_version = match detect_soap_version(content_type) {
743        Ok(v) => v,
744        Err(fault) => return fault_response(fault, SoapVersion::Soap12),
745    };
746
747    // Step 2: Parse envelope.
748    let envelope = match parse_envelope(&body) {
749        Ok(e) => e,
750        Err(fault) => return fault_response(fault, soap_version),
751    };
752
753    // Step 3: Extract body first-child QName.
754    let body_qname = match extract_body_qname(&envelope.body_element) {
755        Ok(q) => q,
756        Err(fault) => return fault_response(fault, envelope.soap_version.clone()),
757    };
758
759    // Step 4: Route using this service's specific dispatch table.
760    let soap_action = headers
761        .get("soapaction")
762        .or_else(|| headers.get("SOAPAction"))
763        .and_then(|v| v.to_str().ok())
764        .map(|s| s.trim_matches('"'));
765
766    let entry = match dispatch::route(table, &body_qname, soap_action) {
767        Ok(e) => e,
768        Err(fault) => return fault_response(fault, envelope.soap_version.clone()),
769    };
770
771    // Step 5: If auth required, validate WS-Security UsernameToken.
772    // Auth is only enforced when a credential validator (`auth_fn`) is configured
773    // via `ServerBuilder::auth`. With no validator the server runs unauthenticated
774    // — see the `auth` builder docs.
775    if entry.auth_required && svc.auth_fn.is_some() {
776        match find_security_header(&envelope.header_children) {
777            None => {
778                return fault_response(
779                    SoapFault::sender("WS-Security header required but not provided"),
780                    envelope.soap_version.clone(),
781                );
782            }
783            Some(security_bytes) => {
784                let auth_fn = match &svc.auth_fn {
785                    Some(f) => f.clone(),
786                    None => {
787                        return fault_response(
788                            SoapFault::sender(
789                                "Authentication required but no credential store configured",
790                            ),
791                            envelope.soap_version.clone(),
792                        );
793                    }
794                };
795                let mut nonce_cache = svc.nonce_cache.lock().await;
796                let now = Utc::now();
797                if let Err(fault) = validate_username_token(
798                    security_bytes,
799                    auth_fn.as_ref(),
800                    &mut nonce_cache,
801                    svc.timestamp_tolerance_secs,
802                    now,
803                ) {
804                    return fault_response(fault, envelope.soap_version.clone());
805                }
806            }
807        }
808    }
809
810    // Step 6: XSD structural validation.
811    if let Err(fault) = dispatch::validate_request(
812        &envelope.body_element,
813        &svc.type_registry,
814        entry.validation_type.as_ref(),
815    ) {
816        return fault_response(fault, envelope.soap_version.clone());
817    }
818
819    // Step 7: Invoke handler (pass header fragments for WS-Addressing/WS-Security use).
820    let response_body = match entry
821        .handler
822        .handle_with_headers(envelope.body_element, &envelope.header_children)
823        .await
824    {
825        Ok(bytes) => bytes,
826        Err(fault) => return fault_response(fault, envelope.soap_version.clone()),
827    };
828
829    // Step 8: Serialize into SOAP envelope.
830    // Use the envelope namespace as the single authoritative SOAP version source for both
831    // serialization and response Content-Type (BLOCK-SS-C04 fix).
832    let response_version = envelope.soap_version.clone();
833    let envelope_bytes = serialize_envelope(response_body, response_version.clone());
834    let content_type_value = response_content_type(&response_version);
835
836    (
837        StatusCode::OK,
838        [("Content-Type", content_type_value)],
839        envelope_bytes,
840    )
841        .into_response()
842}
843
844// ── axum handler: GET /soap?wsdl ──────────────────────────────────────────────
845
846#[derive(serde::Deserialize)]
847struct WsdlQuery {
848    wsdl: Option<String>,
849}
850
851async fn wsdl_get_handler(
852    matched_path: Option<MatchedPath>,
853    State(svc): State<Arc<SoapService>>,
854    Query(params): Query<WsdlQuery>,
855    headers: HeaderMap,
856) -> Response {
857    // Only respond when ?wsdl query parameter is present.
858    if params.wsdl.is_none() {
859        return StatusCode::NOT_FOUND.into_response();
860    }
861
862    // Build the server URL from Host header (or X-Forwarded-Host).
863    let host = headers
864        .get("x-forwarded-host")
865        .or_else(|| headers.get("host"))
866        .and_then(|v| v.to_str().ok())
867        .unwrap_or("localhost");
868
869    // Use the matched route path (per-service in multi-service mode, mount_path in single-service).
870    // MatchedPath is always present when registered via router.route() — use Option as fallback.
871    let path = matched_path
872        .as_ref()
873        .map(|mp| mp.as_str())
874        .unwrap_or(&svc.mount_path);
875
876    let server_url = format!("http://{host}{path}");
877
878    let rewritten = rewrite_wsdl_address(&svc.wsdl_raw, &server_url);
879
880    (
881        StatusCode::OK,
882        [("Content-Type", "text/xml; charset=utf-8")],
883        rewritten,
884    )
885        .into_response()
886}
887
888// ── axum handler: GET /soap?wsdl (per-service route in multi-service mode) ───
889
890async fn wsdl_get_handler_for_route(
891    matched_path: Option<MatchedPath>,
892    State(route_state): State<SoapServiceRoute>,
893    Query(params): Query<WsdlQuery>,
894    headers: HeaderMap,
895) -> Response {
896    // Only respond when ?wsdl query parameter is present.
897    if params.wsdl.is_none() {
898        return StatusCode::NOT_FOUND.into_response();
899    }
900
901    let svc = &route_state.svc;
902
903    // Build the server URL from Host header (or X-Forwarded-Host).
904    let host = headers
905        .get("x-forwarded-host")
906        .or_else(|| headers.get("host"))
907        .and_then(|v| v.to_str().ok())
908        .unwrap_or("localhost");
909
910    let path = matched_path
911        .as_ref()
912        .map(|mp| mp.as_str())
913        .unwrap_or(&svc.mount_path);
914
915    let server_url = format!("http://{host}{path}");
916
917    // In multi-service mode, rewrite ONLY the address for the matched service/port.
918    // Other services' addresses are preserved so that generated clients bind each service
919    // to its own endpoint rather than all to the current request path.
920    let rewritten =
921        rewrite_wsdl_address_for_service(&svc.wsdl_raw, &server_url, &route_state.service_name);
922
923    (
924        StatusCode::OK,
925        [("Content-Type", "text/xml; charset=utf-8")],
926        rewritten,
927    )
928        .into_response()
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934    use crate::fault::SoapFault;
935    use crate::handler::FnHandler;
936    use bytes::Bytes;
937
938    const MINIMAL_WSDL: &[u8] = br#"<?xml version="1.0" encoding="utf-8"?>
939<wsdl:definitions
940    xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/"
941    xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap12/"
942    xmlns:xs="http://www.w3.org/2001/XMLSchema"
943    xmlns:tns="http://example.com/test"
944    targetNamespace="http://example.com/test">
945    <wsdl:types>
946        <xs:schema targetNamespace="http://example.com/test" elementFormDefault="qualified">
947            <xs:element name="Ping">
948                <xs:complexType><xs:sequence/></xs:complexType>
949            </xs:element>
950            <xs:element name="PingResponse">
951                <xs:complexType><xs:sequence/></xs:complexType>
952            </xs:element>
953        </xs:schema>
954    </wsdl:types>
955    <wsdl:message name="PingRequest">
956        <wsdl:part name="parameters" element="tns:Ping"/>
957    </wsdl:message>
958    <wsdl:message name="PingResponse">
959        <wsdl:part name="parameters" element="tns:PingResponse"/>
960    </wsdl:message>
961    <wsdl:portType name="TestPortType">
962        <wsdl:operation name="Ping">
963            <wsdl:input message="tns:PingRequest"/>
964            <wsdl:output message="tns:PingResponse"/>
965        </wsdl:operation>
966    </wsdl:portType>
967    <wsdl:binding name="TestBinding" type="tns:TestPortType">
968        <soap:binding style="document" transport="http://schemas.xmlsoap.org/soap/http"/>
969        <wsdl:operation name="Ping">
970            <soap:operation soapAction="http://example.com/test/Ping"/>
971            <wsdl:input><soap:body use="literal"/></wsdl:input>
972            <wsdl:output><soap:body use="literal"/></wsdl:output>
973        </wsdl:operation>
974    </wsdl:binding>
975    <wsdl:service name="TestService">
976        <wsdl:port name="TestPort" binding="tns:TestBinding">
977            <soap:address location="http://localhost/soap"/>
978        </wsdl:port>
979    </wsdl:service>
980</wsdl:definitions>"#;
981
982    #[test]
983    fn server_builder_builds_without_panic() {
984        let svc = ServerBuilder::from_wsdl_bytes(MINIMAL_WSDL)
985            .handler(
986                "Ping",
987                FnHandler::new(|_body: Bytes| async move {
988                    Ok::<Bytes, SoapFault>(Bytes::from_static(b"<PingResponse/>"))
989                }),
990            )
991            .auth_bypass(["Ping"])
992            .build();
993        assert!(svc.is_ok(), "build should succeed: {:?}", svc.err());
994    }
995
996    #[test]
997    fn server_builder_into_router_returns_router() {
998        let svc = ServerBuilder::from_wsdl_bytes(MINIMAL_WSDL)
999            .handler(
1000                "Ping",
1001                FnHandler::new(|_body: Bytes| async move {
1002                    Ok::<Bytes, SoapFault>(Bytes::from_static(b"<PingResponse/>"))
1003                }),
1004            )
1005            .auth_bypass(["Ping"])
1006            .build()
1007            .unwrap();
1008        // into_router() must not panic
1009        let _router = svc.into_router();
1010    }
1011
1012    #[test]
1013    fn server_builder_fails_with_unregistered_operation() {
1014        // WSDL has Ping but no handler is provided.
1015        let result = ServerBuilder::from_wsdl_bytes(MINIMAL_WSDL).build();
1016        assert!(result.is_err());
1017        match result.unwrap_err() {
1018            BuildError::UnregisteredOperation(op) => assert_eq!(op, "Ping"),
1019            other => panic!("Expected UnregisteredOperation, got: {other:?}"),
1020        }
1021    }
1022
1023    #[test]
1024    fn server_builder_fails_with_unknown_handler_name() {
1025        let result = ServerBuilder::from_wsdl_bytes(MINIMAL_WSDL)
1026            .handler(
1027                "Ping",
1028                FnHandler::new(|_body: Bytes| async move {
1029                    Ok::<Bytes, SoapFault>(Bytes::from_static(b"<PingResponse/>"))
1030                }),
1031            )
1032            .handler(
1033                "NonExistentOp",
1034                FnHandler::new(|_body: Bytes| async move {
1035                    Ok::<Bytes, SoapFault>(Bytes::from_static(b"<resp/>"))
1036                }),
1037            )
1038            .auth_bypass(["Ping"])
1039            .build();
1040        assert!(result.is_err());
1041        match result.unwrap_err() {
1042            BuildError::UnknownOperation(op) => assert_eq!(op, "NonExistentOp"),
1043            other => panic!("Expected UnknownOperation, got: {other:?}"),
1044        }
1045    }
1046
1047    #[test]
1048    fn fault_response_soap12_content_type() {
1049        use crate::wsdl::definitions::SoapVersion;
1050        let fault = SoapFault::sender("test");
1051        let response = fault_response(fault, SoapVersion::Soap12);
1052        let ct = response.headers().get("content-type").unwrap();
1053        assert_eq!(ct.to_str().unwrap(), "application/soap+xml; charset=utf-8");
1054    }
1055
1056    #[test]
1057    fn fault_response_soap11_content_type() {
1058        use crate::wsdl::definitions::SoapVersion;
1059        let fault = SoapFault::sender("test");
1060        let response = fault_response(fault, SoapVersion::Soap11);
1061        let ct = response.headers().get("content-type").unwrap();
1062        assert_eq!(ct.to_str().unwrap(), "text/xml; charset=utf-8");
1063    }
1064
1065    #[test]
1066    fn extract_body_qname_parses_namespaced_element() {
1067        let bytes = b"<tns:Ping xmlns:tns=\"http://example.com/test\"/>";
1068        let qname = extract_body_qname(bytes).unwrap();
1069        assert_eq!(qname.local_name, "Ping");
1070        assert_eq!(qname.namespace.as_deref(), Some("http://example.com/test"));
1071    }
1072
1073    #[test]
1074    fn extract_body_qname_parses_unnamespaced_element() {
1075        let bytes = b"<Ping/>";
1076        let qname = extract_body_qname(bytes).unwrap();
1077        assert_eq!(qname.local_name, "Ping");
1078        assert_eq!(qname.namespace, None);
1079    }
1080
1081    // ── find_security_header QName tests (Finding #9) ────────────────────────
1082
1083    #[test]
1084    fn find_security_header_matches_wsse_namespace() {
1085        let wsse_header = Bytes::from_static(
1086            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>"#,
1087        );
1088        let headers = [wsse_header.clone()];
1089        let result = find_security_header(&headers);
1090        assert!(
1091            result.is_some(),
1092            "Expected wsse:Security header to be found"
1093        );
1094        assert_eq!(result.unwrap(), &wsse_header);
1095    }
1096
1097    #[test]
1098    fn find_security_header_ignores_non_wsse_security_element() {
1099        // A header element named "Security" but in a different namespace must NOT match.
1100        let fake_security = Bytes::from_static(
1101            br#"<ns:Security xmlns:ns="http://example.com/other">some content</ns:Security>"#,
1102        );
1103        let headers = [fake_security];
1104        let result = find_security_header(&headers);
1105        assert!(
1106            result.is_none(),
1107            "Security element in non-WSSE namespace should NOT be selected"
1108        );
1109    }
1110
1111    #[test]
1112    fn find_security_header_ignores_element_containing_security_substring() {
1113        // An element that merely contains the word "Security" in its text must NOT match.
1114        let unrelated = Bytes::from_static(
1115            br#"<ns:Header xmlns:ns="http://example.com/other">Security policy here</ns:Header>"#,
1116        );
1117        let headers = [unrelated];
1118        let result = find_security_header(&headers);
1119        assert!(
1120            result.is_none(),
1121            "Header containing 'Security' substring but wrong QName should NOT be selected"
1122        );
1123    }
1124
1125    #[test]
1126    fn find_security_header_returns_first_valid_wsse_header() {
1127        let fake_security = Bytes::from_static(
1128            br#"<ns:Security xmlns:ns="http://example.com/other">content</ns:Security>"#,
1129        );
1130        let real_security = Bytes::from_static(
1131            br#"<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"/>"#,
1132        );
1133        let headers = [fake_security, real_security.clone()];
1134        let result = find_security_header(&headers);
1135        assert!(result.is_some());
1136        assert_eq!(result.unwrap(), &real_security);
1137    }
1138
1139    // ── max_body_bytes builder option (Finding #10) ───────────────────────────
1140
1141    #[test]
1142    fn server_builder_max_body_bytes_sets_field() {
1143        // Building with a custom max_body_bytes should succeed and produce a router.
1144        let svc = ServerBuilder::from_wsdl_bytes(MINIMAL_WSDL)
1145            .handler(
1146                "Ping",
1147                FnHandler::new(|_body: Bytes| async move {
1148                    Ok::<Bytes, SoapFault>(Bytes::from_static(b"<PingResponse/>"))
1149                }),
1150            )
1151            .auth_bypass(["Ping"])
1152            .max_body_bytes(512 * 1024) // 512 KiB
1153            .build()
1154            .expect("build should succeed");
1155        // Verify the field is stored correctly.
1156        assert_eq!(svc.max_body_bytes, 512 * 1024);
1157        // Router construction must not panic.
1158        let _router = svc.into_router();
1159    }
1160
1161    #[test]
1162    fn server_builder_default_max_body_bytes_is_2mib() {
1163        let svc = ServerBuilder::from_wsdl_bytes(MINIMAL_WSDL)
1164            .handler(
1165                "Ping",
1166                FnHandler::new(|_body: Bytes| async move {
1167                    Ok::<Bytes, SoapFault>(Bytes::from_static(b"<PingResponse/>"))
1168                }),
1169            )
1170            .auth_bypass(["Ping"])
1171            .build()
1172            .expect("build should succeed");
1173        assert_eq!(svc.max_body_bytes, 2 * 1024 * 1024);
1174    }
1175}