Mock your real API in JSON — status codes, errors, validation, latency, and mutable data — so the frontend can develop and test without waiting on a backend.
Define the same endpoints your app will call. Switch success and failure scenarios, validate request shapes, persist collections, proxy selected routes to a live server — or record staging once and replay from .recordings/ with no backend online.
The frontend keeps calling HTTP on your machine. Backend outages stop blocking you. Use flat mocks/*.json or microservice folders; proxy only the routes that should still hit a live service, or capture them with Record & Replay and keep working offline.
- JSON-first — describe routes and responses in plain files; no code required to mock an API
- Frontend-ready — status codes, headers, delays, multipart/raw bodies, and mutable CRUD stores
- Safe by default — startup validation catches broken mocks before they waste your time
- Watch mode — server restarts when mock files change
- Opt-in depth — start static; add
match,request,store, orproxyonly when you need them - Presets —
mock-server add --preset …scaffolds common shapes (CRUD, auth, upload, relations…) so you edit JSON instead of inventing structure - Record & Replay — point
--proxyat staging, hit the app once with--record, then replay those fixtures locally without the upstream
Requires Node.js >= 22.12. This package is a CLI (mock-server) that serves JSON mock files over HTTP (not an embeddable SDK).
npm install http-mock-json --save-dev
npx mock-server init
npx mock-server start
curl -i http://localhost:3001/<your-endpoint>Default port is 3001 (npm run mock:start after init uses the same). Full walkthrough: Getting started.
This README is the full guide and reference. Quick help: FAQ · Troubleshooting · Changelog.
Day-one path: Getting started → optional Concepts. Everything else is lookup when you need it.
Start with the pocket brief docs/ai.md (pipeline, presets, CLI, limits). Index of priority docs: llms.txt. These ship on npm under node_modules/http-mock-json/ — open or @-mention them; IDEs do not auto-inject them into every chat.
Learn (day one):
- Getting started
- Concepts (optional — short glossary + how pieces fit)
Later (when you need it):
- Examples
- Advanced examples
- Record & Replay — capture staging, replay offline
Reference (lookup when needed):
- CLI reference
- Mock file reference
- Validation reference
- Body compatibility
- Mock config reference
- Store reference
Recipes (product-style; after you know the basics):
- Store recipes — store-backed app walkthroughs (Examples C–R)
- Real-world — multi-feature / multipart / folders / hybrid proxy
In about five minutes: install, create a first mock, start the server, curl a response, then switch scenarios by editing JSON.
- Node.js 22.12 or newer
- A Node project with a
package.json(recommended soinitcan add a start script)
npm install http-mock-json --save-devnpx mock-server initBy default this will:
- Create a
mocksdirectory - Add a
mock:startscript (mock-server start) - Prompt you to create a first mock file
init uses the static preset and asks for a file name, endpoint (e.g. data/animals), HTTP methods, then confirm. The scaffold already includes sample bodies so you can curl immediately:
{
"data/animals": {
"GET": {
"nameResponse": "success",
"responses": [
{
"name": "success",
"statusCode": 200,
"body": { "message": "ok" }
},
{
"name": "error",
"statusCode": 404,
"body": { "message": "Not found" }
}
]
}
}
}Edit those bodies to match your API, or flip "nameResponse" to "error" to try the 404 path. More fields (match, store, proxy, …) come later in Concepts.
npx mock-server startOr npm run mock:start. Both listen on http://localhost:3001 by default. Watch mode reloads when you save mock files; validation errors block startup (and a bad reload).
curl -i http://localhost:3001/data/animalsYou should see { "message": "ok" }. Change "nameResponse" to "error", save, and curl again — same URL, "Not found" (watch mode reloads).
- A mocks directory with at least one
.jsonfile - Server at
http://localhost:3001 curlreturns thenameResponsebody- Editing
nameResponse(or bodies) changes the next response after reload
- Concepts — how
nameResponse,match,request,store, andproxyfit together
When you need scaffolds or copy-paste fixtures: CLI — add (--preset …), Examples.
Prefer capturing a real API instead of hand-writing mocks? Use Record & Replay (--proxy + --record). OpenAPI → JSON: CLI — import.
Understand the main ideas behind an http-mock-json mock — enough to choose the right feature — without memorizing every field.
- You can start the server from Getting started
- You have at least one mock JSON file under your mocks directory
Each HTTP method on an endpoint holds a responses array. Every entry has a unique name.
nameResponse is the fallback: when no response’s match applies, the server returns the response whose name equals nameResponse.
Use this to flip success vs error (or any scenario) by editing one string. Field rules: mock file reference.
Optional match on a response selects that scenario when the incoming request fits. Rules can use:
params— route params (/users/:id)query— query stringbody— parsed JSON or urlencoded fieldsheaders— header names (case-insensitive)multipart— multipart fields / file metadatacall— N-th hit (1-based), with optional scope,loop, andreset
Matching is partial: only the keys you list must agree. First matching response in array order wins; if none match, nameResponse is used.
Empty match objects do not match. Details and examples: mock file, samples in Examples.
Optional method-level request checks the inbound payload, query, and/or headers before normal match / nameResponse selection.
Important naming (v4+):
- Use
request.payload(notrequest.body) - Grouped error options live under
request.error(response,format,detail,key)
You can set as (json | form | multipart | raw | text) to require a content mode, or omit it and let Content-Type decide. Failed checks select the configured error response instead of running the usual match pipeline.
Depth: body compatibility, validation, mock file.
Opt-in per endpoint: declare a collection with store (or reference a shared store.id defined elsewhere). Responses can use action (list, get, create, update, patch, delete, restore) instead of a fixed body.
Typical capabilities (see Store reference): seed data, persistence, list filters/pagination, soft delete, relations, unique / key conflicts.
action cannot share a response with proxy or response encoding.
Proxy can appear at several layers:
- Response:
"proxy": true, a URL string, or{ "target", "path?" } - Method default target for
"proxy": true - CLI
--proxyand foldermock.config.jsontargets for"proxy": trueand unmatched routes
When a selected response has proxy set, the original request is forwarded and the mock body / action path is skipped. Unmatched routes can still be forwarded if you configured global or folder unmatched proxy mounts. Upstream redirects are not followed (redirect: "manual"); the mock returns the 3xx response as-is.
Hand-writing every fixture is optional. With --proxy and --record, proxied responses are written under .recordings/ (same mock JSON shape). Stop the server, start again without --record: recordings load by default next to your mocks, so the frontend keeps the same URLs with no upstream.
Typical loop: record against staging → commit or share sanitized fixtures → day-to-day mock-server start (or --recordings-only). Full flags and behavior: CLI — Record & Replay.
For a request that hits a registered mock route, roughly:
- Parse the body when needed (including multipart). Parse failures can become a
requesterror response ifrequestis configured; otherwise they return a client error. requestvalidation — if configured and issues are found, serve the request-error response and stop.- Response selection — walk
responsesin order; first entry with a satisfiedmatchwins; otherwise usenameResponse. - Delay — method-level and/or response-level latency.
- Fulfill:
proxy→ forward to the resolved targetaction+ store → run the store operation (conflicts / not-found use named responses when configured)- otherwise → send the mock
body(or encoded file / base64 body)
After registered routes, unmatched traffic may still hit folder proxyUnmatched mounts, then the global --proxy catch-all.
Startup checks (port, mock shape, references) are separate from this per-request path — see validation.
CORS is enabled by default (browser frontends can call the mock server without a separate CORS mock).
| Term | Meaning |
|---|---|
| mocks directory | Folder passed to --path / -f (default mocks). Contains mock JSON files and optional mock.config.json. |
| mock file | A .json file loaded by start (not mock.config.json). Top-level keys are endpoints. |
| fixture | Sample mock under this repo’s mocks/ (for copy/paste). |
| endpoint | Top-level key in a mock file (e.g. "data/animals"). In prose, “route” means the HTTP path, not a JSON field name. |
| named response | An entry in responses[] with a name. Prefer this over “scenario” when talking about the API. |
nameResponse |
Fallback named response when no match applies. |
match |
Optional object on a response that selects it from the request (params / query / body / …). |
request |
Method-level validation (payload / query / headers) before response selection. Distinct from startup validation. |
| store definition / reference | Full store object vs { "id": "…" } reuse. Prefer these over bare “schema”. |
| recording | Fixture under .recordings/ written by --record while proxying; loaded on start unless --exclude-recordings. |
You can explain, for a given mock, which response will run: validation error, a matched scenario, the nameResponse fallback, a store action, a proxy forward, or a loaded recording.
- Examples — copy-paste samples from this repository
- Advanced examples — one-feature walkthroughs
Lookup in this manual when needed:
| Need | Section |
|---|---|
| Every mock field | Mock file reference |
| CRUD / persist / list / relations | Store |
| Payload modes, files, response encoding | Body compatibility |
Folder layouts (mock.config.json) |
Mock config |
| What fails at startup vs at runtime | Validation |
| CLI flags | CLI |
Binary: mock-server (package http-mock-json, current version 5.1.0). Requires Node.js ≥ 22.12.
Global options: -h / --help, -v / --version.
Flag
-pdepends on the command: oninit/add/import,-pis--path(mocks directory). Onstart,-pis--port; the mocks directory is-f/--path.
Create the folder that will contain the mocks.
mock-server init| Flag | Default | Description |
|---|---|---|
-p, --path <path> |
mocks |
Path to the mocks directory to create |
-m, --mock [value] |
true |
Create a first mock. Accepts true / 1 as true; any other string is false. Bare --mock is true. |
-s, --script [value] |
true |
Add a start script to package.json. Same boolean parsing as --mock. |
Examples:
mock-server init --path api-mocks --mock false --script false
mock-server init --path apps/folder1/mocks --mock false --script falseStart the mock server (loads JSON mocks, validates, listens, then watches for file changes).
mock-server start| Flag | Default | Description |
|---|---|---|
-p, --port <port> |
— | Listen port (integer 1–65535). Overrides mock.config.json port when set; otherwise config port, else 3001. |
-f, --path <path> |
mocks |
Path to the mocks directory (JSON files + optional mock.config.json) |
--proxy <url> |
— | Global proxy target (http / https). Used by responses with "proxy": true and by unmatched routes (after folder proxyUnmatched mounts). Upstream redirects are not followed (redirect: "manual"); 3xx is returned as-is. |
--record |
false |
Record proxied responses into .recordings/ (JSON + binary via encoding: "file"). Requires a proxy target (CLI --proxy, folder proxy / proxyUnmatched, or response proxy). |
--exclude-recordings |
false |
Do not load .recordings/ (mocks only). |
--recordings-only |
false |
Load only .recordings/ (ignore regular mock JSON). Incompatible with --exclude-recordings. |
--reset-store [ids] |
— | Delete persisted store snapshots before the initial start. Bare --reset-store clears all. Comma-separated ids clear only those store ids (runtime ids, including namespace:id when storeNamespace is set). Not re-applied on watch reloads. |
Examples:
mock-server start --port 3001 --path api-mocks --proxy https://api.staging.com
mock-server start --path apps/folder1/mocks
mock-server start --proxy https://api.staging.com --record
mock-server start --exclude-recordings
mock-server start --recordings-only
mock-server start --reset-store
mock-server start --reset-store notes,users
mock-server start --reset-store users:sessionBootstrap realistic fixtures from a live API, then develop and test with the upstream offline. Recordings use the same mock JSON shape (including match for params/query/auth/multipart), so you can edit or commit them like any other mock.
# 1) Record (writes .recordings/ while proxying)
mock-server start --proxy https://api.staging.com --record
# 2) Stop with Ctrl+C (prints wrote / skipped / proxy failures)
# 3) Replay (default loads mocks + recordings)
mock-server startWith mock.config.json folders, recordings are grouped by longest matching prefix into <folder>/.recordings/. Without config, files go under mocks/.recordings/.
| Behavior | Detail |
|---|---|
| What is recorded | Proxied traffic only (local mocks are not overwritten) |
| JSON | Saved as normal response body (including primitives) |
| Binary | encoding: "file" + bytes under .recordings/files/ |
| Bodies | JSON parsed when possible; text/* / xml / csv as strings; binary as encoding: "file"; unknown utf8 as text, otherwise binary. Proxied responses are recorded (no content-type skip for normal API payloads). |
| Redirects | Not followed (redirect: "manual"); 3xx + Location are recorded |
| Path params | Digits → :id / :id2…; segments like v1 / v2 stay literal |
| Headers | Response headers mapped (including Set-Cookie); hop-by-hop omitted. Request Authorization / Cookie go into match.headers so variants of the same route replay correctly |
| Match | Path params, query, non-empty JSON body, auth/cookie headers, and multipart fields/file metadata (filename / mimeType / size); response without match becomes the default (nameResponse) |
| Collisions | In default load mode, a mock route wins over a recording (warning logged) |
| Startup log | Routes grouped under Mocks and Recordings |
.recordings/ writes are ignored by the file watcher (same idea as .store/). The package .gitignore excludes **/.recordings/ because recordings may store Authorization / Cookie in match.headers — commit them only when intentionally sanitized.
Breaking (≥ 2.0.0): --path / -f is the mocks directory itself (default mocks).
Before 2.0.0, -f apps/folder1 meant apps/folder1/mocks. Use -f apps/folder1/mocks now.
Port resolution: CLI -p / --port → mock.config.json port → 3001.
CORS: enabled by default on the HTTP server (including exposing response headers to the browser).
See also: Validation, Mock config, Store — persist / --reset-store.
Scaffold a mock JSON file with an interactive prompt. Every add run uses a preset (default static). You always set the file name and endpoint; only static also asks which HTTP verbs to include.
mock-server add
mock-server add --preset crud
mock-server add --path api-mocks --preset auth-login| Flag | Default | Description |
|---|---|---|
-p, --path <path> |
mocks |
Path to the mocks directory |
--preset <name> |
static |
Which scaffold to write (table below) |
--crud |
false |
Alias for --preset crud |
| Preset | Use when you need… | Writes |
|---|---|---|
static |
A ready-to-curl endpoint you can edit | One route; chosen verbs; sample success / error bodies |
scenarios |
Branching without a store | GET + ?scenario=ok|missing|error (match + delay) |
auth-login |
Login validation + happy/sad paths | POST + request + match (200 / 403) + 401 / 400 |
crud |
Mutable collection + item | store actions: list / create / get / update / patch / delete |
crud-full |
CRUD plus real persistence rules | crud + persist, unique, softDelete, item restore |
paginated-list |
Tables / infinite scroll against seed data | Collection store.list (page, filter, search) + POST create |
relations |
Parent/child resources with FK | Two stores: parent + sibling child (embed, expand, onDelete: restrict) |
upload |
Multipart create + file download | POST multipart + item GET (encoding: "base64") |
proxy-hybrid |
Some routes local, one live upstream | Local GET + sibling …/live proxy (jsonplaceholder) |
Endpoint conventions (store presets): api/notes → collection + api/notes/:id. A trailing /:param is kept on item routes (users/:userId). store.id is the last non-param segment. proxy-hybrid uses that same collection base for the sibling …/live route (so api/notes/:id still yields api/notes + api/notes/live). Existing files prompt before overwrite.
relations prompts: you name the parent collection (default api/users). The preset always scaffolds a second child collection next to it:
- Default child segment is
posts→ e.g. parentapi/userswritesapi/users+api/posts(storesusers/posts, FKuserId, reverse embed, delete restrict). - If that default would collide with the parent (same path or same
store.id— e.g. parentapi/posts), the CLI asks for the child collection name (suggestioncomments) and writes that sibling instead (api/posts+api/comments, FK derived from the parent id →postId). - Child names are a single path segment (
comments,articles); parent FK / embed names follow the parentstore.id(soft-singularized); child relation keys and conflict names follow the child segment.
mock-server add --preset scenarios
mock-server add --preset crud-full
mock-server add --preset relations --path apps/folder1/mocksadd / init write into the root of the mocks directory (they do not create mock.config.json folder layouts). import writes to the root when there is no server/--prefix; with a route prefix it writes mock.config.json + one-level folders. For folder organization, see Mock config.
Generate mock JSON files from an OpenAPI 3.x document (local file or http(s) URL). Offline generator only — start still loads the written JSON, not the OpenAPI file.
mock-server import --openapi ./openapi.yaml
mock-server import --openapi https://example.com/openapi.json -p mocks
mock-server import --openapi ./openapi.yaml --no-split-tags --out my-api --overwrite
mock-server import --openapi ./openapi.yaml --prefix /api/v1
mock-server import --openapi ./openapi.yaml --no-server-prefix| Flag | Default | Description |
|---|---|---|
--openapi <source> |
(required) | OpenAPI 3.0 / 3.1 file path or URL |
-p, --path <path> |
mocks |
Path to the mocks directory |
--out <name> |
from info.title (or openapi) |
Base file name when using --no-split-tags |
--no-split-tags |
off (split by tag) | Write a single JSON file instead of one file per OpenAPI tag (untagged.json when a tag is missing) |
--prefix <path> |
from servers[0].url path |
Route prefix written into mock.config.json folders.*.prefix (overrides the OpenAPI server path) |
--no-server-prefix |
off | Ignore servers[0] path; write flat mock JSON at the mocks root (no mock.config.json) |
--overwrite |
false |
Overwrite existing files without prompting |
--no-request |
off (generate request) |
Do not emit request.payload / query / headers from OpenAPI schemas |
Behavior:
- Paths like
/pets/{petId}becomepets/:petId. - Only
GET/POST/PUT/PATCH/DELETEare imported; other methods are skipped with a warning. - Every documented status becomes a
responses[]entry (success_200,error_404, …).nameResponseis the first 2xx (else the first status). Error responses are present but inactive until you changenameResponseor addmatch. - Response bodies prefer
example→examples→schema.example→ a minimal schema-derived example →{}. request(default on): also writes method-levelrequestfromrequestBodyand from query/header parameters (optional fields asname?). JSON, form, and multipart bodies are mapped when the schema is an object; path params stay in the route. Use--no-requestfor response-only stubs. Schemas the mock engine cannot represent are skipped with a warning (see Troubleshooting — import).- Server base path: if
servers[0].urlhas a path (e.g.https://api.nasa.gov/planetary→/planetary, or/api/v3), the import writesmock.config.jsonand puts tag files under folders that share thatprefix. Endpoint keys stay relative (apod, notplanetary/apod), soGET /planetary/apodworks at runtime. Grouping is still by OpenAPI tag, not by prefix (the server path is usually one shared prefix). - Swagger 2.0 is not supported (convert to OpenAPI 3.x first). Does not generate store CRUD or
matchin this version.
Authoritative field reference for mock JSON files loaded by mock-server start.
A mock file is a JSON object. Top-level keys are endpoints (route paths). Each endpoint is an object whose keys are HTTP methods and, optionally, a sibling store definition/reference.
{
"<endpoint>": {
"store"?: StoreDefinition | { "id": string }, // endpoint level only
"GET" | "POST" | "PUT" | "PATCH" | "DELETE": Method
},
...
}
Guides and samples: Examples, Advanced examples, Real-world.
Related contracts: Body compatibility, Store, Mock config, Validation.
| Rule | Detail |
|---|---|
| Path pattern | Literals: letters, numbers, -, _, ., ~, /. Params like :id / :item-id (letters, numbers, _, - only — not .). |
| Methods | At least one of GET, POST, PUT, PATCH, DELETE (case as written in JSON; validated uppercased) |
store |
Optional; not an HTTP method. Sibling of methods. See Store |
Invalid characters or an endpoint with only store and no methods → startup error.
Method {
nameResponse: string // required — default response name
delay?: number // ms, ≥ 0
proxy?: string | { target, path? } // URL or object; not `true`
request?: Request // see Body compatibility
responses: Response[] // required, non-empty
}
| Field | Required | Notes |
|---|---|---|
nameResponse |
yes | Must match a responses[].name |
responses |
yes | Non-empty array |
delay |
no | Non-negative number |
proxy |
no | http/https URL string or { "target": "<url>", "path"?: string }. true is not allowed at method level |
request |
no | Must include payload, query, and/or headers. See Body compatibility |
Response {
name: string // required
statusCode?: number | string // required unless proxy is set
headers?: Record<string, string>
body?: unknown // required unless proxy or action
encoding?: "file" | "base64" // response serialization only
delay?: number
match?: Match
proxy?: string | true | { target, path? }
action?: StoreAction // list | get | create | update | patch | delete | restore
}
| Field | Required | Notes |
|---|---|---|
name |
yes | Unique among siblings for selection / nameResponse / error refs |
statusCode |
yes* | Numeric (or numeric string). Non-IANA codes → warning. *Optional when proxy is set |
body |
yes* | JSON/primitive payload, or path/base64 string when encoding is set. *Optional when proxy or action is set |
headers |
no | Object of string values |
encoding |
no | "file" or "base64" only. Incompatible with proxy and action |
delay |
no | Overrides method / folder / root delay for this response |
match |
no | Scenario selector (see below) |
proxy |
no | true (inherit target), URL string, or { target, path? } |
action |
no | Requires endpoint store. Incompatible with proxy. See Store actions |
One output mode per response: exactly one of proxy / action / static body (+ optional encoding) runs for the selected response.
encoding:
| Value | body |
Output |
|---|---|---|
| omitted | JSON / primitive | res.json(body) |
"base64" |
base64 string | decoded bytes |
"file" |
relative path under mocks root | file bytes (.. / escape → runtime 500) |
Details: Body compatibility — Response encoding.
action warnings:
bodypresent andaction≠list→ body ignored (warning)action: "delete"withstatusCode≠204→ status ignored (always204)
Object; must include at least one of: params, query, body, headers, multipart, call.
| Key | Shape | Notes |
|---|---|---|
params |
non-empty object | Route params |
query |
non-empty object | Query string |
body |
any JSON value | Compared against parsed body |
headers |
non-empty object | Header matching |
multipart |
non-empty object | Multipart field matching |
call |
positive int or object | Call counting / sequencing |
match.call object:
| Key | Type | Notes |
|---|---|---|
index |
positive int (≥ 1) | Required unless reset: true |
by |
{ body | query | params: string } |
Exactly one of those three keys; scopes the counter |
loop |
boolean | When true with indexes, indexes should be contiguous 1..max (warning if not) |
reset |
boolean | Reset counter; reset-only call must also include params/query/body/headers/multipart |
Within one method, all match.call.by values must be identical (startup error otherwise).
Selection: responses with match are tried; if none match, nameResponse is used. See Advanced examples for scenarios.
| Location | Allowed |
|---|---|
| Response | true | URL string | { "target", "path?" } |
| Method | URL string | { "target", "path?" } (not true) |
mock.config.json root / folder |
URL string | { "target", "path?" } (not true) |
CLI --proxy |
URL string |
When response "proxy": true, target resolution order:
method proxy → folder proxy → root config proxy → CLI --proxy
If none → HTTP 502. Explicit URL / { target } on the response skips the cascade.
Folder proxyUnmatched and stripPrefix are config-level; see Mock config.
request?: {
as?: "json" | "form" | "multipart" | "raw" | "text"
payload?: ...
query?: Record<string, FieldSchema>
headers?: Record<string, FieldSchema>
error?: { response?, format?, detail?, key? }
}
Full contract, field rules, error formats, and 3.x → 4.x migration: Body compatibility.
Legacy keys rejected at startup: request.body, invalidResponse, errorFormat, errorDetail, errorDetailsKey.
"store": { "id": "notes", "key": "id", "seed": [], ... }or reference:
"store": { "id": "notes" }A reference is only { "id": "..." }. Full schema, actions, soft delete, relations, persist, list/filter: Store.
Most specific wins:
response → method → folder (mock.config.json) → root config → 0
{ ...root, ...folder, ...response } — same key → more specific wins.
{
"api/health": {
"GET": {
"nameResponse": "ok",
"responses": [
{
"name": "ok",
"statusCode": 200,
"body": { "ok": true }
}
]
}
}
}Startup validation for mock-server start. Source of truth: src/cli/commands/start/execute-mock.ts, start-mock.ts, files.ts, process-file.ts, and src/validators/*.
When you run mock-server start:
- Load
mock.config.json(if present under the mocks directory): parse + validate config shape. Errors are collected (not thrown yet). Used to resolve the listen port. - Resolve port: CLI
-p→ configport→3001. - Port availability (before mock routes are registered): socket check. If the port is in use, the process fails immediately without finishing mock validation.
- Discover mock files: root
*.json(exceptmock.config.json); with config, also declaredfolders/*(respectingenabled/include/exclude). Missing declared folders → errors. - Parse each mock file: must be a non-empty JSON object (syntax / empty-file errors collected here).
- Collect store definitions from endpoint-level
store(full definitions only); duplicateid→ error. Apply folderstoreNamespacewhen set. - Store relations integrity across the registry (
validateStoreRelationsIntegrity). - Per endpoint / method / response:
- Endpoint path + at least one HTTP method
- HTTP methods:
GET,POST,PUT,PATCH,DELETE - Method:
nameResponse,responses, optionaldelay/proxy/request - Response:
name,statusCode(unlessproxy),body(unlessproxyoraction),match,delay,proxy,encoding,action - Request:
payload/query/headers/as/errorshapes; legacy keys rejected - Store actions, conflict / notFound response name existence, soft-delete rules for
restore - Optional
strictDuplicatesroute ownership when enabled in config
- Emit warnings, then emit errors. Any error → throw
Invalid mock configurationand exit (server does not start).
JSON structure checks are not a final separate step: invalid JSON fails at step 5 when the file is loaded.
| Severity | Effect | Typical causes |
|---|---|---|
| Error | Server does not start | Missing required fields, invalid structure, unknown keys, bad request / store / proxy / encoding combinations, missing named responses, relation integrity failures, invalid persist snapshots at load |
| Warning | Server still starts | Non-standard (non-IANA) statusCode; body ignored when action is set (except list); statusCode ≠ 204 on action: "delete"; non-contiguous match.call indexes when loop: true |
Error messages include file, endpoint, and method when applicable.
After a successful start, the mocks directory is watched (chokidar, debounce 150 ms).
| Behavior | Detail |
|---|---|
| Triggers | add / change / unlink under the mocks directory |
| Persist ignore | Persist snapshots are ignored so store writes do not restart the server: .store/**, custom persist.file paths, their .tmp siblings, and custom persist parent dirs (when not the mocks root) |
--reset-store |
Applied only on the initial CLI start. Watch reloads call executeMock without resetStore |
| Validation failure on reload | Restart is aborted; the process logs that the server could not be restarted and asks you to fix mocks and run the command again. It does not keep waiting in a reload loop for a later fix |
Contract (since 4.0.0) for multi content-type request validation, tolerant intake (multipart / form / raw / text), grouped error options, and binary mock responses (encoding).
Package version: 4.0.3. See also Advanced examples — request validation and the field summary in Mock file.
- Do not fail when the frontend sends
multipart/form-data, urlencoded, raw binary, or text — respond like the real API (usually JSON). - Validate those payloads with one clear shape: always
{ "type": "...", ... }(andformatwhen relevant). - Respond with images/PDFs/etc. via
encoding+body(no separatebodyFilekey).
request?: {
as?: "json" | "form" | "multipart" | "raw" | "text" // omit = auto from Content-Type
payload?: PayloadSchema
query?: FieldMap
headers?: FieldMap
error?: {
response?: string // named response on validation failure
format?: "array" | "map" // default: "array"
detail?: object | string
key?: string // default: "errors"
}
}
At least one of payload, query, headers is required when request is present.
Minimal (JSON):
"request": {
"payload": {
"email": { "type": "string", "format": "email" },
"password": { "type": "string", "minLength": 8 }
},
"error": {
"response": "invalid"
}
}Multipart profile upload (requires "as": "multipart" when any payload field has type: "file"; see 44-profile-body-compat.json):
"request": {
"as": "multipart",
"payload": {
"name": { "type": "string", "minLength": 2 },
"email": { "type": "string", "format": "email" },
"age?": { "type": "number", "min": 18, "max": 120 },
"avatar": { "type": "file", "format": ["png", "jpeg"] },
"banner?": { "type": "file", "format": "image/*" },
"cv?": {
"type": "file",
"format": "pdf",
"maxSize": 5000000,
"message": "CV must be a PDF up to 5MB"
}
},
"error": {
"response": "invalid",
"format": "map"
}
}Force multipart Content-Type (reject non-multipart requests first):
"request": {
"as": "multipart",
"payload": {
"title": { "type": "string" },
"file": { "type": "file", "format": "png" }
},
"error": { "response": "invalid" }
}Raw body (e.g. PUT image):
"request": {
"as": "raw",
"payload": {
"type": "file",
"format": ["png", "jpeg"],
"maxSize": 2000000
},
"error": { "response": "invalid" }
}One shape for every field: an object with type. Use format when you need a string format or a file kind. Do not use ambiguous shorthands like "email": "email" or "avatar": ["png", "jpeg"].
Optional fields: trailing ? on the key ("age?", "cv?").
| Option | string |
number |
boolean |
object |
array |
file |
|---|---|---|---|---|---|---|
format |
email, uuid, url, date |
— | — | — | — | png, jpeg, webp, pdf, image/*, file, MIME, or list |
minLength / maxLength |
✅ | — | — | — | — | — |
pattern |
✅ | — | — | — | — | optional filename pattern |
min / max |
— | ✅ | — | — | — | — |
enum |
✅ | ✅ | — | — | — | — |
properties |
— | — | — | ✅ | — | — |
items |
— | — | — | — | ✅ | — |
minItems / maxItems |
— | — | — | — | ✅ | multiple parts with same name |
maxSize / minSize |
— | — | — | — | — | ✅ (bytes) |
requireFilename |
— | — | — | — | — | ✅ optional |
message |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
messages |
✅ per-rule map (optional) | same | same | same | same | same |
format aliases for files (resolved internally):
| User writes | Means |
|---|---|
png |
image/png |
jpeg / jpg |
image/jpeg |
webp |
image/webp |
gif |
image/gif |
pdf |
application/pdf |
image/* |
any image |
file / */* |
any file part |
image/png |
used as-is |
Legacy type-only shortcut (still allowed for non-file types):
"name": "string"equals { "type": "string" }. Formats and files must use the object form.
Whole-body payload: a single rule object ({ "type": "string", ... } or { "type": "file", ... }) requires as: "text" or as: "raw" (or top-level type: "file"). Otherwise use a field map.
as |
Behavior |
|---|---|
| (omitted) | Auto: detect from the incoming Content-Type, then validate payload |
"json" |
Require JSON; else validation error → error.response |
"form" |
Require application/x-www-form-urlencoded |
"multipart" |
Require multipart/form-data |
"raw" |
Require binary/raw (image/*, application/pdf, octet-stream, audio/*, video/*, …). Also accepts other non-mapped Content-Types when as is explicitly "raw". |
"text" |
Require text/plain |
Auto mapping (detectRequestAs):
Incoming Content-Type |
Mode |
|---|---|
application/json / +json |
json |
application/x-www-form-urlencoded |
form |
multipart/form-data |
multipart |
text/plain |
text |
image/*, application/pdf, application/octet-stream, audio/*, video/* |
raw |
| no / unknown type | null (payload checks may be skipped when there is no body; query / headers still validate) |
Flow when as is set:
1) Does the frontend Content-Type match `as`?
NO → validation error (default or error.response)
YES → validate payload / query / headers
Without as: detect → validate payload for that mode.
Intake rule: if there is no request, or no file rules and no forced as, opaque bodies must not crash the server — select the mock response as usual. Body size limit: 10 MiB (RAW_BODY_LIMIT); larger bodies → 413. Multipart caps: 20 files, 100 fields.
All optional; defaults always apply.
"error": {
"response": "invalid",
"format": "map",
"detail": "{{message}}",
"key": "errors"
}| Key | Default | Role |
|---|---|---|
response |
generic 400 |
Named response to use on failure |
format |
"array" |
"array" = list of issue objects; "map" = { field: [messages] } |
detail |
built-in per format | Template(s) with {{path}}, {{rule}}, {{expected}}, {{received}}, {{message}} |
key |
"errors" |
Property name where errors are injected into the response body |
Generic failure (no named response): status 400, message "Invalid request".
Message resolution per failed rule:
rule.messages[ruleName] → rule.message → library default
Response-only (how to serialize body). Not used on request.
encoding |
body means |
Output |
|---|---|---|
| (omitted) | JSON / primitive | res.json(body) |
"base64" |
base64 string | decoded bytes |
"file" |
relative path under mocks root | file bytes (paths with .. rejected) |
{
"name": "avatar",
"statusCode": 200,
"headers": { "Content-Type": "image/png" },
"encoding": "file",
"body": "fixtures/avatar.png"
}{
"name": "tiny",
"statusCode": 200,
"headers": { "Content-Type": "image/png" },
"encoding": "base64",
"body": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
}| Goal | How |
|---|---|
| Serve a local image/PDF/binary from the mocks folder | "encoding": "file", "body": "assets/avatar.png" (+ usually Content-Type) |
| Serve bytes embedded in the mock JSON | "encoding": "base64", "body": "<base64>" |
| Keep classic JSON mocks | Omit encoding (default res.json) |
| Choose binary vs proxy vs store by scenario | Separate responses (e.g. different match / nameResponse) — one mode per response |
| Validate upload then return JSON | request (multipart/file) + normal JSON body (no encoding required) |
| Proxy multipart/binary upstream unchanged | proxy on the response (uses rawBody; do not set encoding on that same response) |
One response = one output mode. Mixing them on the same response fails validation:
| Combination | Result |
|---|---|
encoding + proxy |
❌ config error |
encoding + action |
❌ config error |
proxy + action |
❌ config error |
encoding not file / base64 |
❌ config error |
encoding set but body is not a string |
❌ config error |
encoding: "file" with empty / whitespace body |
❌ config error |
request.body / flat invalidResponse / … |
❌ config error (use payload / error.*) |
encoding is not ignored when proxy is present: the server refuses to start so dead config is not silent.
These pass config validation but fail when the response is selected:
| Situation | HTTP | Behavior |
|---|---|---|
| File path missing under mocks root | 500 |
JSON { "message": "…" } — Content-Type: application/json |
Path escapes mocks root (../…) |
500 |
Response body file path escapes mocks directory: … |
| Allowed | Not allowed / notes |
|---|---|
payload + optional as, query, headers, error |
Legacy body, invalidResponse, errorFormat, errorDetail, errorDetailsKey |
as: "json" | "form" | "multipart" | "raw" | "text" |
If as is set and Content-Type does not match → validation error → error.response (or generic 400) |
| Whole-body rule object | Requires as: "text" or as: "raw" (or top-level type: "file") |
Field rules per type (see Options by type) |
File shorthand like "avatar": ["png"]; use { "type": "file", "format": … } |
Form / multipart coerce number / boolean from strings |
— |
match.headers / match.multipart after validation passes |
Empty / non-object match.headers / match.multipart → startup error |
incoming request
→ tolerant intake (rawBody when needed)
→ request?
→ as? check Content-Type
→ parse (json | form | multipart | raw | text)
→ validate payload / query / headers
FAIL → error.response (or generic 400)
PASS → match → delay → proxy | action | encoding/body
Exactly one of proxy / action / static body(+optional encoding) runs for the selected response.
request = “is this valid?” · match = “which scenario?” (match.headers / match.multipart included). They do not replace each other.
Proxy value shapes and inheritance: Mock file — proxy, Mock config. Tutorials: Advanced examples, Real-world.
| Removed (3.x) | Use in 4.0 |
|---|---|
request.body |
request.payload |
invalidResponse |
error.response |
errorFormat |
error.format |
errorDetail |
error.detail |
errorDetailsKey |
error.key |
There is no dual-read / alias period: legacy keys are rejected at startup with a clear error.
Out of scope: response multipart builder, GraphQL/XML.
Folder organization (mock.config.json): Mock config.
Mutable store: Store.
Optional folder organization for large mock sets (since 1.18.0). Put mock.config.json at the root of the mocks directory passed to --path.
Repo sample: mocks/mock-config/ (food delivery: auth, orders, payments). See also Examples, Real-world.
Without this file, mocks work as flat JSON files in the mocks directory.
<mocks-dir>/
mock.config.json
health.json # root JSON still loads
auth/
login.json
orders/
list.json
Filename is always mock.config.json (MOCK_CONFIG_FILENAME).
| Option | Type | Default | Purpose |
|---|---|---|---|
delay |
number (≥ 0) | — | Default latency (ms) for root and folder files (folder may override) |
proxy |
URL string | { target, path? } |
— | Default proxy target. Not true |
headers |
Record<string, string> |
— | Default response headers |
strictDuplicates |
boolean | false |
When true, startup fails if the same HTTP method + final route is registered twice |
port |
integer 1–65535 |
3001 (effective) |
Default listen port. CLI -p / --port overrides |
folders |
object | — | Declared subfolders and their settings |
prefix is not allowed at root — only inside folders (startup error if present).
Folder name: letters, numbers, -, _, . only. One level under the mocks root (e.g. auth/*.json). Only folders declared in folders are loaded; undeclared directories are ignored.
| Option | Type | Default | Purpose |
|---|---|---|---|
prefix |
string path | — | Route prefix for every endpoint in that folder (/api/users + login → /api/users/login). No route parameters (:id). Leading/trailing / normalized |
delay |
number (≥ 0) | — | Overrides root delay for files in this folder |
proxy |
URL string | { target, path? } |
— | Overrides root proxy. Not true |
headers |
Record<string, string> |
— | Merged with root headers |
enabled |
boolean | true |
false skips the folder (also ignored by strictDuplicates) |
include |
string[] | all .json |
Basename patterns (*, ?); if set, only matching files load |
exclude |
string[] | none | Basename patterns skipped after include (e.g. *-draft.json) |
stripPrefix |
boolean | false |
When proxying, remove folder prefix from the upstream path. Requires prefix |
proxyUnmatched |
http/https URL |
— | Catch-all proxy for requests under this folder prefix with no mock route. Requires prefix |
storeNamespace |
string | — | Prefixes store ids (session → users:session). Pattern: letters, numbers, -, _, .. Ids that already contain : are left as-is; relation targets in the same folder are namespaced too |
Declared folder missing on disk → startup error.
- Always load root-level
*.jsonexceptmock.config.json. - If
foldersis set, for each declared folder withenabled !== false, load matching*.jsonfiles (include/exclude). - Nested folders deeper than one level are not scanned.
mock-server add/initstill write to the root of the mocks directory.
| Concern | Order |
|---|---|
port |
CLI -p → config port → 3001 |
delay |
response → method → folder → root → 0 |
proxy when response is true |
method → folder → root config → CLI --proxy |
headers |
merge { ...root, ...folder, ...response } (same key → more specific wins) |
Explicit response proxy URL / { target } skips the cascade.
Folder proxyUnmatched only covers unmatched routes under that prefix; CLI --proxy still catch-alls remaining unmatched routes.
{
"delay": 100,
"headers": {
"X-Mock-Env": "local"
},
"folders": {
"users": {
"prefix": "/api/users",
"delay": 200,
"headers": {
"X-Service": "users"
},
"include": ["auth.json", "profile.json"],
"exclude": ["*-draft.json"]
},
"users-v2": {
"prefix": "/api/users",
"enabled": false
}
}
}users/auth.json with endpoint login → POST /api/users/login (prefix + folder delay/headers merged with response headers).
{
"strictDuplicates": true,
"folders": {
"users": { "prefix": "/api/users" },
"auth": { "prefix": "/api/users" }
}
}If both define POST login, startup fails (duplicate final route).
{
"port": 3500,
"folders": {
"users": { "prefix": "/api/users" }
}
}mock-server start # 3500 from config
mock-server start -p 4000 # 4000 (CLI wins)| Request context | Delay used |
|---|---|
Response has delay |
that value |
Else method has delay |
method value |
| Else folder / root config | folder then root |
| None | 0 |
Final headers = { ...root, ...folder, ...response }.
See Mock file — proxy. Orphan proxy: true with no method/folder/root/CLI target → 502.
{
"folders": {
"users": {
"prefix": "/api/users",
"proxy": "http://localhost:4000",
"stripPrefix": true
}
}
}Incoming GET /api/users/42 with "proxy": true → upstream http://localhost:4000/42.
{
"folders": {
"users": {
"prefix": "/api/users",
"stripPrefix": true,
"proxyUnmatched": "http://localhost:4000"
}
}
}Mocked routes under the prefix stay local; other paths under the prefix proxy to the target (with strip when enabled). Outside the prefix: use CLI --proxy.
{
"folders": {
"users": { "prefix": "/api/users", "storeNamespace": "users" },
"orders": { "prefix": "/api/orders", "storeNamespace": "orders" }
}
}Local "id": "session" becomes runtime users:session. Same-folder references keep writing "id": "session". Cross-folder relations use the full id ("store": "users:session").
--reset-store must use the runtime id:
mock-server start --reset-store users:session
mock-server start --reset-storeOpt-in feature (≥ 1.11.0; advanced list filters ≥ 1.12.0; composite unique ≥ 1.13.0; soft delete ≥ 1.14.0; relations ≥ 1.15.0; customizable notFound ≥ 1.16.0; list-on-by-default ≥ 3.0.0). Without store + action, mocks stay static.
Data lives in memory for the process lifetime; optionally survive restarts with persist.
This section is the store field and behavior reference (actions, definition vs reference, soft delete, relations, persist, list/filter, statuses). Store-backed product walkthroughs C–R live in Store recipes. Minimal samples: Example A and Example B below. For multi-feature / multipart / folder scenarios, see Real-world. Also: Examples, CLI --reset-store.
| You need… | Use | Deep dive |
|---|---|---|
| Collection + CRUD | store + action |
Actions, Schema |
| Soft delete / trash | store.softDelete |
Soft delete |
| Relations / FK | store.relations |
Relations |
| Auto ids / defaults | key, template |
Key generation |
| Seed data | seed |
Schema |
| Business uniqueness | unique + 409 |
Conflicts |
| Custom missing item | store.notFound |
Not found |
| Survive restart | persist / --reset-store |
Persist and restart |
| Validate payload/query | request |
Body compatibility |
| Branch by params/query/body/call | match |
Mock file — match, Advanced examples |
| Page / offset / cursor lists | store.list |
List sort and pagination |
| Filters / search / multi-sort | store.list.filter / sort |
Filters / search, Multi-sort |
| Custom list JSON | list placeholders | Response templates |
| Forward to real API | proxy (not with action) |
Mock file — proxy |
| Long product recipes (todo, SaaS, e-commerce, auth, …) | — | Store recipes (Examples C–R) |
Pipeline (every request): request → match → delay → exactly one of proxy / action / static body (optional encoding).
List pipeline (inside action: "list" + store.list): key params → fields (AND) → or → search → sort → page/offset/cursor → templates.
- Put
storeat endpoint level (sibling ofGET/POST/ …, not inside a method). - Define the collection once (full object with
id, optionalkey/seed/template/unique/persist). - Other routes that share data use the reference form:
"store": { "id": "notes" }. - On each response you want to mutate/read the collection, set
"action": "list" | "get" | "create" | "update" | "patch" | "delete" | "restore".
Request pipeline (fixed order):
requestvalidation (if any) → may returnerror.responseand never hits the storematch→ picks a response (nameResponsefallback)delay(once)- Exactly one of
proxy/action/ staticbody(+optionalencoding) on the selected response
| Action | Behavior | Success status | Common errors |
|---|---|---|---|
list |
Returns items. Filters by route key params. List engine on by default (omit / true / object): filter (fields/or/search) → multi-sort → page/offset/cursor. "list": false returns a plain full array. Optional body/headers templates. Soft-deleted items omitted unless ?includeDeleted=true |
response statusCode |
400 (invalid page/sort/order/cursor/filter query) |
get |
Loads one item; all key fields must come from route params. Soft-deleted → 404 unless ?includeDeleted=true |
response statusCode |
404 (default or store.notFound) |
create |
Inserts; merges template + body; auto-generates missing numeric key fields; route params override key fields. Soft-deleted rows do not count toward unique/key |
typically 201 |
400 (body not object), 409 (conflict) |
update |
Full replace (template + body), preserves existing key. Soft-deleted → 404 |
response statusCode |
404 / 400 / 409 |
patch |
Partial merge on existing item (no template), preserves key. Soft-deleted → 404 |
response statusCode |
404 / 400 / 409 |
delete |
Without softDelete: removes item. With softDelete: sets the delete field (ISO) and keeps the row. Runs relations.onDelete on dependents first. Always 204 with empty body on success (statusCode in JSON is ignored, warning if ≠ 204) |
204 |
404 / 409 (restrict) |
restore |
Requires store.softDelete. Clears the delete field and returns the item. Soft-deleted-only; active or missing → 404 |
response statusCode |
404 / 409 |
Rules for action:
- Requires
storeon the endpoint - Cannot be combined with
proxyon the same response bodyis optional; ignored with warning for actions other thanlist- For
list,bodyandheadersmay be templates with placeholders (see below) - Returned items are clones (mutating the HTTP response does not mutate the store)
restoreis only valid when the store definition hassoftDelete
Soft delete means: the item stays in the collection, but is marked deleted (default field deletedAt = ISO timestamp). Clients that call list / get / update / patch treat it as gone, unless they ask for trash with ?includeDeleted=true (or 1). action: "restore" clears the mark.
softDelete is a property of the collection, not of one route:
- Same data, many verbs. After a soft delete,
listmust hide the row,get/patchmust 404, anduniquemust free the email/title. That logic belongs to the store that holds the rows, not to theDELETEresponse alone. DELETEonly triggers the action. The HTTP method still uses"action": "delete". With soft delete on, that action marks; without it, that action removes. Same verb, different store policy.- One policy per
store.id. Every endpoint that references{ "id": "notes" }shares the same in-memory Map. Putting soft delete on the store definition once keeps list/get/delete/restore consistent. Putting it only on theDELETEresponse would leave other actions unaware.
So: configure "softDelete": true (or { "field": "deletedAt" }) on the full store definition. The DELETE endpoint does not need a special soft-delete flag — only "action": "delete" and a store that already has soft delete enabled.
You do not need GET list + POST create + DELETE for soft delete to work. Soft delete only needs:
- A store definition with
softDelete(and usuallyseed, or acreateroute, so there is something to delete). - A response with
"action": "delete".
Example — delete-only mock:
"api/notes/:id": {
"store": {
"id": "notes",
"softDelete": true,
"seed": [{ "id": 1, "title": "Keep" }]
},
"DELETE": {
"nameResponse": "remove",
"responses": [
{ "name": "remove", "statusCode": 204, "action": "delete" }
]
}
}DELETE /api/notes/1 returns 204 and sets deletedAt on that row. There is no list route here, so you cannot “see” the trash over HTTP until you add get/list/restore — but the soft delete did run in the store.
When you do share the store across routes (api/notes + api/notes/:id), keep one full definition (with softDelete) and use { "id": "notes" } elsewhere — see Schema (definition vs reference). Do not add softDelete on a reference; that becomes a second definition and fails startup.
"store": {
"id": "notes",
"softDelete": true
}or "softDelete": { "field": "deletedAt" }.
Without softDelete |
With softDelete: true |
|
|---|---|---|
action: "delete" |
Removes the item | Keeps the item and sets deletedAt (ISO) |
list / get |
Normal | Soft-deleted items hidden (unless ?includeDeleted=true or 1) |
update / patch |
Normal | Soft-deleted → 404 |
unique / key |
Counts all items | Soft-deleted ignored (values free to reuse) |
action: "restore" |
Invalid | Clears deletedAt and returns the item |
Also:
- Absent/
nulldelete field = active. Soft-deleting an already soft-deleted item →404. - Persist keeps soft-deleted rows as-is (same file format).
Opt-in links between stores: type: "one" (FK) and type: "many" (reverse embed). Targets may use a simple or composite store key.
"relations": {
"userId": {
"store": "users",
"join": { "from": "userId", "to": "id" },
"required": true,
"onDelete": {
"action": "restrict",
"conflict": { "response": "has-posts" }
},
"embed": { "as": "user" },
"conflict": {
"response": "invalid-user",
"detail": { "field": "{{field}}", "value": "{{value}}" }
}
},
"orderRef": {
"store": "orders",
"join": {
"from": ["tenantId", "orderId"],
"to": ["tenantId", "id"]
},
"required": true,
"onDelete": "cascade",
"embed": "order"
}
}Shorthand: "userId": "users" → type: "one", join.from = "userId", to = target key, required: false, onDelete: "restrict", default embed key userId$.
| Field | Default | Notes |
|---|---|---|
type |
"one" |
"one" = FK; "many" = reverse collection embed only |
store |
— | Target store.id (required) |
join.from |
relation name | Column(s) on this store (string or string[]) |
join.to |
target’s key | Column(s) on the referenced store; required when the target key is composite; must match that key |
required |
false |
All from parts missing → error when true (one only). Partial from values also fail FK checks |
onDelete |
"restrict" |
String restrict/cascade/setNull, or { "action", "conflict?" } (one only; setNull not with required: true) |
embed |
‹name›$ |
String or { "as": "…" } — property name when expanding; cannot equal a join.from field |
conflict |
default 409 |
Invalid/missing FK on write (one only) |
onDelete.conflict (or top-level conflict as fallback) is used when action: "restrict" blocks deleting the parent. That named response must exist on the parent store’s delete method.
Declared on the parent. Embed-only (no write FK checks, no onDelete).
"relations": {
"posts": {
"type": "many",
"store": "posts",
"join": { "from": "userId" },
"embed": { "as": "posts" }
}
}| Field | Notes |
|---|---|
join.from |
Child column(s) that point at this store’s key (string or string[]; length must match). No join.to on many |
| Integrity | Child store must declare a type: "one" whose join.from equals this join.from and targets this store |
- Flat:
?expand=user/?expand=userId,posts - Nested (max depth 3):
?expand=posts.user - Matching aliases per relation: relation name,
embed/embed.as, and (for simpleone) the singlejoin.fromfield - Soft-deleted related
one→null(unless?includeDeleted=true); soft-deletedmanychildren omitted the same way - Soft-deleted children do not block
onDelete: "restrict" onDeleteruns in two phases: allrestrictchecks (including throughcascadechains) first; only thensetNull/cascademutations. A blocked delete never leaves partial side effects.cascadeis recursive (grandchildren included) and supports self-referential FKs on the same store- Cycles are skipped (no infinite recursion)
- Invalid FK —
POST /api/posts{ "title": "X", "userId": 999 }→ status/body fromconflict.response(e.g.422+INVALID_USER). - Expand one —
GET /api/posts/1?expand=user→ post plus"user": { "id": 1, "name": "Ada" }. - Expand many + nested —
GET /api/users/1?expand=posts.user→ user plusposts: [...], each post with nesteduser. - Restrict —
DELETE /api/users/2while posts reference them →onDelete.conflicton the users DELETE method (e.g.409+HAS_POSTS). - Composite join —
GET /api/acme/order-items/1?expand=orderembeds the order keyed by(tenantId, id).
Behavior summary:
- Write — only
type: "one"validates FKs on create/update/patch/restore. - Expand —
oneembeds an object (ornull);manyembeds an array. - onDelete — only from child
onerelations (including composite joins and self-refs).restrictis evaluated before any mutation;cascadewalks the dependent graph. - Startup — unknown targets, mismatched
join.from/join.to, missing reverse formany, bad seed FKs, missing named conflict responses → fail boot.
Think of store.id as the collection name. Configure it once; point at it everywhere else.
Full definition — all config for that collection, only once per id in the whole mock set:
"store": {
"id": "notes",
"key": "id",
"seed": [],
"template": { "id": 0, "title": "", "done": false },
"unique": ["title"],
"persist": true,
"list": true,
"softDelete": true
}Reference — other endpoints that share the same collection. Only id is allowed:
"store": { "id": "notes" }If you add any other key on a reference (softDelete, seed, unique, …), it becomes a second full definition → startup error (store already defined).
Right:
"api/notes": {
"store": { "id": "notes", "softDelete": true, "seed": [...] }
},
"api/notes/:id": {
"store": { "id": "notes" },
"DELETE": { "responses": [{ "action": "delete", ... }] }
}Wrong (splits config / two definitions):
"api/notes": {
"store": { "id": "notes", "seed": [...] }
},
"api/notes/:id": {
"store": { "id": "notes", "softDelete": true }
}Also fine: put the entire full definition on api/notes/:id and use { "id": "notes" } on api/notes — same rule, one definition only.
| Field | Default | Notes |
|---|---|---|
id |
— | Required. Collection name in memory (and default persist filename) |
key |
"id" |
String, string array, or { "field" } / { "fields", "conflict?" } — not both field and fields |
seed |
[] |
Initial rows. Optional. Every seed item must include all key fields; no duplicate key/unique values. Omit/[] → empty until create or a persist snapshot |
template |
— | Defaults for create / update. Values on key fields are placeholders unless the client sends them |
unique |
— | ["email"] or { "fields": [...], "conflict": { "response", "detail" } } |
persist |
off | true / { "enabled": true, "file?": "relative/path.json" } |
list |
on (page defaults) | Omit / true / {} / object — sort (multi), page/offset/cursor, filters/search for action: "list". false → plain full array (see Filters / search and List sort and pagination) |
softDelete |
off | true / { "field": "deletedAt" } — on the full definition only; see Soft delete |
relations |
off | { "userId": "users" } or object map — FK / reverse embed; see Relations |
notFound |
off | { "response": "missing-user" } — named 404 for missing / soft-deleted items; see Not found |
Rules:
storeis not an HTTP method; the endpoint still needs at least one verb (GET,POST, …).- Unknown keys inside
store→ startup error. - Several endpoints may share the same
store.id; the full definition can appear only once. All ofkey/seed/template/unique/persist/list/softDelete/relations/notFoundbelong on that one definition. - A reference is only
{ "id": "..." }. Any other property = full definition (and will conflict if thatidis already defined). - A reference to an undefined
id→ startup error. seedmust be an array of objects (when present).[]or omitted → empty collection at start (unless a persist snapshot loads).unique.fieldsmust be non-empty. Each entry is a non-empty string,{ "field", "conflict?" }, or{ "fields": ["a","b"], "conflict?" }(composite unique).conflictobjects only allowresponseanddetail.conflict.detailis a non-empty string or a non-empty object whose values are strings (templates).- Named
conflict.responsevalues must exist inresponsesof every method that uses mutating actions (create/update/patch/restore) for that store (includes relationconflict). NamedonDelete.conflict.response(or relationconflictwhen used as restrict fallback) must exist on every method withaction: "delete"of the target store. - In a unique entry object,
fieldandfieldsare mutually exclusive. - Named
notFound.responsemust exist inresponsesof every method that usesget/update/patch/delete/restorefor that store.
key shapes:
"key": "id"
"key": ["tenantId", "id"]
"key": { "field": "id", "conflict": { "response": "duplicate-key" } }
"key": { "fields": ["tenantId", "id"], "conflict": { "response": "duplicate-key" } }unique shapes:
"unique": ["email", "username"]
"unique": {
"fields": [
"email",
{ "field": "username", "conflict": { "response": "duplicate-username" } },
{
"fields": ["tenantId", "email"],
"conflict": { "response": "duplicate-tenant-email" }
}
],
"conflict": {
"response": "duplicate-fields",
"detail": { "campo": "{{field}}", "campos": "{{fields}}", "valor": "{{value}}" }
}
}Composite unique ({ "fields": ["tenantId", "email"] }) requires all listed fields to be present on the item to evaluate. Same email under another tenantId is allowed. {{field}} becomes "tenantId+email"; {{fields}} is ["tenantId","email"]; {{value}} is the JSON array of values.
Merge order, then key resolution for each key field:
base = { ...template, ...body }- If the field is present in route params → use the param (params win over body). Pure numeric strings are coerced to numbers (
"12"→12;"12a"stays a string). - Else if the field is present in the body → keep the body value (template placeholders for that key field are ignored).
- Else → auto-generate a number: among items that share the other key fields (“siblings”), take
max(field) + 1, or1if none.
Examples:
POST /api/noteswith{ "title": "A" }andkey: "id"→idbecomes1, then2, …POST /api/acme/userswith composite key["tenantId","id"]→tenantIdfrom params, nextidamong that tenant only
update (PUT) |
patch |
|
|---|---|---|
| Merge | { ...template, ...body } |
{ ...existing, ...body } |
| Key fields | Always forced back to the existing item’s key (cannot change PK via body) | Same |
| Missing fields | Come from template (then body) | Kept from the existing item |
Checked on create / update / patch (not on list / get / delete):
- Primary-key collision (for
update/patch, the current item is ignored). - Each
uniquefield present on the item — compared withString(value)(so1and"1"collide).
All conflicts are collected; there is no “stop at first” mode.
Which named response / detail is used:
| Situation | Response / detail source |
|---|---|
| Only key conflict(s) | key.conflict if set; else default 409 |
| Exactly one unique conflict (and no key conflict) | That field’s fields[].conflict if set; else unique.conflict; else default |
| Multiple conflicts (any mix) | unique.conflict, falling back to key.conflict; else default |
No conflict.response configured |
Default body below |
Default conflict response:
{
"message": "Duplicate value(s)",
"conflicts": [
{ "field": "email", "value": "a@b.com", "message": "Duplicate value for unique field \"email\"" }
]
}Status 409. For a composite key conflict, field is the key fields joined with + (e.g. "tenantId+id").
Placeholders in the named conflict response body (deep replace):
| Placeholder | Meaning |
|---|---|
{{conflicts}} as the entire property value |
Replaced by an array shaped by conflict.detail (not a JSON string) |
{{conflicts}} inside a larger string |
Replaced by JSON.stringify(...) of that array |
{{field}} / {{value}} / {{message}} |
First conflict only |
{{fields}} |
JSON array of field names for the first conflict (["email"] or ["tenantId","email"]) |
conflict.detail shaping of each conflict entry:
detail |
Each conflict becomes |
|---|---|
| omitted | { "field", "value", "message" } |
| string template | that string with placeholders applied |
| object of string templates | same keys; each value is a template |
Headers from the selected conflict response are applied. delay already ran once before the action (including when the result is a conflict).
Optional. Without store.notFound, missing items (and soft-deleted items treated as missing) keep the default:
{ "message": "Not found" }Status 404.
With config:
"notFound": {
"response": "missing-user"
}{
"name": "missing-user",
"statusCode": 404,
"body": {
"code": "USER_NOT_FOUND",
"message": "User {{id}} was not found in tenant {{tenantId}}",
"key": "{{key}}"
}
}Applies to get / update / patch / delete / restore when the item is missing or soft-deleted (and not requested with ?includeDeleted=true for get). Soft-deleted “missing” paths use the same named response.
Placeholders in the named notFound response body / headers (deep replace):
| Placeholder | Meaning |
|---|---|
{{<keyField>}} |
Value of each store.key field from route params (e.g. {{id}}, {{tenantId}}) |
{{key}} |
Full key as fields joined with + (e.g. "acme+42") |
{{message}} |
Default message ("Not found") |
notFound only allows response (no detail — the response body is the template). The named response must exist on every method that can return store 404 for that store.
| Mode | On mock-server start / watch reload |
|---|---|
No persist |
Registry rebuilt from seed every time |
persist: true / { "enabled": true } |
Loads .store/<id>.json under the mock files root if present and valid; otherwise seed. Successful mutations rewrite the snapshot |
{ "enabled": true, "file": "..." } |
Same, but custom path relative to the mock files root (no absolute paths, no ..) |
false / { "enabled": false } |
Same as no persist |
Snapshot file shape:
{
"items": [ { "id": 1, "title": "A" } ]
}Behavior details:
- Write is atomic: write
*.tmp, then rename over the target. - Your mock JSON definition files are never modified by persist.
- If a write fails, the server logs
Failed to persist store "<id>": ...and keeps the in-memory mutation (the HTTP response still succeeds). - Invalid snapshot at startup (bad JSON, missing
itemsarray, non-object items, missing key fields, duplicate key/unique) → startup fails (server does not start). - Watcher ignores
.store/**, custom persist files, their.tmpsiblings, and custom parent dirs (when those dirs are not the mock files root), so persist I/O does not restart the server. --reset-storedeletes persist files only on the initial CLI start, then loads fromseed. Watch reloads do not re-apply--reset-store.
mock-server start --reset-store # clear all persist files, then start from seed
mock-server start --reset-store notes,users # clear only those store idsYou can also delete .store/<id>.json (or your custom file) manually before start.
| Case | Status | Body |
|---|---|---|
list / get / create / update / patch success |
Response statusCode (use 201 for create if you want) |
Cloned item or array; response headers applied |
delete success |
Always 204 |
Empty (null); JSON statusCode ignored |
Item not found (get / update / patch / delete / restore) |
404 or status of store.notFound.response |
Default { "message": "Not found" } or named body (see Not found) |
Body of create / update / patch is not a JSON object |
400 |
{ "message": "Request body must be a JSON object" } |
| Key / unique conflict | 409 or status of the named conflict response |
Default or named conflict body (see above) |
| Invalid relation FK | status of relations.*.conflict.response (else 409) |
Named body + {{conflicts}} / detail templates |
Parent delete blocked (onDelete restrict) |
status of onDelete.conflict.response (else conflict, else 409) |
Named body on the parent DELETE method |
request validation failed |
Your error.response (or generic 400) |
Never reaches the store |
match selected a static response (no action) |
That response’s status/body | Store is not called |
Implications of the pipeline:
matchcan return a static401/403/etc. on an endpoint that also has store actions.actionandproxycannot share the same response.delayruns once before the action; conflicts /404/400do not wait again.- Response bodies from the store are deep clones; mutating them in the client does not change memory.
| Feature | Relationship |
|---|---|
request |
Validates input before the store. Use it for types/format/minLength; use unique for business uniqueness |
match |
Chooses which response runs; may skip action entirely |
delay / headers |
Applied to action success, named conflict responses, and named notFound responses |
proxy |
Incompatible with action on the same response |
| Watch / restart | Without persist → back to seed. With persist → reload snapshot (unless --reset-store on initial start) |
Configured on the full store definition only (not on { "id": "..." } references).
Requires action: "list". Omitting store.list (or setting true / {}) enables the list engine in page mode with defaults. Set "list": false to return a plain full array (optionally filtered by route params that overlap key).
Static mocks (match + fixed body) are unrelated: they do not use this engine.
- Filter by route params that match
store.keyfields (e.g.:tenantId) - Apply
store.list.filter:fields(AND) →or→search(if configured) - Multi-sort
- Paginate: page | offset | cursor
- If the response has
bodyand/orheaders, apply list placeholders; otherwise return the items array (current page only when the engine is on)
"list": true
"list": {}
"list": falseOmitting list, true, and {} enable page mode with defaults. false disables the list engine (plain full array).
| Option | Default |
|---|---|
page query |
page, default 1 |
pageSize query |
pageSize, default 10, max 100, alias limit |
sort query |
sort, default "id" (no field whitelist) |
order query |
order, default "asc" |
| Key | Type | Meaning |
|---|---|---|
page |
{ query?, default? } |
1-based page (default ≥ 1) |
pageSize |
{ query?, default?, max?, aliases? } |
Page size (default/max ≥ 1; aliases e.g. ["limit"]) |
offset |
{ query?, default? } |
0-based offset (default ≥ 0) |
limit |
{ query?, default?, max? } |
Offset-mode page size |
cursor |
true | { query?, limit? } |
Cursor/keyset mode (see below) |
sort |
{ query?, default?, fields? } |
Sort query name, default expression, optional whitelist |
order |
{ query?, default? } |
"asc" | "desc" — default direction for unsigned sort fields |
filter |
string[] | { fields?, or?, search? } |
eq/ne/gt/gte/lt/lte/in, nested paths, OR group, search |
Unknown keys under store.list → startup error.
"list": {
"page": { "query": "page", "default": 1 },
"pageSize": {
"query": "pageSize",
"default": 10,
"max": 100,
"aliases": ["limit"]
},
"sort": { "query": "sort", "default": "id", "fields": ["id", "name", "price"] },
"order": { "query": "order", "default": "asc" }
}offset used internally = (page - 1) * pageSize.
Declare offset / limit without page / pageSize (unless you intentionally combine modes — see priority):
"list": {
"offset": { "query": "offset", "default": 0 },
"limit": { "query": "limit", "default": 10, "max": 100 },
"sort": { "query": "sort", "default": "id" },
"order": { "query": "order", "default": "asc" }
}A cursor is an opaque bookmark (“continue after this item”), not a page number.
"list": {
"cursor": {
"query": "starting_after",
"limit": { "query": "limit", "default": 10, "max": 100 }
},
"sort": { "query": "sort", "default": "-meta.score", "fields": ["meta.score", "id"] }
}| Form | Effect |
|---|---|
"cursor": true |
Query name cursor; limit query limit (default 10, max 100) |
"cursor": { "query", "limit" } |
Custom query names / defaults |
- Token is base64url JSON of sort values + primary
keyvalues (stable tie-break). Nested sort paths (e.g.meta.score) are included the same way as top-level fields. - Request:
?starting_after=<token>&limit=10(names from config). - Response placeholders:
{{nextCursor}},{{hasMore}}, and{{next}}(URL with the next cursor). - There is no
ending_before(forward-only). - Invalid / empty / mismatched cursor →
400.
When several styles are configured:
- Page if
page/pageSize(or apageSizealias) appear in the query, or neither offset nor cursor params are present and page config exists (page is the default when configured). - Else offset if
offset/limitappear, or offset is configured and cursor is not. - Else cursor if
store.list.cursoris configured. - If only
cursoris configured (no page/offset), cursor mode is used (page defaults are not injected).
Example config with all three modes (use distinct limit query names so they do not collide):
"list": {
"page": { "query": "page", "default": 1 },
"pageSize": { "query": "pageSize", "default": 2, "max": 10 },
"offset": { "query": "offset", "default": 0 },
"limit": { "query": "limit", "default": 2, "max": 10 },
"cursor": {
"query": "starting_after",
"limit": { "query": "cursorLimit", "default": 2, "max": 10 }
},
"sort": { "query": "sort", "default": "id", "fields": ["id"] }
}| Request | Mode that runs | Why |
|---|---|---|
(none) |
page | No offset/cursor params → page default |
?offset=2 |
offset | Offset param present; no page params |
?page=2&offset=0 |
page | Page wins over offset |
?starting_after=<token> |
cursor | Cursor param present; no page/offset |
?offset=1&starting_after=<token> |
offset | Offset wins over cursor |
?starting_after=%%% |
400 | Invalid cursor token |
Try:
# Default → page (look for page= / {{page}} in the envelope)
curl -s 'http://localhost:3001/api/mixed'
# Offset mode
curl -s 'http://localhost:3001/api/mixed?offset=2'
# Page wins when both are present
curl -s 'http://localhost:3001/api/mixed?page=2&offset=0'
# Cursor mode (token from a previous {{nextCursor}} / keyset bookmark)
curl -s "http://localhost:3001/api/mixed?starting_after=${TOKEN}"
# Offset wins over cursor
curl -s "http://localhost:3001/api/mixed?offset=1&starting_after=${TOKEN}"
# Bad cursor → 400
curl -si 'http://localhost:3001/api/mixed?starting_after=placeholder'Tip: avoid giving pageSize the alias limit if the same store also defines offset limit — prefer pageSize + limit + cursorLimit as separate names.
Opt-in under store.list.filter. Only runs for action: "list".
Rules read query params; if a rule’s query param is omitted, that rule is skipped (no error).
Shapes
| Shape | Example | Meaning |
|---|---|---|
| String array | "filter": ["status", "role"] |
AND equality; each string = { field, op: "eq", query: <same name> } |
| Object | "filter": { "fields?", "or?", "search?" } |
Full control; must include at least one of fields, or, search |
Rule object
{ "field": "price", "op": "gte", "query": "minPrice" }| Property | Required | Default | Meaning |
|---|---|---|---|
field |
yes | — | Item path (supports dots: meta.region) |
op |
no | "eq" |
Operator (see table below) |
query |
no | same as field |
Query param name that supplies the compare value |
String shorthand inside fields / or:
"status"is equivalent to:
{ "field": "status", "op": "eq", "query": "status" }Operators (op)
op |
Query example | Keeps item when… | Notes |
|---|---|---|---|
eq |
?status=active |
String(value) === query |
Default; missing/null field → drop |
ne |
?excludeStatus=draft |
value ≠ query | Missing/null field → keep |
gt |
?gtPrice=20 |
value > N | Query must be a number → else 400 |
gte |
?minPrice=10 |
value ≥ N | Same |
lt |
?ltPrice=10 |
value < N | Same |
lte |
?maxPrice=30 |
value ≤ N | Same |
in |
?tag=a,b or ?tag=a&tag=b |
String(value) ∈ list |
CSV and/or repeated params; empty list → 400 |
Numeric ops coerce the query with Number(...). Item values use the same compare rules as sort (numbers, numeric strings, then locale string compare).
Nested paths
field (and search.fields, and sort fields) may use . to walk objects:
{ "id": 1, "meta": { "region": "eu" } }{ "field": "meta.region", "op": "eq", "query": "region" }?region=eu keeps that item. Missing intermediate keys → value is undefined (fails eq / range / in; passes ne).
The same dotted paths work in ?sort=meta.region and in cursor bookmarks when the active sort uses them.
fields (AND)
Every rule whose query param is present must match.
"fields": [
"status",
{ "field": "price", "op": "gte", "query": "minPrice" },
{ "field": "price", "op": "lte", "query": "maxPrice" }
]?status=active&minPrice=10&maxPrice=30 → active and 10 ≤ price ≤ 30.
or (OR among present params)
Same rule shape as fields. After AND rules:
- Collect
orrules whose query param is present (and valid). - If that set is empty → skip OR (no extra filtering).
- Else keep items that match at least one of those rules.
"or": [
{ "field": "status", "op": "eq", "query": "anyStatus" },
{ "field": "meta.region", "op": "eq", "query": "anyRegion" }
]| Request | Effect |
|---|---|
| (neither param) | OR ignored |
?anyStatus=draft |
status is draft |
?anyRegion=latam |
meta.region is latam |
?anyStatus=draft&anyRegion=latam |
draft or latam |
search (text)
"search": { "query": "q", "fields": ["name", "meta.region"] }| Option | Default | Behavior |
|---|---|---|
query |
"q" |
Query param with the search term |
fields |
required | Case-insensitive substring match; item kept if any field matches |
Empty / omitted search term → search skipped. Nested paths allowed in fields.
Full example
"list": {
"page": { "query": "page", "default": 1 },
"pageSize": { "query": "pageSize", "default": 10, "max": 50, "aliases": ["limit"] },
"sort": { "query": "sort", "default": "id", "fields": ["id", "name", "price", "meta.region"] },
"filter": {
"fields": [
"status",
{ "field": "price", "op": "gte", "query": "minPrice" },
{ "field": "price", "op": "lte", "query": "maxPrice" },
{ "field": "price", "op": "gt", "query": "gtPrice" },
{ "field": "price", "op": "lt", "query": "ltPrice" },
{ "field": "status", "op": "ne", "query": "excludeStatus" },
{ "field": "name", "op": "in", "query": "name" },
{ "field": "meta.region", "op": "eq", "query": "region" }
],
"or": [
{ "field": "status", "op": "eq", "query": "anyStatus" },
{ "field": "meta.region", "op": "eq", "query": "anyRegion" }
],
"search": { "query": "q", "fields": ["name", "meta.region"] }
}
}Try (happy path):
# AND equality + range
curl -s 'http://localhost:3001/api/products?status=active&minPrice=10&maxPrice=30'
# Exclusive bounds
curl -s 'http://localhost:3001/api/products?gtPrice=20<Price=40'
# Not equal
curl -s 'http://localhost:3001/api/products?excludeStatus=draft'
# Membership (CSV or repeated)
curl -s 'http://localhost:3001/api/products?name=Alpha,Charlie'
curl -s 'http://localhost:3001/api/products?name=Alpha&name=Echo'
# Nested path (match)
curl -s 'http://localhost:3001/api/products?region=eu'
# Nested sort
curl -s 'http://localhost:3001/api/products?sort=meta.region&order=asc&pageSize=10'
# Nested path (no match) → empty page, total 0 (not an error)
curl -s 'http://localhost:3001/api/products?region=antarctica'
# OR omitted → no OR filtering (full list subject to other rules)
curl -s 'http://localhost:3001/api/products?pageSize=10'
# OR single / multi
curl -s 'http://localhost:3001/api/products?anyRegion=eu'
curl -s 'http://localhost:3001/api/products?anyStatus=draft&anyRegion=latam'
# Text search
curl -s 'http://localhost:3001/api/products?q=cha'Try (sad path → 400):
# Non-numeric compare ops
curl -si 'http://localhost:3001/api/products?minPrice=abc'
curl -si 'http://localhost:3001/api/products?maxPrice=nan'
curl -si 'http://localhost:3001/api/products?gtPrice=x'
# Present but empty string
curl -si 'http://localhost:3001/api/products?ltPrice='
curl -si 'http://localhost:3001/api/products?status='
# in with no values after split/trim
curl -si 'http://localhost:3001/api/products?name='
# Example bodies:
# { "message": "Query \"minPrice\" must be a number" }
# { "message": "Query \"status\" must not be empty" }
# { "message": "Query \"name\" must not be empty" }Filter evaluation order
- Route
keyparams (outsidefilter, always on) fields— AND of present rulesor— if any OR query present, OR of those rulessearch— if term non-empty- Then sort → pagination
{{total}} (and X-Total-Count if you template it) is the count after all filters, before pagination.
Runtime 400 from filters
| Condition | Try | Example message |
|---|---|---|
gt / gte / lt / lte not a number |
?minPrice=abc |
Query "minPrice" must be a number |
| Present but empty string | ?status= |
Query "status" must not be empty |
in present but no values after split/trim |
?name= |
Query "name" must not be empty |
Omitted params are not errors (rule skipped). Unknown region / no matches → 200 with empty items and total: 0.
Startup validation (filter)
- Top-level
filtermust be a non-empty string array or an object. - Object keys only:
fields,or,search. fields/or: non-empty array of strings or{ field, op?, query? }.opmust be one of:eq,ne,gt,gte,lt,lte,in.- Object must include at least one of
fields,or,search.
Invalid shapes (server refuses to start):
"filter": { "fields": [{ "field": "price", "op": "between" }] }→ The "store.list.filter.fields[0].op" must be one of: eq, ne, gt, gte, lt, lte, in
"filter": { "or": [] }→ The "store.list.filter.or" must be a non-empty array of strings or field objects
"filter": { "or": [{ "op": "eq", "query": "status" }] }→ The "store.list.filter.or[0].field" must be a non-empty string
sort accepts one or more comma-separated fields (including nested paths like meta.region). If sort.fields is set, every field must be in that whitelist.
| Form | Example | Effect |
|---|---|---|
Field + order |
?sort=name&order=desc |
Single field; direction from order |
| Signed prefix | ?sort=-price,+name |
price desc, then name asc (+ optional for asc) |
| Explicit | ?sort=price:desc,name:asc |
Same, per-field direction |
| Nested | ?sort=meta.region&order=asc |
Sort by dotted path |
| Default expression | "default": "-meta.score" in config |
Used when the client omits sort |
{{sort}} echoes the active sort string. {{order}} is the direction of the first sort field.
Invalid integers / out of range for page, pageSize, offset, limit; invalid order; sort field outside whitelist; invalid cursor; non-numeric gt/gte/lt/lte; empty in / empty filter query →:
{ "message": "Query \"sort\" field must be one of: id, name, price, meta.region" }(Exact message depends on the failing query.)
On action: "list", body and header values are templates.
Exact string "{{items}}" / "{{total}}" / … is replaced by the typed value (array / number / null / …).
Placeholders embedded in a longer string are stringified.
| Placeholder | Type | Meaning |
|---|---|---|
{{items}} |
array | Current page/slice |
{{total}} |
number | Count after filters |
{{page}} / {{pageSize}} |
number | Page mode meta |
{{offset}} / {{limit}} |
number | Offset (and cursor limit) meta |
{{totalPages}} |
number | ceil(total / pageSize) (page mode) |
{{sort}} / {{order}} |
string | Active sort string / first direction |
{{self}} |
string | Absolute URL of the current request |
{{next}} / {{previous}} |
string | null |
Absolute URLs for neighbors |
{{hasNext}} / {{hasPrevious}} |
boolean | Neighbor flags |
{{linkHeader}} |
string | RFC 5988 Link (rel="next" / rel="prev") |
{{nextCursor}} |
string | null |
Opaque cursor for the next page (cursor mode) |
{{hasMore}} |
boolean | Same as hasNext (handy alias for cursor-style envelopes) |
Without a body template → JSON array of items (already filtered/sorted/paginated).
body on list does not emit the “body ignored” warning (unlike other actions).
Page envelope + Link header
{
"name": "list",
"statusCode": 200,
"action": "list",
"headers": {
"X-Total-Count": "{{total}}",
"Link": "{{linkHeader}}"
},
"body": {
"data": "{{items}}",
"page": "{{page}}",
"pageSize": "{{pageSize}}",
"total": "{{total}}",
"next": "{{next}}",
"previous": "{{previous}}"
}
}Offset / Django-like
{
"name": "list",
"statusCode": 200,
"action": "list",
"body": {
"results": "{{items}}",
"next": "{{next}}",
"previous": "{{previous}}",
"meta": {
"count": "{{total}}",
"offset": "{{offset}}",
"limit": "{{limit}}"
}
}
}Cursor / Stripe-like
{
"name": "list",
"statusCode": 200,
"action": "list",
"body": {
"data": "{{items}}",
"has_more": "{{hasMore}}",
"next_cursor": "{{nextCursor}}",
"next": "{{next}}"
}
}Not implemented (do not expect these):
- Case-insensitive / trimmed unique comparison
- Expand deeper than 3 hops / GraphQL-style field selection on embeds
- HTTP admin routes to reset stores (use
--reset-storeor delete the snapshot file) - Re-implementing
requestrules insidestore
Minimal list/create/get/delete. Empty seed; id auto-increments; titles unique; optional persist.
{
"api/notes": {
"store": {
"id": "notes",
"key": "id",
"seed": [],
"template": { "id": 0, "title": "", "done": false },
"unique": ["title"],
"persist": true
},
"GET": {
"nameResponse": "list",
"responses": [
{ "name": "list", "statusCode": 200, "action": "list" }
]
},
"POST": {
"nameResponse": "create",
"responses": [
{ "name": "create", "statusCode": 201, "action": "create" }
]
}
},
"api/notes/:id": {
"store": { "id": "notes" },
"GET": {
"nameResponse": "get",
"responses": [
{ "name": "get", "statusCode": 200, "action": "get" }
]
},
"DELETE": {
"nameResponse": "remove",
"responses": [
{ "name": "remove", "statusCode": 204, "action": "delete" }
]
}
}
}Try:
curl -s http://localhost:3001/api/notes
curl -s -X POST http://localhost:3001/api/notes -H 'Content-Type: application/json' -d '{"title":"Buy milk"}'
curl -s http://localhost:3001/api/notes/1
curl -s -X DELETE http://localhost:3001/api/notes/1 -o /dev/null -w '%{http_code}\n'Composite key, template defaults, request validation, custom conflict bodies, shared store across collection + item routes, list filtered by :tenantId.
{
"api/:tenantId/users": {
"store": {
"id": "users",
"key": {
"fields": ["tenantId", "id"],
"conflict": { "response": "duplicate-key" }
},
"seed": [
{
"tenantId": "acme",
"id": 1,
"name": "Juan",
"email": "juan@acme.com",
"username": "juan",
"active": true
}
],
"template": {
"tenantId": "",
"id": 0,
"name": "",
"email": "",
"username": "",
"active": true
},
"unique": {
"fields": ["email", "username"],
"conflict": {
"response": "duplicate-fields",
"detail": { "campo": "{{field}}", "valor": "{{value}}" }
}
}
},
"GET": {
"nameResponse": "list",
"responses": [
{ "name": "list", "statusCode": 200, "action": "list" }
]
},
"POST": {
"nameResponse": "create",
"request": {
"payload": {
"name": { "type": "string", "minLength": 2 },
"email": { "type": "string", "format": "email" },
"username": { "type": "string", "minLength": 3 }
},
"error": {
"response": "validation-error"
}
},
"responses": [
{
"name": "validation-error",
"statusCode": 400,
"body": { "code": "VALIDATION_ERROR", "message": "Invalid request" }
},
{
"name": "duplicate-fields",
"statusCode": 409,
"body": { "ok": false, "code": "DUPLICATE", "errores": "{{conflicts}}" }
},
{
"name": "duplicate-key",
"statusCode": 409,
"body": { "code": "DUPLICATE_KEY", "message": "Id already exists for tenant" }
},
{ "name": "create", "statusCode": 201, "action": "create" }
]
}
},
"api/:tenantId/users/:id": {
"store": { "id": "users" },
"GET": {
"nameResponse": "get",
"responses": [
{ "name": "get", "statusCode": 200, "action": "get" }
]
},
"PATCH": {
"nameResponse": "patch",
"responses": [
{
"name": "duplicate-fields",
"statusCode": 409,
"body": { "ok": false, "code": "DUPLICATE", "errores": "{{conflicts}}" }
},
{
"name": "duplicate-key",
"statusCode": 409,
"body": { "code": "DUPLICATE_KEY", "message": "Id already exists for tenant" }
},
{ "name": "patch", "statusCode": 200, "action": "patch" }
]
},
"DELETE": {
"nameResponse": "remove",
"responses": [
{ "name": "remove", "statusCode": 204, "action": "delete" }
]
}
}
}Behavior:
GET /api/acme/users→ only users withtenantId: "acme"POST /api/acme/userswith body{ "name", "email", "username" }→tenantIdfrom params, next numericid,active: truefrom template- Duplicate email/username →
409witherroresshaped bydetail - Invalid email format →
400fromrequest(store never runs)
Store-backed product walkthroughs (Examples C–R): todo, SaaS, commerce, JWT, billing, and more.
Full recipes: docs/store-recipes.md
Index of copy-worthy fixtures under mocks/ (not shipped in the npm package).
Full index: docs/examples.md
One-feature walkthroughs (Purpose → fixture → what to try), each matching a file under mocks/.
Full walkthroughs: docs/advanced-examples.md
Multi-feature product scenarios (multipart, OAuth form, RBAC, folder layouts, hybrid proxy, …).
Full scenarios: docs/real-world.md
Copy-paste samples live under mocks/ (not shipped in the npm package). Index: Examples.
| Tool | Best when you need… |
|---|---|
| http-mock-json | A standalone mock HTTP server driven by JSON files (status, match, validation, store, proxy) for local frontend work |
| json-server | Instant REST CRUD from a single db.json with little ceremony |
| MSW | In-process request interception in the browser and Node tests (no separate server) |
Use http-mock-json when your UI talks to a real HTTP origin and you want file-based scenarios without wiring service workers or writing handlers in code.
Contributions are welcome. See CONTRIBUTING.md for setup, tests, and PR guidelines.
MIT — Alejandro Rodriguez Romero · Project page
- Project page
- GitHub Issues
- Security: SECURITY.md
If this project helps you, consider sponsoring:


