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.

Generated base, hand-written top Two layers of handlers. The lower layer is generated from the OpenAPI document and covers every operation with schema-valid but stateless responses. The upper layer holds a small number of hand-written handlers for operations that need real behaviour, such as a stateful create or a deliberate error. Requests match the upper layer first and fall through to the generated layer, so coverage is complete and intent stays visible. Request any operation Hand-written overrides — matched first POST /orders — writes to the shared store and returns the created record GET /payments/{id} — the 402 branch a test needs and the spec does not describe Generated base — everything else one handler per path and method, from the declared success schema schema-valid, stateless, regenerated whenever the specification changes never edited by hand — the next regeneration would discard the edit falls through

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.

From operation to handler, field by field Four rows mapping parts of an OpenAPI operation onto parts of the generated handler. The path template becomes the matcher with braces converted to colons. The method becomes the handler verb. The first two-hundred-level response code becomes the returned status. The response content schema, or its declared example when present, becomes the body. A fifth row notes that a missing success response becomes an explicit 501. In the specification In the generated handler paths./orders/{orderId} http.get('.../orders/:orderId', …) the method key (get, post, …) the handler verb first 2xx response code { status: 200 } content example, else the schema the response body no 2xx described at all 501 naming the operation

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 edit banner helps; a lint rule that fails on a diff between the committed file and a fresh generation helps more.

  • example and examples are different keywords. A media type can carry a single example or a map of named examples, 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 generation cannot give you Three categories that always need hand-written handlers on top of the generated set: anything stateful, anything whose response depends on a previous request, and any deliberate error case the specification does not describe. Generation covers breadth; overrides cover behaviour. Stateful writes a create that a later read must return generation is stateless by construction Sequence-dependent responses step two differs because of step one the specification describes neither step Deliberate error cases a 402 the specification never mentions it is not in the document to generate from Expect roughly one override per ten generated handlers; a much higher ratio means the specification is under-described.

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.

← Back to Schema-Driven Data Generation