DEV Community

SEN LLC
SEN LLC

Posted on

Mocking an OpenAPI Spec From Scratch in 600 Lines of TypeScript

Mocking an OpenAPI Spec From Scratch in 600 Lines of TypeScript

A tiny Node CLI that reads an openapi.yaml and spins up a mock HTTP server with responses generated directly from your schemas. No prism, no swagger-parser, no openapi-sampler โ€” just Hono, yaml, and one afternoon.

Every frontend team hits the same wall. The design is signed off, the OpenAPI spec is in the repo, the backend has started work but won't have the first endpoint usable for another two weeks. The frontend wants to start. What they really want is a server that, given their openapi.yaml, returns responses that match the shape their code will eventually hit.

The usual answer is Prism. Prism is good. Prism is also 40 MB of npm deps and its own CLI and its own config and its own dynamic-vs-static argument surface. For the 80% case โ€” walk the spec, return example data for each operation โ€” it's overkill.

So I wrote the 80% myself. Called it openapi-mock, and it's about 600 lines of TypeScript.

๐Ÿ”— GitHub: https://github.com/sen-ltd/openapi-mock

screenshot

The whole thing is four pure modules and one side-effectful one. The CLI boots in under 50 ms, the Docker image is 140 MB (mostly Node), and it's got 64 vitest tests. Let me walk through what I learned about OpenAPI mocking by writing it instead of pulling it in.

The problem, in one screen of code

You pass it a spec:

paths:
  /pets:
    get:
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Pet'
components:
  schemas:
    Pet:
      type: object
      required: [id, name]
      properties:
        id:   { type: integer, example: 1 }
        name: { type: string,  example: Doug }
Enter fullscreen mode Exit fullscreen mode

You run:

openapi-mock petstore.yaml --port 3000
Enter fullscreen mode Exit fullscreen mode

You curl:

$ curl -s localhost:3000/pets | jq
[
  { "id": 1, "name": "Doug" }
]
Enter fullscreen mode Exit fullscreen mode

That's the happy path. Now let's dig into the parts that were not obvious before I wrote them.

Part 1: YAML โ†’ object, with $ref resolution

OpenAPI specs are $ref-heavy. If you want the generator to be pure ("give me a schema, I'll give you a value") you need to resolve refs once, up front, so the generator never sees a {$ref: "#/..."} in the wild.

yaml's parse() gives you a plain object โ€” strings, arrays, nested objects. The spec itself is JSON-compatible. So $ref resolution is just a walk with a lookup table:

export function resolveRefs(spec: OpenApiSpec): OpenApiSpec {
  const root = spec as unknown as Record<string, unknown>;

  function lookup(pointer: string, seen: Set<string>): unknown {
    if (!pointer.startsWith('#/')) return { $ref: pointer }; // external, leave alone
    if (seen.has(pointer)) return null;                      // break cycles
    const parts = pointer.slice(2).split('/').map(decodeSegment);

    let node: unknown = root;
    for (const p of parts) {
      if (node && typeof node === 'object' && p in (node as Record<string, unknown>)) {
        node = (node as Record<string, unknown>)[p];
      } else {
        return { $ref: pointer }; // unresolved, leave in place
      }
    }
    const nextSeen = new Set(seen);
    nextSeen.add(pointer);
    return walk(node, nextSeen);
  }

  function walk(node: unknown, seen: Set<string>): unknown {
    if (!node || typeof node !== 'object') return node;
    if (Array.isArray(node)) return node.map((n) => walk(n, seen));
    const obj = node as Record<string, unknown>;
    if (typeof obj.$ref === 'string') return lookup(obj.$ref, seen);
    const out: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(obj)) out[k] = walk(v, seen);
    return out;
  }

  return walk(spec, new Set()) as OpenApiSpec;
}
Enter fullscreen mode Exit fullscreen mode

Three things that bit me:

  1. JSON Pointer escapes. ~0 means ~, ~1 means /. So a path like /users/{id} becomes paths/~1users~1{id}/get when you reference it. It's rare in schemas but it will show up eventually, and the bug is invisible until it isn't.

  2. Cycles. Schemas can reference themselves โ€” Node { child: $ref Node } is a perfectly valid linked list. You need seen or your stack blows up. Putting seen at the lookup level instead of the walk level means sibling references to the same type work fine; only actual cycles get short-circuited.

  3. External refs are a rabbit hole. $ref: './common.yaml#/Pet' is a multi-file resolution problem that drags in file system access, URL fetching, base-path tracking, circular imports across files โ€” and the whole point of a mock tool is to stay small. I decided: if your spec uses external refs, dereference it first with redocly bundle or similar, then point the mock at the bundled file. External refs that sneak through get left in place and render as null. Honest degradation.

Part 2: Schema โ†’ example value

This is the heart of the tool. Given a schema, produce a JSON value. The rules, in priority order:

  1. If example is set, use it verbatim. Spec authors always win.
  2. Then examples (plural, OpenAPI 3.1 style โ€” array or named map).
  3. Then default.
  4. Then enum โ€” pick first (or random under --dynamic).
  5. Then type-based generation, honoring format hints and constraints.

