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

apimock-rs (API Mock) Documentation

A developer-friendly, featherlight and functional HTTP(S) mock server built in Rust.

Who is this for?

  • Developers who want to quickly mock APIs without heavy setup.
  • Beginners who benefit from minimal configuration.
  • Advanced users needing logic-based response behavior.
  • Agents (and scripts) driving apimock non-interactively: checking what a request would return with get, writing a rule with set, and reading --format json output instead of parsing human text.

Quick Start

Easy to start with npm package.

npm install -D apimock-rs

npx apimock
# alternatively, starts with spefic root directory:
# npx apimock -d tests

For Users


Contributing

Getting started

Read in order — each page builds on the last.

  1. Install and first response
  2. Your first config file
  3. Your first rule

Already running and looking for a specific task instead? See Guides.

Install and first response

Install

Three ways to get apimock:

# via npm, into an existing project
npm install -D apimock-rs
npx apimock
# via cargo, as a standalone binary
cargo install apimock
apimock
# via a prebuilt binary — no Node or Rust toolchain needed
# download from https://github.com/apimokka/apimock-rs/releases/latest
tar xzf 'apimock@Linux-x64-gnu-<version>.tar.gz'
cd 'apimock@Linux-x64-gnu-<version>'
./apimock

Whichever you choose, the command you run afterward is apimock (the package on npm is named apimock-rs, but the binary it installs is apimock).

Which prebuilt archive

⬇️ Download from the latest release, then pick the archive for your platform:

PlatformArchive
Linux x64 (glibc)apimock@Linux-x64-gnu-<version>.tar.gz
Linux x64 (musl)apimock@Linux-x64-musl-<version>.tar.gz
Linux aarch64 (musl)apimock@Linux-aarch64-musl-<version>.tar.gz
macOS aarch64apimock@macOS-aarch64-<version>.zip
Windows x64apimock@Windows-x64-<version>.zip

Each archive unpacks to a directory containing the binary plus an apimock.toml, apimock-rule-set.toml and apimock-middleware.rhai. Run ./apimock from that directory and it loads them automatically — no -c flag and no apimock --init step — so a downloaded build is already serving the example rules:

curl http://localhost:3001/health   # --> ok
curl http://localhost:3001/greet    # --> Hello, world.

That also means a downloaded build is ready for the config-file walkthrough as it stands.

Zero configuration needed

Run apimock in an empty directory and it starts immediately — no config file required:

apimock
# Listening on http://127.0.0.1:3001 ...

At this point, every request 404s, because there’s nothing to serve yet.

Your first response

Drop a JSON file into the directory apimock is running from:

mkdir -p api/v1/
echo '{"hello": "world"}' > api/v1/hello.json

Restart apimock (or start it now, if it wasn’t already running), then request the path matching that file — the .json extension is optional:

curl http://localhost:3001/api/v1/hello
# --> {"hello":"world"}

You can check the same thing in a browser at http://localhost:3001/api/v1/hello.

That’s file-based routing: the URL path maps directly to a file path. A directory request (/, /api, /api/v1) looks for an index.json / index.json5 / index.csv / index.html file and 404s if none exists. .json5 files are treated the same as .json; .csv files are converted to a JSON array of rows.

Next: Your first config file, for responses that depend on more than just the URL path.

Your first config file

File-based routing alone can’t vary a response by header, method, or body content — for that, apimock needs a config file.

Generate one

apimock --init

Interactive by default — it prompts for the port, IP, fallback directory, and whether to scaffold a rule-set file, a middleware file, and a TLS section, then writes apimock.toml accordingly (plus whichever of apimock-rule-set.toml / apimock-middleware.rhai you opted into). Passing --yes skips the prompts and writes the defaults directly — see the CLI reference for both.

Run it, and the startup log names every config file it loaded:

apimock
# @ rule_set #1 (./apimock-rule-set.toml)
# Listening on http://127.0.0.1:3001 ...

TOML, briefly

apimock.toml and rule-set files are ordinary TOML. A few things worth knowing if you haven’t used it before:

  • [table] starts a section; everything until the next [...] header belongs to it.
  • Nested tables can be written with dotted headers: [rules.when.request.headers] is shorthand for a headers table inside a when table inside a request table inside rules.
  • [[rules]] (double brackets) is an array of tables — each [[rules]] block starts a new entry in a list, which is why a rule set can have several [[rules]] sections.
  • Keys with characters TOML treats specially (like the . in a dotted body path) need quoting: "customer.tier" = { ... }, not customer.tier = { ... }.

The full TOML specification is at toml.io if you want more than this project needs day to day.

Where files live, relative to what

If you move apimock.toml and its rule-set files into a subdirectory and point apimock at it explicitly:

apimock -c tests/apimock.toml

paths inside that config — service.rule_sets, service.fallback_respond_dir — resolve relative to apimock.toml’s own location, not the directory you ran apimock from. (TLS certificate paths are the one exception — see Serve over HTTPS.)

Next: Your first rule.

Your first rule

--init scaffolds apimock-rule-set.toml, referenced from apimock.toml’s service.rule_sets. Each [[rules]] block is one condition-plus-response pair.

Match on the path alone

[[rules]]
when.request.url_path = ""
respond.text = "home"

[[rules]]
when.request.url_path = "home"
respond.file_path = "home.json"
curl http://localhost:3001/
# --> home
curl http://localhost:3001/home
# --> (content of home.json)

when.request.url_path as a bare string is an exact match. respond.text returns a literal string; respond.file_path serves a file, with its content type inferred from the extension.

Match on method too

[[rules]]
when.request.method = "POST"
when.request.url_path = "/orders"
respond = { text = "order created", status = 201 }

[[rules]]
when.request.method = "GET"
when.request.url_path = "/orders"
respond.text = "order list"

Multiple conditions in one rule are ANDed — both method and url_path have to match. when.request.method is always a bare string: "GET", "POST", "PUT", or "DELETE".

Match on a header

[[rules]]
when.request.url_path = "/private"
[rules.when.request.headers]
authorization = { value = "Bearer eyJhb", op = "starts_with" }
respond.text = "authenticated"

op picks the comparison — starts_with here, equal if omitted. Header names match case-insensitively.

Match on the request body

[[rules]]
when.request.method = "POST"
when.request.url_path = "/orders"
[rules.when.request.body.json]
"customer.tier" = { value = "gold" }
respond.text = "VIP order"

"customer.tier" is apimock’s own dotted-path syntax for reaching into a JSON body — not JSONPath. See Body path syntax before writing anything more complex than one flat key.

What’s next

This is enough to build most mock APIs. Once you outgrow it: Match on URL path and method, Match on headers, and Match on the request body cover every operator available for each; the Guides index covers everything else, including scripting, strategies, TLS, and CI validation.

Guides

Task-indexed — each page stands on its own. New here? Start at Getting started instead.

Looking for a runnable, automatically-verified example rather than a page? See crates/apimock/examples/ — several of the guides above link directly into it.

Serve JSON files from a folder

The headline feature: drop JSON files into a folder and they’re immediately reachable as an API — no rules, no rule set, nothing to author.

mkdir -p api/v1/
echo '{"hello": "world"}' > api/v1/hello.json
apimock

curl http://localhost:3001/api/v1/hello
# --> {"hello": "world"}

The URL path maps directly to a file path under service.fallback_respond_dir (. by default — the current directory). A .json file is served exactly as written — same key order, same whitespace, same formatting. It is not parsed and re-serialised, so what you put on disk is what a client gets back, byte for byte. (.json5 and .csv are different: both are conversions by design — .json5 because JSON5 syntax isn’t valid JSON and has to become some, .csv because a table becomes a JSON array of objects, one per row, keyed by column header — and both are still recognised alongside .json when the extension is optional, below.)

URL-to-file resolution

  • The extension is optional in the request — /hello and /hello.json both resolve hello.json, trying .json, .json5, then .csv in that order for the extension-less form.
  • Percent-encoding is decoded/my%20file.json resolves my file.json, and a non-ASCII filename is reachable using either its literal UTF-8 bytes in the URL or the percent-encoded form.
  • Case is folded at every segment, not only the filename — /API/Users.json, /api/users.json and /Api/USERS.JSON all resolve the same file, whatever case it’s actually saved as on disk. apimock does this folding itself rather than relying on the filesystem: Linux is case-sensitive, Windows and macOS (APFS by default) are not, so a config depending on the filesystem’s own behaviour would work when written and 404 (or resolve something unexpected) the moment it ran somewhere else — the failure mode is specifically “works on the author’s machine, breaks in CI or for a teammate on a different OS”. Uniform, apimock-enforced case folding is what makes a committed rule set behave identically everywhere. Unicode normalisation is a separate question and out of scope: a filename that’s the same characters encoded differently (NFC vs NFD — how an accented letter is represented, not how its case is folded) is filesystem-dependent and not resolved by apimock; that’s a known limitation, not a bug to report.

A full worked example — collection and member endpoints, plus a CSV file — is crates/apimock/examples/serve-json-resources/, runnable and automatically verified; its README walks through it with real curl output.

This is the last stage in the request pipeline — see Matching order and precedence. Once you need conditional responses (different output depending on headers, method, or body content), move on to Match on URL path and method.

Match on URL path and method

Once file-based serving isn’t enough — you need different responses for the same-looking request, or you want to react to the HTTP method — add a rule set.

[service]
rule_sets = ["apimock-rule-set.toml"]
[[rules]]
when.request.url_path = "/health"
respond.text = "ok"

[[rules]]
when.request.method = "POST"
when.request.url_path = "/orders"
respond = { text = "order created", status = 201 }

when.request.url_path on its own is a bare string, equivalent to { value = "...", op = "equal" }. Any of the eleven RuleOp operators can apply here too — starts_with, contains, wild_card, regex, and their negations — see the full list in the Operator reference.

when.request.method only ever needs a bare string: "GET", "POST", "PUT", or "DELETE". Combining method with url_path in one rule ANDs them — both have to match.

Multiple rules matching the same request are resolved by the rule set’s strategy — by default, the first one listed that matches. Worked, verified examples covering status codes by path: crates/apimock/examples/status-codes-and-errors/.

Match on headers

[[rules]]
when.request.url_path = "/orders"
[rules.when.request.headers]
x-api-key = { op = "absent" }
[rules.respond]
text = "missing x-api-key header"
status = 401

[[rules]]
when.request.url_path = "/orders"
[rules.when.request.headers]
x-api-key = { op = "exists" }
[rules.respond]
text = "order created"
status = 201

Header names match case-insensitively. Multiple headers in one [rules.when.request.headers] table are ANDed, same as any other combination of conditions in a rule.

exists/absent check only whether the header key is present — value is ignored for both. Every other operator compares the header’s actual value: equal, contains, starts_with, regex, and their negations — same set as url_path’s, listed in full in the Operator reference.

A worked, verified example gating a whole endpoint on an API key and falling through to more specific rules once authenticated: crates/apimock/examples/match-headers-and-body/.

Match on the request body

[[rules]]
when.request.method = "POST"
when.request.url_path = "/orders"
[rules.when.request.body.json]
"customer.tier" = { op = "equal", value = "gold" }
[rules.respond]
text = "VIP customer order"

[[rules]]
when.request.method = "POST"
when.request.url_path = "/orders"
[rules.when.request.body.json]
"items.0.sku" = { op = "contains", value = "WIDGET" }
[rules.respond]
text = "widget order"

when.request.body.json keys are dotted paths, not JSONPath — see Body path syntax for the exact resolution rules and why "$.a.b"-style paths don’t work. "customer.tier" walks into a nested object; "items.0.sku" indexes into an array with a numeric segment.

The 25 BodyOperator variants cover far more than string equality — numeric comparison (greater_than, less_than), presence (exists/absent), array checks (array_contains, array_length_at_least), typed equality, and structural matching against a JSON object shape (structural_contains). Full list in the Operator reference.

A worked, verified example layering nested-object, array-index, and numeric-comparison conditions by specificity: crates/apimock/examples/match-headers-and-body/.

Return errors and status codes

[[rules]]
when.request.url_path = "/widgets/999"
respond = { text = "widget not found", status = 404 }

[[rules]]
when.request.url_path = "/widgets/rate-limited"
respond = { text = "rate limit exceeded, retry after 30s", status = 429 }

[[rules]]
when.request.url_path = "/widgets/2"
when.request.method = "DELETE"
respond.status = 204

respond.status alone is an empty body with just that status code — useful for 204, or any response where the status is the whole answer. respond = { text = "...", status = N } pairs a status with a message body. Either way, status accepts any HTTP status code.

respond.headers is honoured uniformly alongside status — including a 3xx redirect’s Location header:

[[rules]]
when.request.url_path = "/moved"
respond = { status = 301, headers = { "Location" = "https://example.com" } }

See Response headers for the full default set every response also carries.

A worked, verified example covering the common REST-error range (400, 401, 403, 404, 429, 500) plus a bare 204: crates/apimock/examples/status-codes-and-errors/.

Vary the response for one path

When more than one rule matches the same request, the rule set’s strategy decides which one answers. Five exist.

first_match (the default)

No configuration needed — the first matching rule in file order wins, every time. Deterministic.

priority

[strategy]
priority = { tiebreaker = "first_match" }

[[rules]]
when.request.url_path = "/widgets"
respond.text = "general response"
priority = 1

[[rules]]
when.request.url_path = "/widgets"
respond.text = "special response (higher priority)"
priority = 10

Among matching rules, the highest priority wins — deterministically, regardless of file order. tiebreaker (first_match or uniform_random) decides what happens when two matching rules share the top priority. Note priority always needs its own table, even for a default tiebreaker — strategy = "priority" as a bare string is a parse error, unlike the other four.

weighted_random

[strategy]
weighted_random = { seed = 7 }   # omit `seed` for real randomness

[[rules]]
when.request.url_path = "/weighted"
respond.text = "variant-a"
weight = 3

[[rules]]
when.request.url_path = "/weighted"
respond.text = "variant-b"
weight = 1

Random among matches, weighted by weight (default 1 if omitted) — variant-a above is picked roughly 3 times as often as variant-b.

seed, if set, makes the pick fully deterministic — not a fixed sequence, but the same result every single request, since a fresh RNG is seeded per call. That’s useful for a reproducible test; it is not a way to preview a realistic distribution. Omit seed entirely to see genuine variation across requests.

uniform_random

Same shape as weighted_random (an optional seed), but every match has equal probability — weight is not consulted.

round_robin

strategy = "round_robin"

[[rules]]
when.request.url_path = "/round-robin"
respond.text = "server-a"

[[rules]]
when.request.url_path = "/round-robin"
respond.text = "server-b"

Cycles through matches in file order, one per request: server-a, server-b, server-a, server-b, … Deterministic, and doesn’t need weight or priority set on any rule.

Rotation is per match group, not per rule set (RFC 070). The example above has one group — every request to /round-robin matches the same two rules — so “cycles through matches in file order” is the whole story there. A rule set that serves more than one distinct request shape rotates each shape independently:

strategy = "round_robin"

[[rules]]
when.request.url_path = "/a"
respond.text = "a1"

[[rules]]
when.request.url_path = "/a"
respond.text = "a2"

[[rules]]
when.request.url_path = "/b"
respond.text = "b1"

[[rules]]
when.request.url_path = "/b"
respond.text = "b2"

[[rules]]
when.request.url_path = "/b"
respond.text = "b3"

Requesting /a four times in a row gives a1 a2 a1 a2, exactly as the single-group case above. Requesting /a and /b alternately gives /a: a1 a2 a1 a2 and /b: b1 b2 b3 b1 — each path’s own two- or three-rule cycle, independent of how often the other path is also requested. Two requests that match the same set of rules always share one counter; two requests that match a different set of rules never share one, no matter how they’re interleaved.

Where strategy goes

service.strategy sets the default for every rule set. A rule set’s own top-level strategy field overrides that default for itself only — which is how you can run several strategies side by side, each scoped to its own rule set (and, typically, its own [prefix]). See Rule-set schema.

A worked, verified example running all three of priority, weighted_random, and round_robin from one server, each in its own rule set: crates/apimock/examples/vary-response-by-strategy/.

Simulate slow or flaky backends

[[rules]]
when.request.url_path = "/fast"
respond.text = "instant response"

[[rules]]
when.request.url_path = "/slow"
respond = { text = "eventually...", delay_response_milliseconds = 800 }

respond.delay_response_milliseconds sleeps before responding — useful for exercising a client’s timeout, retry, or loading-state handling against a predictable, artificial delay.

Set it per rule, on respond. A rule-set-wide [default] delay_response_milliseconds also exists in the schema, but currently has no effect on any response — see Rule-set schema.

There’s no built-in mechanism for a genuinely flaky backend (randomly failing a fraction of requests) — only a fixed, deterministic delay. If you need actual failure injection, Rhai middleware can implement it directly.

A worked, verified example with three endpoints at increasing delays: crates/apimock/examples/simulate-slow-backend/.

Serve over HTTPS

[listener]
ip_address = "127.0.0.1"
port = 3443

[listener.tls]
cert = "./cert.pem"
key = "./key.pem"
# port omitted -> this single port serves HTTPS only

With listener.tls.port omitted, the port in [listener] becomes HTTPS-only — no plaintext HTTP listener starts at all. Set listener.tls.port to a different number instead, and both start: plain HTTP on listener.port, HTTPS on listener.tls.port.

cert/key paths are resolved against the process’s current directory, not against apimock.toml’s own location — run apimock from the directory containing the PEM files, or use absolute paths. See apimock.toml root settings for the full field list.

A cert/key that exists but doesn’t parse stops the process, before any listener binds. apimock exits naming the file rather than silently falling back to plain HTTP — if you asked for HTTPS and it isn’t running, that’s a startup failure you’ll see, not a request that looked encrypted and wasn’t. handshake_timeout_seconds (default 10) and max_connections (default 256) bound a connection that opens and never completes its handshake and how many may be in flight at once — both configurable, both generous for local development.

Testing against a self-signed certificate needs curl -k (--insecure) since it isn’t in any trust store:

curl -k https://127.0.0.1:3443/health

A worked, verified example — including a throwaway self-signed test certificate safe to reuse for local mocking — is crates/apimock/examples/secure-with-tls/.

For rotating a certificate without restarting the process, see Reload TLS certificates without restart.

Reload TLS certificates without restart

Not currently possible via the apimock CLI. The mechanism exists in the apimock-server library and is unit-tested, but nothing wires it up to a running server started from the CLI — this page documents that state honestly rather than a workflow you can actually follow today.

What exists

ReloadableCertResolver (crates/apimock-server/src/tls.rs) holds the active certificate behind a lock and can swap it for a freshly-read one via reload_from_paths(cert_path, key_path) — a single atomic pointer swap, no socket rebind, no new listener. In-flight TLS handshakes that started before the swap complete with the old certificate; anything after gets the new one. A failed reload (bad path, unparseable PEM) leaves the previous certificate in place and returns an error rather than breaking TLS.

ServerHandle::reload_tls_certs(cert_path, key_path) (crates/apimock-server/src/control.rs) is the public entry point that would trigger this from outside the server.

Why you can’t reach it

ServerHandle is never constructed anywhere in this repository — not by the apimock CLI, not by any example, not by any test. The HTTPS listener does build a ReloadableCertResolver internally (Server::https_start, crates/apimock-server/src/server.rs), which is why TLS itself works and stays up — but the handle needed to call reload_from_paths from outside is discarded immediately after, with a comment in the source acknowledging the wiring was left unfinished.

Restarting the apimock process is, today, the only way to rotate a certificate.

If you need this now

There is no workaround today — not even from source. An earlier version of this page suggested an embedder could construct a Server directly via the apimock-server crate and reach ServerHandle from there. That was never tried before it was written, and it doesn’t compile.

ServerHandle is #[non_exhaustive] (RFC 052), which blocks exactly this: an out-of-crate struct literal. Confirmed directly — a throwaway crate depending on apimock-server and attempting

#![allow(unused)]
fn main() {
let handle = apimock_server::ServerHandle {
    http_addr: None,
    https_addr: None,
    cert_reloader: None,
};
}

fails with:

error[E0639]: cannot create non-exhaustive struct using struct expression
  --> src/main.rs:5:18
   |
5  |       let handle = ServerHandle {
   |  __________________^
6  | |         http_addr: None,
7  | |         https_addr: None,
8  | |         cert_reloader: None,
9  | |     };
   | |_____^

And #[non_exhaustive] is the only obstacle worth naming, not the whole story: even setting it aside, nothing in this crate’s public API constructs or returns a ServerHandle for anything to call reload_tls_certs on — no ServerHandle::new, no From impl, no method on Server that hands one out. server.rs builds a ReloadableCertResolver internally for the HTTPS listener and drops it, with a comment acknowledging the wiring to expose it was left unfinished (see “Why you can’t reach it”, above). A from-source embedder is in exactly the same position as a CLI user: restart the process to rotate a certificate.

If you need this working, that is a real gap to raise, not something to build around — the library-level pieces (ReloadableCertResolver, ServerHandle::reload_tls_certs) are real and tested; what’s missing is the wiring that would let any caller, embedder included, actually obtain a handle.

Script with Rhai middleware

[service]
middlewares = ["apimock-middleware.rhai"]

Middleware scripts run before any rule set, in the order listed, before matching order and precedence’s second stage — see Matching order and precedence. The first script that returns a value answers the request; if none do, the request falls through to the rule sets.

//! pre-defined variables are available:
//! - url_path: request url path
//! - body: request body json value, defined only when the request has one

if url_path == "/profile" {
    return "data/profile.json";              // shorthand for #{ "file_path": ... }
}
else if url_path == "/profile/json" {
    return #{ "json": "{\"plan\": \"pro\"}" };
}
else if url_path == "/profile/text" {
    return #{ "text": "plan: pro" };
}

if is_def_var("body") {
    if url_path == "/orders" && body.priority == "rush" {
        return #{ "text": "expedited: this order jumps the queue" };
    }
}

return;   // falls through to the rule sets

Return shapes

Script returnsResponse
a plain stringserves that file path
#{ "file_path": "..." }same, spelled out
#{ "json": "..." }literal JSON response body
#{ "text": "..." }literal plain-text response body
nothing (falls off the end)declines — falls through to the rule sets

A relative file path is resolved against the middleware script’s own directory, not the process’s current directory.

is_def_var("body") is only true when the request actually carries a JSON body — checking it before reading body.* avoids a script error on a GET or a bodyless request.

A worked, verified example exercising all four return shapes plus body-driven branching: crates/apimock/examples/scripting-with-middleware/.

Filter the served file tree

This does not filter what the running server serves over HTTP. [file_tree_view] governs the file-tree view returned by the Workspace config-editing API — the surface GUI tooling uses to browse a project’s fallback-respond directory — not the service.fallback_respond_dir request path itself. A file inside node_modules, .git, or any pattern excluded here is still served if a client requests its exact URL path. If you’re trying to keep specific files out of what apimock actually serves, this setting is not the tool for that — there currently isn’t one.

