Mock server (OpenAPI)

Boot a Bun.serve mock from an OpenAPI 3.x spec — stateful CRUD persisted to sqlite/postgres, seedable boot data, envelope-aware round-trips, 422 request validation, and a validation proxy that audits a real backend.

Goal: put an OpenAPI 3.x spec to work as a running server — a mock for frontend dev before the backend exists, a stateful CRUD sandbox that can persist to sqlite/postgres and seed itself, a validator that rejects spec-breaking requests, or a proxy that audits a real backend’s conformance while your tests run through it.

Boot a mock

# Local spec
mock-server ./openapi.yaml -p4000

# Remote spec
mock-server https://petstore3.swagger.io/api/v3/openapi.json -p4747

# OS-assigned port, bind to localhost only
mock-server ./spec.json -p0 --host 127.0.0.1

# Stateful mode — what you POST is what you GET back
mock-server ./openapi.yaml -p4000 --stateful

# Persistent CRUD — the sqlite file survives restarts; seed data on first boot
mock-server ./openapi.yaml -p4000 --state ./mock.sqlite --seed ./seed.json

# Reject spec-violating requests with 422
mock-server ./openapi.yaml -p4000 --stateful --validate

# Validation proxy — forward to a real backend, record violations
mock-server ./openapi.yaml -p4000 \
  --proxy http://localhost:8080 --report violations.ndjson

Flags

FlagDefaultNotes
--swagger <url-or-path>requiredURL or local .json / .yaml / .yml. Swagger 2.0 specs are auto-converted to OpenAPI 3.x. OpenAPI 3.1 made paths optional, so a document describing only webhooks (or only reusable components) loads and mocks 0 routes — the boot line names the webhook count, because webhooks are callbacks your API sends, not endpoints it serves. A document with none of the three is still an error.
--port N30000 requests an ephemeral OS-assigned port.
--host addr0.0.0.0Bind address.
--statefuloffCRUD layer on top of example mode — in-memory unless --state is given. See below.
--state <path|url>offPersist the CRUD store in SQL: bare path or sqlite:// URL for sqlite, postgres:// / postgresql:// for Postgres (any other scheme exits 2). Implies --stateful; excludes --proxy.
--seed <file.json>offInsert boot data into empty collections — { "/api/things": [ {…} ] }. Implies --stateful; excludes --proxy.
--validateoffCheck every matched request against the spec; violations answer 422. Composes with --stateful.
--proxy <upstream>offValidation-proxy mode: forward everything to a real backend, record both-direction violations. Mutually exclusive with --stateful / --state / --seed.
--proxy-timeout N30000Upstream timeout in ms.
--report <path>offAppend violations as NDJSON as they happen. Requires --proxy.

How responses are picked