Here's the core switch:

export function generate(schema: Schema | undefined, opts: GeneratorOptions = {}): unknown {
  if (!schema) return null;

  if (schema.example !== undefined) return schema.example;

  if (schema.examples) {
    if (Array.isArray(schema.examples) && schema.examples.length > 0) {
      return schema.examples[0];
    }
    if (!Array.isArray(schema.examples)) {
      const first = Object.values(schema.examples)[0];
      if (first && typeof first === 'object' && 'value' in first) return first.value;
    }
  }

  if (schema.default !== undefined) return schema.default;

  if (Array.isArray(schema.enum) && schema.enum.length > 0) {
    if (opts.dynamic) {
      const rng = opts.rng ?? Math.random;
      return schema.enum[Math.floor(rng() * schema.enum.length)];
    }
    return schema.enum[0];
  }

  // ... oneOf/anyOf/allOf handling, then type-based dispatch
  const t = pickType(schema);
  switch (t) {
    case 'string':  return genString(schema, opts);
    case 'integer':
    case 'number':  return genNumber(schema, opts);
    case 'boolean': return true;
    case 'array': {
      const itemSchema = schema.items ?? { type: 'string' };
      const count = Math.max(1, schema.minItems ?? 1);
      const out: unknown[] = [];
      for (let i = 0; i < count; i++) out.push(generate(itemSchema, opts));
      return out;
    }
    case 'object': {
      const obj: Record<string, unknown> = {};
      for (const [k, v] of Object.entries(schema.properties ?? {})) {
        obj[k] = generate(v, opts);
      }
      return obj;
    }
    default: return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

A few things worth pointing out:

example is a hammer. If the spec author wrote example: Doug, they want "Doug". Not "string", not a faker result, not the first enum value. Verbatim. This is the single most important rule and it's also the one every mock tool I tried either gets wrong or surprises you with.

oneOf / anyOf picks the first branch. I agonized about this. "Picking the right branch" needs a discriminator, and if the spec doesn't have one, guessing is worse than being consistently wrong โ€” guessing surprises you, and surprises in dev tools cost hours. First branch, every time, documented loudly. If you want variety, put a discriminator in your spec.

allOf is object property-merge. allOf: [A, B] means "has all of A's props and all of B's". The merge is shallow and gets both required arrays concatenated. Good enough.

Format hints under --realistic. Without --realistic, a string is "string" โ€” boring but it always validates against minLength: 0. With --realistic, formats matter:

function genString(schema: Schema, opts: GeneratorOptions): string {
  const fmt = schema.format;
  const rng = opts.rng ?? Math.random;

  if (opts.realistic) {
    if (fmt === 'email') return 'user@example.com';
    if (fmt === 'uuid') return randomUuid(rng);
    if (fmt === 'date') return new Date().toISOString().slice(0, 10);
    if (fmt === 'date-time') return new Date().toISOString();
    if (fmt === 'uri') return 'https://example.com';
    if (fmt === 'ipv4') return '192.0.2.1';
    // ...
  }
  // pad to minLength, truncate to maxLength
  let out = 'string';
  const min = schema.minLength ?? 0;
  const max = schema.maxLength ?? Number.POSITIVE_INFINITY;
  if (out.length < min) out = out.padEnd(min, 'x');
  if (out.length > max) out = out.slice(0, max);
  return out;
}
Enter fullscreen mode Exit fullscreen mode

I used 192.0.2.1 because it's in the TEST-NET-1 range RFC 5737 reserves for documentation, so it can never accidentally do something real. The tiny details like this are how you avoid "oops, my test suite pinged a real server" days.

A tiny deterministic RNG. For --seed to work, the randomness in --dynamic mode has to be controllable. I didn't want to pull in seedrandom:

export function makeRng(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (s + 0x6d2b79f5) >>> 0;
    let t = s;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
Enter fullscreen mode Exit fullscreen mode

That's Mulberry32. Twelve lines, high quality for its size, deterministic across platforms. Same seed in any Node 20 โ†’ same sequence. That's all I need for reproducible test runs.

Part 3: Installing routes into Hono

This is the one side-effectful module. Everything else is schema in, value out or text in, object out. The installer walks the paths dictionary and mounts a handler per operation:

export function createApp(spec: OpenApiSpec, opts: InstallerOptions = {}): Hono {
  const app = new Hono();

  for (const [rawPath, pathItem] of Object.entries(spec.paths)) {
    const honoPath = openapiToHonoPath(rawPath); // /pets/{id} โ†’ /pets/:id
    const pathLevelParams = pathItem?.parameters ?? [];

    for (const method of METHODS) {
      const op = pathItem?.[method];
      if (!op) continue;
      installOne(app, method, honoPath, rawPath, op, pathLevelParams, opts);
    }
  }

  app.all('*', (c) => c.json(
    { error: 'not in spec', path: c.req.path, method: c.req.method },
    404,
  ));

  return app;
}

export function openapiToHonoPath(p: string): string {
  return p.replace(/\{([^/}]+)\}/g, ':$1');
}
Enter fullscreen mode Exit fullscreen mode

Three notes:

The path translation. OpenAPI uses {petId}, Hono uses :petId. One regex. The regex specifically excludes / and } inside the match so accidentally-complex paths don't eat too much.

The catch-all 404. If the client hits a path not in the spec, we return a JSON 404 with the same shape as other errors. No HTML error page, no Hono default. Clients should never have to branch on content-type. This also explicitly does not add a /health endpoint โ€” it's the user's spec, nothing more. Testing this was a separate case because "I accidentally added a debug endpoint" is how mock tools lose trust.

The handler closure captures the response schema. I was tempted to do the response generation at startup โ€” mount a static JSON blob per route. Faster, but kills --dynamic and --seed. So each handler keeps the schema around and re-generates per request. At ~30ยตs per small schema walk, nobody notices.

Part 4: --validate as a contract-test mode

Here's the use case that surprised me: during integration tests, the frontend wants the mock to reject bad requests. If the frontend's HTTP client sends {"name": "widget"} when the spec requires {name, qty}, the test should fail loudly, not silently get a happy response.

So --validate turns on request validation. The pure validator is in src/validator.ts and checks:

  • wrong type
  • missing required
  • string minLength / maxLength
  • number minimum / maximum
  • enum membership
  • oneOf / anyOf (accepted if any branch matches)
  • recursive into object properties and array items

Mismatches return a 422 with a structured list:

{
  "error": "request does not match spec",
  "details": [
    { "path": "qty", "message": "required" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

This turns the mock into a lightweight contract test. Run your frontend tests against it, and any drift between the client's request shape and the spec fails the suite.

Parameters get validated too. Query and path params are always strings on the wire, so before running them through the validator you coerce per schema type:

function coerce(v: string, schema: Schema): unknown {
  const t = Array.isArray(schema.type) ? schema.type[0] : schema.type;
  if (t === 'integer') {
    const n = Number(v);
    return Number.isInteger(n) ? n : v;
  }
  if (t === 'number') {
    const n = Number(v);
    return Number.isNaN(n) ? v : n;
  }
  if (t === 'boolean') {
    if (v === 'true') return true;
    if (v === 'false') return false;
  }
  return v;
}
Enter fullscreen mode Exit fullscreen mode

Without this step, every integer query param would fail with "expected integer, got string", which is correct in some theoretical sense but useless in practice.

Tradeoffs, loudly

Things openapi-mock does not do, on purpose:

  • No OpenAPI 2.0 (Swagger). Only 3.x. The ecosystem has moved.
  • No external $refs. Bundle your spec first. This removed about 300 lines of file/URL handling and a whole class of bugs.
  • No x-faker / x-example-generator / x-mock-* vendor extensions. Deliberately. Once you accept one of these, your spec is no longer portable and every mock tool has its own dialect. If you want faker, post-process the generator output.
  • oneOf / anyOf picks the first branch. Documented loudly. If you need variety, add a discriminator.
  • No real response-validation against the spec. Our responses always match because we generate them; we don't check inbound responses from elsewhere.
  • Path parameters are validated by type, not by format. A format: uuid string is accepted even if it's not a valid UUID. This matches usual OpenAPI semantics where format is annotation, not assertion.

That list was deliberate. Each bullet removed ~50 lines of code and one class of weird behavior.

Try it in 30 seconds

docker build -t openapi-mock .

# run with a spec:
docker run --rm -p 3000:3000 -v $(pwd):/work openapi-mock /work/my-spec.yaml --port 3000

# or the bundled petstore:
docker run --rm -p 3000:3000 \
  -v $(pwd)/tests/fixtures:/work \
  openapi-mock /work/petstore.yaml --port 3000

curl -s localhost:3000/pets | jq
# [{ "id": 1, "name": "Doug", "tag": "dog" }]
Enter fullscreen mode Exit fullscreen mode

Final image is 140 MB (mostly Node itself), non-root user, ENTRYPOINT ["node", "dist/main.js"] so the spec path is just a positional argument.

The bigger lesson

The reason this was fun to write โ€” and worth the weekend โ€” is that most of the OpenAPI ecosystem assumes you need an enormous library to do anything useful. Once I wrote the recursive walk, I realized most OpenAPI tooling is that walk plus a renderer. Validators are that walk plus an assertion. Code generators are that walk plus templates. Mock servers are that walk plus route installation.

The walk is 30 lines. Adding one purpose on top of it is maybe another 100. Everything else is paperwork. And once you own the walk, you can customize it exactly for your use case instead of fighting a framework you didn't write.

The code is on GitHub. It's MIT. Fork it, gut the generator, replace the route installer โ€” the structure stays the same and the sharp edges are already filed down.

Top comments (0)