[file_tree_view]
show_hidden = false
builtin_excludes = true
extra_excludes = ["*.bak", "fixtures/"]
respect_gitignore = true

What it’s actually for: a GUI editing a workspace’s config needs to show a file-tree view of the fallback directory without drowning the user in target/, node_modules/, build artifacts, or files their own .gitignore already excludes from the project. [file_tree_view] tunes that view.

FieldEffect on the editor’s file-tree view
show_hiddenInclude dotfiles/dot-directories
builtin_excludesApply the built-in list — target, node_modules, dist, build, out, __pycache__, .venv, vendor, .cargo, .gradle, .idea, .vscode
extra_excludesAdditional glob patterns, matched against each entry’s bare name
includeAn allow-list glob, files only
respect_gitignoreAlso exclude anything .gitignore would, walking up from the listed directory

Full field reference: apimock.toml root settings § [file_tree_view].

Add or change a rule

apimock set rule writes a rule to disk — no editor, no hand-authored TOML. Adds a new rule by default; pass --rule <n> to change an existing one instead. Either way it keeps the target file’s comments and formatting, and the config/rule-set files don’t need to exist yet — a fresh directory gets a minimal starting pair.

$ apimock set rule --path /orders/1 --status 200 \
    --json '{"id":1,"status":"shipped"}'
Applied:
  rule set: apimock-rule-set.toml (new rule)
  Added: rule set `./apimock-rule-set.toml` — rule set #1 (apimock-rule-set.toml): rules=1
  Updated: root config — apimock.toml: listener / log / service

That wrote:

[[rules]]

[rules.respond]
json = '{"id":1,"status":"shipped"}'
status = 200

[rules.when.request]
url_path = "/orders/1"

Where it writes, and how it decides add vs. change

--config/-c (default ./apimock.toml) and --rule-set (default ./apimock-rule-set.toml) name the two files; both are created if missing. Without --rule, the command adds a new rule — always appended, never inserted, since the address every set invocation uses to find a rule again (--rule-set path + 0-based index) has to stay stable across runs. With --rule <n>, it changes the rule already at that index instead — give it whatever flags actually differ; anything you don’t pass is left as it was.

$ apimock set rule --rule 0 --status 404 \
    --json '{"error":"not found"}' --dry-run
Would apply (--dry-run, nothing written):
  rule set: apimock-rule-set.toml, rule #0
  Updated: rule set `./apimock-rule-set.toml`, rule #0 — rule #1 in rule set #1

--dry-run previews the exact change and writes nothing, full stop — not even a bootstrap file, if the workspace didn’t exist yet. Drop it to actually apply.

--json, not --text, for a JSON body

--json <value> validates the value as JSON, writes it to respond.json, and it’s served as application/json. --text <value> writes respond.text and is always text/plain, even if the value happens to look like JSON — the two are mutually exclusive on purpose, so a body’s content-type is a declared choice, not a guess (see Rule-set schema). Get this wrong and the body is right but the header is not, exactly the kind of mismatch a strict client library rejects.

--format json

$ apimock set rule --path /orders/2 --status 201 \
    --json '{"id":2,"status":"pending"}' --format json
{
  "apimock": "5.19.0",
  "result": {
    "changed_files": [
      "./apimock-rule-set.toml"
    ],
    "changes": [
      {
        "kind": "Added",
        "summary": "added rule #2 in rule set #1",
        "target": "rule set `./apimock-rule-set.toml`, rule #1"
      }
    ],
    "dry_run": false,
    "requires_reload": true,
    "rule_set": "apimock-rule-set.toml"
  },
  "schema": 1
}

requires_reload tells a caller whether a running server needs a restart or reload to pick up the change — currently always true when anything changed, since apimock doesn’t yet reload on its own. A failure carries error.kind instead of result — see the response envelope.

Exit codes

0 applied (or, under --dry-run, would apply). 2 a bad invocation — an unknown or dangling flag, a target outside the confined directory — writes nothing at all, verified by asserting file contents unchanged rather than just reading the exit code. 1 the rule was loaded and addressed successfully but the save itself failed (a conflicting external edit, or an I/O error). Full flag list and every exit code on the CLI reference.

A worked, verified example — crates/apimock/examples/agent-bootstrap/ — walks through bootstrapping a workspace from nothing with set, checking it with get, and validating it, in the order an agent would actually run them.

Check what a request returns

apimock get <path> answers what would the server return for this request — status, headers, body — by reading configuration from disk. No server has to be running. Unlike match-test, it answers from the whole workspace: OPTIONS handling, every rule set in order, and the fallback directory if nothing matched — the same dispatch order, in the same code paths, the running server actually uses.

$ apimock get /orders/1
GET /orders/1

Status: 200
Headers:
  content-length: 27
  ...
  content-type: application/json
Body:
{"id":1,"status":"shipped"}

Answered: rule set #1, rule #1

(... above elides the default header set — CORS, cache-control, and so on, the same on every response — see Response headers for the full list.)

A request nothing matches is still a normal answer, not an error:

$ apimock get /nope
GET /nope

Status: 404
...
Body:


Answered: fallback directory (no rule set matched)

That’s deliberate — a 404 is a legitimate thing to ask about, and get exits 0 for it. match-test is the one that exits 1 on no match, because its whole purpose is checking whether a rule matches; the two commands answer similar-sounding questions on purpose with different exit semantics — see the CLI reference for exactly why.

--why

Add --why to see which rule matched, and — more usefully — why the rules that didn’t match, didn’t:

$ apimock get /orders/1 --why
...
-- Why --
Answered from a rule set.

Rule set #1 (./apimock-rule-set.toml):
  Rule #1: MATCH
    ✓  url_path equal "/orders/1" (actual: /orders/1)
  Rule #2: NO MATCH
    ✗  url_path equal "/orders/2" (actual: /orders/1)

--format json

$ apimock get /orders/1 --format json
{
  "apimock": "5.19.0",
  "result": {
    "matched": { "rule_index": 0, "rule_set_file": "./apimock-rule-set.toml", "rule_set_index": 0 },
    "request": { "method": "GET", "path": "/orders/1" },
    "response": {
      "body": "{\"id\":1,\"status\":\"shipped\"}",
      "headers": [
        { "name": "content-type", "value": "application/json" }
      ],
      "status": 200
    },
    "source": {
      "config": "/abs/path/to/apimock.toml",
      "rule_sets": ["/abs/path/to/apimock-rule-set.toml"]
    },
    "stage": "rule_set"
  },
  "schema": 1
}

(headers is trimmed above to the one line that changes per request; a real response repeats the same default set shown in the text example.)

--why is included by default under --format json, even without passing the flag — an agent reading structured output gets the explanation without a second round trip. It stays off by default in text, so a quick human check isn’t buried in it.

source gives the absolute, resolved paths of the config and every rule set consulted — provenance for an answer that came from files on disk, not from a running process. matched.rule_set_file is the same path set --rule-set accepts, so an address get reports can be handed straight to set to change the rule that produced it, no translation needed.

Two honest limits

A rule set using a randomised or round-robin strategy can answer differently from what a running server would return next — get loads its rule sets fresh each run, with no way to see how far a live server’s own selector has advanced. strategy = "first_match" (the default) is unaffected; it’s deterministic from the request alone. And a non-UTF-8 response body is shown with replacement characters rather than round-tripping exactly, in both formats. Neither is expected to matter often — see the CLI reference for the full detail.

Exit codes

0 answered — including a 404 or “no rule matched”; that’s a result, not a failure. 2 a bad invocation, or the configuration couldn’t be loaded. get never exits 1. Full flag list on the CLI reference.

A worked, verified example — crates/apimock/examples/agent-bootstrap/ — uses get to check a rule right after set writes it, before ever starting a server.

Validate config in CI

apimock validate loads a whole workspace — the root config and every rule set it references — and reports diagnostics, without binding a port. The right shape for a CI step or a pre-commit hook.

$ apimock validate --config ./apimock.toml
Validation passed (2 rules across 1 rule set(s)).
$ echo $?
0

A bare relative --config apimock.toml (no ./ prefix) resolves the same way — see the CLI reference for this and every other flag.

--format json emits a machine-readable response instead — an object with schema, apimock, and a result carrying the diagnostics array plus a summary:

$ apimock validate --config ./apimock.toml --format json
{
  "schema": 1,
  "apimock": "5.19.0",
  "result": {
    "diagnostics": [],
    "summary": { "errors": 0, "warnings": 0, "rule_sets": 1, "rules": 2 }
  }
}

--json (the bare diagnostics array, no envelope) was removed in 6.0.0. Using it now fails with exit 2 and a message naming --format json as the replacement, instead of the array a script might still be parsing — see the migration guide for the exact error text. --format json is the one shape to build new CI steps against; see the CLI reference for the full envelope shape and error.kind values.

Exit codes: 0 clean, 2 the config couldn’t even be loaded — either is enough to fail a CI step. Exit 1 (“at least one error”) and --strict are documented but not reachable today: every condition that would produce a diagnostic is already checked, identically, at load time, so a config with a problem fails to load (exit 2) before validate ever gets to report it as a diagnostic instead. See the CLI reference for the detail.

A worked, verified example (also covering apimock match-test) is crates/apimock/examples/validate-in-ci/.

Dry-run a rule

apimock match-test builds a synthetic request from CLI flags and checks it against a rule set directly — no server, no curl. The right tool for “which rule would this request hit, and why” while you’re authoring a rule set.

$ apimock match-test --rule-set apimock-rule-set.toml \
    --path /orders --method POST --body '{"customer":{"tier":"gold"}}'
Rule #1: /orders  MATCH ★
  ✓  url_path equal "/orders"
  ✓  method POST (actual: POST)
  ✓  body.json "customer.tier"  ==  "gold"  (actual: "gold")

Rule #2: /orders  MATCH
  ✓  url_path equal "/orders"
  ✓  method POST (actual: POST)