The server walks the operation in this order to choose a body:

  1. A response written as { "$ref": "#/components/responses/Foo" } is resolved first. A $ref’d response object is a different thing from a $ref’d schema, and it is the ordinary style in hand-written specs — 95 of the 99 operations in one real-world spec we tested are written that way.
  2. content.<media>.example wins outright.
  3. Otherwise the first entry in content.<media>.examples.
  4. Where a node omits type, it is inferred from the keywords presentformat, pattern and the length bounds are string-only; the numeric bounds are number-only; properties/items say object and array; an enum says the type of the member picked. type is optional in JSON Schema and the other keywords are not decoration: sinao writes items: {format: "string"}, and crust used to emit [null] for it. A node stating none of them still has no type. Then the schema is walked:
    • string → a value that satisfies the schema’s own constraints: a format-aware default for email, date-time, uuid, uri; otherwise a value constructed from pattern — character classes, \d \w \s ., the quantifiers {n} {n,m} + * ?, \uXXXX escapes inside a class ([\u0031-\u0039] is [1-9], and AWS writes it that way), literals, and alternation between top-level branches — and verified against the real regex before it is used, so a construction crust cannot manage degrades to a neutral value rather than a confidently wrong one. Alternation is read only where it separates whole branches: a | nested in a group (^(a|b)$) is not something the character walk can choose between, so it degrades like any other declined construction, while a pipe inside a character class ([\w|-]) is just one of its members and is built normally. Values are then clamped to minLength / maxLength — but never in a way that unmakes the match just verified. A length is bought by widening a quantifier that was already open-ended (+, *, {n,}); a fixed-width {3} is never widened, since that would be the same mistake in a new place. Where a pattern and a length bound cannot both hold, the pattern wins — it is the narrower statement about the field — and --validate still reports the length violation, so the spec’s contradiction stays visible. A format-derived value is likewise never truncated. A schema saying {format: uuid, maxLength: 8} contradicts itself, since a uuid is 36 characters; crust keeps the valid uuid rather than emitting "00000000", which would trade a maxLength violation for a format one and lose the useful value on the way. Lengthening still applies: padding an email to minLength is harmless where truncating a uuid is not. Plain "string" appears only when nothing constrains the field. A pattern crust cannot sample — a character class like [0-9]+, as opposed to \d{3} — falls back to a neutral value rather than a guess: knowingly wrong beats confidently wrong, and --validate reports it.

    • integer / number0, or the nearest value the schema’s own minimum / maximum allows — a {"minimum": 1} page counter is never mocked as 0. Both exclusiveMinimum / exclusiveMaximum spellings are honoured (3.1’s numeric bound and 3.0’s boolean modifier).

    • booleanfalse.

    • array[item], repeated to meet minItems or trimmed to maxItems — a maxItems: 0 mocks as [], not as a one-element array its own validator would reject. Capped at 100 elements, because a mock body nobody can read helps nobody.

    • object → every property generated, with three refinements.

      An optional property crust cannot represent — one re-entering a cycle already expanded as far as it goes — is omitted rather than emitted as an empty object missing its own required fields: absent always validates, present-and-invalid does not. A required property in that position still appears, since presence is forced and the empty shape is the least-bad answer.

      Any name in required that properties never describes is emitted as null — including where a schema has required and no properties at all (how an allOf branch adds only a requirement), and where the required list sits on an allOf node while the properties live in its branches. Nothing constrains such a name, so null satisfies it, and omitting it produced a body crust’s own validator rejects. ton-console requires date_create on a schema defining no such property.

      That placeholder never outranks a branch that does describe the property: turbinelabs declares zone_key: {type: string} in one allOf branch and merely requires it in another. Under additionalProperties: false the schema forbids the key it demands — unsatisfiable — so crust leaves the contradiction visible rather than papering over it.

    • enum → the first value that satisfies the schema’s own declared type; const → that value. probely writes {type: "string", enum: [null, "trial", …]}, where the null is documented prose — taking it emits a value crust’s own validator rejects. Where no member fits, the first stands and the contradiction stays visible.

    • schema-level examples (OpenAPI 3.1’s array form) → first entry.

    • allOf → merged when its branches are objects, together with the node’s own properties. allOf is an intersection, not a replacement: a schema carrying both must satisfy both, and the branches win a conflict as the more specific statement. Real specs nest this constantly — allOf: [{ allOf: [inner], properties: outer }] — and dropping the siblings had crust mocking 2 of one schema’s 12 fields. When the branches are not objects, the first branch that produces a value. allOf means “this, refined”, and what is refined is often a scalar — allOf: [{ $ref: "#/components/schemas/Status" }] around an enum is the common idiom. Where a property is declared in more than one place along the chain, its keywords are merged rather than one side winning: a base contributing type: string and a derived schema contributing enum yield a value satisfying both, since neither mentions what the other declares. (Picking a winner was tried both ways and measured across 4,138 real specs — both precedences lose, because neither side is reliably the narrowing.) Objects still win a mixed allOf — but only when they carry something. A branch that produces an empty object contributed nothing and does not outrank a sibling that has real content: AWS writes every field as allOf: [{ $ref: "…/Real" }, { description: "…" }], and where Real was a list the documentation-only {} used to win and the array was discarded.

    • oneOf / anyOf → first branch, merged with the node’s own properties where it has them; the branch wins a conflict, being the narrower statement about which variant this is. Some specs use a union purely to say “one of these required sets” and declare the actual properties on the node, and taking the branch alone returned null for the whole object.

    • OpenAPI 3.1 union types"type": ["string", "null"], the form that replaced 3.0’s nullable: true — synthesise the first non-null member, so a nullable field still gets representative data instead of a bare null.

  5. A body expands at most 20,000 schema nodes. Past that a $ref to anything that can expand further terminates exactly as a cycle does. A $ref to a scalar — a string, number, boolean or enum — is still followed: it cannot recurse, costs one step, and is where the useful value lives, so refusing those too made every enum-constrained field in a truncated body wrong for nothing saved. Truncation leaves a finite body and mock-server says so on stderr, but a truncated body is not guaranteed to satisfy its own schema: termination follows the same element rule as a cycle, so where the budget runs out on a required array whose element type has requirements of its own, the value is [] against its own minItems. Run --validate to see where. The warning used to claim validity, which told the operator not to look for it. Some real graphs are mutually recursive — presalytics.io/ooxml has Slide.Slides.Details referencing Shared.*.Details referencing back — and with per-path cycle detection the cost of a complete body grows with the number of paths, not nodes: 52 million expansions for 134 responses and 53 seconds for that one file, which from outside is a hang.
  6. Local $refs into components.schemas.* are resolved, and narrowing keywords written beside a $ref applyenum, format, pattern, the bounds, example, default — since OpenAPI 3.1 permits them and they are how a spec restricts one use of a shared type. Structural siblings (type, properties, items) are deliberately ignored: sibling keywords were illegal beside $ref before 3.1, so Swagger-2 conversions are full of leftovers the tools of that era ignored, and acting on them makes correct bodies wrong. A cyclic $ref terminates with the properties its schema REQUIRES and nothing else. Dropping the optional ones is what makes it finite, since the property that closes the loop is nearly always optional — a slot may contain a sub-slot. A required property that closes the loop gets the empty shape of the type it declares. A required array of the type being terminated is finite either way, and which answer is right depends on the ELEMENT. Where the element type requires nothing of its own, the array is filled to minItems with that empty shape — it costs nothing and satisfies the bound, and connect’s evaluation forms need exactly this (EvaluationFormItem requires nothing, so a terminated section keeps its minItems: 1). Where the element has required properties, the array stays []: one element carrying none of them is valid at the top and wrong at every level below, so --validate reports the bound rather than crust hiding it behind a value that only looks right. quicksight’s sheet layouts are that case — Layout requires Configuration. Both the required list and that type are read through allOf — real specs compose recursive types out of branches rather than declaring them on the node (bitbucket’s comment.parent and commit.parents are the canonical case), and reading the node alone found nothing and terminated with null{} for an object, [] for an array, read from type or, where a spec omits it, from the presence of properties/items. It used to terminate with null, which stopped the recursion just as well but put the wrong type in the body, so crust’s own validator rejected it.

