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 withset, and reading--format jsonoutput 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
- Getting started — install and your first mock API, in order
- Guides — task-indexed how-tos
- Reference — exhaustive lookup
- How it works — what apimock does and why
Contributing
- Contributing — build, test, and the RFC process
- Source code (GitHub)
Getting started
Read in order — each page builds on the last.
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:
| Platform | Archive |
|---|---|
| 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 aarch64 | apimock@macOS-aarch64-<version>.zip |
| Windows x64 | apimock@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 aheaderstable inside awhentable inside arequesttable insiderules. [[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" = { ... }, notcustomer.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.
- Serve JSON files from a folder
- Match on URL path and method
- Match on headers
- Match on the request body
- Return errors and status codes
- Vary the response for one path
- Simulate slow or flaky backends
- Serve over HTTPS
- Reload TLS certificates without restart
- Script with Rhai middleware
- Filter the served file tree
- Add or change a rule
- Check what a request returns
- Validate config in CI
- Dry-run a rule
- Watch matches live
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 —
/helloand/hello.jsonboth resolvehello.json, trying.json,.json5, then.csvin that order for the extension-less form. - Percent-encoding is decoded —
/my%20file.jsonresolvesmy 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.jsonand/Api/USERS.JSONall 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 returns | Response |
|---|---|
| a plain string | serves 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.
| Field | Effect on the editor’s file-tree view |
|---|---|
show_hidden | Include dotfiles/dot-directories |
builtin_excludes | Apply the built-in list — target, node_modules, dist, build, out, __pycache__, .venv, vendor, .cargo, .gradle, .idea, .vscode |
extra_excludes | Additional glob patterns, matched against each entry’s bare name |
include | An allow-list glob, files only |
respect_gitignore | Also 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.tomlhas no field that setsTraceConfigor 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
Workspaceedit API’s trace fields are a stub. The GUI-facing config-editing surface hasEditValuevariants 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.rsnever callTraceEmitter::subscribe()or startTraceTransport::accept_loop— the runningapimockprocess never has a subscriber, shipped-binary code included. (subscribe()is called from test code outsidetrace.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 getanswers “what would the server return for this request?” without starting a server — see Check what a request returns.apimock match-testshows every condition on a rule and why it did or didn’t match — see Dry-run a rule.--format jsonon any subcommand gives a structured{"schema", "apimock", "result"}/{"schema", "apimock", "error"}envelope; a failure’serror.kindis 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-configorapimock-routingdirectly, rather than only running theapimockbinary, 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) -> Selfbuilds one with no body (body_json/body_lenbothNone) — the shape every existing caller outsideapimock-routingactually 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— aconst fn, so it works in aconstinitializer, which a runtime-only builder would not.LogConfigdidn’t need one: nothing outsideapimock-configever constructed it with a literal — every existing use goes throughDefaultorDeserialize, 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:
TraceConfiggainedheader_redaction,header_denylist,header_allowlist(RFC 040 — request-header redaction for the trace channel).ParsedRequestandRequestSummaryeach gainedbody_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
matchon 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 anenumrestricts matching, not building its existing variants — a struct-like error variant with every field public stays constructible with ordinaryEnumName::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) -> SelfandBodyConditionPayload::new(kind, path, op, value) -> Self— the RFC’s own required cases: both types lackedDefaultbefore this change (avaluefield with no meaningful empty state), so they needed one built from the fields that actually matter;valueonHeaderConditionPayloadstarts unset (None) since it’s only required for some operators, assign it afterwards.RulePayload,RespondPayload,NodeValidationalready derivedDefault; nothing new to build —Default::default()then assign the fields you need, the same pattern asTraceConfigabove.ValidationIssue::new(severity, message),Diagnostic::new(severity, message)(itsnode_id/filestart unset — assign them for a diagnostic scoped to a node or file),DiffItem::new(kind, target, summary), andConditionWithId::new(id, view)— none of these had a meaningful “empty” state (every field matters), so each got a constructor instead ofDefault.ValidationReportdid have a meaningful default — its existingValidationReport::ok()is that state (no diagnostics, valid) — so it also gainedimpl Defaultthat callsok(), letting it join theDefault::default()-then-assign pattern too.EnvArgs::empty() -> Self(every fieldNone) — not callednewordefaultbecause both names were already taken:EnvArgshas a pre-existing, unrelatedpub fn default() -> AppResult<Option<Self>>that parsesenv::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 theapimock-configstruct and theapimock-serverenum 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 anew/compilethat 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].AppStategainedAppState::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 types —
ServerHandle, 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:
| RFC | What breaks |
|---|---|
| 067 | Most likely to affect you. A credentialed cross-origin request from a non-localhost origin stops being allowed unless you list it |
| 068 | A request body over 32 MiB is refused with 413; a Rhai middleware script is aborted after 10,000,000 operations |
| 074 | An incomplete TLS handshake is dropped after 10s; concurrent TLS connections are capped at 256 |
| 069 | A config that loads today stops loading |
| 070 | A round_robin rule set returns a different sequence |
| 070 | Library consumers only: a public field on RuleSet is removed |
| 072 | A header condition that passes today starts failing |
| 071 | Library consumers only: Server::app_state’s type changes, AppState loses Clone |
| 077 | A 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
(/hello → hello.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:
| RFC | What breaks |
|---|---|
| 075 | A URL segment’s case that used to matter (or not) may now resolve differently — see below for exactly when |
| 075 | A rule set scoped to a prefix like /api stops matching a similarly-spelled sibling path like /apiv2 |
| 076 | A .json file_path response is no longer minified or key-reordered |
| 076 | Library and script consumers only: --format json’s field order changed from alphabetical to schema, apimock, result/error |
| 073 | Library 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] |
| 073 | A query-string value or JSON body field matching the (now broader) credential denylist prints as [redacted] where it used to print verbatim |
| 079 | HttpMethod’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’sDisplayoutput changed from a sentence ("HTTP Method is GET") to a bare, backtick-quoted value (method`GET`), matching every sibling condition’s ownDisplaystyle (url_path`/foo`). Affects only code that formats anHttpMethoddirectly 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()andUrlPath::validate()stay exactly as they were — alwaystrue, 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.tomlroot settings- Rule-set schema
- Operator reference
- Body path syntax
- CLI reference
- Response headers
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]
| Field | Type | Default | Meaning |
|---|---|---|---|
ip_address | string | "127.0.0.1" | Bind address |
port | integer | 3001 | Bind port |
ip_address accepts any address your OS can bind to, IPv4 or IPv6:
ip_address | Binds to |
|---|---|
127.0.0.1 / ::1 | Loopback only (the default) |
A LAN address, e.g. 192.168.1.10 | That 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.
| Field | Type | Default | Meaning |
|---|---|---|---|
cert | string | — | Path to the certificate PEM file |
key | string | — | Path to the private key PEM file |
port | integer, optional | — | If 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_seconds | integer | 10 | An incomplete TLS handshake is dropped after this long |
max_connections | integer | 256 | Maximum 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]
| Field | Type | Default | Meaning |
|---|---|---|---|
verbose.header | bool | false | Log 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.body | bool | false | Log 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]
| Field | Type | Default | Meaning |
|---|---|---|---|
strategy | string or table | "first_match" | Default response strategy — see Vary the response for one path for all five and their syntax |
rule_sets | array of strings, optional | — | Rule-set files, checked in this order — see Rule-set schema |
middlewares | array of strings, optional | — | Rhai middleware files, checked in this order before any rule set — see Script with Rhai middleware |
fallback_respond_dir | string | "." | Directory served by URL path when nothing above matches |
cors_allow_credentials_origins | array 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_bytes | integer | 33554432 (32 MiB) | A request body over this size is refused with 413, before it is buffered |
middleware_max_operations | integer | 10000000 | Rhai 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.
| Field | Type | Default | Meaning |
|---|---|---|---|
show_hidden | bool | false | Show dotfiles and dot-directories in the editor’s file-tree view |
builtin_excludes | bool | true | Apply the built-in exclude list (below) |
extra_excludes | array of glob strings | [] | Additional excludes, matched against each entry’s bare filename (not its full path) |
include | array of glob strings | [] | An allow-list — only applies to files, never to directories |
respect_gitignore | bool | false | Also 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 → .gitignore → include (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]
| Field | Meaning |
|---|---|
url_path | Stripped 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_dir | Prepended 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.
| Field | Shape |
|---|---|
url_path | A bare string (implies op = "equal"), or { value = "...", op = "..." } |
method | A 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.
| Field | Meaning |
|---|---|
file_path | Serve this file’s content — extension decides JSON/JSON5/CSV/binary/text handling |
text | A 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 |
json | A 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) |
status | The HTTP status code |
headers | Custom headers, honoured uniformly on every shape above — see Response headers |
delay_response_milliseconds | Sleep this long before responding — works correctly at the per-rule level |
csv_records_key | For 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 set — file_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_path — RuleOp (11)
when.request.url_path = { value = "...", op = "..." }. The default
when op is omitted is equal.
op | Matches when |
|---|---|
equal | The path equals value exactly (default) |
not_equal | The path does not equal value |
starts_with | The path starts with value |
not_starts_with | The path does not start with value |
ends_with | The path ends with value |
not_ends_with | The path does not end with value |
contains | The path contains value as a substring |
not_contains | The path does not contain value |
wild_card | The path matches value as a glob pattern (*, ?) |
regex | The path matches value as a regular expression, compiled per request |
not_regex | The path does not match value as a regular expression |
Source: crates/apimock-routing/src/rule_set/rule/when/request/rule_op.rs.
headers — HeaderOperator (13)
when.request.headers.<name> = { value = "...", op = "..." }. The 11
value operators above, plus two presence-only operators where value
is ignored:
op | Matches when |
|---|---|
equal | The header’s value equals value exactly (default) |
not_equal | The header’s value does not equal value |
starts_with | The header’s value starts with value |
not_starts_with | The header’s value does not start with value |
ends_with | The header’s value ends with value |
not_ends_with | The header’s value does not end with value |
contains | The header’s value contains value as a substring |
not_contains | The header’s value does not contain value |
wild_card | The header’s value matches value as a glob pattern |
regex | The header’s value matches value as a regular expression |
not_regex | The header’s value does not match value as a regular expression |
exists | The header key is present, regardless of value (value ignored) |
absent | The 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.json — BodyOperator (25)
when.request.body.json."<dotted.path>" = { value = "...", op = "..." }
— see Body path syntax for how the path
resolves. The default op is equal.
op | Matches when |
|---|---|
equal | String-coerced equality (both sides converted to string) — the default, kept for backwards compatibility |
equal_string | Explicit alias for equal |
contains | The (string-coerced) value contains value as a substring |
not_contains | The (string-coerced) value does not contain value |
starts_with | The (string-coerced) value starts with value |
not_starts_with | The (string-coerced) value does not start with value |
ends_with | The (string-coerced) value ends with value |
not_ends_with | The (string-coerced) value does not end with value |
regex | The (string-coerced) value matches value as a regular expression |
not_regex | The (string-coerced) value does not match value as a regular expression |
equal_typed | Exact JSON-type-and-value equality — distinguishes 42 (number) from "42" (string); value is parsed as JSON |
equal_number | Numeric equality; both sides coerced to f64 |
greater_than | Numeric greater-than |
less_than | Numeric less-than |
greater_or_equal | Numeric greater-than-or-equal |
less_or_equal | Numeric less-than-or-equal |
exists | The path resolves to any value, including null (value ignored) |
absent | The path does not resolve to anything (value ignored) |
array_length_equal | The value at the path is an array whose length equals value |
array_length_at_least | The value at the path is an array whose length is ≥ value |
array_contains | The value at the path is an array containing an element equal to value (typed JSON comparison) |
equal_integer | Exact i64 integer equality — avoids the precision loss equal_number’s f64 coercion has above 2^53 |
map_has_key | The value at the path is a JSON object containing the key named by value |
map_does_not_have_key | The value at the path is a JSON object that does not contain the key named by value |
structural_contains | The 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 }
]
}
| Path | Resolves 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):
| Code | Meaning |
|---|---|
0 | Success, including --version / --help |
2 | Usage 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 |
1 | A 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]]
| Flag | Result |
|---|---|
| (no flags) | Zero-config: serves ./ by URL path, port 3001 |
serve | The 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.
| Flag | Result |
|---|---|
--init | Interactive: 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 --yes | Non-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 --middleware | Also 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.
| Flag | Meaning |
|---|---|
--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) |
--strict | Documented to treat warnings as failures (exit 1). Not reachable today — see the note below the exit-codes table |
--quiet | Suppress non-error output |
--format text | Default. Today’s plain-text summary — unchanged whether written explicitly or left implicit |
--format json | The 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/set — get
(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:
kind | Exit code |
|---|---|
usage | 2 |
config_invalid | 2 |
config_unreadable | 2 |
io | 1 |
conflict | 1 |
internal | 1 |
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.
| Flag | Meaning |
|---|---|
--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 |
--why | Explain 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 text | Default. Human-readable |
--format json | The 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.
| Flag | Meaning |
|---|---|
--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-run | Show what would change, without writing anything |
--format text | Default. Human-readable |
--format json | The RFC 053 response envelope |
--allow-outside | Permit --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.
| Flag | Meaning |
|---|---|
--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, -q | Suppress the per-condition breakdown, print only the result (text only — has no effect under --format json, which never prints the breakdown either way) |
--format text | Default. The breakdown described above |
--format json | The RFC 053 response envelope — result.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 --format — 0 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
| Header | Value |
|---|---|
access-control-allow-headers | * |
access-control-allow-methods | GET, POST, PUT, DELETE, OPTIONS |
access-control-max-age | 86400 |
cache-control | no-store |
x-content-type-options | nosniff |
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):
| Request | access-control-allow-origin | vary | access-control-allow-credentials |
|---|---|---|---|
No cookie/authorization | * | * | (absent) |
| Credentialed, origin allowed | The request’s own origin value, reflected back | Origin | true |
| 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(not200). 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-routinglibrary 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 tree — usage, 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
setthrough 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 write —
set’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
| Page | Covers |
|---|---|
| Crates and architecture | Which of the four crates to depend on |
| Editing a configuration | The Workspace model — load, edit, validate, save |
| Running a mock server | Starting a server, reloading, the live match feed |
| API stability | What is promised across 6.x, and what enforces it |
| Known limitations | Surfaces 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.
| Surface | Status |
|---|---|
Workspace::load / apply / save / validate / snapshot / preview_changes | Proven — apimock set and apimock validate drive these |
Workspace::has_external_changes / sync_from_disk | No 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::TraceEmitter | No 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:
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
| Crate | Owns | You depend on it? |
|---|---|---|
apimock-routing | The rule model: RuleSet, Rule, Respond, conditions, match strategies. No I/O, no HTTP. | Transitively, and you will name its types |
apimock-config | Reading apimock.toml and everything it references; path resolution; validation; the Workspace façade you edit through | Yes — this is your main dependency |
apimock-server | HTTP response construction, TLS, middleware, the running server, the trace channel | Yes — if your GUI runs a mock (it presumably does) |
apimock | The CLI binary and its argument parsing | No. 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.
Respondnow 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:
| Question | Authority |
|---|---|
| What is in the public API | crates/<name>/public-api.txt — generated, CI-gated |
| What a config file may contain | docs/src/reference/apimock-toml-root-settings.md, rule-set-schema.md |
| What a rule may match on | docs/src/reference/operator-reference.md |
| What apimock protects against | docs/src/reference/threat-model.md |
| Why a design is the way it is | rfcs/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:
| Method | Returns | Use |
|---|---|---|
load(PathBuf) | Result<Self, WorkspaceError> | Open a config and everything it references |
snapshot() | WorkspaceSnapshot | The whole tree, for rendering |
validate() | ValidationReport | Structured 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() | bool | Enable/disable your save button |
has_external_changes() | bool | ⚠️ see § External changes |
sync_from_disk() | Result<(), WorkspaceError> | ⚠️ see § External changes |
config() | &Config | The resolved config, read-only |
root_path() | &Path | Where 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 inapimock-routing’sRespond::validate. During 6.0.0’s development the config-side copy did not learn about a new field, andvalidatereported 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 seevalidate()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:
| Method | Shape |
|---|---|
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:
| Type | Purpose |
|---|---|
ServerControl | new() — the control handle |
ServerHandle | http_addr, https_addr, cert_reloader, reload_tls_certs(cert, key) |
ServerState | Server lifecycle state |
ReloadHint | None / 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
ServerControlorServerHandle. The CLI callsServer::startand never reloads — it is a one-shot process. These types are unit-tested withinapimock-serverand 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::Reloadturns 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).
| Type | Purpose |
|---|---|
TraceEmitter | emit(id, seq, RequestSummary, Outcome); carries an Arc<TraceConfig> |
TraceConfig | capture_body, max_body_bytes, header_allowlist, header_denylist, header_redaction |
HeaderRedactionMode | How headers are redacted before emission |
Outcome | What happened to the request |
RequestSummary | The 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.
⚠️
TraceEmitterhas no consumer outsideapimock-server’s own internals. The subscription side — how you receive whatemitsends — 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:
- 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. - The baseline. The change is declared in
crates/<name>/public-api.txt—git logover that file is the API’s changelog, generated rather than hand-maintained. - 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:
git log crates/<name>/public-api.txtis the API’s changelog. Not a hand-maintained list that drifts — the actual surface, with the commit that changed it and its message.- 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
matchneeds 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)— aconst fn, so it works in aconstinitializer.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.shbumps 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::control — ServerControl, 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
- Response decision flow
- Architecture
- The workspace and its crates
- Design notes
- Performance
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:
- OPTIONS is checked before anything else (
server.rs:296-298) — before the request is even parsed. AnyOPTIONSrequest gets a CORS-preflight response and nothing downstream ever runs. See Response headers for what that response contains. - Middleware (
server.rs:322-324, dispatching tomiddleware_response,server.rs:361-379): every loaded middleware script is offered the request, in the order it’s listed inservice.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 firstSome). If none of them answer it, the request falls through. - Rule sets (
server.rs:326-350, dispatching torule_set_response,server.rs:382-397): every configured rule set is checked, in the order it’s listed inservice.rule_sets. The first rule set with a matching rule answers the request (server.rs:386-394, the loop returns on the firstSome) — 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. - Fallback file tree (
server.rs:352-357,dyn_route_content): reached only if nothing above answered. Serves a file directly fromservice.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:
strategy | Behaviour |
|---|---|
first_match (default) | The first rule in file order that matches |
priority | Among matches, the one with the highest priority; ties broken by its tiebreaker (first_match or uniform_random) |
weighted_random | Random among matches, weighted by each rule’s weight (default 1) |
uniform_random | Random among matches, unweighted |
round_robin | Cycles 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_pathstrips 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
strategychanges 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]).
| Crate | Responsible for |
|---|---|
apimock-routing | Rule-set model, request matching, read-only views |
apimock-config | apimock.toml loading/validation, the Workspace config-editing API |
apimock-server | The HTTP(S) listener, request dispatch, Rhai middleware, response building |
apimock | Faç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):
| Bench | What it covers |
|---|---|
text_rule | Static text response from a rule — no file I/O after startup |
status_rule | Status-only response — the shortest response path |
file_rule_warm | File-backed rule, page cache warm — steady-state real-world latency |
dyn_route_fallback | Zero-config “just drop JSON in a folder” path |
not_found | 404 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=...
| Flag | Default | Meaning |
|---|---|---|
--rps <N> | 500 | Target request rate |
--duration <SEC> | 10 | How long to sustain the load |
--endpoint <PATH> | /text | URL path to hit — /text / /status / /file / /hello are preconfigured by the fixture |
--concurrency <N> | 256 | Max in-flight requests; exceeding it drops the request and increments errors, so outpacing the server is visible rather than silently absorbed |
--sample-ms <MS> | 100 | How 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.
| Gate | Catches |
|---|---|
fmt | Formatting drift |
clippy | Lint findings, workspace-wide, across every target and feature combination |
test | Behavioural regressions — the full cargo test --workspace suite, 409 tests |
msrv | Code that compiles on your toolchain but not on the pinned minimum one |
audit | Known-vulnerable dependencies, via the RustSec advisory database |
lockfile | A 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.csvfiles 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.verboseis enabled. - An integration test suite backs the server’s behaviour — see Build and test locally.
5. Embedding
- The
spawnCargo feature offers an alternate entry point for runningapimockas a subprocess, forwarding its log output to the parent process over atokio::sync::mpscchannel — see Architecture.