Generating Mock Data from an OpenAPI Spec
Hand-writing a handler per endpoint is fine for six endpoints and hopeless for a hundred and sixty. This page derives the whole mock surface from the OpenAPI document — every path, method, status and schema — and layers hand-written overrides only where real behaviour is needed.
Context: the spec already contains the answer
An OpenAPI document describes every operation, its parameters, its response codes and the schema of each response body. Everything a basic mock needs is already there, written by the team that owns the API. Re-typing it into handlers is duplication with a drift problem attached.
Generation gives three things at once. Completeness: an endpoint nobody remembered still gets a handler, so it cannot silently reach the network. Validity: bodies are generated from the declared schema, so they cannot contradict it. Currency: regenerating is one command, so keeping up with the spec is cheap enough that people actually do it.
What generation cannot give is behaviour. A generated POST /orders returns a plausible order; it does not remember that you created one. That gap is filled by layering, not by editing the generated output.
Solution
1. Dereference the document first
$ref pointers make the walk far harder than it needs to be. Resolve them once, up front:
// scripts/generate-handlers.ts
import SwaggerParser from '@apidevtools/swagger-parser';
import { writeFileSync } from 'node:fs';
import type { OpenAPIV3 } from 'openapi-types';
const spec = (await SwaggerParser.dereference('schemas/openapi.yaml')) as OpenAPIV3.Document;
dereference also validates as a side effect, so a malformed document fails here rather than producing half a handler file.
2. Emit one handler per operation
// scripts/generate-handlers.ts (continued)
const METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const;
interface Emitted { method: string; path: string; status: number; body: unknown; }
const emitted: Emitted[] = [];
for (const [path, item] of Object.entries(spec.paths ?? {})) {
for (const method of METHODS) {
const op = (item as OpenAPIV3.PathItemObject)[method];
if (!op) continue;
// The first 2xx the operation declares is the success case we mock.
const entry = Object.entries(op.responses ?? {}).find(([code]) => code.startsWith('2'));
if (!entry) {
emitted.push({ method, path, status: 501, body: { error: 'not_specified', operation: `${method.toUpperCase()} ${path}` } });
continue;
}
const [code, response] = entry as [string, OpenAPIV3.ResponseObject];
const media = response.content?.['application/json'];
emitted.push({
method,
path,
status: Number(code),
// A declared example beats a generated value: somebody chose it for a reason.
body: media?.example ?? generateFromMedia(media),
});
}
}
The 501 branch is the important detail. An operation with no described success response is a gap in the specification, and emitting nothing would let that request fall through to the real network. An explicit 501 naming the operation turns the gap into a message somebody can act on.
Converting the OpenAPI path template to the mock’s own syntax is the last transformation:
// OpenAPI writes /orders/{orderId}; MSW matches /orders/:orderId
const toMswPath = (p: string) => p.replace(/\{([^}]+)\}/g, ':$1');
const file = `// GENERATED — do not edit. Run: npm run generate:handlers
import { http, HttpResponse } from 'msw';
export const generatedHandlers = [
${emitted
.map(
(e) => ` http.${e.method}('\${BASE}${toMswPath(e.path)}', () =>
HttpResponse.json(${JSON.stringify(e.body)}, { status: ${e.status} })),`
)
.join('\n')}
];
`;
writeFileSync('src/mocks/generated-handlers.ts', file);
console.log(`generate-handlers: ${emitted.length} handler(s)`);
3. Layer the overrides on top
// src/mocks/handlers.ts
import { generatedHandlers } from './generated-handlers';
import { orderHandlers } from './overrides/orders';
import { paymentHandlers } from './overrides/payments';
// Overrides FIRST: MSW matches in array order, so the hand-written ones win.
export const handlers = [
...orderHandlers,
...paymentHandlers,
...generatedHandlers,
];
Ordering is the entire mechanism, and getting it backwards is the most common mistake — with the generated set first, every override is unreachable and nothing warns you. A comment at the point of ordering is worth more than a paragraph in a README.
4. Keep regeneration cheap and visible
{
"scripts": {
"generate:handlers": "tsx scripts/generate-handlers.ts",
"check:handlers": "npm run generate:handlers && git diff --exit-code src/mocks/generated-handlers.ts"
}
}
# .github/workflows/mock-spec.yml
name: Mock spec sync
on:
pull_request:
paths: ["schemas/openapi.yaml", "scripts/generate-handlers.ts"]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20", cache: "npm" }
- run: npm ci
- run: npm run check:handlers
check:handlers fails when the committed handlers do not match what the current spec generates, which is exactly the condition you want caught in review rather than discovered in a month.
Verification
npm run generate:handlers
npm run check:handlers # fails if the committed output is stale
# Every operation in the spec has a handler
node -e "
const spec = require('./schemas/openapi.json');
const ops = Object.values(spec.paths).flatMap(p => Object.keys(p)).length;
const src = require('fs').readFileSync('src/mocks/generated-handlers.ts','utf8');
const gen = (src.match(/http\./g) || []).length;
console.log(ops === gen ? 'coverage: complete' : \`coverage: \${gen}/\${ops}\`);
"
If coverage is short, some operation is being skipped — usually a method the METHODS list omits, such as head or options.
Gotchas and edge cases
-
Editing the generated file is silently undone. The next regeneration overwrites it, and because the change was in a file nobody reviews closely, the loss is discovered weeks later as a mysterious behaviour regression. The
// GENERATED — do not editbanner helps; a lint rule that fails on a diff between the committed file and a fresh generation helps more. -
exampleandexamplesare different keywords. A media type can carry a singleexampleor a map of namedexamples, and a generator that only reads the first ignores every carefully written case in the second. Read both, and prefer a named example matching the operation over a generated value. -
A spec fetched at build time makes the build depend on the provider. If the document is downloaded during
npm run generate:handlers, an outage in the provider’s docs breaks your build. Vendor a copy into the repository, refresh it deliberately, and let the drift check tell you when it is behind — the approach set out in versioning mock contracts alongside API releases.
What the generated set is and is not responsible for
A generated handler set covers the whole API surface, which makes it tempting to treat it as the mock. It is better understood as a floor: the guarantee that no endpoint is unhandled, on top of which the interesting behaviour is written by hand.
It is responsible for coverage. Every operation in the specification has something behind it, so a request to a forgotten endpoint cannot reach the network. That alone justifies generation on any API past a few dozen operations.
It is responsible for shape. Bodies come from the declared schemas, so a client written against the generated mock is written against the contract rather than against somebody’s recollection of it.
It is responsible for currency. Regeneration is one command, so keeping up with the specification is cheap enough to actually happen — which is the difference between a mock that reflects the API and one that reflects the API as of eight months ago.
It is not responsible for behaviour. Nothing generated remembers a previous request, enforces a business rule, or returns the error a particular test needs. Those are hand-written, and expecting the generated set to grow into them is how a generator ends up with configuration options nobody can follow.
It is not responsible for realism. Generated bodies are schema-valid and distributionally arbitrary. Where realism matters — a screenshot, a demo, a layout test — hand-authored or seeded data does better.
Keeping the two responsibilities separate is what makes the arrangement stable: the generated file is never edited and is always regenerable, while the override file is small, hand-written and reviewed. When the override file starts growing faster than the specification changes, that is usually a signal that the specification is under-describing the API rather than that the generator needs more features.
Related
- Generating Mock Data from JSON Schema — the schema walk this generator calls into
- Detecting OpenAPI Contract Drift in CI — catching a specification change before it reaches production
- Writing Custom MSW Response Resolvers — the hand-written overrides that layer on top
← Back to Schema-Driven Data Generation