Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

soap-server is a WSDL-driven SOAP 1.1/1.2 server library for Rust, built on top of axum. You provide a WSDL file, register async handler closures for each operation, and get a SOAP 1.1/1.2 endpoint with no boilerplate envelope handling. It is a transport + dispatch layer — not a code generator or full XSD validator; see Capabilities & Limitations for the precise scope.

Features

  • SOAP 1.1 and 1.2 — auto-detects version from the Content-Type header and envelope namespace; responds in the same version as the incoming request.
  • WSDL-driven dispatch — operations are discovered from the WSDL at server build time. Registering a handler for an operation name that does not exist in the WSDL is a build-time error (.build() returns Err).
  • WS-Security (UsernameToken) — supports PasswordDigest and PasswordText authentication with nonce replay detection and timestamp freshness checks.
  • XSD structural validation — required elements in the request body are validated against the WSDL/XSD schema before the handler is called.

License

soap-server is dual-licensed under MIT OR Apache-2.0. You may choose either license.

Installation

Adding the dependency

cargo add soap-server

Transitive dependencies

soap-server builds on axum (HTTP routing) and tokio (async runtime). Your application needs its own Tokio runtime; the simplest way to add one is:

cargo add tokio --features full

Minimum Supported Rust Version (MSRV)

The MSRV is the rust-version declared in the crate’s Cargo.toml, shown on the crate’s crates.io page.

Quick Start

A complete, runnable service is in the repository as the simple_service example with its WSDL fixture hello.wsdl. Run it with cargo run --example simple_service. This page walks that example from WSDL operation to wire response.

The contract: one operation in the WSDL

hello.wsdl declares a single document/literal operation. The pieces that matter for wiring a handler are the operation name and its input/output element names:

<xs:element name="SayHello">                 <!-- request body element -->
  <xs:complexType><xs:sequence>
    <xs:element name="Name" type="xs:string" minOccurs="1"/>
  </xs:sequence></xs:complexType>
</xs:element>
<xs:element name="SayHelloResponse">         <!-- response body element -->
  <xs:complexType><xs:sequence>
    <xs:element name="Greeting" type="xs:string" minOccurs="1"/>
  </xs:sequence></xs:complexType>
</xs:element>
...
<wsdl:operation name="SayHello"> ... </wsdl:operation>

The server

use soap_server::{escape_text, FnHandler, ServerBuilder, SoapFault};
use bytes::Bytes;

#[tokio::main]
async fn main() {
    let svc = ServerBuilder::from_wsdl_file("examples/hello.wsdl")
        // "SayHello" MUST match the <wsdl:operation name="..."> above, or
        // .build() returns Err — misnamed handlers fail at startup.
        .handler(
            "SayHello",
            FnHandler::new(|body: Bytes| async move {
                // `body` is the <SayHello> element. Parse out <Name> (see the
                // example for the quick-xml read_text + unescape helper)...
                let name = parse_name(&body).unwrap_or_else(|| "world".into());
                // ...then return the <SayHelloResponse> element. The library wraps
                // it in the SOAP envelope verbatim, so escape any text you inject.
                let xml = format!(
                    r#"<SayHelloResponse xmlns="urn:example:hello"><Greeting>Hello, {}!</Greeting></SayHelloResponse>"#,
                    escape_text(&name)
                );
                Ok::<Bytes, SoapFault>(Bytes::from(xml))
            }),
        )
        .path("/hello")
        .build()
        .expect("WSDL build failed");

    let router = svc.into_router();
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    axum::serve(listener, router).await.unwrap();
}
fn parse_name(_b: &bytes::Bytes) -> Option<String> { None }

Try it

Send the SayHello request (SOAP 1.2 — the version is auto-detected):

curl -s http://localhost:8080/hello \
  -H 'Content-Type: application/soap+xml' \
  -d '<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
        <s:Body>
          <SayHello xmlns="urn:example:hello"><Name>Ada</Name></SayHello>
        </s:Body>
      </s:Envelope>'

You get back your SayHelloResponse element, wrapped in a SOAP 1.2 envelope by the library:

<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"><env:Body>
  <SayHelloResponse xmlns="urn:example:hello"><Greeting>Hello, Ada!</Greeting></SayHelloResponse>
