Pure Rust spec-driven mock runtime for:
- HTTP REST from OpenAPI 3.0.x and 3.1.x
- WebSocket from AsyncAPI 2.x and 3.x
- gRPC (unary + server-streaming) from Protobuf over HTTP/2
It supports standalone CLI usage and native Rust SDK embedding for #[tokio::test].
| Feature | spec-mock | Prism |
|---|---|---|
| OpenAPI 3.0 / 3.1 | ✅ | ✅ |
Multi-file $ref resolution |
✅ | ✅ |
Prefer: code=xxx header |
✅ | ✅ |
Prefer: example=xxx header |
✅ | ✅ |
Prefer: dynamic=true header |
✅ | ✅ |
Content negotiation (Accept) |
✅ | ✅ |
| Non-JSON response media types | ✅ | ✅ |
| Declared response headers | ✅ | ✅ |
| RFC 7807 Problem Details errors | ✅ | ✅ |
| Request body validation | ✅ | ✅ |
| Query/path/header param validation | ✅ | ✅ |
| Multi-value query params | ✅ | ✅ |
| Proxy mode with validation | ✅ | ✅ |
| Callbacks / Webhooks | ✅ | ❌ |
| AsyncAPI WebSocket mocking | ✅ | ❌ |
| AsyncAPI v3 support | ✅ | ❌ |
| gRPC Protobuf mocking (HTTP/2) | ✅ | ❌ |
| gRPC server-streaming | ✅ | ❌ |
| Rust SDK (embed in tests) | ✅ | ❌ |
| Configurable body size limit | ✅ | ❌ |
| Content-Type validation (415) | ✅ | ❌ |
| Content negotiation (406) | ✅ | ❌ |
| Configurable WebSocket path | ✅ | ❌ |
| Stateful mocking (scenarios, store) | ✅ | ❌ |
| Stateful WebSocket channels | ✅ | ❌ |
| Stateful gRPC methods | ✅ | ❌ |
| Create-then-read-back resource CRUD | ✅ | ❌ |
| Scenario failure injection | ✅ | ❌ |
- Request validation against spec schema.
- RFC 7807
application/problem+jsonerror responses with structured validation details. - Response generation priority:
example→examples[0]→default→ deterministic schema faker. - Content negotiation via the
Acceptheader across every declared media type; an unsatisfiableAcceptreturns406. WithoutAccept, the JSON family is preferred. - Response body encoding per media type: compact JSON, XML (with schema-derived root
element), and — for
text/*and binary types alike — a string value emitted verbatim,nullas an empty body, and any other value as its JSON form. - Declared response headers are emitted, using
examplewhen present and the faker otherwise. - Faker supports:
pattern(regex), all standardformatvalues, seededenumandoneOf/anyOfvariant selection,discriminator+mapping,additionalProperties,defaultvalues. - Proxy mode for HTTP: forwards upstream response and validates it against OpenAPI response schema.
Preferheader: select response by status code, named example, or force dynamic generation.- Multi-value query parameters: repeated keys are collected and validated against the
itemssubschema. OpenAPI serialization (style/explode) is not implemented. - Callback/webhook firing on matched operations (fire-and-forget).
- Configurable request body size limit (default 10 MiB, 413 on exceeded).
- Content-Type validation returns 415 for media types the operation does not declare.
- gRPC error metadata includes
grpc-status-details-binplusgrpc-messageandgrpc-status. - AsyncAPI v2 and v3 with multi-path WebSocket routing on a configurable base path.
- Stateful mocking via a scenario sidecar: named flows, resource CRUD backed by a store, pre-seeding, per-session isolation, and error injection as a state transition. See Stateful Mocking.
The CLI subcommand is spec-mock serve (via cargo run -p spec-mock -- serve during development).
cargo run -p spec-mock -- serve \
--openapi docs/specs/pets.openapi.yaml \
--http-addr 127.0.0.1:4010
curl http://127.0.0.1:4010/pets/1Invalid request example:
curl -i http://127.0.0.1:4010/pets/abcError responses use RFC 7807 format (application/problem+json):
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Request validation failed",
"errors": [
{
"instance_pointer": "/id",
"schema_pointer": "/minimum",
"keyword": "minimum",
"message": "..."
}
]
}Select a specific HTTP status code:
curl -H "Prefer: code=404" http://127.0.0.1:4010/pets/1Select a named response example:
curl -H "Prefer: example=whiskers" http://127.0.0.1:4010/pets/1Force dynamic (faker-generated) response:
curl -H "Prefer: dynamic=true" http://127.0.0.1:4010/pets/1Combine preferences:
curl -H "Prefer: code=200, example=fluffy" http://127.0.0.1:4010/pets/1A preference that cannot be satisfied is reported instead of being silently ignored:
Prefer: code=418 on a spec without an 418 response returns 404, and
Prefer: example=nope on a response without that named example returns 404.
The Accept header selects among all media types declared for the selected response. Using
the bundled docs/specs/reports.openapi.yaml example:
cargo run -p spec-mock -- serve \
--openapi docs/specs/reports.openapi.yaml \
--http-addr 127.0.0.1:4010
curl -H "Accept: application/json" http://127.0.0.1:4010/report
curl -H "Accept: text/plain" http://127.0.0.1:4010/report
curl -H "Accept: application/xml" http://127.0.0.1:4010/inventoryResponse bodies are encoded per media type:
| Media type family | Body |
|---|---|
application/json, *+json |
compact JSON |
application/xml, text/xml, *+xml |
XML document rooted at xml.name / title / root |
text/*, application/yaml, … |
the string value verbatim, otherwise JSON |
image/*, application/octet-stream, … |
the string value verbatim, otherwise JSON |
An Accept header that matches none of the declared media types returns 406:
curl -i -H "Accept: application/vnd.custom+report" http://127.0.0.1:4010/reportDeclared response headers are emitted when their status is selected:
curl -i -H "Prefer: code=429" http://127.0.0.1:4010/limitedRequests are checked the same way: a non-empty body whose Content-Type is not declared by
the operation returns 415, and only application/json bodies are schema-validated.
Non-JSON bodies satisfy requestBody.required but are not parsed.
Supports both AsyncAPI v2.x and v3.x specs.
cargo run -p spec-mock -- serve \
--asyncapi docs/specs/chat.asyncapi.yaml \
--http-addr 127.0.0.1:4011WebSocket endpoint: ws://127.0.0.1:4011/ws
Use --ws-path to move the WebSocket endpoint:
cargo run -p spec-mock -- serve \
--asyncapi docs/specs/chat.asyncapi.yaml \
--ws-path /socketPer-channel endpoints are derived from the base path, so the example above also serves
ws://127.0.0.1:4011/socket/chat.send.
Input envelope options:
- Explicit channel envelope:
{"channel":"chat.send","payload":{...}} - Alias envelope:
{"topic":"chat.send","data":{...}} - Auto-routing: send raw payload and runtime matches by publish schema.
Serves HTTP/2 over hyper-util with hand-rolled gRPC framing and trailers; tonic is used
only for its Status type, not its transport. Supports unary and server-streaming RPCs.
Client-streaming, bidi-streaming, and server reflection are not supported — grpcurl
requires an explicit -proto.
cargo run -p spec-mock -- serve \
--proto docs/specs/greeter.proto \
--grpc-addr 127.0.0.1:5010 \
--http-addr 127.0.0.1:4012
grpcurl -plaintext \
-import-path docs/specs \
-proto greeter.proto \
-d '{"name":"alice"}' \
127.0.0.1:5010 mock.Greeter/SayHellospec-mock supports --mode mock (default) and --mode proxy.
Proxy example:
cargo run -p spec-mock -- serve \
--openapi docs/specs/pets.openapi.yaml \
--mode proxy \
--upstream http://127.0.0.1:8080 \
--http-addr 127.0.0.1:4010In proxy mode, if upstream JSON response violates OpenAPI schema, runtime returns 502 with aggregated schema errors.
spec-mock serve [OPTIONS]
Options:
--openapi <PATH> OpenAPI spec file path
--asyncapi <PATH> AsyncAPI spec file path
--proto <PATH> Protobuf root .proto file path
--mode <MODE> Runtime mode [default: mock] [possible values: mock, proxy]
--upstream <URL> Proxy upstream base URL
--seed <SEED> Deterministic data seed [default: 42]
--http-addr <ADDR> HTTP bind address [default: 127.0.0.1:4010]
--grpc-addr <ADDR> gRPC bind address [default: 127.0.0.1:5010]
--ws-path <PATH> WebSocket base path [default: /ws]
--max-body-size <BYTES> Maximum request body size in bytes [default: 10485760]
--allow-private-upstream Allow private/loopback/link-local proxy upstreams
--scenarios <PATH> Scenario sidecar declaring stateful behaviour
--max-sessions <N> Cap on live scenario instances [default: 256]
--max-store-entries <N> Cap on stored resources per instance [default: 1024]
--log-level <LEVEL> Log verbosity [default: info]
[possible values: error, warn, info, debug, trace]
--no-control-endpoints Disable the control endpoints under /_specmock/
By default every request is independent: the mock answers from the schema, and nothing is remembered. A scenario sidecar changes that. It declares named flows whose responses depend on what already happened, plus a store so a client can create a resource and read that same resource back.
Behaviour lives in a sidecar resolved beside the OpenAPI document, not in
x-specmock-* extensions inside it. Requiring extensions would mean forking the
input spec, and forking breaks every $ref into shared schema files — after
which the mock is no longer exercising the real contract. See
ADR 0002.
cargo run -p spec-mock -- serve \
--openapi docs/specs/store.openapi.yaml \
--scenarios docs/specs/store.scenarios.yaml \
--log-level info# docs/specs/store.scenarios.yaml
version: 1
operations:
createWidget:
match: { method: POST, path: /widgets }
store:
resource: /widgets
action: create
keyFrom: { in: request, pointer: /id }
responses:
"201": {}
getWidget:
match: { method: GET, path: "/widgets/{id}" }
store: { resource: /widgets, action: read, key: "{id}" }
responses:
"200": {}
listWidgets:
match: { method: GET, path: /widgets }
store: { resource: /widgets, action: list }
responses:
"200": {}
scenarios:
- name: catalogue
initialState: browsing
seed:
- resource: /widgets
items:
- { id: seeded-1, name: Preloaded Widget }
states:
browsing:
createWidget: { to: browsing }
getWidget: {}
listWidgets: {}Create a resource, then read back exactly what was created:
curl -sX POST localhost:4010/widgets \
-H 'Content-Type: application/json' \
-d '{"id":"w1","name":"First"}'
curl -s localhost:4010/widgets/w1 # => {"id":"w1","name":"First"}
curl -s localhost:4010/widgets # => [{"id":"seeded-1",...},{"id":"w1",...}]Quote a path containing
{...}. In YAML flow style an unquoted{id}starts a nested mapping rather than a string. Use block style or quotes.
| Header | Effect |
|---|---|
Prefer: scenario=<name> |
Run that scenario. Absent, the first declared one. |
Prefer: session=<key> |
Address an isolated instance. Absent, one default instance. |
Session keying is opt-in over a singleton default: embedded mode already gives each test its own server, while process mode shares one child across the whole session. Tests that run in parallel ask for their own key.
| Action | Behaviour when the resource is absent |
|---|---|
create |
Stored. A colliding identifier is 409. |
read |
404 |
update (PUT/PATCH) |
404. Not an upsert. |
delete |
404, and later reads also 404 |
list |
[], in creation order |
Update is deliberately not an upsert. RFC 9110 permits create-on-PUT, but for a
mock the 404 is the product: a test that updates something it never created has
a bug, and upserting would let it pass green.
Keys are (collection path template with parameters bound, identifier), so
/users/5/orders and /orders never collide on a shared id.
Query parameters are not applied to list — no filtering, no pagination. A
partially-honoured list is worse than a documented simple one.
An error is a transition into a state that declares one, not a separate mechanism:
states:
healthy:
getWidget: { to: failing, responses: { "503": { body: { error: unavailable } } } }
failing:
getWidget: {}Prefer: code= still works and short-circuits the machine entirely — see
ADR 0001. So does
Prefer: dynamic=true, which bypasses the store. Both are read-only escape
hatches and neither advances state, so neither changes meaning depending on
whether a scenario is active.
Stateful failures are RFC 7807 with structured extension members, not
validation issues. An operation the state machine refuses has no instance and no
schema pointer, so forcing it into a ValidationIssue would mean writing
pointers that point at nothing.
| Condition | Status |
|---|---|
| Unknown scenario name | 404, detail lists the scenarios that exist |
| Operation not legal in the current state | 409 |
| Unknown resource | 404 |
| Create identifier already taken | 409 |
| Live session cap exceeded | 429, detail names --max-sessions |
| Store entry cap exceeded | 500, detail names --max-store-entries |
{
"type": "about:blank",
"title": "Conflict",
"status": 409,
"detail": "operation 'getWidget' is not legal in state 'spent' of scenario 'once' (session '__default__')",
"scenario": "once",
"session": "__default__",
"state": "spent",
"operation": "getWidget"
}With a sidecar the guarantee becomes same seed + same request sequence
produces the same response sequence. A store is irreducibly order-dependent:
delete-then-read-must-404 requires remembering that a deletion happened. See
ADR 0003. State does not
survive a restart.
An invalid request changes nothing — not the state, not the store. The client is
the thing under test, so a client bug must not poison every later assertion. A
store 404 is different: it is a legitimate outcome of a valid request, so it
does advance.
Process mode has no handle into a running child, so state is reachable over HTTP
under the reserved /_specmock/ prefix. It is the only mutating surface on the
server, so it is enabled only with --scenarios or .control_endpoints() and
every request must present the token printed at startup.
curl -H "Authorization: Bearer $TOKEN" localhost:4010/_specmock/state
curl -X POST -H "Authorization: Bearer $TOKEN" localhost:4010/_specmock/resetEmbedded mode uses the handle instead:
let server = MockServer::builder()
.openapi("docs/specs/store.openapi.yaml")
.scenarios("docs/specs/store.scenarios.yaml")
.start()
.await?;
server.reset_scenarios(); // state and store, together
let (scenario, state) = server.scenario_state("").unwrap();The same sidecar drives all three protocols; only the addressing differs.
| Protocol | Operation key | Scenario and session | Identifier source |
|---|---|---|---|
| HTTP | post /widgets |
Prefer: headers |
path parameter (key) |
| WebSocket | channel widgets.created |
?scenario=&session= on the handshake |
message field (keyFrom) |
| gRPC | unary /widgets.Widgets/Create |
lowercase metadata | message field (keyFrom) |
A WebSocket connection is its own session by default and is released when it
closes; ?session= opts into sharing. Over gRPC, a create stores the
generated reply rather than the request, because a gRPC request and its
response are distinct protobuf types — storing the request would hand the client
bytes of the wrong message. The stored message's identifier is aligned with the
key, so reading back by the identifier the client was just given succeeds.
Over gRPC a responses key is a gRPC status code, not an HTTP status:
states:
steady:
unstableWidget: { to: broken }
broken:
unstableWidget: { responses: { "13": {} } } # INTERNAL- Scenarios in proxy mode. The upstream holds the real state; startup rejects the combination and names both settings.
- Filtering or pagination on
list. - State surviving a restart.
- Session expiry. A TTL would reintroduce wall-clock dependence into a tool whose pitch is reproducibility.
- Atomic read-modify-write. Two concurrent
PUTs to one resource may interleave.
Absent a sidecar, every existing test behaves exactly as it did before scenarios
existed. That regression guard is enforced by
an_absent_sidecar_leaves_plain_mocking_untouched.
use specmock_sdk::MockServer;
#[tokio::test]
async fn mock_server_for_test() -> Result<(), Box<dyn std::error::Error>> {
let server = MockServer::builder()
.openapi("docs/specs/pets.openapi.yaml")
.seed(42)
.start()
.await?;
let response = hpx::get(format!("{}/pets/1", server.http_base_url())).send().await?;
assert_eq!(response.status().as_u16(), 200);
server.shutdown().await;
Ok(())
}MockServer::ws_url() reflects the configured WebSocket path, so AsyncAPI specs can be
served on any route:
# use specmock_sdk::MockServer;
# async fn run() -> Result<(), Box<dyn std::error::Error>> {
let server = MockServer::builder()
.asyncapi("docs/specs/chat.asyncapi.yaml")
.ws_path("/socket")
.start()
.await?;
assert!(server.ws_url().ends_with("/socket"));
server.shutdown().await;
# Ok(())
# }use std::path::Path;
use specmock_sdk::MockServer;
# async fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut server = MockServer::builder()
.openapi("docs/specs/pets.openapi.yaml")
.start_process_with_bin(Path::new("target/debug/spec-mock"))
.await?;
let response = hpx::get(format!("{}/pets/1", server.http_base_url())).send().await?;
assert_eq!(response.status().as_u16(), 200);
server.shutdown()?;
# Ok(())
# }just format
just lint
just testThe test suite is layered so that each layer catches a different class of defect.
| Layer | Where | Runs in |
|---|---|---|
| Unit tests | #[cfg(test)] modules next to the code |
just test |
| Property tests | proptest blocks in the same modules |
just test |
| Integration / E2E | crates/*/tests/*.rs |
just test |
| Fuzzing | crates/*/fuzz/fuzz_targets/*.rs |
just fuzz |
| Coverage | instrumented re-run of the suite | just test-coverage |
Property tests reseed on every run, so a single green run is not evidence that a
property is sound. Run the suite a few times, and commit any
proptest-regressions/ files that appear — each one is a minimal counterexample
that should stay covered from then on.
just test runs everything, including the proptest properties — they are part
of the normal loop, not a separate command. Each module pairs example-based
tests for named cases with properties for invariants that must hold across many
generated inputs:
faker— generation is deterministic per seed, integer/string/array bounds are always honoured, and hostile schemas never panic.validate— the validator cache key is insensitive to member ordering but sensitive to any leaf change, so two schemas never share a validator.ref_resolver— JSON-pointer navigation returns the addressed node orNone, andsplit_refalways partitions the input on the first#.schema— extracted mappings are always sorted and stringly typed.router— declared templates always match their instantiation, while unrelated paths never match.media— the JSON/XML/text/binary families stay mutually exclusive, XML output is always a balanced single-rooted tree, and raw metacharacters never survive escaping.negotiate— content negotiation always yields a declared media type and preference parsing never panics.openapi— parsing an arbitrary document never panics.protobuf— gRPC framing round-trips exactly, and decoding arbitrary bytes either succeeds within the frame bounds or fails cleanly.asyncapi— every handled message returns a well-formed outcome.runtime— startup hashing is total and deterministic, and error rendering never leaks absolute paths.sdk— the configuration builder accepts any chain of settings without panicking.
Fuzz targets live in the crate that owns the code under test and are excluded
from the workspace, so they never slow down cargo test:
just fuzz-build # compile the targets
just fuzz 60 # 60s per target
just fuzz-smoke 10 # short CI-friendly run
just ci-fuzz # lint + test + build + smoke fuzz| Target | Crate | Invariant |
|---|---|---|
ref_resolver |
specmock-core |
$ref resolution of hostile documents and ref strings never panics |
json_pointer |
specmock-core |
pointer navigation never returns a value absent from the document |
faker |
specmock-core |
generation is deterministic, bounded, and panic-free |
validate |
specmock-core |
validation is total and its issues are always fully populated |
media_encode |
specmock-runtime |
encoding never panics; JSON round-trips; XML stays balanced |
negotiate |
specmock-runtime |
negotiation only ever returns a declared media type |
The integration-test command runs a side-by-side comparison suite against Prism to verify that spec-mock and Prism return structurally equivalent responses.
Prerequisites:
npm install -g @stoplight/prism-cliRun:
just integration-testConfiguration via environment variables:
| Variable | Default | Description |
|---|---|---|
SPECMOCK_FUZZ_SEED |
42 |
Random seed for reproducible fuzz requests |
SPECMOCK_FUZZ_ITERATIONS |
5 |
Number of fuzz iterations per operation |
SPECMOCK_PRISM_CMD |
auto-detected | Path to Prism binary or npx invocation |
Example with custom seed:
SPECMOCK_FUZZ_SEED=123 SPECMOCK_FUZZ_ITERATIONS=20 just integration-testCI (GitHub Actions example):
- name: Install Prism
run: npm install -g @stoplight/prism-cli
- name: Run integration tests
run: just integration-testKnown behavioral divergences:
- Wrong Content-Type handling: spec-mock returns
415 Unsupported Media Type; Prism returns422or400. Both are valid 4xx responses. This divergence is documented and expected. - Unsatisfiable
Accept: spec-mock returns406 Not Acceptable; Prism falls back to the first declared media type.
- OpenAPI:
docs/specs/pets.openapi.yaml - OpenAPI (media types):
docs/specs/reports.openapi.yaml - AsyncAPI:
docs/specs/chat.asyncapi.yaml - Protobuf:
docs/specs/greeter.proto
Apache-2.0