OpenAPI

Describing the API with OpenAPI

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.

One schema, used for validation and for the documentJavaScript
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:

The head of the generated openapi.yaml path entryYAML
paths:
  /books:
    get:
      tags:
        - Books
      operationId: listBooks
      summary: List books
      security: []
      parameters:
        - schema:
            type: integer

Then 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:

Output of 64
[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.