WASM Custom Rules
WASM Custom Body Matchers
MockServer supports WebAssembly (WASM) modules as custom body matchers. This lets you write complex matching logic in any language that compiles to WASM (Rust, C, Go, AssemblyScript, etc.) and upload the compiled module to MockServer at runtime.
The real constraint behind “any language” is the module shape: it must be a standalone core-WASM module with no WASI imports (the chicory interpreter registers no host functions, so a module that imports WASI — or anything else — will not instantiate and the match fails closed). It must export match (or match_request) and use a single linear memory. In practice this means compiling for a freestanding/no-WASI target — for example Rust's wasm32-unknown-unknown (not wasm32-wasi), or a TinyGo -target=wasm-unknown build.
The WASM module runs inside a pure-Java interpreter (chicory, on its stable 1.x release line), so no native libraries or JNI are required. The module is sandboxed and cannot access the host filesystem, network, or JVM.
Enabling WASM support
WASM body matching is disabled by default. Enable it with:
# environment variable MOCKSERVER_WASM_ENABLED=true # Java system property -Dmockserver.wasmEnabled=true # configuration property file mockserver.wasmEnabled=true
Writing a WASM matcher module
Your WASM module must export a function called match with the following signature:
Full runnable examples: the WASM custom-rule examples in the repository implement the same body matcher in both Rust and Go — including a prebuilt .wasm, the MockServer WASM ABI contract, and build instructions.
;; WAT (WebAssembly Text Format)
(module
(memory (export "memory") 1)
(func $match (export "match") (param $ptr i32) (param $len i32) (result i32)
;; Read $len bytes from linear memory starting at $ptr
;; Return 1 for match, 0 for no match
i32.const 1 ;; always matches (example)
)
)
Before calling match, MockServer writes the HTTP request body (UTF-8 encoded) into the module's linear memory at offset 0. The $ptr parameter is 0 and $len is the byte length of the body.
Rust example
#[no_mangle]
#[export_name = "match"]
pub extern "C" fn match_fn(ptr: *const u8, len: usize) -> i32 {
let body = unsafe { std::slice::from_raw_parts(ptr, len) };
let body_str = std::str::from_utf8(body).unwrap_or("");
if body_str.contains("expected_value") { 1 } else { 0 }
}
Note: When compiling Rust to WASM, export the function as match (you may need #[export_name = "match"] since match is a Rust keyword).
Matching on method, path, query parameters, headers and cookies (richer ABI)
The body-only match export above sees only the request body. If you also need the request method, path, query-string parameters, headers or cookies, export a function called match_request instead. MockServer prefers match_request when present and falls back to match otherwise, so existing body-only modules keep working unchanged.
Instead of just the body, MockServer writes a UTF-8 JSON envelope into linear memory at offset 0 and calls match_request(0, len):
{
"version": 2,
"method": "POST",
"path": "/orders",
"queryStringParameters": { "tenant": ["acme"] },
"headers": { "X-Tenant": ["acme"], "Accept": ["application/json"] },
"cookies": { "session": "abc123" },
"body": "..."
}
Each query-parameter and header name maps to an array of values (so repeated parameters and multi-valued headers are preserved); each cookie name maps to its single value; body is the request body, or null when absent. The top-level version field is the envelope version (currently 2).
The envelope is versioned and backward compatible: newer versions only ever add top-level fields. Version 2 added version, queryStringParameters and cookies to the original method/path/headers/body envelope. A module written for an older version simply ignores the fields it does not know, so it keeps working unchanged.
Authoring SDK
To save you hand-parsing that envelope, the repository ships a tiny, dependency-free Rust authoring crate mockserver-wasm-sdk with typed accessors (req.method(), req.path(), req.query_param("tenant"), req.header("X-Tenant"), req.cookie("session"), req.body(), req.version()) and an export_match_request! macro that wires up the ABI:
#![no_std]
use mockserver_wasm_sdk::{export_match_request, Request};
fn rule(req: &Request) -> bool {
req.method() == "POST"
&& req.path() == "/orders"
&& req.query_param("tenant") == Some("acme")
&& req.cookie("session") == Some("abc123")
}
export_match_request!(rule);
query_param and cookie require envelope version 2; against an older MockServer they return None, so the rule stays backward compatible.
See the SDK crate and sample modules in the repository (each with a prebuilt .wasm): method + path + header and method + path + query parameter + cookie.
Computing the response (response shaping)
A WASM module can also compute the response, not just match the request. If your module exports an optional shape_response function, MockServer calls it after a match, passing the response the matched expectation would return, and applies whatever the module returns — letting you generate dynamic responses in WASM. A module without this export stays a pure matcher; a module can export both match_request and shape_response to match first, then shape.
MockServer writes a JSON shape envelope into linear memory and calls shape_response(0, len). It nests the same request envelope your matcher sees under request, plus the response the expectation would return under response:
{
"version": 3,
"request": { "version": 2, "method": "POST", "path": "/shape", "headers": {}, "body": "{}" },
"response": { "statusCode": 201, "headers": { "Content-Type": ["application/json"] }, "body": "{\"name\":\"acme\"}" }
}
The module returns a possibly-partial response { "statusCode", "headers", "body" }: a returned statusCode replaces the status, each returned header is merged in (overwriting a same-named header, leaving others intact), and a returned body replaces the body. Anything omitted is left unchanged, and returning nothing leaves the response exactly as it was.
The mockserver-wasm-sdk crate makes this ergonomic with a ShapeEnvelope reader and a ResponseBuilder. This module matches POST /shape, adds an X-Shaped header, and rewrites the JSON body:
#![no_std]
use mockserver_wasm_sdk::{export_match_and_shape_response, write_parts, Request, ResponseBuilder, ShapeEnvelope};
fn matches(req: &Request) -> bool {
req.method() == "POST" && req.path() == "/shape"
}
fn shape(env: &ShapeEnvelope, out: ResponseBuilder) -> i64 {
let mut buf = [0u8; 1024];
let original = env.response().body_unescaped(&mut buf).unwrap_or("{}");
let name = Request::new(original.as_bytes()).field("name").unwrap_or("world");
let mut greeting = [0u8; 256];
let body = write_parts(&mut greeting, &["{\"greeting\":\"Hello, ", name, "!\",\"shaped\":true}"]);
out.status(200).header("X-Shaped", "true").body(body).finish()
}
export_match_and_shape_response!(matches, shape);
Response shaping is fail-safe: if a module traps, returns invalid JSON, or returns more than 1 MiB, MockServer keeps the original (unshaped) response and logs a warning once per module — a broken module never breaks the request. See the response-shaping sample module.
Uploading a WASM module
Upload a compiled WASM module using the REST API:
# Upload a WASM module named "myMatcher" curl -X PUT "http://localhost:1080/mockserver/wasm/modules?name=myMatcher" \ --data-binary @my_matcher.wasm
Using a WASM body matcher in an expectation
{
"httpRequest": {
"method": "POST",
"path": "/api/data",
"body": {
"type": "WASM",
"moduleName": "myMatcher"
}
},
"httpResponse": {
"statusCode": 200,
"body": "matched by WASM rule"
}
}
Managing WASM modules
List loaded modules
curl http://localhost:1080/mockserver/wasm/modules # Returns: ["myMatcher", "anotherModule"]
Remove a module
curl -X DELETE "http://localhost:1080/mockserver/wasm/modules?name=myMatcher"
All WASM modules are also cleared when calling PUT /mockserver/reset.
Test a module against a sample request
To check what a module does before wiring it into an expectation, POST the module plus a sample request to /mockserver/wasm/test. This is handy for IDE integrations and quick local iteration — it runs the module against your sample and tells you whether it matched, without storing the module or creating an expectation.
curl -X POST "http://localhost:1080/mockserver/wasm/test" \
-H "Content-Type: application/json" \
-d '{
"module": "<base64-encoded .wasm>",
"request": {
"method": "POST",
"path": "/orders",
"queryStringParameters": { "tenant": ["acme"] },
"headers": { "X-Tenant": ["acme"] },
"cookies": { "session": "abc123" },
"body": "{}"
}
}'
# Returns: {"matched":true}
Supply either module (base64-encoded WASM bytes) or moduleName (a module already uploaded). The request object is optional and defaults to an empty body-only request; within it, queryStringParameters and headers take an array of values (or a single value) per name and cookies takes a single value per name. Like live matching, this is fail-closed: an invalid module returns {"matched":false} rather than an error.
To also preview response shaping, include a candidate response ({ "statusCode", "headers", "body" }). The result then carries a shaped field with the response the module would produce, or null when the module does not shape:
curl -X POST "http://localhost:1080/mockserver/wasm/test" \
-H "Content-Type: application/json" \
-d '{
"module": "<base64-encoded .wasm>",
"request": { "method": "POST", "path": "/shape" },
"response": { "statusCode": 201, "headers": { "Content-Type": ["application/json"] }, "body": "{\"name\":\"acme\"}" }
}'
# Returns: {"matched":true,"shaped":{"statusCode":200,"headers":{"X-Shaped":["true"]},"body":"{\"greeting\":\"Hello, acme!\",\"shaped\":true}"}}
Configuration properties
| Property | Environment Variable | Default | Description |
|---|---|---|---|
| mockserver.wasmEnabled | MOCKSERVER_WASM_ENABLED | false | Enable WASM body matching. Must be set to true to upload, list, delete, or match with WASM modules. When false, control-plane endpoints return 403 and matchers return no match. |
| mockserver.wasmMaxMemoryPages | MOCKSERVER_WASM_MAX_MEMORY_PAGES | 256 | Maximum number of WASM linear memory pages (each page is 64 KiB). Default 256 = 16 MiB. Enforced at WASM instance creation via chicory's memory limits. |
Error handling
WASM matching uses a fail-closed design. If any error occurs during matching (module not found, invalid WASM binary, runtime trap, missing match export), the matcher returns no match rather than throwing an exception. This prevents a broken WASM module from disrupting other expectations.
Security
- WASM modules run inside the chicory interpreter sandbox -- they cannot access the host filesystem, network, or JVM internals
- Linear memory is capped at wasmMaxMemoryPages pages (default 256 = 16 MiB) to prevent resource exhaustion. The limit is enforced at instance creation via chicory's MemoryLimits.
- The feature is opt-in (disabled by default). When wasmEnabled is false, all WASM control-plane endpoints (upload, list, delete) return 403 Forbidden, and WASM body matchers always return no match.
- All WASM control-plane endpoints respect MockServer's authentication settings (mTLS/JWT)