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-Typeheader 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()returnsErr). - WS-Security (UsernameToken) — supports
PasswordDigestandPasswordTextauthentication 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-Typeheader (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 elementQName, with theSOAPActionheader 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"andstyle="rpc"SOAP bindings are parsed and dispatched. For RPC, the wrapper element namespace from the binding is used for routing/validation. - WS-Security UsernameToken.
PasswordDigestandPasswordText, 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 aWsdlLoader(file-based by default, or your own loader for embedded/remote schemas). - Multi-service composition. Each
SoapServicebecomes anaxum::Routermounted 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 > 0is present (by local name, one level deep), acrosssequence,all, and throughextension/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 containsBase64(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 callshandle, 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 variant | SOAP 1.2 | SOAP 1.1 |
|---|---|---|
VersionMismatch | env:VersionMismatch | env:VersionMismatch |
MustUnderstand | env:MustUnderstand | env:MustUnderstand |
DataEncodingUnknown | env:DataEncodingUnknown | env:Server |
Sender | env:Sender | env:Client |
Receiver | env:Receiver | env: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.