Result: MATCH (rule #1)

Every rule is checked, and each one shows exactly which conditions passed and which didn’t — including the rule that didn’t win, which is often more useful than the one that did:

$ apimock match-test --rule-set apimock-rule-set.toml \
    --path /orders --method GET
Rule #1: /orders  NO MATCH
  ✓  url_path equal "/orders"
  ✗  method POST (actual: GET)
  ✗  body (request has no JSON body)

Result: NO MATCH

Exit codes: 0 matched, 1 no rule matched, 2 an argument or input error. A bare relative --rule-set path (no ./ prefix) resolves the same as one prefixed with ./ — true of every flag on every subcommand and the root parser alike (RFC 064).

Full flag list on the CLI reference. A worked, verified example (also covering apimock validate) is crates/apimock/examples/validate-in-ci/.

Watch matches live

Not currently reachable from the apimock CLI, or from anywhere else outside custom Rust code. This page documents that state honestly rather than a workflow you can actually follow today.

What exists

TraceEmitter (crates/apimock-server/src/trace.rs) is a tokio::sync::broadcast-based channel the server emits one event to per request, describing what actually answered it — a matched rule (with its rule-set and rule index), a middleware response, a served fallback file, or a genuine miss (RFC 073; before it, every event wrongly reported the same “miss” regardless) — including whether a body was captured (subject to a max_body_bytes cap). A TraceTransport type can also expose the channel over a Unix-domain socket or TCP, for an external process to subscribe to.

Request headers are redacted before an event is built (RFC 040). By default, well-known credential-bearing headers — authorization, cookie, set-cookie, proxy-authorization, x-api-key — are replaced with the placeholder [redacted]; the header name still appears, so a consumer can tell “redacted” from “the request never sent this header”. Matching is case-insensitive. This is a denylist by default; an allowlist mode exists (TraceConfig::header_redaction = HeaderRedactionMode::Allowlist), which redacts every header except the ones named in TraceConfig::header_allowlist. Both lists are plain Vec<String> fields on TraceConfig — configurable only at this Rust level, for the same reason as everything else on this page: there is no config-file or CLI surface yet.

A request body’s presence and length are always reported; its content never is unless it was JSON and capture was on (RFC 050). RequestSummary.body_len is Some(n) for any n-byte body that arrived — JSON included — so a body’s presence no longer depends on whether it happened to be captured. Together with body_json, that’s three distinguishable states: no body (both absent); a body present and JSON-captured (body_len and body_json both present, capture still gated by capture_body/max_body_bytes exactly as before); a body present but not captured (body_len present, body_json absent — because it wasn’t JSON, or capture was off, or it was over the cap; body_truncated distinguishes that last cause further). The field started narrower — populated only for non-JSON bodies — until review found that left the common case (a JSON body under capture_body’s default of false) still indistinguishable from no body at all, which was the exact ambiguity this RFC exists to close. Content capture is deliberately the ceiling regardless: see RFC 050’s Motivation for why a truncated snippet was rejected, not merely deferred.

Verbose console logging shares this same redaction policy (RFC 051, extended by RFC 073). capture_in_log (crates/apimock-server/src/parsed_request.rs, gated by log.verbose.header, default off) used to print every request header verbatim to the console — the same credential values RFC 040 stopped the trace channel from emitting, just through a different door. It now calls TraceConfig::is_redacted_key — the exact function redact_headers uses — so there is one definition of “which names are credentials,” not two lists that can drift.

log.verbose.body is redacted too, as of RFC 073 — it used to print a query string and a JSON body’s fields verbatim, with no redaction at all, even while header redaction (above) already existed. The same denylist/allowlist now applies to a query parameter’s value and a JSON body’s object keys (recursively, so a secret nested under a non-secret-named parent is still caught) via TraceConfig::redact_query_string/redact_json_value — one policy, wherever a name-value pair can leave the process, not three separate ones. The trace channel’s own capture_body capture is redacted the same way, not only the console path — a captured body reaching an out-of-process UDS/TCP subscriber is at least as serious a leak surface as a local terminal, so it got the same fix.

Why you can’t reach it

  • No config surface. apimock.toml has no field that sets TraceConfig or a transport. The server always constructs the tracer with a fixed default (capture_body: false, max_body_bytes: 8192) — nothing in the config file changes that.
  • The socket/TCP transport is never started. TraceTransport’s accept loop is fully implemented but is not called anywhere in this repository — confirmed by searching every source file. There’s no flag or setting that turns it on.
  • The Workspace edit API’s trace fields are a stub. The GUI-facing config-editing surface has EditValue variants shaped like they’d toggle trace settings, but their handlers only log a message — they don’t write anything back to the config. The comment in the source (“stored in config for persistence”) doesn’t match what the code actually does.
  • Nothing in the shipped binary subscribes to the channel either. main/args.rs never call TraceEmitter::subscribe() or start TraceTransport::accept_loop — the running apimock process never has a subscriber, shipped-binary code included. (subscribe() is called from test code outside trace.rs’s own module too, as of RFC 073’s tranche — crates/apimock/tests/server/trace.rs — but a test proving the mechanism works is not the same as the CLI exposing it.)

If you need this now

The channel itself works and is unit-tested — an embedder constructing a Server directly via the apimock-server crate (the way bench_load.rs constructs one in-process for its own purposes, though that example doesn’t touch tracing) could call TraceEmitter::subscribe() on it directly. That’s a from-source integration; the shipped CLI doesn’t expose a way to do this today.

Troubleshooting

Organised by symptom — what you actually see, not what causes it — since that’s what you have when something isn’t working. Every check below was reproduced against a running server before being written down; each names a command you can run yourself, so a stale entry fails visibly rather than misleading quietly.

My file 404s

A request served by fallback_respond_dir (the zero-config, “drop a JSON file in a folder” mode) can 404 for more than one reason. Check these, in order:

1. Is it actually inside fallback_respond_dir? A file that exists elsewhere on disk but outside the configured directory always 404s — this is deliberate confinement (RFC 063), not a bug, and there’s no opt-out. If you need it served, point fallback_respond_dir at where it actually lives, or move it.

$ curl -i --path-as-is http://localhost:3001/../outside.json
HTTP/1.1 404 Not Found

(--path-as-is matters for this specific check: curl normally resolves .. out of a URL client-side before sending it, same as a browser would, so a plain curl here would send /outside.json — still a 404 if that file doesn’t exist inside fallback_respond_dir either, but not actually testing confinement. --path-as-is sends the raw, unresolved path.)

2. Is the extension one apimock infers? A request with no extension (/users) tries .json, .json5, then .csv, in that order, then directory/index.*. A .txt or other extension isn’t in that list — request it with the extension, or rename the file.

$ curl -o /dev/null -s -w '%{http_code}\n' http://localhost:3001/hello
200   # resolves hello.json

3. Is case actually the problem, or did you rule it out too early? Every path segment is folded case-insensitively — /API/Users.json, /api/users.json, and /Api/USERS.JSON all resolve the same file, whatever case it’s saved as on disk (RFC 075 F-05). If a differently cased request still 404s, case isn’t the cause; look at the other items on this list instead of re-checking case.

4. Is the URL percent-encoded the way you think it is? %20 decodes to a space, and a non-ASCII filename is reachable either by its literal UTF-8 bytes in the URL or the percent-encoded form (RFC 075 F-03) — both resolve the same file:

$ curl -o /dev/null -s -w '%{http_code}\n' 'http://localhost:3001/my%20file.json'
200   # resolves "my file.json"

A + in a path is not decoded to a space — that’s application/x-www-form-urlencoded behaviour for query strings and form bodies, not paths (RFC 3986). If your filename has a literal +, request it unencoded.

5. Did a rule set claim the prefix and then not match? A request under a rule set’s [prefix].url_path is scoped to that rule set — /api matches /api and /api/x, never a sibling like /apixyz (RFC 075 F-02). But once a request falls under a prefix, it’s checked against that rule set’s own rules, not the fallback directory — a miss there is a rule-matching problem (see the next section), not a file-404 one, even though both currently answer 404.

My rule matches everything (or nothing)

Check apimock validate first — a rule with an unrecognised condition key now fails to load, with a specific error, rather than silently matching more broadly than intended (RFC 069). Before 6.1.0, a misspelled condition key was silently ignored — the condition you thought you wrote never existed, so the rule matched on whatever conditions were spelled correctly (or matched everything, if none were). That failure mode is gone: today, the config doesn’t load at all, and the error names the exact key and file:

$ apimock validate --config apimock.toml
apimock validate: failed to load config: invalid rule set TOML in `./rules.toml`
(/path/to/rules.toml): TOML parse error at line 2, column 14
  |
2 | when.request.uri_path = "/only-here"
  |              ^^^^^^^^
unknown field `uri_path`, expected one of `url_path`, `method`, `headers`, `body`
(did you mean `url_path`?)

If validation passes and a rule still matches more (or less) than expected, it’s a genuine matching question, not a config-loading one — see Dry-run a rule (apimock match-test), which shows every condition checked and why each one did or didn’t match, and Matching order and precedence for how rule sets and rules are tried in order.

My snapshot test broke after upgrading

A .json file_path response is now served exactly as written — byte for byte (RFC 076). It used to be parsed and re-serialised on every request: minified, with object keys sorted alphabetically, regardless of how the file was actually formatted on disk. A snapshot or golden-file test built against that old, minified/alphabetised output now sees the file’s real bytes instead:

$ printf '{\n  "zebra": 1,\n  "apple": 2\n}\n' > data.json
$ curl http://localhost:3001/data
{
  "zebra": 1,
  "apple": 2
}

If your test expected {"apple":2,"zebra":1}, that expectation was pinning the old defect, not the intended behaviour — update it to the fixture file’s own bytes. See Serve JSON files from a folder for the full explanation, and Migrating to 6.2.0 if you’re upgrading across this change specifically. .json5 and .csv are unaffected — converting them is the point, not a defect.

My CORS request fails (credentials not reflected)

A credentialed cross-origin request (carrying Cookie or Authorization) only gets its Origin reflected back if that origin is allowed (RFC 067). http://localhost:* and http://127.0.0.1:* are always allowed; anything else needs to be listed in [service].cors_allow_credentials_origins. An unlisted origin still gets a response — just the same non-credentialed access-control-allow-origin: * a request with no credentials gets, which a browser then refuses to expose to credentialed cross-origin JavaScript:

$ curl -i -H 'Origin: https://my-app.example.com' -H 'Cookie: session=abc' http://localhost:3001/data
access-control-allow-origin: *
vary: *
# no access-control-allow-credentials — browser blocks the credentialed read

Add the origin to the config and it reflects correctly:

[service]
cors_allow_credentials_origins = ["https://my-app.example.com"]
access-control-allow-origin: https://my-app.example.com
access-control-allow-credentials: true
vary: Origin

See Response headers for the full table, and the threat model for why an unlisted origin isn’t refused outright.

My request is refused with 413

A request body over [service].max_request_body_bytes (default 32 MiB) is refused before it’s buffered, not after.

$ head -c 40000000 /dev/zero | curl -s -i -X POST --data-binary @- http://localhost:3001/data
HTTP/1.1 413 Payload Too Large

request body exceeds the configured limit (33554432 bytes)

If you legitimately need larger request bodies, raise the limit:

[service]
max_request_body_bytes = 67108864  # 64 MiB

There’s no way to disable the cap entirely — a bound, even a generous one, is the point. Before it existed, a request body of any size was collected whole; the external audit measured one 256 MiB request taking the process from 9 MiB RSS to 462 MiB, reachable by a single unauthenticated request (see the threat model).

Still stuck?

  • apimock get answers “what would the server return for this request?” without starting a server — see Check what a request returns.
  • apimock match-test shows every condition on a rule and why it did or didn’t match — see Dry-run a rule.
  • --format json on any subcommand gives a structured {"schema", "apimock", "result"}/{"schema", "apimock", "error"} envelope; a failure’s error.kind is one of a closed, documented set (usage, config_invalid, config_unreadable, io, conflict, internal) — see the CLI reference for what each means and which exit code it maps to.

Migrating to 6.0.0

Everything on this page has shipped, in 6.0.0. It covers two different kinds of break:

  • CLI changes. 5.19.0 warned about the one that could be warned about (validate --json); the rest are described here.
  • Library changes, which could not be warned about at all — there is no mechanism for a Rust compiler warning to say “this will be a breaking change in a future major version” the way a CLI can print to stderr. If you depend on apimock-server, apimock-config or apimock-routing directly, rather than only running the apimock binary, this page is your only notice.

If you are on 5.19.x, the CLI sections are the ones to read first: each names the invocation that changed and what to write instead. If you depend on the crates, read the library sections — those are the breaks a compiler error will surprise you with rather than a message.

Written from 5.19.0 as a preview and revised at the 6.0.0 release, so what you are reading describes the released behaviour rather than an expectation of it.

CLI: apimock validate --json is removed

Shipped in 6.0.0. Covered in depth in the CLI reference and the validate-in-CI guide; summarised here because it’s the one break you can act on immediately.

--json (a bare diagnostics array) was deprecated as of 5.19.0 and is now removed. --format json, available since 5.19.0, carries the response shape 6.0.0 keeps — switch to it and verify against a real binary. Using --json now fails loudly rather than silently changing what a script parses — exit 2, a message naming --format json as the replacement:

$ apimock validate --config ./apimock.toml --json
apimock validate: --json was removed in 6.0.0; use --format json instead, which emits the RFC 053 response envelope
Usage: apimock validate --config <apimock.toml> [--strict] [--quiet] [--format text|json]
$ echo $?
2

If --format json was also given alongside --json, the same error comes back enveloped instead (RFC 053, error.kind: "usage"), rather than as the plain text above — the caller already asked for machine-readable output, so the error stays machine-readable too. This is the one place, across the whole 6.0.0 release, where a removed CLI flag fails this way (RFC 048 § 7) — the general policy for breaking CLI invocations at a major version, not specific to this one flag.

match-test’s text output is untouched — 6.0.0 adds --format json to it rather than reshaping what it prints. Bare apimock keeps working, and apimock serve is now its explicit spelling — see the next section.

CLI: an unknown subcommand is now a usage error

Shipped in 6.0.0. A bare word in the subcommand position that isn’t serve, get, set, match-test or validate used to silently start a server — apimock banana, a typo like apimock gte, ran a mock server until killed, with nothing on stderr to say the word wasn’t recognised. Fixed the same way RFC 059 already fixed an unknown flag: exit 2, stderr names the unknown subcommand, a near-match suggestion where the edit distance makes one plausible, no server started:

$ apimock banana
apimock: unknown subcommand 'banana'
$ echo $?
2

$ apimock validat -c apimock.toml
apimock: unknown subcommand 'validat'; did you mean 'validate'?
$ echo $?
2

A flag at the same position (apimock -p 3001, apimock --init) is unaffected — this only closes the bare-word case a flag typo there was already caught for. apimock serve is not caught by this fix — see the next section: it’s a real, intentional subcommand, recognised before this check ever runs.

CLI: apimock serve is now real

Shipped in 6.0.0. RFC 053 specified apimock serve as the explicit spelling of bare apimock from the start; it was never built until now.

apimock serve [flags] is identical to bare apimock [flags] in every respect — same zero-config default, same -c/-p/-d, same --init, same --help/--version, same failure behaviour for a config that won’t load. Bare apimock is not deprecated and is not going anywhere; serve is an addition, never a requirement.

respond.json, and rules written by an earlier apimock set --json

respond now names what kind of body it serves. Alongside file_path and text there is json, and a rule that uses it is served as application/json:

[rules.respond]
json = '{"id":1,"name":"ada"}'

A rule declares exactly one of file_path, text and json. Content-type is derived from that choice, and an explicit respond.headers.content-type still overrides it — on every one of them, which was not previously true for .json files (see Response headers).

text is unchanged and stays text/plain, including when its content happens to be JSON. That is deliberate: a body that looks like JSON is not a JSON body.

This matters if you used apimock set --json before 6.0.0. It wrote respond.text, so those rules serve text/plain; charset=utf-8 — the body is correct, the header is not, and a client calling .json() under a strict library may reject it. Existing configs are not rewritten automatically, because silently editing your config on load is more surprising than the problem it fixes. To fix a rule, either rename the field:

# before                        # after
[rules.respond]                 [rules.respond]
text = '{"id":1}'               json = '{"id":1}'

or re-run apimock set --json against it, which now writes json.

Also new: a rule serving a .json file whose contents are not valid JSON now fails apimock validate and fails to load, instead of loading and returning 500 on every request. If a config that worked before now refuses to load, this is the likely reason — the error names the file and the position. Such a rule could never serve; apimock now says so at load time rather than per request.

Library: five public structs are now #[non_exhaustive]

Shipped in 6.0.0 (RFC 052) — this is the one item on this page that has already shipped rather than being a preview, because main is the 6.0.0 line and the break is real from this point on for anyone building against it.

TraceConfig, RequestSummary (apimock-server::trace), ParsedRequest (apimock-routing), LogConfig, and VerboseConfig (apimock-config) are all pub structs with public fields. Before this change, constructing one with a struct literal, or exhaustively destructuring one (let Foo { a, b, c } = value; naming every field), both compiled from any crate. Now both stop compiling from outside the type’s defining crate — fields stay publicly readable by name (value.body_json still works everywhere), only literal construction and exhaustive destructuring are affected.

What replaces a struct literal, concretely — the two types with a real cross-crate constructor:

  • ParsedRequest::new(url_path: String, component_parts: hyper::http::request::Parts) -> Self builds one with no body (body_json/body_len both None) — the shape every existing caller outside apimock-routing actually wanted. Chain .with_body(body_json: Option<Value>, body_len: Option<usize>) to attach one, replacing whatever was there before (it doesn’t merge with a prior call).
  • VerboseConfig::new(header: bool, body: bool) -> Self — a const fn, so it works in a const initializer, which a runtime-only builder would not. LogConfig didn’t need one: nothing outside apimock-config ever constructed it with a literal — every existing use goes through Default or Deserialize, both untouched by #[non_exhaustive].

TraceConfig and RequestSummary got the attribute but no new constructor — every construction site for both, checked across the whole workspace, was already inside apimock-server, the crate that defines them, so nothing outside that crate was ever affected. TraceConfig::default() (already existed) remains how to build one from elsewhere if you need to; a real cross-crate literal site would need its own constructor the same way ParsedRequest’s did, and none exists today.

What replaces exhaustive destructuring: match or destructure with .. to ignore fields you don’t use (let Foo { a, .. } = value;), which already compiled before this change and keeps compiling after it — the mechanical fix, if you hit this, is adding ...

Why now, in one release, rather than piecemeal: three RFCs landing on main this month (040, 050, and the shape of 051’s own configuration surface) each added fields to one or more of these types, and every one of those additions was, strictly, a breaking API change that went unnoticed until asked about directly. RFC 052 takes that break once, deliberately, instead of repeating it by accident — see RFC 052 itself for the full reasoning.

Whether the GUI constructs any of these five is still an open question (RFC 052’s Unresolved 1) — the constructors above were built for what this workspace’s own code needs, established from source rather than guessed at. If the GUI turns out to construct one of the three that didn’t get a constructor, that’s an additive addition on top of this shape, not a redesign.

Library: Prefix is now #[non_exhaustive], and respond_dir stopped growing

Shipped in 6.0.0 (RFC 058) — like the five-struct change above, this is live from this point on, not a preview.

The bug. apimock_routing::rule_set::prefix::Prefix::respond_dir_prefix resolved the directory Respond::file_path is served from, then wrote that resolved value back into the same field it read the user’s own respond_dir = "…" from. Since that field is also what got persisted back to the rule-set TOML, a load-then-save cycle resolved the already-resolved value again — respond_dir grew by one ./ segment on every save, without bound ("./.""././.""./././." → …). It shipped in 5.19.0; any tool that loads a workspace and saves it — apimock set, and the GUI once it lands on this contract — triggered it. Values already grown by it are semantically unchanged (./././. and . are the same directory), so nothing using them was ever actually wrong, just increasingly cluttered on disk.

The fix. respond_dir_prefix now holds only what a person actually wrote in [prefix] — untouched by loading, and absent entirely (no [prefix] manufactured) when the file never had one. The resolved directory the matcher needs lives in a new field, RuleSet::resolved_respond_dir — read it via RuleSet::dir_prefix(), unchanged in shape from before this fix, if you were calling that already.

A file already grown by the bug heals itself, gradually. The next time a rule set whose respond_dir is purely ./-segments ("./.", "././.", …) is saved for any other reason, that value collapses to "." as part of the same write — not a standalone rewrite of files nobody asked to change. An authored path like respond_dir = "responses" or "./responses" is never touched by this, only a value that is provably nothing but the current directory repeated. If you have a rule set that predates this fix and haven’t triggered a save on it since, its respond_dir may still read as several ./s stacked up; that’s inert and can be left alone, cleaned up by hand, or left for the next set/GUI save to normalise on its own.

Prefix gained #[non_exhaustive] in the same change (it’s pub, though not re-exported from apimock_routing’s crate root) — construct one via Deserialize (TOML parsing), the only way anything in this workspace ever did; a struct literal against Prefix now only compiles from inside apimock-routing itself.

Prefix::validate also changed signature, in the same fix and for the same reason: it used to read the resolved directory off self (pub fn validate(&self, rule_set_idx: usize) -> bool), which only worked because that was the field this bug overwrote with the resolved value. Once respond_dir_prefix stopped holding the resolved form, validate had nowhere left to read it from, so it now takes that directory as a parameter instead: pub fn validate(&self, resolved_respond_dir: &str, rule_set_idx: usize) -> bool. A public-API break for the same reason as the field itself — call it with rule_set.dir_prefix() for the first argument, the accessor that already existed for this.

Library: TraceConfig, ParsedRequest, and RequestSummary already had new fields, before #[non_exhaustive]

For the historical record — the reason RFC 052 exists at all. RFC 040 and RFC 050 each added fields to these types before #[non_exhaustive] existed to absorb that:

  • TraceConfig gained header_redaction, header_denylist, header_allowlist (RFC 040 — request-header redaction for the trace channel).
  • ParsedRequest and RequestSummary each gained body_len (RFC 050 — a non-JSON request body’s presence and length, never its content).

Both additions predate #[non_exhaustive] landing, so a struct literal written against an older version of either type would already have needed updating for this reason alone, independent of the #[non_exhaustive] change above. This is exactly the class of break #[non_exhaustive] now exists to prevent recurring.

Library: error variants are boxed, and #[non_exhaustive] now covers the whole public API

Shipped in 6.0.0 (RFC 041) — like the changes above, this is live from this point on, not a preview. It closes the gap this section used to describe as deferred.

The boxing break. ConfigError::ConfigParse.source and RoutingError::RuleSetParse.source change from toml::de::Error (88 bytes — the sole cause of every clippy::result_large_err suppression either crate carried) to Box<toml::de::Error> (8 bytes). Display output and Error::source() are unchanged — #[source] still reaches through the box — so this is a representation change, not a behavioural one. If you destructure either variant and bind source by value expecting toml::de::Error, you now get Box<toml::de::Error>; dereference it (*source) to get the inner value back, or call methods through the box as before (Deref transparently forwards).

The #[non_exhaustive] sweep now covers the whole re-exported public API, not just the five structs from earlier this page. RFC 052 said “this is the change that stops the pattern” of a field or variant addition silently being a breaking change; RFC 041 is that change. The method: every type named in a pub use at each of the four crates’ lib.rs, minus what RFC 052 and RFC 058 already covered, minus structs with no public fields (nothing outside the crate could ever construct those by literal anyway, so the attribute buys nothing) — roughly 43 types. Two consequences, same as the five-struct change above:

  • An exhaustive match on any of these types, from outside its defining crate, now needs a wildcard arm.
  • Struct-literal construction (even naming every field) stops compiling from outside the defining crate. The six error enums are an exception worth calling out explicitly: #[non_exhaustive] on an enum restricts matching, not building its existing variants — a struct-like error variant with every field public stays constructible with ordinary EnumName::Variant { .. } syntax across the crate boundary. Only the struct types below lose literal construction outright.

What replaces a struct literal, for the payload and CLI-argument types that had real cross-crate construction sites:

  • HeaderConditionPayload::new(name, op) -> Self and BodyConditionPayload::new(kind, path, op, value) -> Self — the RFC’s own required cases: both types lacked Default before this change (a value field with no meaningful empty state), so they needed one built from the fields that actually matter; value on HeaderConditionPayload starts unset (None) since it’s only required for some operators, assign it afterwards.
  • RulePayload, RespondPayload, NodeValidation already derived Default; nothing new to build — Default::default() then assign the fields you need, the same pattern as TraceConfig above.
  • ValidationIssue::new(severity, message), Diagnostic::new(severity, message) (its node_id/file start unset — assign them for a diagnostic scoped to a node or file), DiffItem::new(kind, target, summary), and ConditionWithId::new(id, view) — none of these had a meaningful “empty” state (every field matters), so each got a constructor instead of Default.
  • ValidationReport did have a meaningful default — its existing ValidationReport::ok() is that state (no diagnostics, valid) — so it also gained impl Default that calls ok(), letting it join the Default::default()-then-assign pattern too.
  • EnvArgs::empty() -> Self (every field None) — not called new or default because both names were already taken: EnvArgs has a pre-existing, unrelated pub fn default() -> AppResult<Option<Self>> that parses env::args() and is fallible, kept under #[allow(clippy::should_implement_trait)] since renaming it would itself be a breaking change.
  • Everything else in the sweep — Config, ListenerConfig, ServiceConfig, NodeId, ReloadHint (both the apimock-config struct and the apimock-server enum form), MiddlewareHandler, Server, App, and every fieldless-variant enum (Severity, BodyOp, HeaderOp, UrlPathOp, NodeKind, ConfigFileKind, DiffKind, BodyConditionKind, ServerState) — either already had a working constructor (Default, or a new/compile that was already the only way anything in this workspace built one) or, for the fieldless enums, needed nothing at all: unit variants are always constructible by name regardless of #[non_exhaustive]. AppState gained AppState::new(config, middlewares, tracer) since it had neither before.

One class of type got the attribute and deliberately no constructor: library-produced view and result typesServerHandle, ApplyResult, SaveResult, ConfigFileView, ConfigNodeView, RuleSetView, RuleView, HeaderConditionView, BodyConditionView, UrlPathView, RouteMatchView, MatchedRule, MatchConsidered, RouteValidationIssue, FileTreeView, FileNodeView, ScriptRouteView. Nothing in this workspace builds one of these by hand — every one comes back from a call (Workspace::apply, Workspace::save, Workspace::snapshot, a route match, …), never gets constructed to go into one. If a test you own built one of these with a struct literal to fake a return value, that stops compiling; there is no Default/new() replacement, by design — adding constructors nobody calls in production just to satisfy a test would be the wrong fix. Drive the real call that produces the value instead (run the Workspace operation, or the route match, and assert on what it returns), or hold onto a value the library already handed you rather than reconstructing one.

The six error enums also gain kind(). Since #[non_exhaustive] forces every downstream match to carry a wildcard arm, a caller with no other way to branch on failure class would otherwise fall back to matching on Display text — worse than before. Each of ConfigError, WorkspaceError, ApplyError, SaveError, RoutingError, and ServerError gains a .kind() method returning its own #[non_exhaustive] *Kind enum, one kind per variant:

#![allow(unused)]
fn main() {
match err.kind() {
    ConfigErrorKind::Parse => { /* … */ }
    ConfigErrorKind::RuleSet => { /* … */ }
    _ => { /* … */ }
}
}

This is a separate taxonomy from apimock::cmd::envelope::ErrorKind (the CLI’s published, schema-versioned contract) — the two are not fused, and neither delegates to the other. WorkspaceErrorKind in particular does not delegate to ConfigErrorKind even though WorkspaceError::Config wraps a ConfigError: match on source() if you need the inner detail.

What isn’t changing

Worth stating plainly, since a migration page can read as longer than it is: the exit-code scheme (0/1/2, set in RFC 049) is not changing — no new code was introduced. Specific invocations did move within it this cycle: a subcommand flag given no value, previously 0 or 1 depending on the flag, is now always 2 (RFC 064). Stream discipline (diagnostics to stderr, machine-readable output to stdout) is not changing. validate’s own diagnostics, severities, and exit codes are not changing — only --format json’s wrapping shape around them is new. Nothing about how a mock server matches or responds to requests is changing.

Migrating to 6.1.0

Everything on this page ships in 6.1.0. It is a minor release — no feature is removed — but it is not a no-op upgrade: several fixes change what an existing setup does, three add a setting you may want to adjust, and one (RFC 069) will refuse to load a config that loads today, if that config contains a mistyped key.

Read the table first. If none of its rows describes your setup, the upgrade is uneventful.

Nine RFCs land here, from all three completed tranches of the external audit. Each is a fix that changes what an existing setup does, which is why this is a minor, not a patch — RFC 070 and RFC 071 additionally change the public API, which affects library consumers only:

RFCWhat breaks
067Most likely to affect you. A credentialed cross-origin request from a non-localhost origin stops being allowed unless you list it
068A request body over 32 MiB is refused with 413; a Rhai middleware script is aborted after 10,000,000 operations
074An incomplete TLS handshake is dropped after 10s; concurrent TLS connections are capped at 256
069A config that loads today stops loading
070A round_robin rule set returns a different sequence
070Library consumers only: a public field on RuleSet is removed
072A header condition that passes today starts failing
071Library consumers only: Server::app_state’s type changes, AppState loses Clone
077A contrived directory layout could serve a different file, on a case-sensitive filesystem only (see below — almost certainly doesn’t apply to you)

Every one of these is a genuine correctness fix for behaviour the external audit found; none is a style or convenience change. If your setup changes under one of them, it was already answering incorrectly — see each RFC for the reproduction.

CORS: credentialed cross-origin requests need an allowlist now

RFC 067 — the audit’s highest-ranked security finding, and the change most likely to affect you.

Before, when a request carried a Cookie or Authorization header, apimock reflected its Origin back verbatim in Access-Control-Allow-Origin and set Access-Control-Allow-Credentials: true — for any origin, with no allowlist and no way to turn it off:

$ curl -H 'Origin: https://evil.example' -H 'Cookie: session=abc' …
access-control-allow-credentials: true
access-control-allow-origin: https://evil.example    # before: any origin

That is the textbook CORS misconfiguration. Binding to 127.0.0.1 was never a mitigation for it: the dangerous request comes from your own browser, on any page you happen to visit, aimed at your own loopback listener.

Now: http://localhost:* and http://127.0.0.1:* are always allowed. Any other origin must be listed explicitly:

[service]
cors_allow_credentials_origins = ["https://app.example.com"]

The default is empty. Requests without Cookie or Authorization are unaffected — they still get the safe Access-Control-Allow-Origin: * with no credentials.

If a browser-based setup stops working after upgrading, this is the first thing to check. The symptom is a CORS failure in the browser console on a credentialed request, from a page served somewhere other than localhost. Add that origin to the list. See Response headers.

New limits on what one request can consume

RFC 068. Two resources were unbounded and reachable by a single request. Both now have a default limit, and both are configurable.

Request bodies — 413 over 32 MiB. Bodies were buffered whole with no cap; the audit measured one 256 MiB request taking the process from 9 MiB to 462 MiB of RSS. A body over the limit is now refused with 413 before it is buffered:

[service]
max_request_body_bytes = 33554432   # the default, 32 MiB

Rhai middleware — aborted after 10,000,000 operations. A script that did not terminate wedged a tokio worker permanently; a few wedged the server. Scripts now run under an operation limit:

[service]
middleware_max_operations = 10000000   # the default

Both defaults are deliberately generous — normal use should never reach either. If you legitimately post bodies larger than 32 MiB, or run a deliberately heavy script, raise the limit rather than working around it.

New limits on TLS handshakes and connections

RFC 074. Two more unbounded resources, on the HTTPS path only — these do not affect a plain-HTTP listener.

[listener.tls]
handshake_timeout_seconds = 10   # the default
max_connections = 256            # the default

An incomplete TLS handshake is now dropped after the timeout rather than held open indefinitely, and concurrent TLS connections are capped. A client that opens a connection and never completes the handshake can no longer accumulate.

If you drive HTTPS with more than 256 concurrent connections, raise max_connections. RFC 074 also makes TLS failures loud rather than silent — if you were unknowingly running with a TLS problem, you will now hear about it at startup instead of discovering it later.

Config: an unknown key in a rule, condition, or respond block now fails to load

RFC 069. A mistyped key inside [[rules]]headerz instead of headers, or any other typo in a rule, when/request condition, or respond block — used to be silently discarded. The rule still loaded, apimock validate reported success, and the rule then matched more requests than it was written to, because the condition the author intended simply wasn’t there:

$ apimock validate -c ./cfg.toml
Validation passed (1 rules across 1 rule set(s)).      # before: wrong

$ apimock validate -c ./cfg.toml
apimock validate: failed to load config: ... unknown field `headerz`,
expected one of `url_path`, `method`, `headers`, `body`
(did you mean `headers`?)                               # after: correct
exit 2

If a config that worked before now fails to load, this is the likely reason. The error names the exact key and, where the edit distance makes one plausible, suggests the field you probably meant — the same near-match courtesy an unknown CLI flag already gets. Fix the key name (or remove it, if it was never meant to do anything) and the config loads again, this time actually enforcing what it always looked like it enforced.

Scope: this applies to the rule-facing surface only — [[rules]] and everything under it, plus a rule set’s own [prefix], [default], and [guard] blocks. Root apimock.toml sections ([listener], [service], [log], [file_tree_view]) are unaffected by this change — an unknown key there is still accepted, unchanged, for now (RFC 069’s own recorded, deliberate deferral, revisited once the settings RFCs 067/068 added there have settled).

Every config under examples/ and this project’s own test corpus was checked directly against this change — none contained a dead key; nothing needed fixing beyond the fix itself.

round_robin now rotates per match group, not per rule set

RFC 070. round_robin kept one counter for the whole rule set, not one per distinct set of matching rules. A rule set that only ever served one shape of request never noticed; a rule set serving more than one did, and for some shapes never rotated at all:

# a rule set with 2 rules matching /a, 3 matching /b
# requesting /a alone, four times — this part was always correct
a1 a2 a1 a2

# alternating /a and /b — the bug
/a: a1 a1 a1 a1     # before: never rotates
/a: a1 a2 a1 a2     # after: rotates independently of /b

If you have a round_robin rule set that serves more than one distinct request shape, its rotation sequence changes under this fix — from a broken one to the one the strategy was always documented as providing. A rule set with only one match group (every rule matches the same request shape) is unaffected; its sequence is unchanged. See Vary the response for one path for the corrected general-case description.

No config or code change is required to adopt this fix — it’s a matching-behaviour correction, not a new setting. If something downstream was asserting on the old (broken) sequence specifically, that assertion needs updating; nothing could have been correctly depending on it, since the old sequence was undocumented and wrong.

Library API: a public field on RuleSet is removed

RFC 070 — library consumers only. This section does not affect running apimock as a server, or any configuration. It matters only if you depend on the apimock-routing crate directly.

RuleSet carried its round-robin position as a public field:

#![allow(unused)]
fn main() {
pub round_robin_counter: Arc<AtomicUsize>,
}

Per-group rotation (above) cannot be expressed by a single counter, so that state is now a map keyed by the matched rule set — and it is private. The public field is removed, and no replacement field takes its place:

#![allow(unused)]
fn main() {
// 6.0.0
let n = rule_set.round_robin_counter.load(Ordering::Relaxed);

// 6.1.0 — no equivalent: the rotation state is internal
}

If this breaks your build, please tell us. The field held RuleSet’s own scheduling bookkeeping and was public only because RuleSet is a plain data struct. It appears nowhere outside apimock-routing in this workspace and we know of no reason to read it — but if you had one, we would rather hear it than assume.

This is a breaking change to the public API within a major version. Those are rare and we avoid them; the project does not promise they are impossible. What it does promise is that none of them reaches a release undeclared — see API stability.

Header matching now fails closed on non-UTF-8 values

RFC 072. A header condition (when.request.headers) against a request header whose value isn’t valid UTF-8 used to match unconditionally — logged an error, then treated the condition as satisfied regardless of operator. A gate that can’t evaluate its input was silently opening rather than staying closed:

[rules.when.request.headers]
x-token = { op = "equal", value = "expected" }
$ curl -H 'x-token: <invalid-utf-8-bytes>' http://127.0.0.1:PORT/gated
# before: matched anyway, "expected" or not
# after: does not match — the condition cannot be satisfied by a
#        value it cannot read, regardless of which operator it uses

exists/absent are unaffected — both check only whether the header key is present, before ever attempting to read its value, so a present-but-undecodable header still satisfies exists and still fails absent (the header genuinely is present; “cannot be read” and “not present” are different things, and this fix does not conflate them).

apimock match-test and apimock get --why now agree with the server on this input too — before this fix, match-test treated a non-UTF-8 header value as an empty string and answered independently of the operator, which for a not_equal (or similar) condition could disagree with what the server actually did. An agreement test now pins both paths to the same corpus so this cannot silently drift again.

If a rule was relying on this to match a request whose header value happens to not be valid UTF-8, that rule now correctly refuses it. This was already a bypass of whatever the condition was gating; there is no supported way to opt back into the old behaviour, by design.

Library API: Server::app_state is now shared, not cloned

RFC 071 — library consumers only. This section does not affect running apimock as a server, or any configuration.

Every request used to clone the whole AppState (and therefore the whole Config, rule sets included) out from behind a lock — cost proportional to configuration size, on every request, serialised through that lock. AppState is now held once and shared:

#![allow(unused)]
fn main() {
// 6.0.0
pub struct Server {
    pub app_state: AppState,
    // ...
}

// 6.1.0
pub struct Server {
    pub app_state: std::sync::Arc<AppState>,
    // ...
}
}

apimock_server::service’s second parameter changes the same way (Arc<Mutex<AppState>>Arc<AppState>), and AppState no longer implements Clone — nothing needs to clone it any more, and removing the impl closes the door on silently reintroducing the per-request clone this RFC exists to remove. AppState::config’s field type and AppState::new’s constructor signature are unchanged — every existing read through those was already by reference, so only the handle around AppState needed to change, not AppState itself.

If this breaks your build, replace an owned AppState (e.g. from cloning Server::app_state before this change) with an Arc<AppState> and read its fields through the Arc rather than a lock.

This is a breaking change to the public API within a major version, in the same sense as RFC 070’s field removal above — declared here and in the baseline, not undeclared. See API stability.

A narrow, disclosed precedence change in the zero-config fallback

RFC 077. The fallback respond_dir used to resolve a request by listing the whole directory on every request, even when the exact file already existed. It now tries the exact path, then extension inference (/hellohello.json, the shape this zero-config mode exists for) before ever listing the directory — the listing is now reached only for a case mismatch (e.g. a URL that canonicalised differently than the filesystem did).

This is behaviour-identical for every configuration we could construct a test for, and the one exception it has depends on the filesystem’s own case sensitivity, not only on the directory layout: a directory containing both a bare, differently-cased file (e.g. FOO, no extension) and an extension match for the same request (e.g. foo.json), where the request has no extension.

  • Case-sensitive filesystem (Linux, the usual case for a server deployment): before, the bare differently-cased file won (found by the listing, which ran first); now, the extension-inferred file wins (found by the cheaper stat, which now runs first). This is the actual behaviour change.
  • Case-insensitive filesystem (macOS APFS by default, Windows NTFS by default): the new exact-path stat for the extension-less request already resolves to the differently-cased file at the OS level, so the bare file wins on both sides of this change — no behaviour change there, even for this exact layout.

Nothing in this project’s own test corpus or examples has ever exercised this layout; a dedicated test (bare_differently_cased_file_vs_extension_match_resolves_per_filesystem_case_sensitivity in dyn_route.rs) now pins both outcomes, detecting the running filesystem’s actual case sensitivity rather than assuming it from the OS. CI’s three-platform matrix runs it on Linux, macOS, and Windows and all three pass, which is consistent with both branches occurring (GitHub’s macOS and Windows runners default to case-insensitive filesystems) — though the test itself, passing under whichever branch a given runner takes, doesn’t directly prove which branch each one ran.

If you don’t keep both a bare, extension-less file and an extension-inference-eligible file with the same name in the same fallback directory, this does not affect you on any platform.

What isn’t changing

Every other strategy (first_match, priority, weighted_random, uniform_random) is unchanged — the audit found no defect in any of them, and RFC 070 doesn’t touch them. Header matching for a value that is valid UTF-8 is unchanged — RFC 072 only closes the non-UTF-8 gap. File content-type detection (text vs. binary) is unchanged — RFC 077’s P-05 removed a redundant second file read but kept the exact same UTF-8-validity decision.

No config setting is required to get any of these fixes — they are corrections to existing behaviour, or internal performance work, not opt-in features. Tranche 1’s three (RFCs 067, 068, 074) do add settings, but only so you can raise a limit or widen an allowlist if its default is too strict for you; leaving them unset gives you the safe default, which is the point of the fix.

Migrating to 6.2.0

Filename and version number are a placeholder — no release number has been decided for this cycle yet; RFC 066 § 2 keeps that decision outside this page’s author entirely (versions, tags, and publishing are never touched without explicit instruction). 6.2.0 is written here only as “the next minor after 6.1.0” — 6.1.0 is already tagged and carries tranches 1–3 of the external audit, so this tranche’s entries land in a new page rather than being folded into that one. Rename this file and its SUMMARY.md entry to match whatever the release process actually settles on.

Four RFCs land here so far, from the external audit’s fourth and fifth tranches. All of these are fixes that change what an existing setup does — the same reasoning that made tranches 1–3 a minor, not a patch:

RFCWhat breaks
075A URL segment’s case that used to matter (or not) may now resolve differently — see below for exactly when
075A rule set scoped to a prefix like /api stops matching a similarly-spelled sibling path like /apiv2
076A .json file_path response is no longer minified or key-reordered
076Library and script consumers only: --format json’s field order changed from alphabetical to schema, apimock, result/error
073Library consumers of apimock_server::trace only: every trace event used to report the wrong outcome; a new Outcome::Middleware variant needs handling in an exhaustive match, and Outcome is now #[non_exhaustive]
073A query-string value or JSON body field matching the (now broader) credential denylist prints as [redacted] where it used to print verbatim
079HttpMethod’s Display output changed from a sentence to a bare value

Every one of these is a genuine correctness fix for behaviour the external audit found; none is a style or convenience change. If your setup changes under one of them, it was already answering inconsistently or unfaithfully — see each RFC for the reproduction.

URL paths are now percent-decoded, and case-folded at every segment

RFC 075, F-03 and F-05. Two related fixes to how a URL path becomes a file or a rule match.

Percent-decoding (F-03). A URL segment containing %XX escapes is now decoded before matching — %20 becomes a space, %C3%A9 becomes é. Before this, a fixture whose name needed encoding (a space, a non-ASCII character) was permanently unreachable, however it was requested:

$ mkdir -p api && echo '{}' > 'api/my file.json'
$ curl http://localhost:3001/api/my%20file.json
# before: 404 — decoding never happened at all
# after:  200 — resolves api/my file.json

This cannot reintroduce path traversal. Decoding runs before dot-segment normalisation, so a percent-encoded .. (%2e%2e, any mix of case, with the slash encoded or not) is stripped by the same mechanism that already strips a literal .. — and the confinement check added for GHSA-72g6-wgrg-vhm7 still runs regardless, as an independent backstop. Both layers were verified together before this shipped.

Case-folding, extended to every segment (F-05). Case-insensitive filename matching already existed; it’s now applied to every segment of the path, not only the last one:

$ mkdir -p API && echo '{}' > API/users.json
$ curl http://localhost:3001/api/users.json
# before: 404 on Linux, 200 on Windows/macOS — same config, same
#         request, different answer depending on the filesystem
# after:  200 on every platform — apimock folds the case itself

If your setup depended on the old inconsistency — a config that only worked because a differently-cased path segment happened to 404 on your platform (or resolve on it) — this now resolves the same way everywhere. That is the fix, not a regression: a committed rule set should not depend on which OS runs it.

Unicode case-folding, not just ASCII. É and é are treated as the same case, matching what a case-insensitive filesystem (macOS APFS, Windows NTFS) already does for free — Linux now does the same folding itself rather than 404ing where the other two platforms wouldn’t have. This is case folding, not Unicode normalisation (NFC vs NFD — how an accented character is encoded, not how its case is folded): normalisation remains explicitly out of scope, a filesystem-dependent question this project doesn’t chase.

One narrow, disclosed exception, in the same spirit as tranche 3’s own precedence disclosure: if a directory holds both a bare, extension-less file and a same-named file with an extension (e.g. foo and foo.json), for an extension-less request the exact-path match and the extension-inferred match are now tried in that order before falling back to a case-insensitive listing scan. In the overwhelming common case (one file, one name) this changes nothing; it can only matter for a directory layout intentionally holding two same-stemmed candidates, which nothing in this project’s own test corpus or examples does.

A rule set’s url_path prefix now matches at a segment boundary

RFC 075, F-02. A rule set’s [prefix].url_path used to be compared against the request path with a plain string prefix check — meaning a rule set scoped to /api also claimed /apiv2, /apixyz, or any other path that merely started with the same characters:

[prefix]
url_path = "/api"
$ curl http://localhost:3001/apiv2/users
# before: matched by /api's rule set, however unrelated apiv2 was
# after:  not matched — /api only ever matches /api itself or /api/...

If a request that used to reach this rule set now 404s (or falls through to a different rule set or the dyn-route fallback), this is why. Anything this un-matches was matched by accident — the fix is to scope the request under the correct prefix, not to work around the correction. A prefix of exactly / is unaffected: it was already, and remains, a deliberate catch-all matching every request.

.json files are now served exactly as written

RFC 076, F-04 and P-04. A .json file_path response used to be parsed and re-serialised on every request — minified, and with object keys sorted alphabetically, regardless of how the file was actually written:

$ echo '{
  "zebra": 1,
  "apple": 2
}' > data.json
$ curl http://localhost:3001/data
# before: {"apple":2,"zebra":1}      — reordered and minified
# after:  {
#   "zebra": 1,
#   "apple": 2
# }                                   — served exactly as written