Every rule above exists to keep the mock from producing a body --validate would reject. That is not a figure of speech: crust’s mock and its validator read the same file, so a body one makes and the other refuses is unambiguously a crust bug, and the rules new in this release were found exactly that way — a sweep of 128,546 responses across 4,138 real specs currently leaves three that still fail, all the same one.

It is a goal rather than a guarantee, and the honest cases are named above: an element with its own requirements, and a graph past the node budget. Both leave a required array empty on purpose, because the alternative moves the violation somewhere less visible. --validate is what tells you which you have.

A path templated the Express way — /things/:id rather than OpenAPI’s /things/{id} — is matched literally. crust does not rewrite it, because silently reinterpreting your spec is a worse failure than the one it fixes; instead it names the count on stderr at boot, since the route total would otherwise look healthy while those routes are unreachable by any real client:

mock-server: 7 route(s) from openapi.json
mock-server: 3 path(s) use Express-style ':param' — OpenAPI templates parameters as '{param}',

The boot line also names **patterns written as JavaScript regex literals** — `/^[0-9]{5}$/i`, delimiters and flags included, where JSON Schema expects a bare regex. The leading slash is then matched literally, so no value can satisfy the pattern and every response carrying one is unsatisfiable — silently, since the failures look like ordinary validation noise. crust does not rewrite them: guessing at what a spec meant is how a mock starts lying.
             so these are matched LITERALLY and will not match a real value

The response carries the content type the spec documents for that operation — application/json when present, otherwise the first documented type. A non-JSON type (text/html, application/javascript) is written as text rather than serialised as JSON: a mock that claims text/html and sends a JSON document is lying twice, and --proxy rejects its own mock for it.

