Mock file reference
This document describes the YAML schema for APIfae mock definitions.
File organization
Mock files are stored in the mocks/ directory of your APIfae project. The loader:
- Recursively scans all subdirectories
- Processes files with
.yamlor.ymlextensions - Allows any directory structure you prefer
mocks/
├── users.yaml # Flat structure
├── orders.yaml
└── products/ # Nested structure
├── list.yaml
└── detail.yaml
Schema overview
Each mock file contains an array of endpoint definitions:
# mocks/example.yaml
- path: /endpoint
method: GET
responses:
- status: 200
body: { message: "Hello" }
Endpoint definition
| Field | Type | Required | Description |
|---|---|---|---|
path |
string | Yes | URL path pattern, supports {param} placeholders |
method |
string | Yes | HTTP method (GET, POST, PUT, DELETE, PATCH, etc.) |
responses |
array | Yes | List of possible responses |
Path parameters
Use {name} syntax to capture path segments:
- path: /users/{id}
method: GET
responses:
- status: 200
body:
id: "{{path.id}}" # Use captured value in response
Response definition
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
status |
integer | Yes | - | HTTP status code (200, 404, 500, etc.) |
headers |
object | No | {} |
Response headers as key-value pairs; values are templated — see Templating in headers |
body |
any | No | null |
Response body (JSON, string, or any YAML value), serialised as JSON |
body_file |
string | No | null |
Path to a file, relative to the mocks directory, served as-is |
raw_body |
string | No | null |
Written to the socket verbatim — see Raw bodies |
raw_body_base64 |
string | No | null |
Base64, decoded and written verbatim |
when |
object | No | null |
Conditions for when this response applies |
scenario |
string | No | null |
Named scenario for programmatic switching |
delay_ms |
integer | No | null |
Delay in milliseconds before responding |
transition_to |
string | No | null |
Moves a state machine to this state when this response is returned — see Scenario files |
A response may set at most one body field. Declaring two is a load error naming the file and both fields, rather than a silent precedence.
Example with all fields
- path: /api/data
method: POST
responses:
- status: 201
headers:
X-Request-Id: "{{uuid()}}"
Cache-Control: "no-cache"
body:
id: "{{uuid()}}"
created_at: "{{now()}}"
delay_ms: 100
Raw bodies
body is serialised as JSON on the way out. That is what you want almost
always, and it is exactly what you do not want when the response is not JSON:
body: '{"data":[{"id":' # served as "{\"data\":[{\"id\":" — valid JSON!
raw_body puts the string's bytes on the wire untouched:
- path: /api/orders
method: GET
responses:
- status: 200
headers:
Content-Type: "application/json"
raw_body: '{"data":[{"id":' # a genuinely truncated payload
That covers malformed JSON, truncated payloads, XML, CSV, HTML error pages,
plain text and the empty body. For bytes that are not valid UTF-8 — an image, a
gzip stream, protobuf — use raw_body_base64:
- status: 200
headers:
Content-Type: "image/png"
raw_body_base64: "iVBORw0KGgo="
Malformed base64 is a load error, not an empty response.
raw_body is not a template
Unlike body, raw_body and raw_body_base64 are never rendered.
Verbatim means verbatim: raw_body: "<p>{{ user.name }}</p>" serves those
braces literally. This matters because record --convert writes captured
bodies into raw_body, and recorded HTML and JavaScript routinely contain
{{ }}. A body that needs templating is a body.
Large bodies
body_file serves a file from the mocks directory byte for byte and is the
better home for anything large or binary:
- status: 200
headers:
Content-Type: "image/png"
body_file: ".bodies/logo.png"
Content type
In order:
- If the response declares a
Content-Typeheader, that is what is sent — and it is sent once. The mock always wins. - Otherwise
bodyandbody_fileare served asapplication/json. - Otherwise
raw_bodyandraw_body_base64are served asapplication/octet-stream. The server never sniffs a raw body to guess.
Conditional responses (when)
The when block defines conditions that must match for this response to be selected. All conditions in a when block must match (AND logic).
Available matchers
| Matcher | Description | Example |
|---|---|---|
path.* |
Match path parameters | path.id: "123" |
query.* |
Match query string parameters | query.page: "2" |
header.* |
Match request headers (case-insensitive) | header.Authorization: "Bearer token" |
body.* |
Match JSON body fields | body.email: "test@example.com" |
body_regex |
Regex match against raw body | body_regex: '"type":\s*"admin"' |
Examples
- path: /users/{id}
method: GET
responses:
# Match specific path parameter
- when:
path.id: "999"
status: 404
body: { error: "User not found" }
# Match query parameter
- when:
query.format: "xml"
status: 200
headers:
Content-Type: "application/xml"
body: "<user><id>1</id></user>"
# Match header
- when:
header.X-Api-Version: "v2"
status: 200
body: { version: 2, data: {} }
# Match JSON body field
- when:
body.status: "cancelled"
status: 400
body: { error: "Cannot process cancelled items" }
# Multiple conditions (AND logic)
- when:
path.id: "123"
query.force: "true"
status: 200
body: { force_updated: true }
# Default response (no conditions)
- status: 200
body: { id: "{{path.id}}" }
Response matching order
- Responses with matching
whenconditions are checked first (in order) - Named
scenarioresponses are skipped unless activated - First response without
whenis the default fallback
Templating
Response bodies and response header values support Jinja2-style templating via minijinja. See Templating in headers for the two ways a header differs.
Variables
| Variable | Description |
|---|---|
{{path.X}} |
Path parameter value |
{{query.X}} |
Query string parameter |
{{header.X}} |
Request header value |
{{body.X}} |
JSON body field value |
Built-in functions
| Function | Description | Example Output |
|---|---|---|
{{uuid()}} |
Random UUID v4 | "a1b2c3d4-..." |
{{now()}} |
ISO 8601 timestamp | "2025-12-16T10:30:00Z" |
{{fake_name()}} |
Random full name | "John Smith" |
{{fake_email()}} |
Random email address | "john.smith@example.com" |
{{fake_url()}} |
Random URL | "https://user123.example.com" |
The parentheses are required. {{uuid}} is the function itself, not a call,
and rendering it is an error rather than a value:
uuid is a function — write {{uuid()}} to call it
apifae validate reports it against the file and line, and the server answers
with a 500 error document instead of the
response, so the mistake never reaches a client as a plausible-looking string.
By default these five change on every request. Three calls to the same endpoint give three different responses, so any snapshot assertion, recorded HTTP fixture, or Playwright/Cypress check that compares the response body will fail intermittently.
Start the server with
--seedand they stop changing — see Deterministic mode below. That is the answer for a test suite. Writing the value literally still works and is still the simplest thing for a field that has one right answer:body: id: "ord_8f14e45fceea" # deterministic — safe to snapshot createdAt: "2026-08-01T09:15:00Z"
apifae initgenerates static values from your spec'sexample:entries for this reason.
Undefined variables
The rule is:
An unknown name is an error. A missing key is a blank.
The four namespaces above — path, query, header, body — are always
defined, even when a request carries nothing for them. Whether a key is
present is a property of the request, so a missing one renders empty:
body:
cursor: "{{query.cursor}}" # no ?cursor= → ""
trace: "{{header.x_trace}}" # header absent → ""
echo: "{{body.email}}" # GET, or no such field → ""
That is what makes echoing an optional parameter work without ceremony. Say so explicitly when you want a different blank, or a real default:
page: "{{query.page | default('1') | int}}" # renders 1
A name that is not one of those namespaces, a helper, or a built-in is a typo — no request could ever supply it — so it is reported against the file, the endpoint and the field:
mocks/api.yaml:1 - GET /csp headers.X-Csp: NONCE is not defined.
Templates can use path, query, header, body, the helpers (uuid(), now(),
fake_name(), fake_email(), fake_url()), or {% raw %}…{% endraw %} to keep
braces literal
If those braces are data rather than a template, that last option is the one you want:
headers:
X-Csp: "{% raw %}script-src 'nonce-{{NONCE}}'{% endraw %}"
apifae validate reports unknown names as errors and exits non-zero; apifae up
prints them at startup and on every reload, then serves the value blank. Both
check response headers as well as bodies.
Recorded mocks are escaped for you.
apifae record --convertwraps any captured header or JSON string containing{{or{%in{% raw %}so it replays byte for byte — a CSP nonce, a URI Template in aLinkheader, a rate-limit pattern.raw_bodyandraw_body_base64are never rendered at all and need no escaping. Mocks recorded before this change are not escaped: if one carries brace syntax,validatewill tell you, and re-runningrecord --convertfixes it.
Deterministic mode
--seed makes the five helpers reproducible, so you can keep generated data
and assert on it:
apifae up --seed 42
apifae serve --seed 42
Or make it the default for the repository, in apifae.yaml:
server:
port: 4000
host: "127.0.0.1"
cors: true
seed: 42 # --seed on the command line still wins
With a seed set:
uuid(),fake_name(),fake_email()andfake_url()draw from a seeded generator;now()returns the fixed instant2026-01-01T00:00:00+00:00;- the same method + path + query returns byte-identical bytes across runs, processes and machines.
Each request is seeded from its own identity rather than from a running counter, which has two consequences worth knowing:
- Order does not matter.
GET /usersreturns the same body whether your suite hits it first or fortieth, and whether or not other requests run in parallel. - A different query is a different request.
?page=1and?page=2get their own stable data. Reordering the pairs (?a=1&b=2vs?b=2&a=1) does not.
Within one response the values still differ from one another — a body with three
{{uuid()}} fields gets three distinct ids, the same three every time.
The seed is read once at startup: editing server.seed while up --watch is
running reports "restart required" rather than taking effect. Without --seed
or server.seed, nothing changes — the helpers stay random.
Filters
All standard minijinja filters are available:
body:
page: "{{query.page | default('1') | int}}"
name: "{{body.name | upper}}"
trimmed: "{{query.search | trim}}"
What a template returns
A template renders to text. That text is the value — as a string — unless
it starts with [ or { and parses as JSON, in which case the parsed structure
is the value.
That one rule explains everything below, including the two things that surprise people:
"{% if ... %}true{% else %}false{% endif %}"yields the string"true", not the booleantrue. Same for numbers.- A YAML block scalar (
|) that emits- itemlines yields a string of YAML source, not a list.validatewill correctly reportexpected array, got string.
Templating in headers
Header values get the same helpers and the same path / query / header /
body context as bodies:
- path: /users/{id}
method: GET
responses:
- status: 201
headers:
Location: "/users/{{path.id}}"
X-Request-Id: "{{uuid()}}"
body:
id: "{{path.id}}"
Two differences from a body:
- A header renders to text, and stays text. The
what a template returns rule about
[/{parsing as JSON does not apply — a header value is always the literal text that came out. - A value that cannot legally be sent is skipped, with the reason logged at
error. Header values may not contain newlines or control characters, so a template that produces one costs you that header and nothing else; the rest of the response is served normally.
A header whose template fails to render is as loud as a body that fails: the
response is a 500 whose body is
{"error": "Template error in header <name>: …"}, and the mock's own headers
are not sent — see When a response cannot be
built.
Under --seed, header values are drawn from the same
per-request stream as the body — after it, in alphabetical order of header name
— so they are reproducible across processes exactly like a body is.
raw_body and raw_body_base64 responses still template their headers, even
though their bodies deliberately do not: the
exemption exists to preserve captured bytes, and a header is hand-written.
The server's own Content-Type default and the CORS headers are never
templated.
When a response cannot be built
Sometimes the server cannot produce the response a mock declares at all — a
template that will not render, a body_file it cannot read, a malformed
raw_body_base64. It answers with
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
{"error": "Template error: …"}
The declared status is not used. The response it belongs to does not exist,
so serving 200 would let a test asserting status === 200 pass against a mock
that is broken — and a client that checks the status before parsing would take
the error envelope for a payload. A mock is a server-side artefact, so a fault
in one is a 5xx. The mock's own headers are dropped for the same reason: they
describe a body that was never produced, and one of them may be Content-Type.
A declared 5xx is unaffected — status: 503 with a body that renders is
served as 503, which is the whole point of being able to mock a failure.
Two things that are deliberately not this:
- A header value that renders but cannot legally be sent — that header is skipped and the real response is served with its declared status.
- An unknown name inside a template, which renders blank
and is reported by
validateinstead.
apifae up prints these at startup and apifae up --strict refuses to start at
all, so the 500 is the last line of defence rather than the first.
Conditionals and loops
To build a real array, emit JSON syntax from the loop:
body:
items: '[{% for i in range(3) %}{"id": "ord_{{i}}", "index": {{i}}}{% if not loop.last %},{% endif %}{% endfor %}]'
{"items":[{"id":"ord_0","index":0},{"id":"ord_1","index":1},{"id":"ord_2","index":2}]}
range(10000) works the same way, which is how you get a collection big enough
to exercise pagination or a virtualised list without a hand-maintained fixture.
The same trick gives you real booleans and numbers — wrap them in an object:
body:
meta: '{"has_more": {% if query.page | default(1) | int < 10 %}true{% else %}false{% endif %}}'
meta.has_more is then a genuine boolean. If a field needs to be a bool or a
number and nothing about it varies, just write it literally.
Response delays
Simulate network latency with delay_ms:
- path: /api/slow
method: GET
responses:
# Simulate 500ms latency
- status: 200
delay_ms: 500
body: { message: "Slow response" }
# Simulate timeout scenario
- when:
query.timeout: "true"
status: 504
delay_ms: 30000
body: { error: "Gateway timeout" }
Named scenarios
Scenarios switch a whole workspace between response sets at runtime, with no
restart. This works today — see Scenario files for the full
workflow (apifae scenario list / set / reset / create, and
apifae up --scenario NAME to pre-activate one at startup).
A response tagged with scenario: is served only while that scenario is
active, so it never shadows the default. The name must also be declared in a
scenarios/*.yaml file, or activating it fails with Unknown scenario —
apifae init writes scenarios/from-spec.yaml for the error responses it
generates from an OpenAPI spec.
A scenario: tag also tells apifae validate that the deviation is
deliberate: the response is not checked against the list of status codes
the OpenAPI spec declares for that path. Where the spec does describe that
status, the body is still validated against its schema — the tag exempts the
response from the spec's permission to exist, not from being well-formed.
- path: /api/health
method: GET
responses:
- scenario: maintenance
status: 503
body: { error: "Service under maintenance" }
- scenario: degraded
status: 200
body: { status: "degraded", message: "Partial outage" }
- status: 200
body: { status: "healthy" }
Complete example
# mocks/users.yaml
# List users
- path: /users
method: GET
responses:
- status: 200
body:
users:
- id: "{{uuid()}}"
name: "{{fake_name()}}"
email: "{{fake_email()}}"
- id: "{{uuid()}}"
name: "{{fake_name()}}"
email: "{{fake_email()}}"
total: 2
page: "{{query.page | default('1') | int}}"
# Get single user
- path: /users/{id}
method: GET
responses:
- when:
path.id: "999"
status: 404
body: { error: "User not found" }
- status: 200
body:
id: "{{path.id}}"
name: "{{fake_name()}}"
email: "{{fake_email()}}"
created_at: "{{now()}}"
# Create user
- path: /users
method: POST
responses:
- when:
body.email: "taken@example.com"
status: 409
body: { error: "Email already exists" }
- status: 201
delay_ms: 100
headers:
Location: "/users/{{uuid()}}"
body:
id: "{{uuid()}}"
name: "{{body.name}}"
email: "{{body.email}}"
created_at: "{{now()}}"
# Delete user
- path: /users/{id}
method: DELETE
responses:
- status: 204
Spec validation
apifae validate compares every mock against the workspace's OpenAPI spec, and
apifae up runs the same check at startup. For the live-traffic side — mocks
against a running API, and that API against the spec — see
Keep a mock honest.
Exit codes
| errors | warnings | clean | |
|---|---|---|---|
apifae validate |
1 | 0 | 0 |
apifae validate --strict |
1 | 1 | 0 |
An error is a mock that disagrees with something the spec actually says: a
body that does not match the declared schema, or a status the spec does not
declare on a response with no scenario: tag. A file that fails to load is an
error too, in both modes — it is not a deviation, it is broken.
Something the spec actually says is the operative phrase. A status declared
without a body — 204: {description: No Content}, a 202, an error whose
shape the spec does not pin down — is checked for nothing and reported as
nothing: the status is exactly what the spec declares, and there is no schema to
compare a body against. A default: response declares every status, with or
without a schema.
A warning is a mock the spec has nothing to say about: no operation matches
its method and path. A workspace has exactly one spec and it describes the
service you are building, so mocks recorded from a service you consume land
here. They do not fail a plain validate; --strict is what promotes them.
apifae up --strict refuses to start on errors. Warnings never block startup.
Templates are rendered before the body is checked
Validation compares the body the server would serve, not the body as written: every template is rendered first, so the JSON-emitting loop above is checked as the array it produces, not as the string it is in the file.
The request it renders against is synthetic — every {param} in the mock's own
path is the literal sample, query and headers are empty — and the generated
helpers (uuid(), fake_*(), now()) run seeded, so two validate runs over
an unchanged workspace always agree.
Two consequences worth knowing:
- A template that renders to malformed JSON stays a string, and validation reports it — that is a real defect, and the response really would be served as a string.
- A template that does not render at all is an error in its own right
(
... does not render: ...), because the server would answer that endpoint with a500instead of the response.
Excluding mocks from spec validation
Mocks that deliberately do not derive from the workspace spec — anything
apifae record --convert captured from a third party, most often — can be
withheld from the comparison entirely:
# apifae.yaml
mocks:
directory: mocks
validation:
exclude:
- "mocks/vendor/**"
- "mocks/zen.yaml"
Patterns are globs matched against workspace-relative mock file paths. * stops
at a directory separator; ** crosses them. Excluded files are loaded and
served exactly as before — they are simply never compared against the spec, so
they neither warn nor fail, in either mode.
Error messages
Invalid mock files produce detailed error messages:
Error in mocks/users.yaml at line 5, column 3: missing field `status`
→ - body: { users: [] }
The loader validates:
- YAML syntax
- Required fields (
path,method,status) - Valid field types