If your setup, or a snapshot/golden-file test built against it, depends on the old minified-and-alphabetised output, this changes what you get. That output was never documented and was always an unannounced side effect of how the file happened to be parsed and rebuilt — the zero-config promise is “the JSON you put on disk is what a client gets back,” and this is what makes it true. A .json5 file_path is unaffected: JSON5 syntax isn’t valid JSON, so converting it remains the point, not a defect. Inline respond.json is also unaffected in the sense that matters here — it still converts (JSON5 tolerant, and it may be minified), but its key order now survives the conversion too, for the same underlying reason as the next section.

Library API: the --format json envelope field order changed

RFC 076 § 3 — library and script consumers only. This section matters if you parse --format json output by comparing serialised text (rather than by key, which is what the format is actually for) or if you depend on apimock-routing/apimock-config/apimock-server serialising a serde_json::Value map in a particular order.

Fixing .json file fidelity (above) and inline respond.json’s key order both required enabling serde_json’s preserve_order feature — a workspace-wide switch, since it changes how every serde_json::Value map serialises, not something scopable to one call site. This also changed the RFC 053 CLI envelope’s (--format json’s) field order:

// before: alphabetical (serde_json's default without preserve_order)
{"apimock":"6.1.0","result":{...},"schema":1}

// after: insertion order — matches every example this project's own
// docs have shown since RFC 053
{"schema":1,"apimock":"6.1.0","result":{...}}

This was accepted deliberately, not absorbed as a side effect — RFC 076 § 3 required an explicit choice between accepting the change and scoping preserve_order away from the envelope. Accepting it was chosen because the new order matches what this project’s own documentation already showed as the example output on every page covering --format json; the old alphabetical order was the thing quietly disagreeing with the docs, not the other way round.

If your consumer parses the envelope as a JSON object (reading .schema, .apimock, .result/.error by key, as the format’s own --format json name implies), this does not affect you — JSON objects are unordered by specification, and nothing about this changes which keys exist or what they mean. It only affects a consumer comparing serialised bytes directly, or relying on iteration order over a parsed map.

The live match feed now reports what actually happened

RFC 073 F-08 — library consumers of apimock_server::trace only. Before this fix, every trace event’s outcome reported Miss { status: 0 } regardless of what the server actually did — a matched rule, a middleware response, a served fallback file and a genuine 404 were all indistinguishable on the wire:

// before: every event, whatever actually happened
{"type":"miss","status":0}

// after: what actually happened, for every response path
{"type":"matched","rule_set_index":0,"rule_index":2}
{"type":"middleware","file_path":"auth.rhai","status":200}
{"type":"fallback","file_path":"data/users.json","status":200}
{"type":"miss","status":404}

If your consumer matches Outcome exhaustively, the new Middleware { file_path, status } variant needs a match arm — nothing else in the enum’s shape changed. If your consumer only reads specific fields (event["outcome"]["type"], say), this only affects you insofar as the type/status values you now receive are the real ones instead of always "miss"/0.

Outcome is now #[non_exhaustive]. Adding Middleware already broke an exhaustive match, so the enum is marked #[non_exhaustive] in the same change — every future variant this project adds will be free (a compile-time non-issue for a match that already carries a wildcard arm), rather than repeating this exact break at the next addition. If you match on Outcome exhaustively today, this change requires a _ => ... (or equivalent) arm regardless of whether you also need to handle Middleware specifically — the compiler will point at the exact match expression either way.

Verbose logging and the trace channel now redact query strings and body keys too

RFC 073 S-05. log.verbose.body used to print a request’s raw query string and full JSON body with no redaction at all, even though log.verbose.header (and the trace channel’s own header capture) already redacted credential-shaped headers:

$ curl 'http://localhost:3001/login?token=secret' -d '{"password":"hunter2"}'
# before (log.verbose.body = true):
#   [request.query] token=secret
#   [request.body.json]
#   { "password": "hunter2" }
# after:
#   [request.query] token=[redacted]
#   [request.body.json]
#   { "password": "[redacted]" }

The same denylist/allowlist that already governed headers (header_denylist/header_allowlist/header_redaction) now governs a query parameter’s value and a JSON body’s object keys too — recursively for the body, so a secret nested under a non-secret-named parent is still caught. The built-in default denylist also grew: token, access_token, refresh_token, password, secret, client_secret and api_key join the existing header names (authorization, cookie, set-cookie, proxy-authorization, x-api-key) — names a query string or body would plausibly use that a header never would.

If you had configured a custom header_allowlist/header_denylist expecting it to govern headers only, it now also applies to query strings and bodies — the same list, one policy, not a second list to configure separately. If your trace-channel subscriber reads capture_body’s captured body, it now receives the same redacted version a verbose console log would show, not the raw one — this applies whether you connect over the in-process subscribe() API or the UDS/TCP transport.

The trace transport’s access control (Unix sockets; TCP has none)

RFC 073. A Unix-domain socket trace subscriber’s socket file is now created with owner-only (0600) permissions — previously it inherited whatever the process umask produced, which on many default shell configurations left it group- or world-readable. If another local user was relying on reading this socket, that access is now refused; run apimock as that user, or use the TCP transport instead (no permissions model applies to a TCP port the same way).

The TCP trace transport still has no authentication of any kind; apimock now logs a startup warning if the configured address isn’t loopback, but does not refuse to bind one. This was already true before RFC 073 — only the warning is new — see the threat model’s own statement of it.

A few internal behaviours that were never real are gone

RFC 079, a cluster of small hygiene fixes with no behaviour change except one cosmetic one:

  • HttpMethod’s Display output changed from a sentence ("HTTP Method is GET") to a bare, backtick-quoted value (method`GET`), matching every sibling condition’s own Display style (url_path`/foo`). Affects only code that formats an HttpMethod directly and compares or displays the resulting string — nothing in this project’s own tests or docs did, so this is expected to be a narrow change if it affects anyone at all.
  • RuleSet::validate(), DefaultRespond::validate() and UrlPath::validate() stay exactly as they were — always true, now documented as intentionally trivial rather than left to look like an oversight. Nothing to update; noted here so the “kept, not removed” decision reads as deliberate rather than accidental.
  • bad_request_response (400) is still uncalled — re-verified, not newly discovered; kept for a future caller (audit F-09) with an updated comment explaining why. No behaviour change; internal only.

What isn’t changing

.csv conversion is unaffected by RFC 076 — it’s already a transformation, and stays one. Row order was always source order (a JSON array, never subject to the alphabetical-keys issue this RFC fixes); each row’s own object now keys its fields in the CSV’s column order rather than alphabetically, the same preserve_order side effect as everywhere else on this page — a cosmetic change for CSV specifically, since which columns exist and what they contain is unchanged. Rule-set scoping semantics other than the prefix segment-boundary fix above are unchanged. No config setting is required to get any of the fixes on this page; all are corrections to existing behaviour, not new opt-in features.

Reference

Exhaustive lookup — what exactly a setting or a flag does. Looking for a walkthrough instead? See Guides.

apimock.toml root settings

The root config file’s four top-level tables. All are optional — an empty or missing apimock.toml is valid, and falls back to zero-config, port-3001, serve-./-by-path behaviour.

[listener]
ip_address = "127.0.0.1"
port = 3001

[listener.tls]
cert = "./cert.pem"
key = "./key.pem"
# port = 3002   # omit to serve HTTPS-only on `listener.port`
# handshake_timeout_seconds = 10
# max_connections = 256

[log]
verbose = { header = true, body = true }

[service]
strategy = "first_match"
rule_sets = ["apimock-rule-set.toml"]
middlewares = ["apimock-middleware.rhai"]
fallback_respond_dir = "."
# cors_allow_credentials_origins = ["https://app.example.com"]
# max_request_body_bytes = 33554432
# middleware_max_operations = 10000000

[file_tree_view]
show_hidden = false
builtin_excludes = true
extra_excludes = ["*.bak"]
include = []
respect_gitignore = false

[listener]

FieldTypeDefaultMeaning
ip_addressstring"127.0.0.1"Bind address
portinteger3001Bind port

ip_address accepts any address your OS can bind to, IPv4 or IPv6:

ip_addressBinds to
127.0.0.1 / ::1Loopback only (the default)
A LAN address, e.g. 192.168.1.10That interface
0.0.0.0 / ::Every interface — reachable from outside the machine

Binding to 0.0.0.0/:: or a LAN address exposes the mock server beyond localhost — fine on a trusted network, a real exposure on anything else.

[listener.tls]

Enables HTTPS. Both cert and key must point at files that exist — checked at startup, not lazily.

FieldTypeDefaultMeaning
certstringPath to the certificate PEM file
keystringPath to the private key PEM file
portinteger, optionalIf set, HTTPS listens here and plain HTTP continues on listener.port. If omitted, listener.port itself becomes HTTPS-only — no plaintext HTTP listener starts at all
handshake_timeout_secondsinteger10An incomplete TLS handshake is dropped after this long
max_connectionsinteger256Maximum concurrent HTTPS connections. Beyond this, a new connection waits for a slot rather than being refused — the server recovers as soon as one closes

Relative cert/key paths resolve against the process’s current directory, not against apimock.toml’s own location — unlike rule_sets and fallback_respond_dir below, which do resolve relative to the config file. Run apimock from the directory containing the cert/key files, or use absolute paths. See Serve over HTTPS for a full working example.

A cert/key that exists but fails to parse stops startup — the server never binds any listener, HTTP included. Before this was fixed, a malformed PEM silently fell back to HTTP-only, which is worse than a loud failure: an operator who configured HTTPS would not otherwise know they didn’t get it.

[log]

FieldTypeDefaultMeaning
verbose.headerboolfalseLog request headers. Credential-bearing headers (authorization, cookie, set-cookie, proxy-authorization, x-api-key) print as [redacted] — same policy, same defaults, as the trace channel (RFC 040, RFC 051)
verbose.bodyboolfalseLog request bodies and query strings. Redacted (RFC 073) — a query-string value or JSON body field whose key matches the same credential denylist that already governs headers (token, password, api_key, and friends — see the threat model) prints as [redacted], recursively for nested body fields; everything else prints verbatim

[service]

