An OpenAPI document is a JSON or YAML file describing every path, parameter, body and response your API has. It is worth writing because it is machine-readable: one file feeds a documentation page (Interactive Docs), a request validator, a mock server, contract tests and generated clients. The current release, OpenAPI 3.1, aligns its schema dialect with JSON Schema 2020-12. Hand-maintaining it is a losing game; within two sprints it describes an API you no longer have. Generate it from the schemas you already validate with — since this chapter uses Zod 44,027 (Schema Validation with Zod), @asteasolutions/zod-to-openapi 9.1.0 beside zod 4.6.5 and yaml is the shortest path.
import { z } from 'zod';
import { OpenAPIRegistry, OpenApiGeneratorV31, extendZodWithOpenApi }
from '@asteasolutions/zod-to-openapi';
extendZodWithOpenApi(z);
const registry = new OpenAPIRegistry();
const Book = registry.register('Book', z.object({
id: z.string().openapi({ example: 'bk_012' }),
title: z.string().max(200).openapi({ example: 'The Hobbit' }),
year: z.number().int().min(1450).max(2100).openapi({ example: 1937 })
}).openapi('Book'));
registry.registerPath({
method: 'get', path: '/books', tags: ['Books'],
operationId: 'listBooks', summary: 'List books', security: [],
request: { query: z.object({ page: z.coerce.number().int().min(1).default(1) }) },
responses: { 200: { description: 'A page of books', content: { 'application/json': {
schema: z.object({ data: z.array(Book), page: Page }) } } } }
});
export const document = new OpenApiGeneratorV31(registry.definitions).generateDocument({
openapi: '3.1.0', servers: [{ url: 'https://api.example.com/api/v1' }],
info: { title: 'Bookshelf API', version: '1.4.0', license: { name: 'MIT', identifier: 'MIT' } }
});registry.register puts each schema in components/schemas and refers to it by $ref from then on. The yaml package writes a 5 KB file in which Zod constraints have become JSON Schema keywords and the query object one parameters entry per field, marked required: false where the Zod field was optional:
paths:
/books:
get:
tags:
- Books
operationId: listBooks
summary: List books
security: []
parameters:
- schema:
type: integerThen lint it, because a document that parses is not the same as a document that is usable. Redocly's CLI (@redocly/cli 2.53.3, MIT) checks structure against the specification and adds a recommended ruleset. Run npx @redocly/cli@2.53.3 lint openapi.yaml against the first draft — before the operationId, security and license entries above existed — and it fails; adding them leaves one warning, the house rule about example.com:
[1] openapi.yaml:100:5 at #/paths/~1books/get Every operation should have security defined on it or on the root level. Error was generated by the security-defined rule. [3] openapi.yaml:2:1 at #/info Info object should contain `license` field. [5] openapi.yaml:100:5 at #/paths/~1books/get/operationId Operation object should contain `operationId` field. ❌ Validation failed with 2 errors and 4 warnings. ... and after the fixes: [1] openapi.yaml:10:10 at #/servers/0/url Server `url` should not point to example.com or localhost. Woohoo! Your API description is valid. 🎉
Both were real: a missing operationId gives generated clients names like getBooks_1, and a missing security entry leaves a reader unable to tell a public endpoint from one you forgot to protect — for a genuinely public route the fix is the explicit security: []. Run the lint in continuous integration (Fixtures and CI), regenerate the file in the same job, and fail the build on a diff.