Build a Config-Driven API Mock Server
A mock server that reads route definitions from a JSON file with wildcard matching, latency injection, conditional responses, and a recording mode that replays real upstream traffic as fixtures.
Project goal
Build an API mock server driven entirely by a routes.json file, supporting path patterns with parameters, per-route latency injection, response selection by request body matcher, and a record mode that captures real upstream responses into fixtures the mock can replay.
Final features
- Path patterns like /users/:id/orders match and extract parameters into the response template
- Per-route latency and status injection reproduce slow and failing upstreams deterministically
- Body matchers select among multiple candidate responses for one route
- Record mode proxies to a real upstream and persists interactions as replayable fixtures
- CORS and default headers are configurable globally without repeating them per route
Architecture
A route table compiled at load time from routes.json, a matcher that walks the table on each request, a responder that renders templates with path parameters and canned state, and an optional recorder that wraps a real upstream. Recording writes fixtures in the same schema routes.json uses, so replay needs no translation layer.
Prerequisites
- Node.js 20 LTS or later
- Understanding of REST routing and path parameters
- Basic JSON templating familiarity
Dependencies
- node >=20 — HTTP server, file watching, and proxying for record mode
- vitest ^2.1.0 — Matcher unit tests and end-to-end mock behavior tests
Directory structure
api-mock/
├── src/
│ ├── server.js
│ ├── matcher.js
│ ├── responder.js
│ └── recorder.js
├── routes.json
├── fixtures/
│ └── users-list.json
├── test/
│ ├── matcher.test.js
│ └── server.test.js
└── package.json
Phases
1. Route table and matcher
Compile routes.json into segments, match request paths against patterns with :param extraction, and resolve the first matching route definition.
Files: src/matcher.js
function compile(pattern) {
const segments = pattern.split('/').filter(Boolean);
return segments.map((s) => (s.startsWith(':') ? { param: s.slice(1) } : { literal: s }));
}
function match(compiled, path) {
const parts = path.split('/').filter(Boolean);
if (parts.length !== compiled.length) return null;
const params = {};
for (let i = 0; i < compiled.length; i++) {
const seg = compiled[i];
if (seg.literal && seg.literal !== parts[i]) return null;
if (seg.param) params[seg.param] = decodeURIComponent(parts[i]);
}
return params;
}Expected behavior: Matcher tests pass for literal, param, mixed, and non-matching paths, including URL-encoded parameter values.
2. Responder with latency and matchers
Render the selected response with template substitution of path parameters, apply configured latency, and choose between candidate responses by request body matchers.
Files: src/responder.js
async function respond(route, params, req, res) {
const choice = pickCandidate(route.responses, req.body);
if (route.latencyMs) await delay(route.latencyMs);
const body = render(choice.body, params);
res.writeHead(choice.status ?? 200, { 'content-type': 'application/json', ...globals.headers });
res.end(JSON.stringify(body));
}Expected behavior: A route with two body-matched candidates returns the right one per request, and latency is observable in tests through an injected clock without real sleeps dominating the suite.
3. Record mode
Proxy requests to a real upstream, persist the request and response pair as a fixture, and emit a routes.json entry that replays it exactly.
Files: src/recorder.js
const upstream = await fetch(target + req.url, { method: req.method, headers: filtered, body });
const text = await upstream.text();
fixtures.push({
request: { method: req.method, path: req.url },
response: { status: upstream.status, body: safeJson(text) },
});Expected behavior: Recording a session against a stub upstream produces fixture files that, when replayed by the mock, return identical status codes and bodies.
4. Hot reload and CORS
Watch routes.json and swap the compiled table without restarting the server, and apply global CORS and default headers to every response.
Files: src/server.js
watch(routesFile, async () => {
const table = compileRoutes(JSON.parse(await readFile(routesFile, 'utf8')));
routeTable.swap(table); // reference swap keeps in-flight requests on the old table
});Expected behavior: Editing routes.json is reflected on the next request without a restart, and OPTIONS preflight requests answer with the configured CORS headers.
Testing
Run npx vitest. Matcher tests enumerate pattern shapes including encoded params and trailing slashes. Responder tests use an injected delay to assert latency injection deterministically and cover body-matched candidate selection. Server tests hit a live instance on an ephemeral port for status codes, headers, CORS preflight, and hot reload by rewriting routes.json mid-test. One round-trip test records against a stub upstream, replays through the mock, and asserts byte-identical responses.
Troubleshooting
Route with a URL-encoded parameter does not match
Decode each matched segment with decodeURIComponent inside the matcher, after the segment count check, so %2F cannot split a path.
Latency makes the test suite slow
Inject the delay function as a constructor dependency; tests pass a manual clock resolver and never await real time.
Hot reload serves half the old and new routes
Compile the entire table before swapping the reference; never push routes one by one into the live table.
Deployment
Run as a local dependency for frontend development (mocks near the code under test) or as a shared stub environment in CI. Mount routes.json and fixtures as volumes so images stay generic. In record mode, point the target at a staging environment with safe credentials, and commit the generated fixtures as reviewable JSON diffs.
Known limitations
- Stateful scenarios need explicit state blocks in routes.json; the mock has no built-in session or database semantics.
- Record mode captures single request-response pairs, so chained flows dependent on server-side state must be curated by hand after recording.
Project overview
Frontend and integration work stalls whenever the real API is unavailable, unfinished, or too slow to develop against. A config-driven mock server removes the dependency: routes, payloads, latencies, and failure states all live in one committed JSON file that behaves identically on every machine, in every CI run, on every teammate’s laptop.
The project stays honest by making the mock file a first-class artifact: recorded from real traffic when possible, reviewed in pull requests, and replayed byte-for-byte. A mock that drifts from reality is worse than no mock, so the record mode exists specifically to keep drift small, and the round-trip test exists to prove it stays small.
Four capabilities define the finished product:
- Path patterns with parameters —
/users/:id/ordersmatches and extracts:idinto the response template, including URL-encoded values. - Latency and status injection — per-route
latencyMsandstatusreproduce slow and failing upstreams deterministically, without a chaos toolkit. - Body-matched candidates — one route can carry multiple candidate responses, selected by matching the request body, which covers most conditional UI logic with zero server-side state.
- Record mode — the server proxies to a real upstream and persists each interaction as a fixture in exactly the schema
routes.jsonalready uses, so replay needs no translation layer.
The architecture is a straight line: a route table compiled at load time from routes.json, a matcher that walks the table on each request, a responder that renders templates with path parameters and canned state, and an optional recorder that wraps a real upstream. Global CORS and default headers apply once, not per route.
A representative route definition shows the whole surface in one place:
{
"globals": { "headers": { "x-powered-by": "mock" } },
"routes": [
{
"method": "GET",
"pattern": "/users/:id/orders",
"latencyMs": 120,
"responses": [
{ "match": { "body": { "status": "active" } }, "status": 200, "body": { "orders": [1, 2] } },
{ "status": 200, "body": { "orders": [] } }
]
}
]
}
Setup
Verify the prerequisites before starting: Node.js 20 LTS or later, an understanding of REST routing and path parameters, and basic familiarity with JSON templating.
The runtime footprint is deliberately one test dependency — the server itself has zero runtime packages, which keeps startup instant and the tool installable anywhere:
| Dependency | Version | Why it is here |
|---|---|---|
| node | >=20 | HTTP server, file watching, and proxying for record mode |
| vitest | ^2.1.0 | Matcher unit tests and end-to-end mock behavior tests |
Create the project and install:
mkdir api-mock && cd api-mock
npm init -y
npm install -D vitest@^2.1.0
Configure the environment in package.json — a start script that loads routes.json from the working directory, and the test runner:
{
"name": "api-mock",
"type": "module",
"scripts": { "start": "node src/server.js", "test": "vitest run" }
}
The directory structure mirrors the architecture: one module per responsibility, routes.json and fixtures/ at the root where they are easy to mount as volumes in CI.
Phase 1: Route table and matcher
The matcher compiles patterns once into literal and param segments, matches request paths against them with :param extraction, and resolves the first matching route definition.
// src/matcher.js
export function compile(pattern) {
const segments = pattern.split('/').filter(Boolean);
return segments.map((s) => (s.startsWith(':') ? { param: s.slice(1) } : { literal: s }));
}
export function match(compiled, path) {
const parts = path.split('/').filter(Boolean);
if (parts.length !== compiled.length) return null;
const params = {};
for (let i = 0; i < compiled.length; i++) {
const seg = compiled[i];
if (seg.literal && seg.literal !== parts[i]) return null;
if (seg.param) params[seg.param] = decodeURIComponent(parts[i]);
}
return params;
}
export function compileRoutes(config) {
return config.routes.map((r) => ({ ...r, compiled: compile(r.pattern) }));
}
export function resolve(table, method, path) {
for (const route of table) {
if (route.method !== method) continue;
const params = match(route.compiled, path);
if (params) return { route, params };
}
return null;
}
Three details decide whether this feels solid or flaky in daily use. The length check runs first, so /users/1/orders/2 never matches a three-segment pattern by accident. Decoding happens after the structural match — a parameter value containing %2F decodes to a slash inside the params object but cannot split the path into extra segments, which is the classic routing vulnerability this ordering closes. And first-match-wins for overlapping routes is a documented rule rather than a priority score: explicit ordering in the JSON file is easier to reason about, and easier to review in a pull request, than an invisible ranking algorithm.
Acceptance for this phase: matcher tests pass for literal, param, mixed, and non-matching paths, including URL-encoded parameter values, trailing-slash rejection, and method mismatches.
Phase 2: Responder with latency and matchers
The responder renders the selected response with template substitution of path parameters, applies configured latency, and chooses between candidate responses by request body matchers.
// src/responder.js
export function createResponder({ delay = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
async function respond(route, params, req, res, globals = {}) {
const body = await readBody(req);
const choice = pickCandidate(route.responses ?? [], body);
if (route.latencyMs) await delay(route.latencyMs);
const payload = render(choice.body, params);
res.writeHead(choice.status ?? 200, {
'content-type': 'application/json',
...(globals.headers ?? {}),
});
res.end(JSON.stringify(payload));
}
return { respond };
}
function pickCandidate(candidates, body) {
for (const c of candidates) {
if (!c.match) return c;
if (isSubset(c.match.body, body)) return c;
}
return candidates[candidates.length - 1] ?? { status: 500, body: { error: 'no response configured' } };
}
function render(value, params) {
return JSON.parse(JSON.stringify(value), (k, v) =>
typeof v === 'string' ? v.replace(/\{(\w+)\}/g, (_, name) => params[name] ?? v) : v);
}
The design decisions here are about determinism. Latency runs through an injected delay function: production passes a setTimeout wrapper, tests pass a resolver they control, so the suite asserts that a 120ms route actually delayed without ever sleeping 120 real milliseconds. Candidate selection is subset matching — the configured match.body must appear inside the request body — which is order-insensitive and tolerant of extra fields, exactly the property that makes matchers survive upstream payload evolution. Templates are plain {param} string interpolation revived through a JSON replacer, so nested response bodies work without a template engine dependency.
Acceptance for this phase: a route with two body-matched candidates returns the right one per request, and latency is observable in tests through the injected clock without real sleeps dominating the suite.
Phase 3: Record mode
Record mode proxies requests to a real upstream, persists the request and response pair as a fixture, and emits a routes.json entry that replays it exactly.
// src/recorder.js
import { writeFile, mkdir } from 'node:fs/promises';
export async function recordOne(target, req, body) {
const filtered = filterHopHeaders(req.headers);
const upstream = await fetch(target + req.url, {
method: req.method, headers: filtered, body: ['GET', 'HEAD'].includes(req.method) ? undefined : body,
});
const text = await upstream.text();
const fixture = {
request: { method: req.method, path: req.url },
response: { status: upstream.status, body: safeJson(text) },
};
await mkdir('fixtures', { recursive: true });
await writeFile(`fixtures/${slug(req)}.json`, JSON.stringify(fixture, null, 2));
return fixture;
}
function safeJson(text) {
try { return JSON.parse(text); } catch { return text; }
}
The point of the recorder is format convergence: it writes fixtures in the same schema the replay path consumes, so a recorded session becomes a replayable route with no translation step and no second format to maintain. Hop-by-hop headers (connection, transfer-encoding, content-length from the upstream) are stripped before proxying so the replayed response carries only what the client actually needs.
The discipline around record mode matters as much as the code: point it at a staging environment with safe credentials, record a session once, then commit the generated fixtures as reviewable JSON diffs. A fixture that changes in a pull request is reviewable evidence of API drift — the mock updating itself in lockstep with reality.
Acceptance for this phase: recording a session against a stub upstream produces fixture files that, when replayed by the mock, return identical status codes and bodies.
Phase 4: Hot reload and CORS
The server wires everything together, watches routes.json and swaps the compiled table without restarting, and applies global CORS and default headers to every response.
// src/server.js
import { createServer } from 'node:http';
import { watch } from 'node:fs';
import { readFile } from 'node:fs/promises';
export function createMockServer({ routesFile = 'routes.json', table, responder, globals }) {
let current = table;
watch(routesFile, async () => {
try {
const next = compileRoutes(JSON.parse(await readFile(routesFile, 'utf8')));
current = next; // reference swap keeps in-flight requests on the old table
} catch (err) {
console.error('routes.json reload failed, keeping previous table:', err.message);
}
});
return createServer(async (req, res) => {
if (req.method === 'OPTIONS') {
res.writeHead(204, corsHeaders(globals.cors));
return res.end();
}
const hit = resolve(current, req.method, urlPath(req.url));
if (!hit) { res.writeHead(404); return res.end(JSON.stringify({ error: 'no route' })); }
await responder.respond(hit.route, hit.params, req, res, globals);
});
}
Two failure modes shape this phase. A malformed edit to routes.json must keep the previous table and log loudly, never crash the server or serve from a half-parsed file — the reference swap guarantees in-flight requests finish against the table they started with, and the compile-before-swap rule guarantees the next request sees all the new routes or none of them. The CORS preflight path answers OPTIONS directly with the configured headers, so frontend teams can point a dev server proxy at the mock on day one.
Acceptance for this phase: editing routes.json is reflected on the next request without a restart, and preflight requests answer with the configured CORS headers.
Design trade-offs
Zero runtime dependencies over a routing framework. Express or Fastify would give mature routing, but the matcher here is sixty lines with exactly the semantics the project needs, and the zero-dependency constraint keeps the tool installable as a dev-only helper that starts in milliseconds. The cost is owning the edge cases — encoded slashes, trailing slashes, method matching — which is precisely why the matcher gets its own test file.
First-match-wins over priority scores. Ranking algorithms for overlapping routes exist in bigger frameworks; here, order in a JSON array is the priority, and it renders in diffs exactly as it behaves at runtime.
Subset body matchers over full JSONPath predicates. Full predicate languages buy expressiveness nobody uses twice; a shallow subset match covers the “empty cart vs loaded cart” class of conditional responses and stays readable in review. When a matcher stops fitting, the answer is two routes with different patterns, not a bigger matcher language.
Fixtures as JSON diffs over binary HAR files. HAR is the standard capture format, but it is verbose and machine-shaped. Fixture JSON in the replay schema is human-shaped: reviewers see the actual contract change in the pull request where the API drifted.
Testing strategy
Run npx vitest. The suite has three tiers, each deterministic by construction. Matcher tests enumerate pattern shapes — literal, param, mixed, non-matching, encoded params, trailing slashes — as pure functions with no I/O. Responder tests use the injected delay to assert latency injection without real sleeps and cover body-matched candidate selection including the no-match fallback. Server tests hit a live instance on an ephemeral port for status codes, headers, CORS preflight, and hot reload by rewriting routes.json mid-test.
The capstone is the round-trip test: record against a stub upstream, replay through the mock, and assert byte-identical responses. That single test enforces the project’s core promise — that recorded reality and replayed mock agree — and it is the one that fails first when any of the phases drift, which is exactly the property a test suite should have.
One structural choice in the test layout deserves a note: the matcher and responder tests never bind a port. They call match, resolve, and respond as plain functions with constructed request and response stubs, which keeps them in the milliseconds and lets the port-binding tier stay small — four or five server tests instead of forty. The port tier exists for the things only a real socket proves: that CORS preflight reaches the OPTIONS branch, that hot reload observes the file watcher, and that the 404 path actually writes a status code to the wire. Everything else is function-level evidence, and the split keeps the whole suite under two seconds while the routes file grows.
Extending the project
The seams are already in place for the obvious next steps. Stateful scenarios — login-then-dashboard flows — need explicit state blocks in routes.json with a counter or session key in the matcher; the recorder gives you the raw interactions, and curating them into a stateful script is deliberately left manual because hand-curated flows encode intent better than blind capture. GraphQL mocking is a single route that pattern-matches on the query field of the body rather than the URL. OpenAPI-driven route generation is a loader that compiles a spec into the same route table, and the table schema is already close enough that the generator is mostly renaming.
For deployment, run the mock as a local dependency for frontend development or as a shared stub environment in CI, mounting routes.json and fixtures/ as volumes so the image stays generic and the configuration stays reviewable.
One drift-prevention habit is worth adopting from day one: schedule a weekly job that replays the committed fixture corpus against the staging API and records the divergences. The mock’s value decays exactly as fast as its fixtures drift from the real responses, and a scheduled comparison turns invisible drift into a reviewable report — the same round-trip discipline the test suite enforces locally, extended to the environment the mock is supposed to imitate. Teams that skip this step end up with a mock that confidently serves last quarter’s API shape, which is worse than no mock because it passes every test.
Troubleshooting playbook
Three failures account for most of the debugging time this project will ever cost you, and each has a one-paragraph diagnosis.
A route with a URL-encoded parameter does not match. The path /files/report%2Ffinal should deliver the parameter value report/final, not fail to match a three-segment pattern. The cause is decoding at the wrong stage: if the matcher decodes before the segment count check, the encoded slash splits the path and the length comparison fails. The fix is ordering — split first, count segments, then run decodeURIComponent on each captured parameter. That ordering is already in the phase 1 code, and the regression test pins it with the exact encoded input above.
Latency makes the test suite slow. A routes file with three latencyMs: 500 routes means the server tests alone sleep a second and a half per run. The cause is the responder reaching for global setTimeout directly. The fix is the injected delay from phase 2: production wires a real timer, tests wire a resolver that records the requested delay and returns immediately, and the assertion checks the recorded value instead of measuring wall time. The suite stays in milliseconds no matter how much latency the configuration simulates.
Hot reload serves half old and half new routes. The symptom is a request matching a route that was just deleted, or missing one that was just added, intermittently. The cause is mutating the live table incrementally — pushing or splicing routes one by one while requests arrive. The fix is the compile-before-swap rule from phase 4: build the complete new table, then assign it in one statement, so every request sees one consistent generation and in-flight requests finish against the table they started with.