FieldTypeDefaultMeaning
strategystring or table"first_match"Default response strategy — see Vary the response for one path for all five and their syntax
rule_setsarray of strings, optionalRule-set files, checked in this order — see Rule-set schema
middlewaresarray of strings, optionalRhai middleware files, checked in this order before any rule set — see Script with Rhai middleware
fallback_respond_dirstring"."Directory served by URL path when nothing above matches
cors_allow_credentials_originsarray of strings, optional[]Exact origins (beyond the always-allowed http://localhost:* / http://127.0.0.1:*) allowed credentialed CORS reflection — see Response headers
max_request_body_bytesinteger33554432 (32 MiB)A request body over this size is refused with 413, before it is buffered
middleware_max_operationsinteger10000000Rhai operations one middleware evaluation may perform before it’s aborted — see Script with Rhai middleware

Relative rule_sets and fallback_respond_dir paths resolve against apimock.toml’s own directory, regardless of the process’s current directory when apimock was started.

[file_tree_view]

This does not affect what the running server serves over HTTP. It filters the file tree shown by the Workspace config-editing API (consumed by GUI tooling, and incidentally by apimock validate’s internal rule/rule-set count) — not the fallback_respond_dir request path. A file inside node_modules, .git, or any excluded pattern here is still served over HTTP if a client requests its exact path.

FieldTypeDefaultMeaning
show_hiddenboolfalseShow dotfiles and dot-directories in the editor’s file-tree view
builtin_excludesbooltrueApply the built-in exclude list (below)
extra_excludesarray of glob strings[]Additional excludes, matched against each entry’s bare filename (not its full path)
includearray of glob strings[]An allow-list — only applies to files, never to directories
respect_gitignoreboolfalseAlso exclude anything a .gitignore (found by walking up from the listed directory, stopping at the first .git) would ignore

Built-in excludes (when builtin_excludes = true, matched by exact bare name): target, node_modules, dist, build, out, __pycache__, .venv, vendor, .cargo, .gradle, .idea, .vscode. Note .git itself is not in this list — it’s hidden by the separate dotfile filter when show_hidden = false, but would reappear in the editor’s view if show_hidden = true and .git isn’t added to extra_excludes explicitly.

extra_excludes and include both use standard glob syntax (*, ?, […]) via the globset crate. Filter order, each one able to reject an entry outright: dotfile filter → built-in excludes → extra_excludes.gitignoreinclude (files only).

Rule-set schema

A rule-set file — one of the paths listed in service.rule_sets — has five possible top-level tables/keys, only one of which ([[rules]]) is required.

strategy = "round_robin"          # optional: overrides service.strategy, this file only

[prefix]
url_path = "/api/v2"
respond_dir = "responses"

[default]
delay_response_milliseconds = 1000   # currently has no effect — see below

[guard]                              # currently has no effect at all — see below

[[rules]]
when.request.method = "POST"
when.request.url_path = "/orders"
when.request.headers.x-api-key = { op = "exists" }
when.request.body.json."customer.tier" = { op = "equal", value = "gold" }
respond = { file_path = "vip-order.json" }
priority = 10
weight = 3

strategy (top-level, optional)

A bare string for a unit strategy ("first_match", "round_robin", "uniform_random", "weighted_random"), or a table for priority (which always needs its own table, even to accept default settings — priority = "..." is a parse error). Overrides service.strategy for this rule set only. See Vary the response for one path for the full syntax of all five.

[prefix]

FieldMeaning
url_pathStripped from the front of the request path before this rule set’s rules are matched — a rule’s own when.request.url_path only needs to name what comes after it
respond_dirPrepended to every respond.file_path in this rule set

url_path matches at a segment boundary, not as a raw prefix: /api matches /api and /api/x, never /apixyz or /apix. A rule set scoped to /api never claims a request to an unrelated, similarly- spelled path.

[default]

The only field is delay_response_milliseconds. It currently has no effect on any response — it’s parsed and printed in the startup log, but nothing applies it. The per-rule respond.delay_response_milliseconds (below) works correctly; this rule-set-wide equivalent does not. See Simulate slow or flaky backends.

[guard]

A zero-field table today — there is nothing to put inside it, and a [guard] block with any content fails to parse. It carries a // todo: comment in the source for a rule-set-wide condition that was never implemented. Don’t configure it expecting it to gate anything; nothing reads it beyond printing an empty line in the startup log.

[[rules]]

Each rule is when (what has to be true of the request) plus respond (what to send back), plus two optional strategy-specific fields.

when.request

At least one of the following is required; multiple conditions within one rule are ANDed.

FieldShape
url_pathA bare string (implies op = "equal"), or { value = "...", op = "..." }
methodA bare HTTP method string: "GET", "POST", "PUT", or "DELETE"
headers.<name>{ value = "...", op = "..." } per header, ANDed; header names match case-insensitively
body.json."<dotted.path>"{ value = "...", op = "..." } per path, ANDed — see Body path syntax

Every operator for url_path/headers/body.json is listed in the Operator reference.

respond

At least one of file_path, text, json, or status is required.

FieldMeaning
file_pathServe this file’s content — extension decides JSON/JSON5/CSV/binary/text handling
textA literal response body, always served as text/plain; charset=utf-8 (unless overridden by headers) — including when its content happens to look like JSON. A body that looks like JSON is not a JSON body; use json for that
jsonA literal response body, declared as JSON — served as application/json (unless overridden by headers). Validated at load time: must parse, and loading fails otherwise (see below)
statusThe HTTP status code
headersCustom headers, honoured uniformly on every shape above — see Response headers
delay_response_millisecondsSleep this long before responding — works correctly at the per-rule level
csv_records_keyFor a CSV file_path, the dotted path under which the parsed rows are nested in the JSON response (default key: records)

Content-type is derived from which field is setfile_path from its extension, text always text/plain; charset=utf-8, json always application/json — and an explicit headers.content-type always overrides that default, on every one of the three.

Validity rules: file_path, text and json are mutually exclusive — exactly one may be set. file_path combined with status is rejected — a custom status code is only available with text or json. text/json combined with status is allowed (a custom-status message body). file_path must resolve to a file that exists under the rule set’s respond_dir/prefix.respond_dir — and if its extension is .json/.json5, its content must parse as JSON too. Both checks run at startup (apimock validate, and loading a config to run the server), not per-request: a rule that couldn’t be served either way now fails to load, naming the file and the parse position, instead of loading and returning 500 on the first request that reached it. json’s own inline value is validated the same way, naming the rule.

priority and weight

Per-rule fields, read only when the governing strategy needs them: priority (integer, for the priority strategy) and weight (unsigned integer, default 1, for weighted_random). Both are ignored under every other strategy.

Operator reference

Every operator apimock-rs supports for matching, across the three places conditions appear: when.request.url_path, when.request.headers, and when.request.body.json. 49 variants total, generated from the enum source directly rather than transcribed — see How this table is generated for the method, so you can re-run it yourself against any future version.

url_pathRuleOp (11)

when.request.url_path = { value = "...", op = "..." }. The default when op is omitted is equal.

opMatches when
equalThe path equals value exactly (default)
not_equalThe path does not equal value
starts_withThe path starts with value
not_starts_withThe path does not start with value
ends_withThe path ends with value
not_ends_withThe path does not end with value
containsThe path contains value as a substring
not_containsThe path does not contain value
wild_cardThe path matches value as a glob pattern (*, ?)
regexThe path matches value as a regular expression, compiled per request
not_regexThe path does not match value as a regular expression

Source: crates/apimock-routing/src/rule_set/rule/when/request/rule_op.rs.

headersHeaderOperator (13)

when.request.headers.<name> = { value = "...", op = "..." }. The 11 value operators above, plus two presence-only operators where value is ignored:

opMatches when
equalThe header’s value equals value exactly (default)
not_equalThe header’s value does not equal value
starts_withThe header’s value starts with value
not_starts_withThe header’s value does not start with value
ends_withThe header’s value ends with value
not_ends_withThe header’s value does not end with value
containsThe header’s value contains value as a substring
not_containsThe header’s value does not contain value
wild_cardThe header’s value matches value as a glob pattern
regexThe header’s value matches value as a regular expression
not_regexThe header’s value does not match value as a regular expression
existsThe header key is present, regardless of value (value ignored)
absentThe header key is not present (value ignored)

Header names are matched case-insensitively (HTTP semantics). Source: crates/apimock-routing/src/rule_set/rule/when/request/headers/header_operator.rs.

body.jsonBodyOperator (25)

when.request.body.json."<dotted.path>" = { value = "...", op = "..." } — see Body path syntax for how the path resolves. The default op is equal.

opMatches when
equalString-coerced equality (both sides converted to string) — the default, kept for backwards compatibility
equal_stringExplicit alias for equal
containsThe (string-coerced) value contains value as a substring
not_containsThe (string-coerced) value does not contain value
starts_withThe (string-coerced) value starts with value
not_starts_withThe (string-coerced) value does not start with value
ends_withThe (string-coerced) value ends with value
not_ends_withThe (string-coerced) value does not end with value
regexThe (string-coerced) value matches value as a regular expression
not_regexThe (string-coerced) value does not match value as a regular expression
equal_typedExact JSON-type-and-value equality — distinguishes 42 (number) from "42" (string); value is parsed as JSON
equal_numberNumeric equality; both sides coerced to f64
greater_thanNumeric greater-than
less_thanNumeric less-than
greater_or_equalNumeric greater-than-or-equal
less_or_equalNumeric less-than-or-equal
existsThe path resolves to any value, including null (value ignored)
absentThe path does not resolve to anything (value ignored)
array_length_equalThe value at the path is an array whose length equals value
array_length_at_leastThe value at the path is an array whose length is ≥ value
array_containsThe value at the path is an array containing an element equal to value (typed JSON comparison)
equal_integerExact i64 integer equality — avoids the precision loss equal_number’s f64 coercion has above 2^53
map_has_keyThe value at the path is a JSON object containing the key named by value
map_does_not_have_keyThe value at the path is a JSON object that does not contain the key named by value
structural_containsThe value at the path is an array containing at least one element that is a superset of the JSON object in value — every key in value present with an equal value; extra keys on the element are fine

Source: crates/apimock-routing/src/rule_set/rule/when/request/body/body_operator.rs.

How this table is generated

Every variant above is pulled directly from each enum’s source, not hand-copied — re-run this yourself against any checkout to confirm the table above is current:

for f in \
  crates/apimock-routing/src/rule_set/rule/when/request/rule_op.rs:RuleOp \
  crates/apimock-routing/src/rule_set/rule/when/request/headers/header_operator.rs:HeaderOperator \
  crates/apimock-routing/src/rule_set/rule/when/request/body/body_operator.rs:BodyOperator
do
  file="${f%%:*}"; enum="${f##*:}"
  echo "=== $enum ==="
  awk -v enum="$enum" '
    $0 ~ "pub enum " enum " *\\{" { in_enum=1; next }
    in_enum && /^}/ { in_enum=0 }
    in_enum {
      line = $0; gsub(/^[[:space:]]+/, "", line)
      if (line ~ /^[A-Z][A-Za-z0-9]*,?$/) { gsub(/,$/, "", line); print line }
    }
  ' "$file" | sed -E 's/([a-z0-9])([A-Z])/\1_\2/g' | tr '[:upper:]' '[:lower:]'
done

Counts: 11 RuleOp, 13 HeaderOperator, 25 BodyOperator — 49 total, matching every table above exactly.

Body path syntax

when.request.body.json conditions and respond.csv_records_key both address a value inside a JSON body using apimock’s own dotted-path mini-syntax. This is not JSONPath (RFC 9535) — see Design notes for why.

The rule

A path is a sequence of segments joined by .:

  • A segment against a JSON object is a key lookup.
  • A segment that parses as a non-negative integer, against a JSON array, indexes into that array.
  • Anything that doesn’t resolve — a missing key, an out-of-range index, indexing into a non-array — makes the path resolve to nothing, which is a non-match for every operator except absent.

Implementation: json_value_by_jsonpath in crates/apimock-routing/src/util/json.rs, which walks the path with str::split('.'), folding into the JSON value one segment at a time.

Examples

Given this request body:

{
  "customer": { "tier": "gold" },
  "items": [
    { "sku": "WIDGET-42", "qty": 3 },
    { "sku": "GADGET-7", "qty": 1 }
  ]
}
PathResolves to
"customer.tier""gold"
"items.0.sku""WIDGET-42"0 indexes the first array element
"items.1.qty"1
"items.2.sku"nothing — index 2 is out of range
"customer.email"nothing — key doesn’t exist

What this is not

"$.customer.tier" does not work the way it would in JSONPath. The leading $ has no special meaning here — it’s treated as a literal object key, which almost never exists, so the condition silently never matches. There’s no [0] bracket-array syntax either; array indexing is a plain numeric path segment, as in the table above.

This distinction matters enough to repeat: a condition written with $.-prefixed pseudo-JSONPath doesn’t error — it just never matches, and a rule that never matches is easy to miss in testing. See Dry-run a rule for a way to check a condition actually matches before relying on it.

CLI reference

--version and --help

apimock --version
apimock --help
apimock <subcommand> --help

Both short-circuit before anything else — before a config file is read and before any listener binds. They work with no config file present and with a deliberately broken one; that’s deliberate, not incidental: “what version am I running” is the question asked precisely when something is wrong. --help (or -h) is reachable per subcommand too — apimock match-test --help and apimock validate --help print that subcommand’s own usage, not the top-level one.

Output goes to stdout; exit code 0.

Unrecognised arguments

Anything starting with - that isn’t one of the flags documented on this page is an error, not silently ignored:

$ apimock --prot 4000
apimock: unknown option '--prot'; did you mean '--port'?

A near-match suggestion appears where one exists. The message goes to stderr, exit code 2, and no server is started — a typo used to start a server on a port nobody asked for; now it doesn’t start anything.

The same is true one position over: a bare word where a subcommand goes that is not serve, get, set, match-test or validate is an error, not a silent invitation to start a server:

$ apimock banana
apimock: unknown subcommand 'banana'
$ apimock gett
apimock: unknown subcommand 'gett'; did you mean 'get'?

Same exit code, same stream, same near-match treatment — this is specifically about the subcommand position (the first argument after apimock); a flag there (apimock -p 3001) is a flag attempt, not a subcommand attempt, and falls under the flag rule above instead.

--flag=value

Every value-taking flag on every command accepts --flag=value alongside the space form --flag value-c=./apimock.toml and --config ./apimock.toml are equivalent, on every subcommand and the root command (RFC 064 Amendment 1). Splits at the first = only, so a value that itself contains = (--json={"a":"b=c"}) keeps every = after the first as part of the value; only a token that starts with - is ever considered for this form, so a positional value containing = (get "/a?x=1&y=2") or a space-form value containing = (-H "Authorization: Basic YWJj==", a separate argv token from -H itself) is never mistaken for one.

--flag= (nothing after the =) is an explicit empty value — the same as --flag "" — distinct from a dangling --flag with nothing after it at all, which is still a usage error (RFC 064). This applies to content flags (--text, --json, --body), where an empty value is a real, meaningful answer (an empty response body). It does not apply to path-valued flags (--config/-c, --rule-set/-r, --body-file, --file): an empty value there is always a usage error naming the flag (--config / -c must be a non-empty path, got ''), the same style already used for a flag whose value fails to parse (--status, --delay) — found in review of this amendment, since a path silently resolved from "" fails several layers downstream by naming an empty path rather than the flag that produced it.

A repeatable flag accepts the = form on each occurrence — --header=A: 1 --header=B: 2 adds two headers, the same as --header "A: 1" --header "B: 2".

A no-value (boolean) flag given any = form is always a usage error, exit 2. --dry-run=true, --dry-run=false and --dry-run= are all rejected — never read as “present.” This is deliberate, not an oversight: --allow-outside (RFC 062’s write-path confinement opt-out) is one of these flags, and treating --allow-outside=false as “present” would silently disable confinement on an invocation that asked, in writing, to keep it on. There is no --flag=bool feature anywhere in this CLI — only rejection, applied identically to =true, =false and = alike, so accepting one form and not the other can never become a trap someone later “simplifies” into accepting both.

Exit codes

These apply across the whole CLI, match-test, validate and get included (each also documents its own diagnostic-specific codes below — get in particular reuses 0/2 only, never 1; see its own section):

CodeMeaning
0Success, including --version / --help
2Usage error — an unrecognised option, a known option given a value that doesn’t parse (e.g. --port notanumber), or (subcommands only, see below) a known option given no value at all
1A referenced file not existing, or a subcommand-specific diagnostic failure (match-test’s “no rule matched”, set’s save failing after a valid edit — see each subcommand’s own section)

Subcommands (get, set, validate, match-test) catch a flag given no value — the end of the argument list, or immediately followed by another flag — at the same scanning step that catches an unrecognised flag, and report it the same way: a usage error, exit 2 (RFC 064).

The root command (apimock [-p <port>] [-c <config>] ..., see Running the server) does not share that fix yet — it parses its own arguments separately (crates/apimock/src/args.rs), out of RFC 064’s scope. There, a dangling flag is never caught as that — a missing value — up front; each flag instead fails wherever its own value is next used, and which exit code results depends on how it fails. -c/-d with nothing after them are checked for existence (Path::exists()) against an empty path, which is always false, so both fail as exit 1, the same code as a referenced file genuinely not existing. -p with nothing after it is instead checked by parsing the empty string as a u16, which fails to parse rather than to exist, so it’s caught as a usage error, exit 2, the same as --port notanumber. The two flags don’t disagree by design — they just fail at different checks that happen to return different codes.

Running the server

apimock [serve] [-p <port>] [-d <dir>] [-c <config>] [--init [--yes] [--middleware]]
FlagResult
(no flags)Zero-config: serves ./ by URL path, port 3001
serveThe explicit spelling of the above (RFC 053) — identical to bare apimock with every flag below, never required
-p, --port <port>Listen on a custom port
-d <dir>Serve a custom fallback directory instead of ./
-c, --config <path>Load a config file. A bare relative path resolves the same as one prefixed with ./-c apimock.toml and -c ./apimock.toml are equivalent

apimock serve is a spelling of the invocation above, not a separate command — apimock serve -c apimock.toml is exactly apimock -c apimock.toml, byte-for-byte, including --help and --version. It exists so a script or an agent can name what it’s doing explicitly without bare apimock reading as accidental.

--init

Scaffolds a starting config in the current directory. Never overwrites an existing ./apimock.toml.

FlagResult
--initInteractive: prompts for port, IP, fallback dir, whether to scaffold a rule-set file, a middleware file, and a TLS section. Writes apimock.toml, plus whichever of apimock-rule-set.toml / apimock-middleware.rhai you opted into
--init --yesNon-interactive: writes the same defaults every prompt above defaults to (127.0.0.1:3001, rule-set file included, TLS commented out), no prompts
--init --middlewareAlso scaffold apimock-middleware.rhai. Combines with --yes

When stdin isn’t a TTY (piped, CI, a Docker build), --init silently falls back to the same defaults --yes would produce, even without --yes explicitly passed.

apimock validate

apimock validate --config <path> [--strict] [--quiet] [--format text|json]

Loads the whole workspace — root config and every rule set it references — and reports diagnostics, without binding a port.

FlagMeaning
--config, -c <path>Required. The root config to validate. A bare relative path resolves the same as one prefixed with ./-c apimock.toml and -c ./apimock.toml are equivalent (RFC 064; previously validate parsed this flag separately from every other command and didn’t get the fix)
--strictDocumented to treat warnings as failures (exit 1). Not reachable today — see the note below the exit-codes table
--quietSuppress non-error output
--format textDefault. Today’s plain-text summary — unchanged whether written explicitly or left implicit
--format jsonThe RFC 053 response envelope: an object with schema, apimock, and exactly one of result/error, instead of a bare array

--json (the bare diagnostics array, deprecated in 5.19.0) was removed in 6.0.0. Using it is now a usage error, exit 2, naming --format json as the replacement — enveloped (error.kind: "usage") if --format json was also given, plain stderr otherwise. See the migration guide for the exact error text.

Exit codes: 0 clean, 2 the config couldn’t be loaded at all, the invocation itself was invalid (--json, or --format given a value other than text/json), or a required flag was missing/dangling.

Exit 1 (“at least one error”) is documented but not reachable today, and neither is --strict’s effect. Workspace::load — which validate calls before it ever produces a diagnostic — already checks, identically, every condition that could otherwise appear in the diagnostics report (a respond block that’s empty or has conflicting fields, a respond.file_path that doesn’t exist, a missing fallback_respond_dir) and fails to load if any of them is present. So a config either loads with zero diagnostics (exit 0) or fails to load (exit 2) before reaching the exit-1 path at all; nothing anywhere constructs a Severity::Warning diagnostic either, so --strict (which only promotes warnings to failures) has nothing to act on even in principle. Documented as-is rather than fixed — a real fix changes config-load validation shared with server startup, larger than this page’s scope.

The response envelope (--format json)

Introduced in 5.19.0 (RFC 054), ahead of 6.0.0’s get/setget (below) is the first of the two to actually use it. A successful validation:

{
  "schema": 1,
  "apimock": "5.19.0",
  "result": {
    "diagnostics": [
      { "severity": "error", "message": "…", "node_id": "…", "file": "…" }
    ],
    "summary": { "errors": 0, "warnings": 0, "rule_sets": 1, "rules": 2 }
  }
}

A config that failed to load (validate never got as far as producing diagnostics):

{
  "schema": 1,
  "apimock": "5.19.0",
  "error": { "kind": "config_invalid", "message": "…" }
}

error.kind is one of usage, config_invalid, config_unreadable, io, conflict, internal — a closed, stable set; treat an unrecognised value as a generic failure rather than erroring on it, since new kinds may be added later without a schema bump. A validation that ran and found problems is still a result, not an error — the envelope’s top-level shape answers “did this command run”, not “is the config valid”; check result.summary.errors for the latter. schema starts at 1; a later, incompatible change to this shape increments it.

Each kind maps to a process exit code:

kindExit code
usage2
config_invalid2
config_unreadable2
io1
conflict1
internal1

The mapping is many-to-one on purpose — the envelope’s kind is a caller-facing category (what went wrong), the exit code is a shell-facing signal (did it work); a script branching on exit code alone still separates “bad invocation” (2) from “ran, but failed” (1) without needing to parse the envelope at all.

apimock get

apimock get <path> [-c <config>] [-m <METHOD>] [-H "Name: value"]... \
  [-b <json> | --body-file <path>] [--why] [--format text|json]

Answers what would the server return for this request — status, headers, body — from configuration on disk, with no server running. Unlike match-test, it answers from the whole workspace (apimock.toml and everything it references), and covers every dispatch stage the server does, in the same order: OPTIONS → rule sets → the fallback directory. A zero-config workspace (no rule sets at all) is answered correctly, because the fallback-directory stage is where zero-config mode’s answers come from — a get that stopped at rule sets would be wrong for that case, which is most of them.

FlagMeaning
--config, -c <path>The root config to answer from. Default: ./apimock.toml if it exists, otherwise zero-config — same resolution the server itself uses
--method, -m <METHOD>The request’s HTTP method (default: GET)
--header, -H "Name: value"Add a header; repeatable
--body, -b <json>The request’s JSON body, inline
--body-file <path>The request’s JSON body, from a file
--whyExplain which rule set and rule decided the answer, and for a near-miss, which specific condition failed. Off by default in text, on by default with --format json
--format textDefault. Human-readable
--format jsonThe RFC 053 response envelope, including provenance (the absolute paths of the config and rule sets that answered)

--format json’s matched object also carries rule_set_file alongside rule_set_index/rule_index — the same rule-set path --why reports (see below), added so the address can be handed to apimock set’s --rule-set/--rule unmodified, without a second --why round trip just to learn the path.

Middleware is never executed. If any is configured, the answer says so explicitly (middleware.configured/middleware.note in JSON, a console note in text) and proceeds anyway — the response may be wrong if a middleware would have intercepted the request, and the answer is marked incomplete rather than silently omitting that risk. There is no flag to run middleware; that would mean executing Rhai scripts as a side effect of a read command, which this project’s stated preference for the safer option rules out.

Exit codes deliberately differ from match-test’s. get exits 0 even when nothing matched — a 404, or “no rule matched”, is a legitimate answer to a legitimate question (RFC 053: this is a result, not an error). match-test still exits 1 on no match; the two commands answer similar-sounding questions with different exit semantics on purpose, documented here rather than aligned, since changing match-test’s exit code now would be an unannounced breaking change.

Exit codes: 0 answered (including a 404 or no match), 2 a bad invocation or the configuration couldn’t be loaded.

Two honest limits, both narrow. A [[rules]] strategy = "round_robin" (or uniform_random/weighted_random, or priority with a uniform_random tiebreak) rule set can answer differently from what a running server would return next. get loads its own rule sets fresh from disk each run, with their own round-robin counter starting at 0 and their own random draw — it has no way to observe how far a live server’s selector has already advanced, or to reproduce an unseeded draw, so its answer is one legitimate possibility, not a prediction of the server’s next response. There’s no fix for this: it’s the same drift a static answer always risks against live state, which is exactly what provenance exists to name rather than hide. strategy = "first_match" (the default) and priority with the default first_match tiebreak are unaffected — both are deterministic from the request alone. Separately, a response body that isn’t valid UTF-8 is shown with replacement characters rather than round-tripping exactly, in both --format text and --format json — a mock server’s bodies are expected to be JSON or text, so this is believed to be a narrow gap rather than a common one.

--why’s JSON shape

"why": {
  "note": "Answered from a rule set.",
  "rule_sets": [
    {
      "rule_set_index": 0,
      "rule_set_file": "/abs/path/apimock-rule-set.toml",
      "rules": [
        {
          "rule_index": 0,
          "matched": false,
          "conditions": [
            { "name": "url_path", "expectation": "equal \"/orders\"", "actual": "/orders", "matched": true },
            { "name": "body.json:customer.tier", "expectation": "equal \"gold\"", "actual": "\"silver\"", "matched": false }
          ]
        }
      ]
    }
  ]
}

Only the rule sets dispatch actually consulted are listed — if an earlier one answered, later ones were never reached by the server either, so they aren’t listed here. actual is always present, even for conditions whose text-format output never showed it historically (url_path, headers) — the JSON shape is not constrained to match match-test’s older, narrower text output.

apimock set

apimock set rule [-c <config>] [--rule-set <path>] [--rule <n>] \
  [--path <url_path>] [--method <METHOD>] [-H "Name: value"]... \
  [--status <code>] [--json <value> | --text <value>] [--file <path>] \
  [--delay <ms>] [--dry-run] [--format text|json] [--allow-outside]

Adds a rule (the default), or changes an existing one when --rule is given, and writes it to the rule-set file — keeping that file’s comments and formatting (RFC 056). Neither the root config nor the rule-set file need to exist yet — a fresh directory gets a minimal starting pair of files, not the example-filled scaffold --init writes.

Addressing is by natural key, never a process ID. Every apimock set invocation is a new process, so a new load of the config — anything keyed by a process-local ID would be meaningless to the next invocation. set addresses a rule by (rule-set file path, 0-based rule index) instead — the same shape get’s --format json matched/--why already reports. An address printed by get can be passed to --rule-set/--rule unmodified.

FlagMeaning
--config, -c <path>The root config to edit. Default: ./apimock.toml, created if absent
--rule-set <path>The rule-set file to add to, or edit within. Default: ./apimock-rule-set.toml, created if absent
--rule <n>Edit the existing rule at this 0-based index, instead of adding a new one
--path <url_path>The rule’s url_path condition
--method <METHOD>The rule’s method condition
--header, -H "Name: value"Add a header condition; repeatable. With --rule, layers onto the existing rule’s conditions rather than replacing them
--status <code>The response status code
--json <value>The response body, as JSON (validated at parse time). Writes respond.json, served as application/json (RFC 065)
--text <value>The response body, as plain text — mutually exclusive with --json
--file <path>The response body, served from a file
--delay <ms>Delay the response by this many milliseconds
--dry-runShow what would change, without writing anything
--format textDefault. Human-readable
--format jsonThe RFC 053 response envelope
--allow-outsidePermit --rule-set to resolve outside the config directory. See below

--rule’s index is 0-based, matching get’s JSON contract rather than its 1-based text display — the machine-readable convention, since that is the one meant to compose. Addressing a rule set by a path not in service.rule_sets when --rule is also given, or an out-of-range rule index, is a usage error — not a panic, and not a silent no-op.

--rule-set is confined to the config’s own directory tree by default (RFC 062) — a path that canonicalises outside it (../elsewhere.toml, an absolute path elsewhere) is a usage error, exit 2, and nothing is written, including a bootstrap file. --allow-outside opts out for the cases where it’s actually wanted — a person at a shell pointing at a shared rule-set file elsewhere is not a mistake the same way an AI agent composing an unreviewed path is. This confinement is CLI-layer only; apimock-config’s library API (and so the GUI, once it exists) does not inherit it. See the threat model for the full reasoning.

A file changed on disk since it was loaded is refused, not overwritten (RFC 056) — error.kind: "conflict", distinguished from an unrelated read failure ("io"). No file is modified when either happens.

--dry-run never reports a NodeId. Its preview resolves every changed node back to the same natural-key address set accepts, the same way a successful save’s own changes array does — nothing process-local ever appears in set’s output, on any path, success or error.

Scope of this cut. service.middlewares is never added, changed or removed by any set invocation — existing entries pass through untouched (RFC 048 § 9 T2, deferred rather than refused). DeleteRule, MoveRule and RemoveRuleSet aren’t reachable from set yet — those renumber existing rules, which would break the positional address this command’s whole design depends on staying stable across invocations. One rule change per invocation; there is no batch flag.

Exit codes: 0 applied (or, under --dry-run, would apply), 1 loaded and addressed successfully but the save failed (conflict, io, or an internal error), 2 a bad invocation or the configuration couldn’t be loaded.

apimock match-test

apimock match-test --rule-set <path> [--rule <n>] [--path <url_path>] \
  [--method <METHOD>] [--header "Name: value"]... \
  [--body <json> | --body-file <path>] [--quiet] [--format text|json]