An operation that documents no responses object at all — legal since OpenAPI 3.1 made it optional — has nothing to conform to, so --validate and --proxy report no undocumented-status for it. Reporting one would be inventing a violation rather than finding one.

Status code selection within a matched operation:

200 → 201 → first 2xx → default → first defined

application/json content is preferred; otherwise the first content type wins.

Routing: literal segments take precedence over {param} siblings, so GET /pets/mine wins over GET /pets/{id}.

What you’ll see

Unmatched paths return 404. Matched paths with the wrong method return 405. Per request, one line goes to stderr:

GET    /pets        200  3ms
POST   /pets        201  2ms
DELETE /pets/99     404  0ms

Ctrl-C (or SIGTERM) shuts the server down cleanly.

Stateful mode — --stateful

Example mode replays the same spec example forever — fine for “does the client render a pet”, useless for “create a pet, then edit it”. --stateful adds an in-memory CRUD layer so the mock remembers:

  • POST on a collection creates — the stored item is the spec-synthesized base merged under your request body, plus an id (yours if the body has one, else a random uuid).
  • GET on an item returns exactly what’s stored; GET on the collection returns everything stored, shaped like the spec’s collection envelope (a documented { items: [...] } wrapper is preserved; a bare array stays a bare array).
  • PATCH / PUT merge the body over the stored item. DELETE removes it and returns 204. Unknown ids are 404.
  • Untouched collections keep serving spec examples — consumers see no change until they write.

A full round trip, driven as a crust capture chain — the same vocabulary as your smoke suites:

mock-server ./pets-openapi.json -p4000 --stateful --validate
crust -c '{"name": "Rex", "tag": "dog"} | POST :4000/pets | assert (r => r.status === 201) | (r => r.json()) | capture PET_ID (p => p.id)
GET :4000/pets/$PET_ID | (r => r.json()) | assert (p => p.name === "Rex" && p.tag === "dog")
{"tag": "good dog"} | PATCH :4000/pets/$PET_ID | (r => r.json()) | assert (p => p.tag === "good dog" && p.name === "Rex")
GET :4000/pets | (r => r.json()) | assert (l => l.items.length === 1)
DELETE :4000/pets/$PET_ID | expect 204
GET :4000/pets/$PET_ID | expect 404'

Create, capture the generated id, read your own write back, merge a PATCH over it, see it in the collection listing, delete, and hit the tombstone — all against a server that existed for exactly as long as the demo. The mock’s stderr tells the same story:

POST   /pets                        201  1ms
GET    /pets/52ab6ddb-…             200  0ms
PATCH  /pets/52ab6ddb-…             200  0ms
GET    /pets                        200  0ms
DELETE /pets/52ab6ddb-…             204  0ms
GET    /pets/52ab6ddb-…             404  0ms

When it beats example mode: any client flow that reads its own writes — create-then-list screens, edit forms, optimistic-UI reconciliation, multi-step wizards, and fixture suites that assert on round-tripped values. Stick with plain example mode when you want deterministic, spec-exact bodies on every request. State lives in memory by default (restart = clean slate) — add --state (next section) to persist it.

Entity envelopes round-trip

Plenty of APIs don’t return the entity bare — the create response wraps it: {"thing": {"id": …, "name": …}}. The stateful layer detects and honors that envelope. When the POST’s 201 example (or schema-synthesized body) is an object with exactly one object-valued property and no top-level id, the store keeps the bare entity and re-wraps per direction: POST responds with the envelope around the real stored entity, item GET/PATCH/PUT re-wrap the same way, and request bodies arriving wrapped are unwrapped before storing. So the natural capture — the same one you’d write against the real API — works against the mock:

crust -c '{"thing": {"name": "Crusty", "kind": "widget"}} | POST :4000/api/things | assert (r => r.status === 201) | (r => r.json()) | capture TID (t => t.thing.id)
GET :4000/api/things/$TID | (r => r.json()) | assert (t => t.thing.name === "Crusty" && t.thing.id === process.env.TID)
{"thing": {"kind": "gadget"}} | PATCH :4000/api/things/$TID | (r => r.json()) | assert (t => t.thing.kind === "gadget" && t.thing.name === "Crusty")'

Line 1 captures t.thing.id — the wrapped id — and it’s the real stored id, so lines 2 and 3 round-trip. Detection is deterministic and flat on ambiguity: two object-valued properties, a top-level id, or a non-object body all mean the plain flat behavior above, unchanged. Flat specs capture p.id exactly as before.

Persistent state and seeding — --state, --seed

--state <path|url> moves the CRUD store out of memory and into SQL via Bun.SQL — a bare file path or sqlite:// URL for sqlite, a postgres:// / postgresql:// URL for Postgres. State survives restarts and is shared cross-process: every request reads through to the database (no cache), writes are single-statement upserts, last write wins. --seed <file.json> inserts boot data. Both imply --stateful and exclude --proxy.

The seed file maps collection path templates to entity arrays — items without an id get a uuid:

{
  "/api/things": [
    { "id": "seed-1", "name": "Seeded Thing", "kind": "widget" },
    { "name": "No id — gets a uuid", "kind": "gadget" }
  ]
}
mock-server ./things-openapi.json -p4000 --state ./mock.sqlite --seed ./seed.json
mock-server: 5 route(s) from ./things-openapi.json
mock-server: listening on http://0.0.0.0:4000 (stateful) (state: sqlite) (seeded 2)

Seeding is empty-only and restart-safe: collections that already have rows are skipped entirely, so re-booting over a persistent store never duplicates and never clobbers accumulated state. Seeded collections count as written — their lists serve the seeded data, not spec examples. Unknown collection keys (no matching route) print a boot warning and are skipped; an unreadable or invalid seed file exits 1.

A full session, restart included

Everything from the envelope section works the same — the store just outlives the process now. Create, capture, verify, then restart the mock and read the same id back:

# terminal A
mock-server ./things-openapi.json -p4000 --state ./mock.sqlite --seed ./seed.json
# terminal B — a real session against it
{"thing": {"name": "Crusty", "kind": "widget"}} | POST :4000/api/things | assert (r => r.status === 201) | (r => r.json()) | capture TID (t => t.thing.id)
GET :4000/api/things | (r => r.json()) | assert (l => l.items.length === 3)   # 2 seeded + 1 created

# Ctrl-C terminal A, boot it again — same --state, same --seed
# boot line now says: (state: sqlite) (seeded 0)   ← seed skipped, collection has rows

GET :4000/api/things/$TID | (r => r.json()) | assert (t => t.thing.name === "Crusty")

The re-boot seeds nothing (seeded 0) and the entity created before the restart is still there, envelope intact. Because the state is plain SQL, it’s also shareable: two mock processes on different ports backed by the same --state serve the same entities.

The table contract

The store is one table, created idempotently at open, and its shape is a public contract — assert on it from anything that speaks SQL:

CREATE TABLE IF NOT EXISTS crust_mock_state (
  collection TEXT NOT NULL,   -- the collection path template, e.g. '/api/things'
  id         TEXT NOT NULL,   -- the entity id (stringified)
  doc        TEXT,            -- sqlite: JSON text | postgres: JSONB
  updated_at TEXT NOT NULL,   -- sqlite: ISO 8601 | postgres: timestamptz DEFAULT now()
  PRIMARY KEY (collection, id)
)

doc is always the bare entity — envelopes are stripped before storage. Query it with json_extract(doc, '$.name') on sqlite, doc->>'name' on postgres.

SQL-assert the mock from a .pipes suite

crust’s sql builtin speaks sqlite URLs (DATABASE_URL=sqlite://./mock.sqlite just works), which closes a loop: a .pipes suite can create against the mock over HTTP and then assert the mock’s own database — the same POST-then-sql pattern you’d run against a real backend, end to end before that backend exists:

# mock-state.pipes
{"thing": {"name": "Piper", "kind": "widget"}} | POST $BASE/api/things | assert (r => r.status === 201) | (r => r.json()) | capture TID (t => t.thing.id)
GET $BASE/api/things/$TID --timeout 2s | expect 200
sql "SELECT json_extract(doc, '$.name') AS name FROM crust_mock_state WHERE collection = '/api/things' AND id = '$TID'" | assert (r => r.name === "Piper")
DELETE $BASE/api/things/$TID | expect 204
sql "SELECT count(*) AS c FROM crust_mock_state WHERE collection = '/api/things' AND id = '$TID'" | assert (r => Number(r.c) === 0)
// mock-state.setup.ts — sibling, auto-detected by test-pipes
export default async () => {
  process.env.BASE = "http://localhost:4000";
  process.env.DATABASE_URL = "sqlite://./mock.sqlite";
};
test-pipes mock-state.pipes
#   PASS  mock-state.pipes:2  {"thing": {"name": "Piper", "kind": "widget"}} | POST $BASE/api/things | …
#   ...
# 1 file(s): 5 pass, 0 fail

The $.name inside the sql string survives env expansion (. can’t start a var name), $TID expands from the capture, and the DELETE line’s follow-up proves the row is really gone — not just 404-ing. Against postgres, swap the two sql lines to doc->>'name' and count(*)::int AS c.

Request validation — --validate

By default the mock accepts anything that routes (existing suites may rely on that). --validate opts in: every matched request is checked against the spec before any mock or stateful handling. A violating request answers 422 with the header x-crust-validation: request and a violations body. Here’s a real one — a POST that breaks four constraints at once:

curl -si -X POST :4000/things -H 'content-type: application/json' \
  -d '{"name":"ab","kind":"sprocket","priority":9,"sku":"bad"}'
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
x-crust-validation: request
{
  "error": "request validation failed",
  "violations": [
    { "pointer": "/name",     "rule": "minLength", "message": "length 2 < minLength 3",
      "expected": 3, "received": "ab", "location": "body" },
    { "pointer": "/kind",     "rule": "enum",      "message": "value not in enum",
      "expected": ["widget", "gadget"], "received": "sprocket", "location": "body" },
    { "pointer": "/priority", "rule": "maximum",   "message": "9 > maximum 5",
      "expected": 5, "received": 9, "location": "body" },
    { "pointer": "/sku",      "rule": "pattern",   "message": "does not match pattern",
      "expected": "^[A-Z]{3}-[0-9]{4}$", "received": "bad", "location": "body" }
  ]
}

Every violation carries a JSON pointer into the body (or the param name), the rule that fired, a human message, expected/received, and a location (body/path/query). One request, every violation named — not just the first.

What is checked: path/query parameters (string values coerced to the declared integer/number/boolean first; header/cookie params skipped), required query params, requestBody.required, unparseable JSON bodies, and JSON body schemas — type (incl. 3.1 ["string","null"]), required, properties/items, enum, nullable, anyOf/oneOf/allOf, format (uuid, email, date, date-time, uri), pattern, minLength/maxLength, minimum/maximum (both exclusive* forms), minItems/maxItems. A union violation names the branch it is describingclosest (branch #1 Quote) failed: … — because “closest” means the branch with the fewest errors, which is often not the one the body was built from; without the name the failure reads as being about a variant nobody chose.

The governing rule: a schema the walker can’t judge validates successfully. Unknown formats, uncompilable patterns, unresolvable $refs, and unsupported keywords (not, uniqueItems, multipleOf, …) never produce a violation — the validator never invents a failure it can’t justify. Non-JSON bodies (multipart, form) pass untouched. Extra properties are never rejected unless you pass --strict, which enforces a literal additionalProperties: false — and only at object nodes the walker can fully judge: allOf-merged objects, nodes with combinator siblings, and patternProperties stay exempt (the classic validator false positive), while anyOf/oneOf branches enforce internally. --strict implies --validate and composes with --proxy in both directions.

--validate composes with --stateful: an invalid POST answers 422 and creates nothing — which is exactly what makes the combination a good stand-in backend for frontend form work. (The mock’s 422 is deliberately distinct from your real server’s documented 400 contract — generated negative fixtures still belong against the real thing.)

Validation proxy — --proxy <upstream>

The proxy flips the question. The mock asks “does my client speak the spec?”; the proxy asks “does my real backend speak the spec?” — while your existing tests provide the traffic:

mock-server ./openapi.json -p4000 \
  --proxy http://localhost:8080 --report violations.ndjson

Every request is forwarded to the upstream (hop-by-hop headers stripped, 3xx passed through untouched) and the upstream’s response is returned as-is — while both directions are checked against the spec and violations are recorded, never enforced. Your test suite doesn’t notice the proxy exists; the proxy notices everything:

  • Requests get the same checks as --validate — but a violating request is still forwarded, and recorded with direction: "request".
  • Responses are checked for a documented status (exact key, then NXX range, then default — miss records undocumented-status), documented content-type, and JSON body schema conformance.
  • Requests matching no documented operation are forwarded anyway and recorded as undocumented-operation — your spec’s blind spots, enumerated.
  • An unreachable upstream answers 502 {"error": "upstream unreachable", …} and is not recorded — infra failures aren’t spec violations.

Run a session through it and the per-request log grows a violations column:

GET    /things                      200  8ms [1 violation(s)]
POST   /things                      200  1ms [1 violation(s)]
POST   /things                      200  1ms [2 violation(s)]
GET    /internal/metrics            200  0ms [1 violation(s)]

Reading the findings

Findings are a JSON endpoint on the proxy itself — which means they’re pipeable with the same crust vocabulary as everything else:

# Summarize a session: rule + direction + where
GET :4000/__crust/violations | (r => r.json()) \
  | (v => v.count + " violation(s): " + v.violations.map(x => x.rule + " " + x.direction + " " + (x.pointer ?? x.path)).join(", "))
# 5 violation(s): type response /items/0/priority, undocumented-status response , minLength request /name, …

The endpoint returns {count, dropped, violations} (capped at 1000 in memory, oldest dropped); DELETE /__crust/violations clears it between phases. Each recorded violation adds ts, direction, method, path, template, and (response side) status to the validation fields. A real recorded response violation:

{
  "pointer": "/items/0/priority",
  "rule": "type",
  "message": "expected integer, got string",
  "expected": "integer",
  "received": "high",
  "ts": "2026-08-12T15:52:49.214Z",
  "direction": "response",
  "location": "body",
  "method": "GET",
  "path": "/things",
  "template": "/things",
  "status": 200
}

That’s the drift the type checker can’t see: the spec says priority is an integer, the server has been sending "high" for six months, and every client has quietly coped.

A conformance gate in CI

--report appends each violation as one NDJSON line as it happens — survives a crashed run, greps clean, and turns “run the suite through the proxy” into a pass/fail conformance step:

# 1. boot the proxy in front of the service under test
crust -c 'mock-server ./openapi.json -p4000 --proxy http://localhost:8080 --report violations.ndjson' &
crust -c 'wait :4000/things --timeout 20s'

# 2. run the EXISTING test suite through the proxy
BASE=http://localhost:4000 crust -c "test-pipes 'tests/**/*.pipes'"

# 3. gate: zero recorded violations, or fail with the count
crust -c 'GET :4000/__crust/violations | (r => r.json()) | assert (v => v.count === 0)'

Step 3’s failure message prints the violations object itself, and violations.ndjson is the artifact to archive. --proxy implies validation of both directions (--validate alongside it is accepted, redundant) and is mutually exclusive with --stateful — state belongs to the real backend in this mode.

Pairing with crust pipelines

The mock is just an HTTP server — drive it with normal crust pipelines.

# Smoke-test every fixture against the mock
mock-server ./openapi.yaml -p4000 &
test-fixture fixtures/*.crust.ts -o report.md

# Burn-in: 10k requests across 100 workers
range(0, 9999) | parallel 100 | GET :4000/pets | expect 200 | stats

Limits

Honest about what doesn’t work yet:

  • Remote $ref resolution
  • additionalProperties: false is not enforced by plain --validate — add --strict to enforce the literal-false form (see the governing rule above). It is opt-in because most specs never ask for it: of the 4,138 APIs-guru specs, 157 (3.8%) declare it somewhere. Swept under --strict, those 157 give 141 additionalProperties violations across 16,321 responses — and every one is a spec-supplied example carrying a property its own schema forbids, never a body crust synthesised. A --strict rejection is that spec’s own contract being honoured.
  • Faker-style data (every string is just "string" unless the format is known)
  • Hot-reload on spec changes
  • API smoke tests — one-liners, capture-chained .pipes suites and fixtures against the mock.
  • Generated negative fixtures — the spec’s other use: deriving rejection tests (run those against the real server, not the mock).
  • Stress testing--count + --threads for high-volume runs against the mock.