</env:Body></env:Envelope>

Fetch the contract itself with a GET — every service path also serves its WSDL, with the <soap:address> rewritten to the request URL:

curl -s 'http://localhost:8080/hello?wsdl'

Step-by-step

1. ServerBuilder::from_wsdl_file

ServerBuilder::from_wsdl_file("path/to/service.wsdl") loads and parses the WSDL at the given filesystem path. The builder also accepts raw WSDL bytes or a custom WsdlLoader implementation for resolving imports over other transports.

2. .handler("MyOperation", ...)

.handler registers an async operation handler. The first argument is the operation name exactly as it appears in the WSDL <operation> element. Registering a name that is not in the WSDL causes .build() to return an error.

The second argument is any value that implements SoapHandler. The FnHandler wrapper converts a closure Fn(Bytes) -> Future<Output = Result<Bytes, SoapFault>> into a SoapHandler without you needing to implement the trait manually.

The Bytes argument your closure receives is the SOAP Body’s first child element as self-contained XML — all ancestor namespace declarations are re-emitted on the fragment root, so you can parse it independently.

Return Ok(Bytes) containing the response body element XML (without an enclosing envelope — the library adds the envelope), or return Err(SoapFault) to send a SOAP Fault.

3. .build()

Parses the WSDL, validates that every registered operation name exists, builds the dispatch table, and returns a Result<SoapService, BuildError>. Fail fast: call .expect or handle the error at startup.

4. svc.into_router()

Converts the built SoapService into an axum::Router mounted at the URL derived from the WSDL <service><port address> element. The router is a standard axum Router and can be nested or merged with other routers.

5. axum::serve

Standard axum server startup. The library has no opinion on TLS termination, timeouts, or other middleware — compose those layers on top of the returned Router before calling axum::serve.

Implementing SoapHandler directly

For handlers that need access to SOAP header fragments (e.g. WS-Addressing), implement the SoapHandler trait directly and override handle_with_headers:

#![allow(unused)]
fn main() {
use soap_server::{SoapHandler, SoapFault};
use bytes::Bytes;
use async_trait::async_trait;

struct MyHandler;

#[async_trait]
impl SoapHandler for MyHandler {
    async fn handle(&self, body: Bytes) -> Result<Bytes, SoapFault> {
        Ok(Bytes::from(r#"<MyResponse xmlns="urn:example"/>"#))
    }

    async fn handle_with_headers(
        &self,
        body: Bytes,
        headers: &[Bytes],
    ) -> Result<Bytes, SoapFault> {
        // Each element of `headers` is the raw bytes of one direct child of <Header>.
        let _ = headers; // inspect as needed
        self.handle(body).await
    }
}
}

Capabilities & Limitations

What soap-server does, what it deliberately does not, and where the edges are. Read this before assuming a WSDL will “just work” — the crate is a faithful SOAP transport and dispatch layer, not a code generator or a full XSD validator.

What it does

  • SOAP 1.1 and 1.2. The version is auto-detected from the Content-Type header (text/xml → 1.1, application/soap+xml → 1.2) and the envelope namespace; the response is emitted in the same version as the request. Faults are mapped to the correct per-version codes (see Architecture).
  • WSDL-driven dispatch. Operations are read from the WSDL at .build() time. Requests are routed by the body’s first-child element QName, with the SOAPAction header as a fallback key. Registering a handler for an operation name not present in the WSDL is a build-time error, not a runtime panic.
  • Document and RPC bindings. Both style="document" and style="rpc" SOAP bindings are parsed and dispatched. For RPC, the wrapper element namespace from the binding is used for routing/validation.
  • WS-Security UsernameToken. PasswordDigest and PasswordText, with nonce replay detection and timestamp freshness checks. Per-operation bypass via .auth_bypass([...]). See WS-Security.
  • WSDL/XSD import & include resolution. .build() resolves imported and included schema documents through a WsdlLoader (file-based by default, or your own loader for embedded/remote schemas).
  • Multi-service composition. Each SoapService becomes an axum::Router mounted at the path from its WSDL <service><port address>; merge several into one app.
  • Raw-bytes handlers. Your handler receives the body element as self-contained XML bytes (ancestor namespaces re-emitted on the fragment root) and returns response XML bytes. You own parsing and serialisation.

What “XSD structural validation” means here

Before your handler runs, the request body is checked against the operation’s input type — but only shallowly:

  • It verifies that every top-level child element with minOccurs > 0 is present (by local name, one level deep), across sequence, all, and through extension/restriction.
  • For xs:choice, individual branches are not independently required (a valid request supplies one branch), so a choice contributes no required names.

It explicitly does not:

  • validate datatypes, formats, or value facets (pattern, enumeration, minInclusive, length, …);
  • validate nested/recursive structure below the first level;
  • enforce maxOccurs, element ordering, or attribute presence;
  • reject unknown/extra elements.

In short: it catches “you forgot a required field,” not “this field is malformed.” Treat handler-side parsing as the real validation boundary.

Limitations / not supported

  • No typed handlers or code generation. There is no proc-macro and no WSDL→Rust struct generation. You work with XML bytes, not generated types.
  • No SOAP encoding (use="encoded", section 5). RPC/encoded bindings dispatch, but encoded value graphs are not decoded — the body bytes are handed to you as-is.
  • No MTOM / XOP attachments, no SwA. Request/response are single XML parts.
  • No WS-Addressing dispatch. Header fragments are exposed to handlers (handle_with_headers) so you can implement addressing yourself, but routing is by body element / SOAPAction only.
  • No automatic response validation. Bytes you return are wrapped in an envelope and sent verbatim; the crate does not check them against the WSDL.
  • WS-Security is UsernameToken only — no X.509, SAML, signing, or encryption.

Operational notes

  • Built on axum/Tokio; you provide the runtime and bind the listener.
  • Nonce replay state lives in a RotatingNonceCache. In a multi-process / load- balanced deployment the cache is per-process, so replay protection is per-node — pin a client to one node or front with sticky sessions if strict replay rejection across the fleet matters.
  • All five XML special characters are escaped via escape_text/escape_attr; use them when composing response XML from untrusted data.

WS-Security

soap-server supports the WS-Security UsernameToken profile with both PasswordDigest and PasswordText credential modes.

Enabling authentication

Call .auth(...) on the ServerBuilder before .build():

use soap_server::{FnHandler, ServerBuilder, SoapFault};
use bytes::Bytes;

#[tokio::main]
async fn main() {
    let svc = ServerBuilder::from_wsdl_file("path/to/service.wsdl")
        .auth(|username: &str| -> Option<String> {
            // Look up the expected password for the given username.
            // Return Some(password) to allow, None to deny.
            match username {
                "admin" => Some("s3cr3t".to_string()),
                _ => None,
            }
        })
        .handler(
            "MyOperation",
            FnHandler::new(|_body: Bytes| async move {
                Ok::<Bytes, SoapFault>(Bytes::from(
                    r#"<MyOperationResponse xmlns="urn:example"/>"#,
                ))
            }),
        )
        .build()
        .expect("WSDL build failed");

    let router = svc.into_router();
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    axum::serve(listener, router).await.unwrap();
}

The closure receives the username from the incoming <wsse:UsernameToken> and must return the expected plaintext password (or None to reject). The library performs the digest comparison internally.

Exempting operations

Use .auth_bypass([...]) to list operation names that do not require a <wsse:Security> header. This is useful for clock-sync or discovery operations that must be reachable unauthenticated:

#![allow(unused)]
fn main() {
use soap_server::ServerBuilder;
ServerBuilder::from_wsdl_file("path/to/service.wsdl")
    .auth(|username: &str| Some("password".to_string()))
    .auth_bypass(["GetSystemDateAndTime"])
    // ...
;
}

PasswordDigest and PasswordText

Both variants of wsse:Password are accepted:

  • PasswordText — the <wsse:Password> element contains the plaintext password. The library compares it with constant-time equality against the value your closure returns.
  • PasswordDigest — the <wsse:Password> element contains Base64(SHA-1(nonce + created + password)). The library recomputes the digest using the password your closure returns and compares with constant-time equality.

The compute_digest and validate_username_token helpers from soap_server::wssec are also exported at the crate root if you need to implement custom token validation logic.

A UsernameToken request

A PasswordDigest request carries a <wsse:Security> header in the SOAP envelope. The client computes Password = Base64(SHA-1(Nonce + Created + password)) from a fresh random Nonce and the current Created timestamp:

<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
  <s:Header>
    <wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"
                   xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
      <wsse:UsernameToken>
        <wsse:Username>admin</wsse:Username>
        <wsse:Password Type=".../username-token-profile-1.0#PasswordDigest">9kFw...=</wsse:Password>
        <wsse:Nonce>LKqI...=</wsse:Nonce>
        <wsu:Created>2026-06-03T08:00:00Z</wsu:Created>
      </wsse:UsernameToken>
    </wsse:Security>
  </s:Header>
  <s:Body>
    <MyOperation xmlns="urn:example"/>
  </s:Body>
</s:Envelope>

Nonce replay and timestamp freshness

Every request with a PasswordDigest token is checked against a rotating in-memory nonce cache. The cache window defaults to 300 seconds (±150 s half-window). A nonce seen within that window causes the request to be rejected with a Sender fault.

Timestamp freshness is enforced with a default tolerance of ±300 seconds. Requests whose <wsu:Created> timestamp falls outside that window are rejected.

The RotatingNonceCache type is exported publicly if you need to pass a pre-configured cache instance to the builder for non-default window sizes. Adjust the timestamp window with .timestamp_tolerance_secs(...) on the builder.

Distributed deployments

The nonce cache is per process, in memory. Behind a load balancer with several server processes, a nonce is only known to the node that first saw it — so a replay that lands on a different node is not detected. If strict cross-fleet replay rejection matters, pin each client to one node (sticky sessions), terminate auth at a single front door, or accept that replay protection is per-node. Timestamp freshness, by contrast, is stateless and holds on every node (assuming their clocks are in sync).

Authentication failure response

Operations that require authentication but receive a missing or invalid <wsse:Security> header receive a Sender fault (SOAP 1.1: Client) in the SOAP version of the request:

<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
  <env:Body>
    <env:Fault>
      <env:Code><env:Value>env:Sender</env:Value></env:Code>
      <env:Reason><env:Text xml:lang="en">WS-Security header required but not provided</env:Text></env:Reason>
    </env:Fault>
  </env:Body>
</env:Envelope>

Architecture

Overview

soap-server is a pipeline of discrete stages that run for every incoming HTTP request. Each stage is implemented in its own module. The library never generates code at compile time (no proc-macros); all WSDL analysis happens at server startup inside .build().

HTTP request
    │
    ▼
[envelope]   parse_envelope — detect SOAP version, extract header children + body element
    │
    ▼
[dispatch]   DispatchTable::lookup — route body first-child QName to a DispatchEntry
    │
    ▼
[dispatch]   validate_request — XSD structural check against TypeRegistry
    │
    ▼
[server]     WS-Security check (if auth enabled and operation not in bypass set)
    │
    ▼
[handler]    SoapHandler::handle_with_headers — your application logic
    │
    ▼
[envelope]   serialize_envelope — wrap response bytes in a SOAP envelope
    │
    ▼
HTTP response

Modules

dispatch

Builds and holds the DispatchTable — a pair of HashMap keyed on body-element QName (primary) and SOAPAction string (fallback). The table is built once from a ResolvedWsdl during .build() and never mutated per-request, so lookups are O(1) and lock-free.

Each DispatchEntry carries the Arc<dyn SoapHandler>, an auth_required flag, the input_type QName used for routing, and a validation_type QName used for XSD structural validation. Both may be None for operations with empty or omitted input elements.

The build step rejects any operation name that was registered via .handler() but does not appear in the WSDL — this is the mechanism that makes unregistered-operation detection a build-time error rather than a runtime panic.

server

ServerBuilder and SoapService live here. ServerBuilder accumulates the WSDL source, handler map, auth closure, bypass set, and other configuration. .build() resolves the WSDL (including any imported XSDs), constructs the DispatchTable and TypeRegistry, and returns a SoapService.

SoapService::into_router() mounts the service’s HTTP handler at the URL derived from the WSDL <service><port address> element and returns a plain axum::Router. Merging multiple such routers is how multi-WSDL / multi-service deployments are composed:

#![allow(unused)]
fn main() {
use soap_server::ServerBuilder;
async fn example() -> Result<(), Box<dyn std::error::Error>> {
let svc_a = ServerBuilder::from_wsdl_file("a.wsdl").build()?.into_router();
let svc_b = ServerBuilder::from_wsdl_file("b.wsdl").build()?.into_router();

// Each router mounts at its own path; merge them into one axum app.
let app = svc_a.merge(svc_b);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
axum::serve(listener, app).await?;
Ok(())
}
}

handler

Defines the SoapHandler trait and the FnHandler convenience wrapper.

SoapHandler has two methods:

  • handle(body: Bytes) -> Result<Bytes, SoapFault> — receives the body element fragment, returns response XML bytes.
  • handle_with_headers(body: Bytes, headers: &[Bytes]) -> Result<Bytes, SoapFault> — default implementation calls handle, ignoring header fragments. Override this to inspect WS-Addressing or other header-level protocols.

FnHandler<F> wraps any Fn(Bytes) -> impl Future<Output = Result<Bytes, SoapFault>> into a SoapHandler.

fault

SoapFault carries a FaultCode, a human-readable reason string, an optional plain-text detail string, and an optional detail_xml field for pre-formed XML detail content.

FaultCode variants map to both SOAP 1.1 and 1.2 fault code strings:

FaultCode variantSOAP 1.2SOAP 1.1
VersionMismatchenv:VersionMismatchenv:VersionMismatch
MustUnderstandenv:MustUnderstandenv:MustUnderstand
DataEncodingUnknownenv:DataEncodingUnknownenv:Server
Senderenv:Senderenv:Client
Receiverenv:Receiverenv:Server

The detail_xml field takes precedence over detail when both are set, and its content is emitted verbatim (not escaped) into the fault detail element.

envelope

parse_envelope and serialize_envelope handle the SOAP envelope layer for both protocol versions. detect_soap_version infers the version from the Content-Type header (text/xml → SOAP 1.1, application/soap+xml → SOAP 1.2). The parsed ParsedEnvelope exposes the SOAP version, a Vec<Bytes> of header child fragments, and the body first-child element as self-contained XML bytes (with ancestor namespace declarations re-emitted on the fragment root).

qname

QName is a lightweight (Option<String>, String) tuple representing an XML qualified name. It is the key type for DispatchTable’s element-based lookup map. QName::new creates a namespaced name; QName::local creates a no-namespace name.

xml_escape

escape_text and escape_attr are thin wrappers over quick_xml::escape::escape for escaping all five XML special characters. They are separate functions to communicate intent at call sites and to allow future divergence (e.g. skipping " in text-only contexts). Both functions are exported at the crate root.

Conformance

soap-server’s SOAP output is validated against independent authorities, not just self-checked. This page summarises what that means and how to reproduce it.

What was validated

A differential conformance and interop harness (crossref/, a non-published workspace member) exercises 25 seed scenarios — 23 conformance + 2 interop — to verified status. It runs in two layers:

  • Layer 1 — in-process replay. Scenarios run against a controlled in-process service; each response is normalised (parse → path-scoped mask → deterministic serialize) and diffed against a golden snapshot, so behaviour changes show up as reviewable snapshot diffs.
  • Layer 2 — external authority (Docker). Responses are validated by independent, fully containerised authorities: a Java XML oracle (JAXP / Xerces schema validation + Apache Santuario exclusive C14N), an Apache CXF reference server, and real third-party SOAP client containers for interop. The host needs only Docker and the Rust toolchain — no Java, Python, or CXF installed locally.

What a pass means — and doesn’t

A pass means the enveloping, faults, WS-Security handling, and dispatch produce SOAP that is schema-valid and interoperable with mainstream SOAP stacks (CXF, zeep) for the exercised scenarios. It does not certify every WSDL construct or the features listed as unsupported in Capabilities & Limitations.

Reproducing it

# Layer 1 (no Docker): replay against frozen snapshots
cargo test -p crossref --test layer1_replay

# Layer 2 (Docker): conformance + interop against external authorities
cargo run -p crossref --bin layer2 -- --promote --interop

Full details, the scenario contract, and the authority setup are in the harness README: https://github.com/NavistAu/soap-server/blob/main/crossref/README.md.