Builds a synthetic request from the flags below and checks it against a rule set directly — no server, no network request. In text (the default), prints a per-condition breakdown for every rule (or just the one named by --rule), then a final Result: MATCH (rule #N) or Result: NO MATCH line.

FlagMeaning
--rule-set, -r <path>Required. The rule-set file to check against
--rule <n>Check only this rule, 1-based
--path, -p <url_path>The synthetic request’s URL path
--method, -m <METHOD>The synthetic request’s HTTP method
--header, -H "Name: value"Add a header; repeatable
--body, -b <json>The synthetic request’s JSON body, inline
--body-file <path>The synthetic request’s JSON body, from a file
--quiet, -qSuppress the per-condition breakdown, print only the result (text only — has no effect under --format json, which never prints the breakdown either way)
--format textDefault. The breakdown described above
--format jsonThe RFC 053 response enveloperesult.matched (bool), result.match_rule_index (0-based, null if none), result.request (method, path), and result.rules[] — one entry per rule checked, each with rule_index, matched, and the same per-condition name/expectation/actual/matched detail the text breakdown prints

Added in 6.0.0 (RFC 059) — the one command outside RFC 053’s envelope until now, so an agent driving it previously had to scrape the text breakdown. Additive only: text stays byte-identical to before, and exit codes are unaffected by --format0 matched, 1 no rule matched, 2 an argument or input error, the same under both formats. This is match-test’s own success/failure axis, deliberately different from get’s, which always exits 0 for a legitimate “no match” answer.

Exit codes: 0 matched, 1 no rule matched, 2 an argument or input error (bad flag, file not found, invalid JSON body).

See Validate config in CI and Dry-run a rule for worked examples of both commands, including their exact output.

Response headers

Every response — including a 404 — carries a fixed set of default headers. Some vary by request; none of this is configurable globally.

Always present

HeaderValue
access-control-allow-headers*
access-control-allow-methodsGET, POST, PUT, DELETE, OPTIONS
access-control-max-age86400
cache-controlno-store
x-content-type-optionsnosniff

Source: DEFAULT_RESPONSE_HEADERS in crates/apimock-server/src/constant.rs. A date header also appears on every response, but nothing in apimock-server sets it explicitly — it’s added by the underlying HTTP transport layer, not application code.

connection: keep-alive — HTTP/1.1 only

apimock-server sets connection: keep-alive on every response it builds, alongside the headers above — but unlike them, it isn’t always present on the wire. Connection is a hop-by-hop header defined for HTTP/1.1’s own connection-management model; HTTP/2 multiplexes many requests over one connection and has no equivalent concept, so RFC 9113 § 8.2.2 requires an intermediary to strip it, and hyper does so correctly before this project’s own DEFAULT_RESPONSE_HEADERS value ever reaches the wire. This is a transport-layer removal, not something apimock-server’s own code special-cases per protocol.

Verified against a running server, both protocols, same request:

$ curl -s -i --http1.1 http://127.0.0.1:3011/hello.json | grep -i connection
connection: keep-alive

$ curl -s -i -k --http2 https://127.0.0.1:3012/hello.json | grep -i connection
$ # (no output — the header is genuinely absent, not empty)

If you’re asserting on this header in a test against apimock, either force HTTP/1.1 or don’t assert on it at all when HTTP/2 is in play.

CORS — origin and credentials

access-control-allow-origin, vary, and (conditionally) access-control-allow-credentials depend on whether the request looks authenticated — defined as carrying a cookie or authorization header — and, if so, whether the request’s origin is allowed credentialed reflection (RFC 067):

Requestaccess-control-allow-originvaryaccess-control-allow-credentials
No cookie/authorization**(absent)
Credentialed, origin allowedThe request’s own origin value, reflected backOrigintrue
Credentialed, origin not allowed**(absent)

An origin is “allowed” if it’s http://localhost:* or http://127.0.0.1:* (implicitly, always — no configuration needed), or appears exactly in [service].cors_allow_credentials_origins (empty by default). An unlisted, non-loopback origin gets the same response as a request with no credentials at all — the response is still served, but without the headers a browser needs to expose it to a credentialed cross-origin read. See the threat model for why.

Source: default_response_headers / is_likely_authenticated_request / is_credentialed_reflection_allowed in crates/apimock-server/src/response_handler.rs.

OPTIONS requests

Handled before anything else in the request pipeline — before middleware, before rule matching, before parsing the body. Every OPTIONS request gets:

  • Status 204 No Content (not 200).
  • content-length: 0.
  • The full default header set above, including the CORS headers.

Source: handle_options in crates/apimock-server/src/server.rs. See Matching order and precedence for where this sits in the overall request flow.

Custom headers via respond.headers

respond.headers adds or overrides headers on a per-rule basis, uniformly across every respond shape — file_path (JSON, JSON5, CSV, binary, or plain text), text, json, and status-only, with or without a custom status code. An explicit content-type in respond.headers always overrides whatever content-type the response would otherwise derive: from a file’s extension, from text’s text/plain; charset=utf-8 default, or from json’s application/json default.

This section used to carry a per-shape table of exceptions — several respond shapes silently dropped custom headers entirely (RFC 045), and every shape that did honour a custom content-type still had it overwritten by the derived default immediately afterward (RFC 065). Both are now fixed by routing every response-building call site through one shared step (ResponseHandler::with_custom_headers, applied only after the body — and its derived content-type — is already set), so there’s no longer a shape-by-shape exception to list: if you set respond.headers, including content-type, it’s honoured, on every shape.

Threat model

This page states, deliberately, what apimock’s actual security surface is in 6.0.0 — what each actor can do, what apimock allows on purpose and why, and what apimock is not trying to protect against. It supersedes RFC 048 § 9, which was written before apimock set existed and never revisited once it shipped — the gap this page exists to close, by living somewhere it will actually be read again.

Non-goals — read this first

apimock is a development tool. It is not hardened against hostile input, and it is not designed for multi-tenant use. It should not be exposed to an untrusted network. Nothing below changes that. If you need a mock server a stranger can safely send traffic to, this isn’t it — bind it to localhost, run it behind something that is designed for that job, or don’t expose it at all.

apimock also does not defend a user against their own commands. If you type apimock set --rule-set /etc/whatever.toml --allow-outside, it does what you asked — the same way cp or rm would.

Actors

  • A person at a shell. Trusted, along with the filesystem they own. Explores by trial, reads output, adjusts. apimock does not second-guess a command this actor typed themselves.
  • An AI CLI agent. This is the actor 6.0.0’s CLI surface is designed for, and the one that changes the picture from earlier releases: it composes commands from material it did not author — a spec being mocked, a filename in a task description, a path another tool handed back — runs them non-interactively, and builds on the result without a human reviewing any single step. “The user asked for it” does not hold the same way here, because the user did not type the command; an agent acting on untrusted input can be induced to run one it shouldn’t.
  • CI. Runs a fixed set of commands, asserts exit codes, never answers a prompt, has no hidden network dependency.
  • The GUI application. A long-lived session against the apimock-config/apimock-routing library API directly — not through the CLI. Its trust model is “the library API keeps working,” not anything CLI-specific.
  • An MCP host. Behaves and fails the same way the AI CLI agent does, through an adapter. No separate model is needed for it.

Surface

What the server reads. The root config (apimock.toml), every file listed in service.rule_sets, every file listed in service.middlewares (.rhai scripts, compiled once at startup — no file-watch or hot-reload), and TLS certificate/key files when configured. All are read from paths an operator put in their own config file. A file actually served in response to a request — whether found via the dyn-route fallback, a rule’s respond.file_path, or a path a Rhai middleware returns — is confined to the directory it was resolved against; see T3, below.

What the CLI writes. apimock set creates or rewrites the root config and a rule-set file, in place, preserving comments and key order (RFC 056). --init writes a starter config (and optionally a rule-set file, a middleware file, a TLS section) non-interactively or interactively. Nothing else in the CLI writes to the filesystem.

What middleware can do. Rhai’s engine is constructed with Engine::new() — the default, unsandboxed configuration; apimock registers no filesystem, network, or environment-variable access, and Rhai’s standard library doesn’t expose those on its own, so a script’s own code cannot open sockets or read arbitrary files directly. A script receives exactly two values: the request’s url_path and its parsed JSON body — no headers, no method. Its return value drives the response: a string names a file to serve, or a map selects file_path / json / text. A script that fails to compile or panics at runtime is logged and the request falls through to the next stage — it cannot crash the process.

A non-terminating script fails its own request, not the process (RFC 068 S-03). Before RFC 068, this page said a failing script “cannot crash the process, but it can silently degrade routing” — true about crashing, wrong about non-termination: a script evaluated directly on an async worker thread, with no operation limit, simply never returned, which is a stalled server, not degraded routing. [service].middleware_max_operations (default 10,000,000, generous for any reasonable script) bounds a script by work done; evaluation also runs in a spawn_blocking task rather than directly on the async runtime, so a script that still doesn’t terminate costs one slow request, not one permanently lost worker. Fixed call-depth and string/array/map-size ceilings apply regardless of the configured operation limit — there is no legitimate mock-middleware reason to need more of either.

A request body is capped before it is buffered (RFC 068 S-02). [service].max_request_body_bytes (default 32 MiB) bounds how much of one request body is ever collected into memory; a body over the limit gets 413 instead of being buffered first. Before this, a body of any size was collected whole — the external audit measured one 256 MiB request taking the process from 9 MiB RSS to 462 MiB, reachable by a single unauthenticated request with no connection limit to bound concurrency either.

What TLS touches. apimock terminates TLS itself via rustls — this is not a reverse-proxy setup. Certificates can hot-reload without rebinding the listener (RFC 020): in-flight handshakes finish on the old cert, new connections get the new one. There is no client-certificate (mTLS) support; enabling or disabling TLS itself still requires a full restart.

Deliberate allowances, with reasons

apimock set creates a file containing rule-set TOML at a caller-named path — confined by default (RFC 062). The underlying capability is real: set is, in the abstract, a file-creation primitive. For a person at a shell this is unremarkable — the same category of thing cp’s destination argument is. For an AI CLI agent composing an unreviewed path, it stops being unremarkable, because the “caller” who named the path and the user who will be blamed for what happened aren’t reliably the same judgment. set refuses a --rule-set (or any other caller-supplied write target) that resolves outside the root config’s own directory treeusage, exit 2, nothing written, not even a bootstrap file — unless --allow-outside opts back in. Resolution is by canonicalised path where the target exists, and by canonicalised parent where it doesn’t, since set legitimately creates files that don’t exist yet and a naive existence-requiring check would break ordinary bootstrapping. Refusing rather than warning follows the precedent already set for --dry-run (RFC 057 REVIEW-001 § 4): a safety affordance that sometimes acts anyway is worse than one that declines outright, because the exception is invisible at the call site.

This confinement is CLI-layer only. apimock-config’s library API — and so the GUI, once it consumes it directly — does not inherit it. This is deliberate, not an oversight: pushing the check into Workspace would change a published library API to protect against a threat model (an untrusted caller composing paths) that doesn’t describe the GUI’s own actor. If confinement should hold for every caller of the library, that’s a follow-up RFC’s decision, not something bundled quietly into this one.

--file (on get/set) is out of scope for this confinement, on purpose. set --file <path> never reads that path — it stores the string as respond.file_path in the rule-set TOML, for the server to read later, at serve time. It’s a reference, not a write target, so the write-path confinement above doesn’t apply to it the way it applies to --rule-set. (get --body-file <path> is unrelated: it’s a genuine local read, used only to build a synthetic request for apimock get’s own dry-run matching — never anything the server itself touches.)

Verbose logging redacts headers, query strings and body keys alike — RFC 073 S-05. log.verbose.header (default off) prints every request header, with anything matching the credential-shaped denylist (authorization, cookie, set-cookie, proxy-authorization, x-api-key, token, access_token, refresh_token, password, secret, client_secret, api_key, or a configured allowlist/denylist) replaced with a redacted marker — RFC 051. log.verbose.body, independently gated and also default off, used to print the raw query string and the full JSON body with no redaction at all — RFC 051 flagged this itself (its own Unresolved Question 2) and deliberately left it for a later RFC rather than scope-creeping into it. RFC 073 closes that gap: the same denylist/allowlist that already governed headers now governs a query-string parameter’s value (?token=secret?token=[redacted]) and a JSON body’s object keys, recursively ({"password": "hunter2"}{"password": "[redacted]"}, however deeply nested) — one policy, applied wherever a name-value pair can leave the process, not a separate list per surface. This also covers the trace channel’s own capture_body (RFC 023), not only the console: an out-of-process subscriber over the UDS/TCP transport receives the same redacted body a verbose console log would show, not the raw one.

The trace transport is not authenticated (RFC 073). A Unix-domain socket subscriber connects with owner-only (0600) filesystem permissions since RFC 073 — the socket file used to inherit whatever the process umask produced, often readable by any local user. That permission restriction has no Windows equivalent (the UDS transport is Unix-only; Windows always uses TCP) and the TCP transport itself has no login, token, or allowlist of any kind — anything that can open a connection to the configured address receives the live request trace feed. apimock only warns (loudly, at startup) if the configured address isn’t loopback; it does not refuse to bind one, since an operator may have a real reason this process can’t see. Bind the TCP trace transport to loopback, or prefer the Unix-socket transport wherever the platform supports it, the same way the server’s own listener defaults to loopback for the same reason (see the Non-goals section above).

Credentialed CORS reflection is allowed, but only for a named or loopback origin (RFC 067). When a request carries Cookie or Authorization, apimock reflects the request’s Origin into Access-Control-Allow-Origin and sets Access-Control-Allow-Credentials: true — but only if that origin is http://localhost:*, http://127.0.0.1:* (allowed implicitly — a page served from the developer’s own machine is already inside the trust boundary the loopback bind assumes), or named in [service].cors_allow_credentials_origins (exact origin strings, empty by default). Every other credentialed request still gets a response — refusing it outright would break the many requests that carry a Cookie incidentally and need no CORS at all — but with the same safe, non-credentialed Access-Control-Allow-Origin: * a request with no Cookie/Authorization gets; the browser is what then refuses a credentialed cross-origin script access to the response.

Before RFC 067, this was unconditional: any origin got credentialed reflection, no allowlist, not configurable — the textbook CORS misconfiguration, and this page’s own D-04 gap (the audit’s finding that this allowance existed without a stated reason here). Binding to 127.0.0.1 is not a mitigation for the unconditional case: the dangerous request originates from the developer’s own browser, on a page they merely visited, targeting their own loopback listener — the default bind protects against a remote attacker reaching the port, not against this.

Settled decisions, restated in full

T2 — a configuration write becomes code execution — decided 2026-08-17: deferred, not refused. service.middlewares lists Rhai scripts the server compiles and runs; set could, in principle, attach one. It does not: set’s first cut never adds, changes, or removes service.middlewares entries — existing entries pass through untouched.

This was not decided on maintenance cost, though it was first argued that way. Checking the source showed the machinery set would need already exists: Server::new already compiles and propagates middleware failures loudly at startup, middleware paths already resolve against the config directory, and requires_reload already models the “changes take effect on restart” semantics set would need. The maintenance argument was asserted without checking the code, and it was wrong.

The real argument is about a capability, not effort: a caller who can invoke set could cause the server to run a file of their choosing on its next boot. For a person at a terminal that’s unremarkable — they could edit the file directly. For an agent acting on untrusted input, it is the difference between changing what a mock returns and running code in the process — worth a deliberate scope decision rather than an incidental default.

That argument has a hole worth stating honestly: refusing does not prevent it. An agent that can be induced to run apimock set can be induced to write the .rhai file directly instead. The refusal is a speed bump against a capable attacker, not a barrier — its value is against the inadvertent case (a “just set this field” verb quietly gaining code-execution as a side effect), not a determined one.

The real cost of building this later isn’t maintenance either — it’s correctness. A set that writes a middleware path can leave a workspace that no longer boots (a missing file, or one that doesn’t compile) — discovered only when the server next starts, long after set reported success. Building this means pulling Rhai compilation into set’s own path so success is verified before it’s reported, not treating service.middlewares like every other field.

If middleware attachment is built later, it must be through an explicit command or flag — never through a generic “set this field” verb — so code execution is never a side effect of an ordinary config edit, and intent is visible in whatever composed the command.

T1 — path traversal through a caller-supplied write path — status as of 6.0.0: enforced. RFC 048 required this without specifying a mechanism; RFC 062’s confinement (above) is that mechanism, for set’s one caller-supplied write target.

T3 — path traversal through the serve path (the read side) — status as of 6.0.0: enforced. Complementary to T1, and the gap this page itself flagged when it first shipped (RFC 062) — now closed (RFC 063). A resolved file is served only if it stays within the directory it was resolved against, at every site that can produce one: the dyn-route fallback (a request-derived path), a rule’s respond.file_path, and a path a Rhai middleware script returns (both operator-authored). Each checks by canonicalising the resolved candidate and confirming it remains inside the canonicalised base directory for that site — the fallback respond dir, the rule set’s own respond dir, or the middleware script’s own directory, respectively. A violation is a bare 404, indistinguishable from an ordinary not-found, so a prober learns nothing about whether the target exists.

Unlike T1, this has no opt-out. RFC 062 gave set --rule-set an escape hatch because a caller naming an outside path is asking for it and is the only one exposed; the serve path is reachable by anything that can send a request, so no config toggle turns it off. If files genuinely live elsewhere, point respond_dir at them directly — explicit, per rule set, already supported.

Unlike T1, this is not CLI-layer only. It’s enforced inside apimock-server itself — the running server, and apimock get (RFC 055), which calls the exact same dispatch functions the server does, so neither can answer differently than the other for the same request. The asymmetry the previous version of this page flagged — write path confined, read path open — no longer exists.

As defence in depth, normalize_url_path also strips a .. segment from the request path before it reaches file resolution at all — this closes the ordinary case earlier, but it is not the fix: it cannot help respond.file_path or a Rhai-returned path (neither is built from a URL), and a symlink escaping the base is caught only by canonicalise-and-compare. Two independent controls, deliberately: the RFC’s own framing was “neither alone is the fix.”

This was a vulnerability in released versions, not only a v6 hardening. Before the fix, the dyn-route fallback joined a request-derived path onto the response directory and checked only that the result existed, so a request carrying an un-normalised .. segment could read a file outside it.

Affected: 4.0.0 through 5.19.0 — both supported lines, confirmed by exploit at 4.8.0 and on current code. Fixed in 4.8.1, 5.19.1 and 6.0.0, published with GHSA-72g6-wgrg-vhm7.

Whichever line you are on, the patch release for that line is the fix — there is no requirement to change major version. On 4.x the npm release carries the v4x dist-tag (npm install apimock-rs@v4x), since latest tracks the newest line.

Practical exposure was bounded: apimock binds 127.0.0.1 by default, so it was not reachable off-host unless the listener had been pointed elsewhere, and it required a client that does not normalise .. before sending (browsers and most proxies and HTTP libraries do). Bounded is not the same as absent, which is why it was fixed rather than documented.

Per-request cost: the base directory is canonicalised once, when the server starts (or apimock get runs) — not per request. The only per-request work is canonicalising the resolved candidate, measured at under a microsecond on a warm filesystem cache, immaterial next to the network I/O already in every request.

Other threats RFC 048 named, current status:

  • Indirect prompt injection reaching set through an AI agent isn’t solvable inside the CLI — apimock’s obligation is not to amplify it: no shell evaluation of arguments, no implicit writes, and destructive operations stay explicit rather than inferred. Unchanged by this RFC.
  • Secret leakage through verbose output — see the redaction section above; addressed (RFC 073 extended header redaction to query strings and body keys). Not a claim that every possible secret shape is caught — the denylist is name-based, so a value under an unanticipated key still prints, the same fail-open trade-off RFC 040 already made for headers.
  • Symlink / TOCTOU on a configuration writeset’s atomic write-then-rename (RFC 056) and external-change detection (RFC 024, 042) cover the write side; nothing here re-litigates that.
  • A server-hosted configuration API — never built. Not a live surface.
  • Supply chain of new dependencies — covered by the existing cargo audit / lockfile CI gates (RFC 033), no new mechanism needed per dependency.

Using apimock as a library

Most of this documentation is about running apimock as a command. This section is for building on it — linking the crates into your own application rather than driving the binary.

The obvious case is a GUI: a long-lived session that loads a configuration, lets someone edit it, validates as they go, and runs a mock server against the result. The library API was shaped for that, and the threat model names a GUI application as one of apimock’s actors.

Where to start

PageCovers
Crates and architectureWhich of the four crates to depend on
Editing a configurationThe Workspace model — load, edit, validate, save
Running a mock serverStarting a server, reloading, the live match feed
API stabilityWhat is promised across 6.x, and what enforces it
Known limitationsSurfaces without a proven consumer, and other honest gaps

In one paragraph

Depend on apimock-config and apimock-server; apimock-routing comes along and you will name its types. Load with Workspace::load, render from snapshot(), mutate with apply(EditCommand), surface problems from validate(), preview with preview_changes(), commit with save(). Run the mock with apimock_server::server::Server. Everything here is covered by an API declaration gate as of 6.0.0: no change to these surfaces reaches a release without appearing in the baseline and the release notes.

Read this before relying on anything

Parts of this API were designed for a library consumer but have never had one. Some are exercised end to end by the CLI, with tests; some have no caller anywhere in the project.

SurfaceStatus
Workspace::load / apply / save / validate / snapshot / preview_changesProvenapimock set and apimock validate drive these
Workspace::has_external_changes / sync_from_diskNo consumer. Unit-tested inside apimock-config; never driven by an application
apimock_server::control::{ServerControl, ServerHandle, ServerState}No consumer. The CLI calls Server::start directly and never reloads
apimock_server::trace::TraceEmitterNo consumer outside the server’s own internals

The proven rows have been shaped by a real caller meeting real edges. The others have not — expect missing conveniences and signatures that are correct without being comfortable.

Known limitations goes into what specifically is unresolved about each.

A note on accuracy

Every API shape quoted in this section came from the checked-in public-API baselines (crates/*/public-api.txt), which are generated from the crates and gated in CI. If this documentation and a baseline disagree, the baseline is correct — please open an issue.

Where to find them, because it is not where you would look. The baselines live on the repository’s default branch:

https://github.com/apimokka/apimock-rs/tree/main/crates

They are not in the 6.0.0 tag — the gate that produces them landed shortly after 6.0.0 shipped — and they are not in the published crate tarballs, where exclude = ["public-api.txt"] keeps a CI artefact out of what consumers download. Releases after 6.0.0 will carry them at the tag; the tarball exclusion is permanent and deliberate.

The 6.0.0 baselines are still an accurate record of 6.0.0’s surface: they were generated from a tree with no source changes since the tag.

Reported by the apimokka team, who went looking for the tiebreaker this page promises and found nothing at the tag.

Crates and architecture

The four crates, and which you depend on

CrateOwnsYou depend on it?
apimock-routingThe rule model: RuleSet, Rule, Respond, conditions, match strategies. No I/O, no HTTP.Transitively, and you will name its types
apimock-configReading apimock.toml and everything it references; path resolution; validation; the Workspace façade you edit throughYes — this is your main dependency
apimock-serverHTTP response construction, TLS, middleware, the running server, the trace channelYes — if your GUI runs a mock (it presumably does)
apimockThe CLI binary and its argument parsingNo. Its library surface is CLI internals — args, app, init_interactive. Nothing you want

Dependency direction, which is also the layering:

apimock-routing   (no internal deps)
      ↑
apimock-config    (depends on routing)
      ↑
apimock-server    (depends on config + routing)
      ↑
apimock           (the CLI; depends on all three)

Do not depend on apimock. It re-exports the other three (pub use apimock::config etc.), but it also drags in the CLI. Depend on what you need directly.

[dependencies]
apimock-config  = "6"
apimock-server  = "6"
apimock-routing = "6"   # only if you name its types explicitly

Why the split exists, and why it matters to you

The 5.0 refactor moved HTTP-response construction out of the routing crate specifically so routing could be a clean dependency target for a future GUI. Respond’s own module documentation says so:

“the routing crate must stay free of hyper body / response helpers so that it can be a clean dependency target for a future GUI. Respond now just describes what the user wrote in their TOML; the server consumes that description and builds the actual HTTP response.”

The practical consequence: you can model, display and edit a configuration using apimock-routing + apimock-config without linking a server at all. If your GUI has a “design rules” mode that does not need a live mock, it does not need apimock-server, and does not pull in hyper, rustls or tokio’s networking.

Whether that separation is worth exploiting is your call — but it was built deliberately, and it is there.

Where the authority lives for each question

When this package and the code disagree, these are the sources of truth, in order:

QuestionAuthority
What is in the public APIcrates/<name>/public-api.txt — generated, CI-gated
What a config file may containdocs/src/reference/apimock-toml-root-settings.md, rule-set-schema.md
What a rule may match ondocs/src/reference/operator-reference.md
What apimock protects againstdocs/src/reference/threat-model.md
Why a design is the way it isrfcs/done/ — every accepted design, with its reasoning

The RFCs are worth knowing about. They are not marketing documents; they record what was rejected and why, which is usually the part you need when a design looks odd.

Editing a configuration — the Workspace model

This is your core loop, and the part of the library most deliberately shaped for you.

The model

A Workspace is a loaded configuration you edit by domain commands, not by TOML text. You never write TOML; you issue an EditCommand and the workspace applies it, preserving the file’s comments and formatting (RFC 056 — toml_edit, not re-serialisation).

#![allow(unused)]
fn main() {
use apimock_config::{Workspace, view::EditCommand};

let mut ws = Workspace::load(PathBuf::from("./apimock.toml"))?;
let snap   = ws.snapshot();          // render this
let report = ws.validate();          // show these as diagnostics
let result = ws.apply(command)?;     // mutate
let diff   = ws.preview_changes();   // "you are about to write this"
let saved  = ws.save()?;             // commit to disk
}

The full surface

Quoted from crates/apimock-config/public-api.txt:

MethodReturnsUse
load(PathBuf)Result<Self, WorkspaceError>Open a config and everything it references
snapshot()WorkspaceSnapshotThe whole tree, for rendering
validate()ValidationReportStructured diagnostics — see below
apply(EditCommand)Result<ApplyResult, ApplyError>One domain-level edit
preview_changes()Vec<DiffItem>What save() would write
save()Result<SaveResult, SaveError>Write to disk
has_unsaved_changes()boolEnable/disable your save button
has_external_changes()bool⚠️ see § External changes
sync_from_disk()Result<(), WorkspaceError>⚠️ see § External changes
config()&ConfigThe resolved config, read-only
root_path()&PathWhere it was loaded from
describe(NodeId)Option<String>Human label for a node
list_directory(&Path)Vec<FileNodeView>For a file-picker over the respond dir
rule_set_id_at(usize)Option<NodeId>Positional → id
rule_id_at(usize, usize)Option<NodeId>Positional → id
respond_id_at(usize, usize)Option<NodeId>Positional → id

EditCommand — the complete set (15)

AddRuleSet          RemoveRuleSet         UpdateRuleSetStrategy
AddRule             UpdateRule            DeleteRule            MoveRule
UpdateRespond
AddHeaderCondition  UpdateHeaderCondition RemoveHeaderCondition
AddBodyCondition    UpdateBodyCondition   RemoveBodyCondition
UpdateRootSetting

This is a closed set. If a GUI gesture does not map onto one of these, there is no supported way to express it — open an issue rather than editing TOML behind the workspace’s back. Adding a variant is additive and cheap; two writers to the same file is not.

NodeId — and the thing that will bite you

Every node has a NodeId. They are minted fresh on each Workspace::load() — the CLI’s own set.rs says so directly:

“a fresh UUID minted per Workspace::load(). That is fine for a GUI [session] …”

So a NodeId is valid for the lifetime of one loaded Workspace and no longer. Do not persist one, do not put one in a URL, do not send one to a process that might reload.

The *_id_at(...) methods exist precisely to convert a positional address — “rule set 0, rule 2”, which is stable across loads — into a current NodeId. Persist positional addresses; resolve to ids after each load.

validate() — why it returns structures, not strings

ValidationReport carries Diagnostic values with Severity, message, and the NodeId they attach to. That shape exists for you: apimock-config’s validation module says so, contrasting itself with the routing crate’s log::error!-and-return-bool approach —

“a GUI needs structured (severity, message, target_id) triples it can render inline.”

So you can put a red underline on the exact node. That is the intent; use it.

⚠️ Known duplication, and it has bitten once. apimock-config’s validation is a second implementation of the rules in apimock-routing’s Respond::validate. During 6.0.0’s development the config-side copy did not learn about a new field, and validate reported a false error on every affected rule. There is now a test asserting the two agree (respond_validator_agreement.rs), added after that incident. If you ever see validate() disagree with whether a config actually loads, that is a bug in us — report it, do not work around it.

Saving, and conflict detection

save() is not a blind write. Before writing, it compares each file’s current on-disk content against the text captured at load. A mismatch returns SaveError::Conflict rather than overwriting (RFC 056).

For a long-lived GUI session this is the important one. A user edits in your GUI, edits the same file in their editor, then clicks save — they get a conflict, not silent data loss. Surface it as a conflict, offer reload-or-overwrite, and do not retry blindly.

External changes — the ⚠️ part

has_external_changes() and sync_from_disk() exist for the “file changed underneath a long-lived session” case, which is a GUI problem and essentially only a GUI problem.

Nothing in this repository calls them. They are unit-tested inside apimock-config and have never been driven by a real application. The CLI does not need them — it loads, edits and exits within one invocation.

You are the first consumer. Specifically unclear, and worth establishing early:

  • What granularity has_external_changes() reports at — whole workspace, or per file.
  • Whether sync_from_disk() preserves unsaved in-memory edits, or discards them. Establish this before building a UI around it; the answer changes what you can offer the user.
  • Whether it is cheap enough to poll on a timer, or wants a filesystem watcher in front of it.

Please let us know what you find. This is exactly the surface where a first real consumer produces better design than more speculation would.

Running a mock server

Starting one

apimock_server::server::Server is the entry point:

MethodShape
Server::new(Config)async fn -> ServerResult<Self>
Server::start(&self)async fn
Server::bind_http(&self)async fn -> ServerResult<Option<TcpListener>>
Server::bind_https(&self)async fn -> ServerResult<Option<(TcpListener, TlsAcceptor)>>
Server::serve_http(&self, TcpListener)async fn
Server::serve_https(&self, TcpListener, TlsAcceptor)async fn

Config comes from the workspace you already have — Workspace::config() returns &Config.

start() is the simple path; the bind_*/serve_* pair is the split version, which is what you want if you need the bound address before serving begins. For a GUI that displays “listening on 127.0.0.1:3001”, or that binds port 0 and must discover the real port, bind first, read the address, then serve.

This is a tokio async API. Your GUI framework’s event loop and a tokio runtime have to coexist; that is your architectural decision, not ours, but it is the first real one you will make.

Port 0 is supported and useful

-p 0 binds an ephemeral port; the CLI’s own tests rely on it. For a GUI, this is how you avoid “port 3001 already in use” as a first-run experience. Bind, read the actual SocketAddr, show it.

Reacting to configuration changes — ⚠️ unproven

apimock_server::control exists for the lifecycle a GUI has and the CLI does not:

TypePurpose
ServerControlnew() — the control handle
ServerHandlehttp_addr, https_addr, cert_reloader, reload_tls_certs(cert, key)
ServerStateServer lifecycle state
ReloadHintNone / Reload / Restart

ReloadHint is the useful idea here. It converts to and from apimock_config::view::ReloadHint, so the config layer can tell you how much a given edit costs: nothing, a reload, or a full restart. That lets your GUI apply most edits without dropping the listener, and only warn about the ones that need a restart.

⚠️ Nothing in this repository uses ServerControl or ServerHandle. The CLI calls Server::start and never reloads — it is a one-shot process. These types are unit-tested within apimock-server and have no real consumer.

Treat the reload path as designed but unexercised. Prove it does what you need early, in a spike, rather than discovering its shape late. If ReloadHint::Reload turns out not to be applicable without a restart in some case, that is a finding we want.

TLS

ServerHandle::reload_tls_certs(&str, &str) reloads certificates without restarting — RFC 020/021’s hot reload. There is a user guide at docs/src/guides/reload-tls-certificates-without-restart.md.

This path is exercised (there are TLS reload tests), unlike the rest of control.

The live match feed — ⚠️ unproven

apimock_server::trace is the “watch requests arrive and see which rule answered” channel. There is even a CLI guide for the concept (docs/src/guides/watch-matches-live.md).

TypePurpose
TraceEmitteremit(id, seq, RequestSummary, Outcome); carries an Arc<TraceConfig>
TraceConfigcapture_body, max_body_bytes, header_allowlist, header_denylist, header_redaction
HeaderRedactionModeHow headers are redacted before emission
OutcomeWhat happened to the request
RequestSummaryThe request, summarised

TraceConfig’s redaction settings are not decoration. RFC 051 and RFC 040 added them because a live request feed will otherwise show Authorization headers and request bodies on screen — and a GUI is more likely than a CLI to have that on a shared screen, or in a screenshot in a bug report. Decide your defaults deliberately. We would suggest starting redacted and letting the user opt in.

RFC 073 fixed two things worth knowing if you built against an earlier version. Before it, every event’s outcome reported Miss { status: 0 } regardless of what actually happened — a matched rule, a middleware response and a genuine 404 were indistinguishable. Every response path now emits the outcome that actually occurred (Matched, the new Middleware, Fallback, or Miss) — if you match on Outcome exhaustively, the new Middleware variant needs handling. Separately, header_denylist/header_allowlist/header_redaction now also govern query-string parameter values and JSON body object keys (recursively), not headers alone — the same redaction, applied everywhere a name-value pair can leave the process, so a captured body (capture_body) reaching your subscriber is redacted the same way a verbose console log line would be.

AppState::new(Config, LoadedMiddlewares, TraceEmitter) is where the emitter is wired in.

⚠️ TraceEmitter has no consumer outside apimock-server’s own internals. The subscription side — how you receive what emit sends — is the part you should establish first. If it is awkward, say so; a live feed nobody has consumed is the definition of an unexercised design.

What the CLI does, as a reference

apimock get answers “what would the server return for this request?” without starting a server, by running the same dispatch the server uses. If your GUI wants a “preview this rule’s response” button, that is the model to copy — and apimock-server’s response construction is reachable directly.

crates/apimock/src/cmd/get.rs is the reference implementation, and its tests assert it agrees with a real server (get_agrees_with_server.rs). Worth reading before you build a preview feature.

API stability — what we promise, and what enforces it

What we promise

No public API change reaches a release undeclared. Every change to every crate’s public surface is detected by CI, recorded in a checked-in baseline, and written up in the release notes — and, when it needs steps from you, in a migration guide.

That is deliberately not “nothing will ever break within 6.x”. Inside a major version we avoid breaking changes and they are rare, but the project does not claim they are impossible, and no CI job could enforce such a claim. What is mechanically enforced is that a break cannot happen quietly.

How a change reaches you, in order:

  1. The compiler, first, where it can. #[non_exhaustive] types and boxed error variants make a whole class of change a non-event (RFC 052, RFC 041). Where a removal has a migration path worth warning about, #[deprecated] warns you a release ahead.
  2. The baseline. The change is declared in crates/<name>/public-api.txtgit log over that file is the API’s changelog, generated rather than hand-maintained.
  3. The release notes and migration guide, which is where you meet it as prose rather than as a compiler error.

The gate (RFC 039)

Each crate has a checked-in baseline of its complete public API:

crates/apimock/public-api.txt            166 lines
crates/apimock-config/public-api.txt    1868 lines
crates/apimock-routing/public-api.txt   1444 lines
crates/apimock-server/public-api.txt     803 lines

CI regenerates these with cargo-public-api and fails if they differ from the checked-in file. Nothing auto-updates: a commit that changes the API must contain the baseline change, which puts the API diff in front of a reviewer in the pull request.

Purely additive changes fail too, until the baseline is updated. That is deliberate: the gate’s job is to make every change to the surface declared, not to judge which kind of change it is. Deciding whether a break is acceptable is semver’s job and the maintainers’ — see RFC 039’s own non-goals.

Two consequences for you, both good:

  1. git log crates/<name>/public-api.txt is the API’s changelog. Not a hand-maintained list that drifts — the actual surface, with the commit that changed it and its message.
  2. These files are the answer to “what can I call?” They are generated from the crates and gated, so they cannot drift from the code on the branch that carries them. This documentation quotes them; if the two disagree, believe the baseline.

They live on the default branch, and only there — see the section index for why, and for the link. They are absent from the 6.0.0 tag and from every published crate tarball.

The 6.0.0 baselines were generated from a tree with no source changes since the tag, so their content is the released surface even though their location is not the release.

#[non_exhaustive] — what you cannot do (RFC 052)

Several public types are #[non_exhaustive]. From outside the defining crate this means:

  • No struct-literal construction — including ..Default::default().
  • No exhaustive matching — your match needs a _ arm.

Field access by name still works everywhere. Only literal construction and exhaustive matching are affected.

Affected types include TraceConfig, RequestSummary, ParsedRequest, LogConfig, VerboseConfig, Prefix, and most of apimock_server::control and the error enums. The complete, current list is in the baselines — grep for #[non_exhaustive].

Where a constructor was needed, one exists:

  • ParsedRequest::new(url_path, component_parts) — then .with_body(body_json, body_len) to attach one.
  • VerboseConfig::new(header, body) — a const fn, so it works in a const initializer.
  • TraceConfig::default(), ServerControl::new(), and so on.

If you need to construct a #[non_exhaustive] type and there is no constructor, that is a gap — let us know. RFC 052 added constructors where a cross-crate caller actually existed; you are a cross-crate caller who did not exist yet. docs/src/guides/migrating-to-6-0.md has the full reasoning.

Errors (RFC 041)

Error variants are boxed, and the enums are #[non_exhaustive]. You will be matching with a _ arm; that is intended, and it is what lets us add a variant without breaking you.

The error types you will meet: WorkspaceError, ApplyError, SaveError, ConfigError (from apimock-config); ServerError / ServerErrorKind / TlsKind (from apimock-server).

SaveError::Conflict is the one with real semantics for a GUI — see 02-editing-configuration.md.

Semver, concretely

  • 6.x — breaking changes are avoided and rare. Any that occur are declared in the baseline, documented in that version’s migration guide, and deprecation-warned first where that is practical. Upgrade with the release notes to hand.
  • 7.0 — may break. There is already one known candidate; see 06-known-gaps.md § 1.
  • The four crates are always published together at the same version. version.sh bumps every manifest in lockstep and CI asserts it. Do not mix 6.0.0 of one with 6.1.0 of another; the internal pins are exact and will not let you anyway.

MSRV

rust-version = "1.91.0", asserted by a CI job. Note the API baseline job runs on a pinned nightly — that is an inspection tool only and says nothing about what you need to compile. 1.91.0 is the number that binds you.

Known limitations

Stated here so you meet them in documentation rather than in a debugger.

The public module surface is wider than it was designed to be

apimock-server has 16 of 16 top-level modules declared pub mod; apimock-routing, 6 of 6. So apimock_server::http_util, ::dyn_route, ::response_handler, apimock_routing::util::glob and many others are public API.

None of it was designed as an external surface. It is public because nothing ever narrowed it — which was only noticed when 6.0.0’s API baseline put the whole surface in one file for the first time.

What this means in practice:

  • It is genuinely public and genuinely gated, so depending on it will not break within 6.x.
  • Narrowing it would be a breaking change, so it cannot happen within 6.x — the API gate would refuse it. But it is a real possibility whenever the next incompatible release is. If you build on apimock_server::http_util, you may be building on something that is public by accident rather than by design.
  • If you depend on one of these modules, say so. A module a real consumer needs is an argument for keeping it public deliberately — which is a better outcome than narrowing it blind.

Three surfaces have no proven consumer

Introduced on the section index; the specifics:

Workspace::has_external_changes() / sync_from_disk() — for the “file changed underneath a long-lived session” case, which is essentially a library-consumer problem. The CLI never needs them: it loads, edits and exits inside one invocation. Unresolved:

  • Whether sync_from_disk() preserves unsaved in-memory edits or discards them. This changes what you can offer a user, so establish it before designing around it.
  • What granularity has_external_changes() reports at — whole workspace or per file.
  • Whether it is cheap enough to poll, or wants a filesystem watcher in front of it.

apimock_server::controlServerControl, ServerHandle, ServerState, ReloadHint. ReloadHint is the valuable idea: it converts to and from apimock_config::view::ReloadHint, so the config layer can say whether an edit costs nothing, a reload, or a restart. Unresolved: whether ReloadHint::Reload genuinely avoids a restart in every case it claims.

apimock_server::trace::TraceEmitter — the live match feed. emit() is called by the server’s internals; the subscription side has no consumer, and how a caller receives what emit sends is the part to establish first.

[guard] is a stub

apimock_routing’s Guard is an empty struct with a // todo:. The rule-set schema documents it as not yet doing anything. There is nothing behind it to build against.

validate’s severity axis is effectively single-valued

The CLI reference records why: Workspace::load rejects every condition that could become a Severity::Error, so a configuration either loads clean or fails to load. Nothing anywhere constructs a Severity::Warning.

ValidationReport carries a real severity type, but in practice one value. If you build a warnings-versus-errors distinction in your UI, you will be the first thing that needs warnings to exist — which is a reasonable thing to want, and worth raising as a request rather than working around.

Validation is implemented twice

apimock-config’s node validation is a second implementation of apimock-routing’s Respond::validate. The duplication is deliberate: a structured (severity, message, target_id) triple is what a GUI can render inline, and the routing crate’s log::error!-and-return-bool form is not.

They diverged once during 6.0.0’s development — one copy did not learn about a new field, and validation reported a false error on every affected rule. There is now a test asserting the two agree.

If validate() ever disagrees with whether a configuration actually loads, that is a defect in apimock, not something to work around.

This section is the library documentation

The rest of docs/ is written for someone running the CLI. There is no deeper library guide behind this section — rustdoc on the crates, and these pages.

If you find yourself writing internal notes to explain this API to your own team, those notes are the documentation that is missing. They would be welcome.

How it works

Explanation — what apimock does and why, so you can predict a configuration change’s effect before making it.

Matching order and precedence

Every request is decided by the same four-stage sequence, and the first stage to produce a response wins — nothing after it runs. This page traces that sequence directly from crates/apimock-server/src/server.rs so you can predict what a given request will do before you send it.

The sequence

OPTIONS?  ──yes──▶  CORS preflight response (204), nothing else runs
   │ no
   ▼
Middleware  ──answered──▶  that response, nothing else runs
   │ unanswered
   ▼
Rule sets   ──matched───▶  that response, nothing else runs
   │ unmatched
   ▼
Fallback file tree  ──found──▶  the file
   │ not found
   ▼
404

The service entry point’s own doc comment states this order in one line (server.rs:286-289): “OPTIONS → middleware → rule sets → dyn_route (fallback).” The implementation, service() (server.rs:290-358), does exactly that:

  1. OPTIONS is checked before anything else (server.rs:296-298) — before the request is even parsed. Any OPTIONS request gets a CORS-preflight response and nothing downstream ever runs. See Response headers for what that response contains.
  2. Middleware (server.rs:322-324, dispatching to middleware_response, server.rs:361-379): every loaded middleware script is offered the request, in the order it’s listed in service.middlewares. The first one that returns a value answers the request; the rest are never called (server.rs:365-377, the loop returns on the first Some). If none of them answer it, the request falls through.
  3. Rule sets (server.rs:326-350, dispatching to rule_set_response, server.rs:382-397): every configured rule set is checked, in the order it’s listed in service.rule_sets. The first rule set with a matching rule answers the request (server.rs:386-394, the loop returns on the first Some) — which rule within that set answers it is decided by that rule set’s strategy (see below). Rule sets after the first match are never consulted.
  4. Fallback file tree (server.rs:352-357, dyn_route_content): reached only if nothing above answered. Serves a file directly from service.fallback_respond_dir, resolved by URL path. If no matching file exists, the response is a 404. Zero-config mode — no rule sets, no middleware — is just this one stage.

Nothing here is configurable. There is no setting that changes the relative order of these four stages; every rule set and every middleware in a config it participates in is layered underneath this same sequence.

What wins when more than one thing could match

Across rule sets: the first rule set in service.rule_sets that has any matching rule wins outright — completely, not partially. Even if a later rule set would have matched more specifically, it is never consulted once an earlier one matches.

Within one rule set: its strategy decides which of its own matching rules answers, independent of the other rule sets entirely. Five strategies exist:

strategyBehaviour
first_match (default)The first rule in file order that matches
priorityAmong matches, the one with the highest priority; ties broken by its tiebreaker (first_match or uniform_random)
weighted_randomRandom among matches, weighted by each rule’s weight (default 1)
uniform_randomRandom among matches, unweighted
round_robinCycles through matches, one per request

service.strategy sets the workspace default; a rule set’s own top-level strategy field overrides it for that rule set only. See Vary the response for one path for worked examples of all five, and apimock.toml root settings for the exact syntax.

prefix, guard, and per-rule-set strategy

  • prefix.url_path strips a leading segment from the request path before that rule set’s rules are matched against it — it changes what a rule sees, not the order rule sets are tried in.
  • Per-rule-set strategy changes which rule within that set answers, as above — it has no effect on whether that rule set is reached in the first place.
  • [guard] does nothing. It’s a zero-field struct (crates/apimock-routing/src/rule_set/guard.rs) carrying only a // todo: comment for a condition that was never implemented. A [guard] table in a rule set has no effect on matching, on ordering, or on anything else — do not configure it expecting it to gate a rule set. Its future is an open decision, not something this documentation can describe as working.

One more thing that looks like it should affect this page, but doesn’t

[default].delay_response_milliseconds — a rule set’s own top-level [default] table — is parsed and shown in the startup log, but is never applied to any response. It has no effect on timing, on matching, or on anything else a request experiences. The per-rule respond.delay_response_milliseconds field works correctly; the rule-set-wide one does not. See Simulate slow or flaky backends for the field that actually works.

Response decision flow

A diagram view of Matching order and precedence — start there for the prose explanation and the code citations behind it.

flowchart TD
    A[Request received] --> B{Method is OPTIONS?}
    B -- yes --> B1[204 No Content<br/>CORS preflight headers]
    B -- no --> C{Any middleware<br/>answers it?}
    C -- yes --> C1[Middleware's response]
    C -- no --> D{Any rule set has<br/>a matching rule?}
    D -- yes --> D1[That rule's response,<br/>chosen by its strategy]
    D -- no --> E{File exists under<br/>fallback_respond_dir?}
    E -- yes --> E1[File content]
    E -- no --> F[404 Not Found]

Each middleware script is tried in the order it’s listed; the first one that returns a value wins. Each rule set is tried in the order it’s listed; the first one with a matching rule wins, and that rule set’s strategy decides which of its own matching rules answers. See Vary the response for one path for the five strategies.

Architecture

apimock-rs is a Cargo workspace of four crates under crates/ (Cargo.toml:26-33), version 5.15.0, edition 2024, MSRV 1.91.0 (Cargo.toml [workspace.package]).

CrateResponsible for
apimock-routingRule-set model, request matching, read-only views
apimock-configapimock.toml loading/validation, the Workspace config-editing API
apimock-serverThe HTTP(S) listener, request dispatch, Rhai middleware, response building
apimockFaçade re-export + the apimock binary

Dependency direction

A one-way graph rooted at apimock-routing — no crate depends back up it:

flowchart LR
    server[apimock-server] --> config[apimock-config]
    server --> routing[apimock-routing]
    config --> routing
    facade[apimock façade + CLI] --> server
    facade --> config
    facade --> routing

Not a strict three-link chain: apimock-server depends on apimock-config and apimock-routing directly, not only transitively through config. apimock-server’s own module doc states the split plainly: rule-set matching logic lives in apimock-routing; config parsing and validation lives in apimock-config (crates/apimock-server/src/lib.rs:11-13).

apimock-routing

Depends on no other workspace crate (crates/apimock-routing/Cargo.toml:13-24 has no apimock-* line). Owns the rule-set schema (rule_set.rs), matching (strategy.rs), and read-only views for external tooling (view/).

crates/apimock-routing/src/
├── error.rs
├── lib.rs
├── parsed_request.rs
├── rule_set.rs      rule_set/
├── strategy.rs
├── util.rs          util/
└── view.rs          view/

apimock-config

Depends on apimock-routing only (crates/apimock-config/Cargo.toml:16; the comment there is explicit: “Rule-set parsing is delegated to the routing crate because the rule model lives there — the config crate only orchestrates loading.”). Owns apimock.toml loading/validation (config.rs) and the GUI-facing config-editing API (workspace.rs).

crates/apimock-config/src/
├── config.rs      config/
├── error.rs
├── lib.rs
├── path_util.rs   path_util/
├── toml_writer.rs
├── view.rs
└── workspace.rs   workspace/

apimock-server

Depends on both apimock-config and apimock-routing (crates/apimock-server/Cargo.toml:14-15). Owns the listener (server.rs, tls.rs), request dispatch (see Matching order and precedence), Rhai middleware (middleware.rs), and response construction (response.rs, response_handler.rs).

crates/apimock-server/src/
├── constant.rs
├── control.rs
├── dyn_route.rs
├── error.rs
├── http_util.rs
├── json_path_util.rs
├── lib.rs
├── middleware.rs        middleware/
├── parsed_request.rs
├── respond_response.rs
├── respond_util.rs
├── response.rs           response/
├── response_handler.rs
├── server.rs
├── tls.rs
└── trace.rs

apimock façade + CLI

Depends on all three (crates/apimock/Cargo.toml:18-21). Both a library and a binary: src/lib.rs re-exports the other three crates under short aliases —

#![allow(unused)]
fn main() {
pub use apimock_config as config;
pub use apimock_routing as routing;
pub use apimock_server as server;
}

(crates/apimock/src/lib.rs:31-33) — and src/main.rs is the apimock binary’s entry point, consuming that same library crate. There’s no explicit [[bin]] section; the binary target is Cargo’s implicit convention from src/main.rs.

crates/apimock/src/
├── app.rs
├── args.rs      args/
├── cmd/         (match_test.rs, validate.rs)
├── lib.rs
├── logger.rs
└── main.rs

A spawn feature (crates/apimock/Cargo.toml:14-16, off by default) adds an alternate constructor that forwards log output to an embedding process over an mpsc::Sender<String> — for running apimock as a subprocess of something else, rather than as a standalone CLI.

History

Version 5.0.0 split the previously monolithic codebase into this four-crate structure (CHANGELOG.md, ## [5.0.0]). Version 5.1.1 moved each crate — including the façade, which had briefly stayed co-located with the workspace root — into its own directory under crates/ (CHANGELOG.md, ## [5.1.1]), which is the layout described above. Neither src/config.rs, src/server.rs, nor src/core/server/routing.rs — paths from before the split — exist anywhere in the repository today.

The workspace and its crates

Architecture covers what each crate is responsible for and how they depend on each other. This page covers the workspace itself — the mechanics that hold the four crates together as one project.

The root Cargo.toml is workspace-only

It has no [package] section of its own — only [workspace], [workspace.package], [workspace.dependencies], and the build profiles. Its own header comment explains why (Cargo.toml:1-24): until 5.1.1, the apimock façade’s package metadata lived in this same file, mixing workspace-wide concerns with one crate’s own. 5.1.1 split the façade out into crates/apimock/, leaving this file responsible only for:

  • listing the four workspace members;
  • [workspace.package] — version, edition, MSRV, licence, and the rest of the metadata every crate inherits, so they can’t drift apart;
  • [workspace.dependencies] — every shared external dependency’s version, pinned once;
  • the release/dev build profiles, which only take effect from the workspace root regardless of which member is being built.

Why a façade crate at all

cargo install apimock and npx apimock both need a crate literally named apimock. Splitting the implementation into responsibility crates (apimock-config, apimock-routing, apimock-server) but keeping a thin façade that re-exports them means the install story stays simple — users keep typing the same name they always have, regardless of how the implementation is organised underneath (Cargo.toml:18-24).

Version and dependency pinning

All four crates share one version number (5.15.0 as of this page), one edition (2024), and one MSRV (1.91.0) via version.workspace = true / edition.workspace = true / rust-version.workspace = true in each crate’s own manifest. ./version.sh --update is what changes this (see The quality gates).

External dependencies are pinned once, in [workspace.dependencies] (Cargo.toml:49-81), and each crate’s own [dependencies] pulls the version from there rather than stating its own — so two crates can never end up depending on different versions of the same external crate. The internal crates (apimock-config, apimock-routing, apimock-server) are declared the same way, as path dependencies pinned to the workspace version (Cargo.toml:52-54).

Build profiles

Set once, at the workspace root, because Cargo profile sections only take effect from there:

[profile.release]     # shrink executable size
opt-level       = "z"
lto             = true
strip           = true
codegen-units   = 1

[profile.dev]         # to reasonably improve productivity
opt-level       = 1
lto             = false
incremental     = true

The release profile trades build time for a smaller binary (opt-level = "z", full LTO, stripped symbols, single codegen unit) — sensible for a tool distributed as a downloadable executable. The dev profile does the opposite: light optimisation and incremental compilation, for faster local iteration.

Design notes

Why apimock-rs behaves the way it does, for the two decisions readers run into most often.

Why read-on-demand, not preloaded

No response file is read at startup. Each one is read from disk only when a matching request arrives — task::spawn_blocking moves the actual fs::read/fs::read_to_string call onto a dedicated blocking-I/O thread pool, off the async runtime’s request-handling threads (crates/apimock-server/src/response/file_response.rs:82,93).

This is what keeps startup time and memory use flat regardless of how large a mock dataset grows: nothing scans or loads the whole file tree up front. The fallback file-tree serving path (crates/apimock-server/src/dyn_route.rs) does its own directory read per request, not once at startup. The only mechanism in the codebase that scans a directory tree ahead of time is the Workspace snapshot API used by config-editing tooling — a GUI-facing feature, unrelated to serving requests. See Filter the served file tree for that distinction in more detail.

Why dotted paths, not JSONPath

when.request.body.json conditions and respond.csv_records_key both use apimock’s own dotted-path mini-syntax — "customer.tier", "items.0.sku" — object keys joined by ., a numeric segment indexing into an array. This is deliberately not canonical JSONPath (RFC 9535): a "$.foo.bar"-style path is not special-cased, and [0] bracket-array syntax isn’t recognised. See Body path syntax for the exact resolution rules.

This distinction isn’t cosmetic. A rule-set fixture in this project’s own history was written using $.-prefixed pseudo-JSONPath paths, which silently never matched anything (because a leading $ is just a literal object key to this resolver, one that essentially never exists) — the fixture shipped broken for three releases before anyone noticed, precisely because a broken condition fails silently by never matching, rather than by erroring. Every place in this documentation that shows a body-path condition says so explicitly, for that reason.

Performance

Three ways to measure apimock-rs yourself, rather than a claim to take on trust — see Design notes for the read-on-demand model these exist to verify. All three are dev-only: no new runtime dependency, nothing shipped in a release build.

cargo bench --bench routing — pure matching cost

Microbenchmarks RuleSet::find_matched in isolation — no HTTP, no tokio, no file I/O. Three scenarios (first_rule_hit, last_rule_hit, miss_all_specific_rules), each parametrised over rule-set sizes of 1, 10, and 100 rules (crates/apimock/benches/routing.rs). Useful when changing the matcher itself — a new operator, prefix handling — for a sub-microsecond-resolution before/after comparison. Finishes in under a minute.

cargo bench --bench response_latency — end-to-end HTTP latency

Stands up a real apimock server on a random port once per run, then benches five response kinds through a reqwest client (crates/apimock/benches/response_latency.rs):

BenchWhat it covers
text_ruleStatic text response from a rule — no file I/O after startup
status_ruleStatus-only response — the shortest response path
file_rule_warmFile-backed rule, page cache warm — steady-state real-world latency
dyn_route_fallbackZero-config “just drop JSON in a folder” path
not_found404 path — worth tracking separately since misconfigured clients hit it often

The gap between text_rule and file_rule_warm is the measured cost of the per-request file read described in Design notes, on your own machine, rather than an asserted number.

cargo run --release --example bench_load — sustained-load sampler

Criterion measures per-iteration wall time; it can’t show what happens to process RSS or CPU while sustaining a given request rate for a while. bench_load (crates/apimock/examples/bench_load.rs) is a standalone binary that does that, by constructing a Server in-process via the public App API and sampling /proc/self/{status,stat} alongside HTTP-level latency and throughput.

cargo run --release --example bench_load -- \
    --rps 500 --duration 10 --endpoint /text

Output is CSV on stdout, one line per sample interval, plus a final # summary line. The shape below is illustrative of the output format — it is not a captured measurement of this project; run the command yourself for real numbers on your own machine:

# apimock bench_load: rps=500 duration=10s endpoint=/text concurrency=256 sample_every_ms=100
t_ms,rss_kb,cpu_user_ticks,cpu_sys_ticks,inflight_requests,completed,errors,avg_latency_us
0,18760,1,0,0,1,0,332
102,18760,9,0,1,51,0,245
...
# summary duration_s=10.02 target_rps=500 achieved_rps=... completed=... errors=... avg_latency_us=... peak_rss_kb=...
FlagDefaultMeaning
--rps <N>500Target request rate
--duration <SEC>10How long to sustain the load
--endpoint <PATH>/textURL path to hit — /text / /status / /file / /hello are preconfigured by the fixture
--concurrency <N>256Max in-flight requests; exceeding it drops the request and increments errors, so outpacing the server is visible rather than silently absorbed
--sample-ms <MS>100How often to sample RSS / CPU

RSS and CPU-tick columns come from /proc/self/{status,stat} and are Linux-only — the program prints a notice and reports zeros for those two columns on macOS / Windows. Latency and throughput columns work everywhere. To compare two builds, run both at the same target RPS values and compare achieved_rps, peak_rss_kb, and avg_latency_us.

Contributing

How to build, test, and change apimock-rs itself.

Looking to report a bug or open a pull request? See .github/CONTRIBUTING.md for the project’s PR policy.

Build and test locally

git clone https://github.com/apimokka/apimock-rs.git
cd apimock-rs
cargo build --workspace
cargo test --workspace

No rust-toolchain.toml — any stable toolchain at or above the pinned MSRV works day to day. The MSRV itself, 1.91.0 (Cargo.toml [workspace.package]), is what CI actually checks; see The quality gates.

Running the tests

cargo test --workspace is the gate — not --lib. The difference is large: --lib runs 212 tests (the four crates’ unit tests only); the full --workspace command runs all 409, adding the integration suites under crates/apimock/tests/ — 140 tests in tests/server.rs alone. --lib is a fine fast-feedback loop while iterating, but it silently skips just under half the suite, so it is never the check that decides whether something passes.

No network access is needed. TLS-related tests generate their own throwaway self-signed certificate at test time (rcgen, via crates/apimock/tests/util/tls.rs) rather than fetching one.

Building the docs site

Not required for working on the Rust workspace — only if you’re editing docs/src/. Needs mdbook and the mdbook-mermaid preprocessor (docs/book.toml):

cargo install mdbook mdbook-mermaid
cd docs && mdbook build

.github/workflows/docs.yaml deploys docs/book/’s output on every push to main — there is no staging step, so a change that leaves the site incoherent (a broken link, a SUMMARY.md entry with no page) goes live as soon as it merges. Build locally before pushing a docs change.

The quality gates

Six checks run on every push to main and every pull request; all six are required to merge. .github/CONTRIBUTING.md carries the copy-pasteable command list — this page explains what each one is for and when it runs; it doesn’t restate the commands.

GateCatches
fmtFormatting drift
clippyLint findings, workspace-wide, across every target and feature combination
testBehavioural regressions — the full cargo test --workspace suite, 409 tests
msrvCode that compiles on your toolchain but not on the pinned minimum one
auditKnown-vulnerable dependencies, via the RustSec advisory database
lockfileA Cargo.toml edit whose Cargo.lock update was forgotten

audit also runs on a weekly schedule, independent of any push or pull request — a scheduled run turning red with no new commit is correct behaviour, not a broken gate. It means an advisory was published against a dependency this project already uses; that’s new information about existing code, not about your change.

Reproduce all six locally before opening a pull request — see .github/CONTRIBUTING.md for the exact commands.

Version bumps

./version.sh --update <version> updates the workspace manifest and every npm package (including the optionalDependencies platform-binary pins) together, and verifies the result. Individual version fields aren’t hand-edited.

The RFC process

Design decisions of any size live as RFCs under rfcs/ in the repository, governed by RFC 000. This page summarises the shape of that process; RFC 000 is the authority on it.

The short version

  • rfcs/proposed/ — written and under review; not yet approved.
  • rfcs/accepted/ — approved by the project owner. Implementation may start, or may already be finished and merged, but the work has not been released yet.
  • rfcs/done/ — released; the historical record.
  • rfcs/archive/ — withdrawn or superseded.

The accepted/ step exists because approving a design and shipping it are separate events here, performed by different people. Without a folder for the gap between them, approved-but-unreleased RFCs sat in proposed/ with a Status claiming they still awaited approval — the exact folder/field disagreement described next.

The folder is the source of truth for an RFC’s state, not the Status field written inside the file. The field is kept consistent with the folder as a matter of hygiene — update it in the same commit that moves the file — but if the two ever disagree, the folder wins. RFC 000 names this failure mode directly: a Status: Proposed file sitting in done/ tells a reader two different things at once, and that’s a defect in the document, not a detail to shrug off.

Numbers are assigned once, when an RFC is first created, and are never reused — a withdrawn RFC’s number stays retired in archive/ permanently rather than being freed up.

Where this documentation fits in

Nothing on this page — or anywhere else in this documentation site — is a substitute for reading the RFC that actually decided something. Where a page states why apimock behaves a particular way, and that reason traces to a specific RFC, the page says so; the RFC itself is the detailed record.

Vision and goals

Vision

A developer-friendly, sleek, functional HTTP(S) mock server that doesn’t require complicated configuration, but accepts rich customisation around routing when you need it.

Designed around:

  • Easy setup. A single small executable; config-less mode works out of the box.
  • Performance. Fast to start, light on memory — see Design notes.
  • Cross-platform support.

Goals

1. Basic

  • File-based routing needs no configuration at all.
  • .json, .json5, and .csv files are all served as JSON.

2. Customisation

  • Rule-based routing for conditional responses.
  • Per-response or per-rule-set delay (respond.delay_response_milliseconds) to simulate a slow backend.
  • Custom HTTP status codes via respond.status.

3. Dynamic processing

  • Multiple responses for the same URL path, chosen by header, body content, or response strategy.
  • Middleware as Rhai scripts, for cases rule-based matching can’t express.

4. Safe and observable usage

  • Config validation — missing files, unreachable rules — via apimock validate.
  • Startup logs print every loaded rule set and rule.
  • Request headers and body are logged when log.verbose is enabled.
  • An integration test suite backs the server’s behaviour — see Build and test locally.

5. Embedding

  • The spawn Cargo feature offers an alternate entry point for running apimock as a subprocess, forwarding its log output to the parent process over a tokio::sync::mpsc channel — see Architecture.