@codewithagents/openapi-msw
@codewithagents/openapi-msw generates MSW v2 mock handlers with seeded Faker data from your OpenAPI 3.1 spec: point it at a spec, get a type-safe handlers.ts you can drop into a browser worker or a Node mock server instantly.
- One handler per operation: every
get,post,put,patch, anddeleteoperation becomes anhttp.<method>(path, resolver)entry in an exportedhandlersarray. - Seeded, deterministic mocks:
faker.seed(<seed>)(default42) is written into the generated file, so every run with the same spec and seed produces identical mock values. Change the seed, re-generate, get a new but still stable dataset. - MSW v2 path syntax: OpenAPI
{id}path params are rewritten to MSW colon style:id. Every param in a path is converted (/orgs/{orgId}/repos/{repoId}becomes/orgs/:orgId/repos/:repoId). - Works in the browser and in Node: handler paths are anchored to an origin (the
*wildcard by default, or yourbase_url), so they match the absolute URLs Node'sfetchsends undersetupServeras well as browser requests undersetupWorker. - Schema-aware bodies: each handler returns a Faker-generated body shaped from the operation's 2xx JSON response schema, honouring string formats, enums, arrays, and nested objects.
- Opt-in fixture builders:
fixtures: truealso writesfixtures.ts, one typeda<Name>(overrides?)builder per component schema for inline, per-test handlers. See Fixture builders. - Prettier-clean output: the generated
handlers.tspassesprettier --checkout of the box. - OpenAPI 3.x: 3.1.x is the primary target; 3.0.x is supported in practice (8 of the 13 showcase specs are 3.0.x and all compile under
tsc --strict).
Install
pnpm add -D @codewithagents/openapi-msw
# or
npm install -D @codewithagents/openapi-msw
The generated handlers.ts imports from msw and @faker-js/faker directly, so both are peer dependencies you install yourself:
pnpm add msw @faker-js/faker
| Peer dependency | Range |
|---|---|
msw | ^2.0.0 || ^3.0.0 |
@faker-js/faker | ^10.5.0 |
This package depends on openapi-zod-ts for spec parsing and shared config plumbing, but it does not require you to generate models or a client. Run it standalone or alongside the rest of the suite.
Quick start
1. Create openapi-msw.config.json in your project root:
{
"input_openapi": "./spec/api.json",
"output": "./src/mocks",
"seed": 42,
"max_array_items": 3,
"depth_cap": 30
}
Only input_openapi and output are required. The three numeric fields are optional and default to the values shown above.
2. Run the generator:
npx openapi-msw
# or point at a config elsewhere:
npx openapi-msw --config ./config/openapi-msw.config.json
The CLI takes no positional arguments. Available flags are --config <path>, --help / -h, and --version / -v. When --config is given, relative paths in the config resolve from the config file's directory.
3. handlers.ts appears in your output directory:
The output directory is created recursively if it does not exist. Wire the handlers into MSW as usual:
// browser worker
import { setupWorker } from 'msw/browser'
import { handlers } from './src/mocks/handlers'
export const worker = setupWorker(...handlers)
// Node / test server
import { setupServer } from 'msw/node'
import { handlers } from './src/mocks/handlers'
export const server = setupServer(...handlers)
Generated output
Given a task API with GET /api/v1/tasks (returns a paged list) and DELETE /api/v1/tasks/{id} (204 No Content), the generated handlers.ts looks like this:
// This file is auto-generated by @codewithagents/openapi-msw, do not edit
import { http, HttpResponse } from 'msw'
import { faker } from '@faker-js/faker'
faker.seed(42)
export const handlers = [
http.get('*/api/v1/tasks', () =>
HttpResponse.json({
items: Array.from({ length: 3 }, () => ({
id: faker.string.uuid(),
title: faker.lorem.word(),
status: faker.helpers.arrayElement(['pending', 'in_progress', 'done']),
priority: faker.number.int({ min: 1, max: 1000 }),
assigneeEmail: faker.internet.email(),
createdAt: faker.date.recent().toISOString(),
})),
total: faker.number.int({ min: 1, max: 1000 }),
})
),
http.delete('*/api/v1/tasks/:id', () => HttpResponse.json(null, { status: 204 })),
]
A committed, drift-checked sample lives at packages/integration/generated/handlers.ts, generated from packages/integration/spec/api.json.
Config reference
openapi-msw.config.json:
| Field | Required | Default | Description |
|---|---|---|---|
input_openapi | Yes | n/a | Path to the OpenAPI 3.x spec (JSON or YAML) |
output | Yes | n/a | Directory to write handlers.ts (created recursively) |
seed | No | 42 | Passed to faker.seed() in the generated file. Must be an integer >= 0 |
max_array_items | No | 3 | Length of arrays in generated mocks. Must be an integer >= 1 |
depth_cap | No | 30 | Schema recursion depth before bailing out to null. Must be an integer >= 1 |
base_url | No | * wildcard origin | Origin (optionally with a base path) prefixed to every handler path, e.g. "https://api.test" gives https://api.test/api/v1/tasks. Trailing slashes are dropped. Must be a non-empty string |
| fixtures | No | false | When true, also write fixtures.ts (per-schema fixture builders). Must be a boolean |
| models_import | No | "./models.js" | Module specifier fixtures.ts imports the model types from (the openapi-zod-ts models.ts). Must be a non-empty string |
The optional keys are validated only when present. Every key except projects can also be set per entry inside a projects array.
Multi-spec generation. Replace the top-level fields with a projects array to generate one handlers.ts per spec. Mixing a projects array with top-level input_openapi / output throws.
{
"projects": [
{ "input_openapi": "./specs/public.json", "output": "./src/mocks/public" },
{ "input_openapi": "./specs/admin.json", "output": "./src/mocks/admin" }
]
}
Fixture builders (fixtures: true)
handlers.ts and fixtures.ts solve two different problems:
| File | Role | Use it for |
|---|---|---|
handlers.ts | Mock layer. A full handler set, one per operation, with one seeded dataset | A dev worker (setupWorker), a demo backend, or the baseline setupServer(...handlers) that every test starts from |
fixtures.ts | Per-test data. One builder per component schema that returns a complete, valid value | Inline handlers inside a single test (server.use(...)) where the test controls the exact response |
Component tests that declare inline handlers need complete objects of the schema types. Hand-written fixtures drift from the spec; generated builders do not. Turn them on with "fixtures": true, and fixtures.ts is written next to handlers.ts:
import { http, HttpResponse } from 'msw'
import { server } from './mocks/server'
import { aTask } from './mocks/fixtures.js'
it('shows the task title', async () => {
const TASK = aTask({ title: 'Write docs', status: 'done' })
server.use(http.get('*/api/v1/tasks/:id', () => HttpResponse.json(TASK)))
// render, then assert on TASK.title ...
})
For every schema in components.schemas the generator emits:
export function aTask(overrides?: Partial<Task>, variant = 0): Task
aTask.full = (overrides?: Partial<Task>, variant = 0): Task => ...
- Naming.
a<Name>, oran<Name>when the type name starts with a vowel (anOwner,anUpdateTaskRequest).<Name>is the exact type nameopenapi-zod-tsemits inmodels.ts. - Typed against your models.
fixtures.tsdoesimport type { ... } from './models.js'(theopenapi-zod-tsoutput).overridesisPartial<Name>, so a wrong value or an unknown key is a compile error. Pointmodels_importelsewhere when the models do not live next to the mocks, e.g."models_import": "../api/models.js". - Shallow merge.
aTask({ owner })replacesownerwholesale; it does not merge into a generatedowner. - Required-only by default.
aTask()fills only the properties listed inrequired(recursively, in nested objects too).aTask.full()fills optional properties too, on the object itself and on objects built by one nested builder level (inaPet.full()a nestedowneris full as well; anything below it is required-only), so.full()stays bounded on large, deeply connected specs.writeOnlyproperties are always left out, matching the read-shape model type. - Deterministic per call. Each call reseeds a private Faker instance (separate from the global
fakerthathandlers.tsuses) from(seed, builder, variant). The same call always returns the same value, regardless of which builders ran before it or in which order tests run, so snapshots and assertions stay stable. Relative dates (date-time/datefields) are computed against a fixed reference date (2025-01-01T00:00:00.000Z) rather than "now", so they are stable across runs and days too. Pass a differentvariantto get a different, still deterministic value, for example two distinct items in one list:[aTask({}, 0), aTask({}, 1)]. - Nested schemas reuse their builders. A property that
$refs another component schema calls that schema's builder (anOwner(...), oranOwner.full(...)inside.full()within the depth above) with a variant drawn from the caller's seeded Faker, so nested values are deterministic yet array items are distinct from each other, andfixtures.tsstays linear in the number of schemas. A reference back into a cycle (a self-reference such asTreeNode.children, orA -> B -> A) is guarded: the recursion goes one nested level deep, then yieldsnull, so every builder terminates. - Non-object schemas (enums, arrays, primitives) get a value builder without overrides:
aTaskStatus(variant?)andaTaskStatus.full(variant?). - Values are best-effort. Generated values follow the same Faker rules as
handlers.ts(see below); they are cast to the model type rather than proven against it, so put values a test depends on inoverrides.
How bodies are generated
The resolver body for each handler is built from the operation's selected 2xx JSON response schema. Faker calls are chosen per schema node:
| Schema | Generated value |
|---|---|
string, format email | faker.internet.email() |
string, format uuid | faker.string.uuid() |
string, format date-time | faker.date.recent().toISOString() |
string, format date | faker.date.recent().toISOString().slice(0, 10) |
string, format uri / url | faker.internet.url() |
string (other) | faker.lorem.word() |
integer, int32, int64 | faker.number.int({ min: 1, max: 1000 }) |
number (other) | faker.number.float({ min: 0, max: 1000, fractionDigits: 2 }) |
boolean | faker.datatype.boolean() |
enum | faker.helpers.arrayElement([...literals]) |
array | Array.from({ length: max_array_items }, () => item) |
object with additionalProperties | Object.fromEntries(...) with two generated entries |
null / unresolvable | null |
Non-obvious behavior
- The seed lives in the output, not the runtime.
faker.seed(<seed>)is the first executable line of the generated file. To change mock values, re-generate with a differentseed. Two runs of the same spec and seed produce byte-identical handlers. - Handler URLs are origin-anchored. A bare relative matcher such as
'/api/v1/tasks'is resolved by MSW againstlocation.href, so in Node (setupServerfrommsw/node) it never matches an absolute request likefetch('http://localhost/api/v1/tasks'). Generated handlers therefore always carry an origin:'*/api/v1/tasks'by default (MSW's wildcard: any origin, browser and Node alike), or'<base_url>/api/v1/tasks'whenbase_urlis set. Setbase_urlwhen you want handlers to match only one origin, or when your server URL carries a base path the spec'spathsdo not include (for example"https://api.test/v2"). Output generated before this change used bare relative paths; re-generate to pick up the fix. - 2xx selection order. The generator prefers a
200response, then201, then the first 2xx code that declares anapplication/jsonbody. A200emitsHttpResponse.json(body)with no status argument; any other 2xx (for example201) emitsHttpResponse.json(body, { status: N }). - No JSON body. If the chosen 2xx response has no
application/jsonschema, the handler emitsHttpResponse.json(null, { status: N })(status from the first 2xx code, defaulting to204). If that response instead declares a non-JSON content type (text/plain,text/csv,application/octet-stream, image, and so on) the handler emitsnew HttpResponse(null, { status: N })rather than falsely claimingapplication/json. Onlyapplication/jsoncontent is mocked with a faker body. allOfis merged;anyOf/oneOfis not unioned. Properties of allallOfmembers are merged into one object and theirrequiredarrays concatenated. ForanyOf/oneOfthe generator picks the first concrete (non-$ref) member, or resolves the first member if all are refs.depth_capbounds recursion. The depth counter increments only on$refresolution and onallOf/anyOf/oneOfresolution steps. Array nesting and plain property access do not count. When depth exceedsdepth_cap, the node emitsnull /* depth cap reached */.- Circular refs terminate cleanly. A
$refback to an already-visited schema name emitsnull /* circular ref: depth cap reached */, distinct from the depth-cap comment, so self-referential schemas do not loop. - Type unions and missing types. For an OpenAPI 3.1
typearray, the generator uses the first element. A schema with notypebut withpropertiesis treated as an object; otherwise it emitsnull. - Path-rooted guard rails.
input_openapiandoutputpaths that resolve into system directories (/etc,/usr,/bin,C:\Windows, and similar) are rejected as likely misconfiguration. This is a guard rail, not a security sandbox.
Programmatic API
The package also exports a programmatic surface for building tooling around it:
import {
generateHandlers,
generateFixtures,
generate,
loadConfig,
loadConfigs,
} from '@codewithagents/openapi-msw'
// Build the handlers file in memory from a parsed spec.
const { filename, content } = generateHandlers(spec, {
seed: 42,
maxArrayItems: 3,
depthCap: 30,
baseUrl: 'https://api.test', // optional; omit for the '*' wildcard origin
})
// filename === 'handlers.ts'
// Build fixtures.ts in memory.
const fixtures = generateFixtures(spec, {
seed: 42,
maxArrayItems: 3,
depthCap: 30,
modelsImport: './models.js',
})
// fixtures.filename === 'fixtures.ts'
generate runs the full config-driven flow (load config, parse spec, write files). loadConfig / loadConfigs read and validate a config file. The exported types are MswConfig, HandlerGenOptions, FixtureGenOptions, and GeneratedFile.
Part of the openapi-zod-ts suite
openapi-msw is one of five published packages in the codewithagents/openapi-zod-ts monorepo. Four generators emit files from a single OpenAPI spec, and one runtime helper maps API errors in app code:
| Package | Role |
|---|---|
openapi-zod-ts | Generator: TypeScript models, a native fetch client, and Zod schemas |
@codewithagents/openapi-server | Generator: framework-agnostic service interface plus an optional hono | express | fastify | none router (default none) |
@codewithagents/openapi-react-query | Generator: typed React Query v5 hooks over the generated client |
@codewithagents/openapi-msw | Generator: this package, MSW v2 handlers with seeded Faker mock data |
@codewithagents/api-errors | Runtime helper: map backend error responses to form-field errors |
See the full-stack reference app (Fastify + React + react-query, with a browser e2e suite) for these packages working together end to end.
License
MIT