This document describes how the OpenAPI specification (docs/openapi.yaml) is generated, how to regenerate it after making changes, and how to verify the result.
Audience: Contributors adding or modifying API endpoints.
# Regenerate the spec from current Zod schemas
npm run generate:openapi
# Verify the spec matches registered routes
npx tsx scripts/openapi-drift.jsThe OpenAPI spec is not hand-written. It is generated programmatically from the Zod schemas in src/schemas/ using @asteasolutions/zod-to-openapi.
The generation script is scripts/generate-openapi.ts. It:
- Imports all schemas from
src/schemas/index.ts. - Registers each schema as an OpenAPI component via
registry.registerComponent(). - Registers each route with its request/response schemas via
registry.registerPath(). - Generates the OpenAPI 3.0.0 document.
- Writes the result to
docs/openapi.yaml.
| File | Purpose |
|---|---|
scripts/generate-openapi.ts |
Generation script — all route registrations live here |
src/schemas/index.ts |
Barrel export for all Zod schemas |
src/schemas/*.ts |
Individual schema modules (trust, bond, attestations, etc.) |
docs/openapi.yaml |
The generated output (checked in, do not edit by hand) |
scripts/openapi-drift.js |
Drift detector that compares registered routes against the spec |
After any change to Zod schemas or route registrations, run:
npm run generate:openapiThis overwrites docs/openapi.yaml with a fresh copy. The command is deterministic — running it twice with the same input produces identical output.
Regenerate the spec whenever you:
- Add a new endpoint — register it in
scripts/generate-openapi.tsand add its Zod schemas insrc/schemas/. - Modify a request or response shape — update the Zod schema, then regenerate.
- Remove an endpoint — remove the registration from
scripts/generate-openapi.tsand regenerate.
Create or extend a schema file in src/schemas/. For example, to add a new GET /api/widgets/:id endpoint:
// src/schemas/widgets.ts
import { z } from 'zod';
export const widgetParamsSchema = z.object({
id: z.string().uuid(),
});
export const widgetResponseSchema = z.object({
id: z.string().uuid(),
name: z.string(),
createdAt: z.string().datetime(),
});Export the new schemas from src/schemas/index.ts.
Open scripts/generate-openapi.ts and add a registry.registerPath() call:
import * as schemas from '../src/schemas/index.js';
registry.registerPath({
method: 'get',
path: '/api/widgets/{id}',
summary: 'Get a widget',
description: 'Returns a single widget by ID.',
tags: ['Widgets'],
request: { params: schemas.widgetParamsSchema },
responses: {
200: {
description: 'Widget found',
content: { 'application/json': { schema: schemas.widgetResponseSchema } },
},
404: {
description: 'Widget not found',
content: { 'application/json': { schema: z.any() } },
},
},
});Add the route to the routes array in scripts/openapi-drift.js:
{ path: '/api/widgets/:id', method: 'get' },Note the drift detector uses Express-style :param syntax, while the OpenAPI generator uses {param} syntax.
npm run generate:openapi
npx tsx scripts/openapi-drift.jsBoth commands must pass before pushing.
scripts/openapi-drift.js compares the routes registered in its getRegisteredRoutes() function against the paths present in docs/openapi.yaml.
The script exits non-zero if:
- A route in the code is missing from the spec (you forgot to regenerate).
- A route in the spec does not exist in the code (stale spec or orphaned registration).
- The spec file is empty or malformed.
Admin routes under /api/admin/* are excluded from the public contract check.
npx tsx scripts/openapi-drift.jsExpected output on success:
✅ No OpenAPI contract drift detected. All routes match spec.
# Preview with Redocly
npx @redocly/cli preview-docs docs/openapi.yaml
# Or paste into https://editor.swagger.ioBefore pushing any PR that touches API routes or schemas:
- Run
npm run generate:openapito regeneratedocs/openapi.yaml. - Run
npx tsx scripts/openapi-drift.jsto confirm no drift. - Run
npx tsc --noEmitto verify TypeScript compiles. - Run
npm testto verify tests pass. - Run
npm run lintto verify linting passes. - Run
npm run security:scanandnpm run sbom:checkif the PR adds new dependencies.
In non-production environments (NODE_ENV !== 'production' and NODE_ENV !== 'test'),
the server automatically validates every JSON API response against the OpenAPI spec.
When a handler emits a response shape that does not match the schema declared in
docs/openapi.yaml, a loud error block is printed to the console:
╔══════════════════════════════════════════════════════════════╗
║ RESPONSE SHAPE VIOLATION — OpenAPI contract mismatch ║
╠══════════════════════════════════════════════════════════════╣
║ Route: GET /api/bond/0x1234 ║
║ Status: 200 ║
╠══════════════════════════════════════════════════════════════╣
║ Validation errors: ║
║ - address: Required ║
║ - status: Invalid enum value ║
╠══════════════════════════════════════════════════════════════╣
║ Fix: update the handler or the Zod schema so they match. ║
║ Regenerate: npm run generate:openapi ║
╚══════════════════════════════════════════════════════════════╝
The validation never blocks the response — it fails loud, not closed.
In production (NODE_ENV=production) the validation middleware is a complete
no-op with zero runtime overhead.
- At startup, the dev server reads
docs/openapi.yaml. - It builds a lookup table mapping every registered route (
method + path) to its expected response Zod schema per HTTP status code. - A global middleware intercepts all
res.json()calls. - For each response, it looks up the matching route and validates the body
against the declared schema using Zod's
safeParse(). - On mismatch, the violation is logged to
console.errorand the structured logger.
If you need stricter validation or want to validate responses for routes
that don't have schemas in the OpenAPI spec, use the validateResponse()
middleware directly:
import { validateResponse } from '../middleware/validateResponse.js'
import { bondResponseSchema } from '../schemas/index.js'
router.get('/:address',
validate({ params: bondPathParamsSchema }),
validateResponse({ schema: bondResponseSchema }),
handler
)This middleware is a no-op in production and test environments.
If you see a shape violation in dev:
- Is the handler buggy? — Check that the handler returns all required fields with the correct types as declared in the Zod schema.
- Is the Zod schema stale? — The schema may have been updated without
regenerating the OpenAPI spec. Run
npm run generate:openapi. - False positive with
z.any()schemas? — Some endpoints usez.any()in the OpenAPI spec. These are skipped by the validator. If you want stricter validation for such endpoints, replacez.any()with a concrete Zod schema inscripts/generate-openapi.ts.
- API reference — full endpoint documentation with cURL examples
- Schema conventions — how Zod schemas are structured in this project
- Contributing & Testing — full test suite walkthrough