Zod 4 schemas to an OpenAPI 3.1 document with zod-to-openapi: working versions (tested 2026-10-15)
Register Zod 4 schemas, path and query parameters, security schemes and routes in an OpenAPIRegistry and generate an OpenAPI 3.1 document (JSON and YAML) from the same schemas used for runtime validation, tested offline.
passed5 passed · 0 failed2026-10-15 15:05 UTC- Status
- passed
- Last passed
- 2026-10-15 15:05 UTC
- Tests
- 5 / 5
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38755782594
The code
Exactly the files that passed. Your agent can pull them with get_recipe("zod-to-openapi-document-generation") (install), or see them on GitHub.
{
"name": "zod-to-openapi-document-generation",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"zod": "4.6.7",
"@asteasolutions/zod-to-openapi": "9.1.0",
"yaml": "2.9.1"
},
"devDependencies": {
"tsx": "4.23.15",
"@types/node": "26.6.5"
}
}
import { z } from "zod";
import { extendZodWithOpenApi, OpenAPIRegistry, OpenApiGeneratorV31 } from "@asteasolutions/zod-to-openapi";
import { stringify } from "yaml";
extendZodWithOpenApi(z);
/** The same Zod schemas validate requests at runtime and describe them in the OpenAPI document. */
export const registry = new OpenAPIRegistry();
const bearer = registry.registerComponent("securitySchemes", "bearerAuth", { type: "http", scheme: "bearer", bearerFormat: "JWT" });
export const UserId = registry.registerParameter("UserId", z.uuid().openapi({ param: { name: "id", in: "path" }, example: "7f9c2a4e-0b7d-4c1a-9a51-2f3c1d9e8b10" }));
export const User = registry.register("User", z.object({
id: z.uuid(),
email: z.email().openapi({ example: "[email protected]" }),
name: z.string().min(1).max(100),
role: z.enum(["admin", "member"]).default("member"),
createdAt: z.iso.datetime(),
}).openapi({ description: "A registered user" }));
export const CreateUser = registry.register("CreateUser", User.pick({ email: true, name: true, role: true }));
export const Problem = registry.register("Problem", z.object({
type: z.string(), title: z.string(), status: z.number().int(),
errors: z.array(z.object({ path: z.string(), message: z.string() })).optional(),
}));
registry.registerPath({
method: "get", path: "/users/{id}", operationId: "getUser", tags: ["users"], summary: "Fetch one user",
security: [{ [bearer.name]: [] }],
request: { params: z.object({ id: UserId }) },
responses: {
200: { description: "The user", content: { "application/json": { schema: User } } },
404: { description: "Not found", content: { "application/problem+json": { schema: Problem } } },
},
});
registry.registerPath({
method: "post", path: "/users", operationId: "createUser", tags: ["users"], summary: "Create a user",
security: [{ [bearer.name]: [] }],
request: { body: { required: true, content: { "application/json": { schema: CreateUser } } } },
responses: {
201: { description: "Created", content: { "application/json": { schema: User } } },
422: { description: "Validation failed", content: { "application/problem+json": { schema: Problem } } },
},
});
registry.registerPath({
method: "get", path: "/users", operationId: "listUsers", tags: ["users"],
request: { query: z.object({ role: z.enum(["admin", "member"]).optional(), limit: z.coerce.number().int().min(1).max(100).default(20) }) },
responses: { 200: { description: "Users", content: { "application/json": { schema: z.array(User) } } } },
});
export function buildDocument() {
return new OpenApiGeneratorV31(registry.definitions).generateDocument({
openapi: "3.1.0",
info: { title: "Users API", version: "1.0.0" },
servers: [{ url: "https://api.example.test" }],
});
}
export const toYaml = () => stringify(buildDocument());
import { test } from "node:test";
import assert from "node:assert/strict";
import { parse } from "yaml";
import { buildDocument, toYaml, CreateUser } from "../src/openapi.ts";
const doc = buildDocument() as any;
test("document has paths, operation ids and a 3.1 header", () => {
assert.equal(doc.openapi, "3.1.0");
assert.deepEqual(Object.keys(doc.paths).sort(), ["/users", "/users/{id}"]);
assert.equal(doc.paths["/users/{id}"].get.operationId, "getUser");
assert.equal(doc.paths["/users"].post.operationId, "createUser");
});
test("registered schemas become $refs into components", () => {
const ok = doc.paths["/users/{id}"].get.responses["200"].content["application/json"].schema;
assert.deepEqual(ok, { $ref: "#/components/schemas/User" });
const body = doc.paths["/users"].post.requestBody.content["application/json"].schema;
assert.deepEqual(body, { $ref: "#/components/schemas/CreateUser" });
assert.ok(doc.components.schemas.Problem);
});
test("zod constraints map to JSON Schema keywords", () => {
const user = doc.components.schemas.User;
assert.deepEqual([...user.required].sort(), ["createdAt", "email", "id", "name"]);
assert.equal(user.properties.email.format, "email");
assert.equal(user.properties.id.format, "uuid");
assert.equal(user.properties.createdAt.format, "date-time");
assert.equal(user.properties.name.maxLength, 100);
assert.deepEqual(user.properties.role.enum, ["admin", "member"]);
assert.equal(user.properties.role.default, "member");
assert.equal(user.description, "A registered user");
});
test("path, query params and security are documented", () => {
const params = doc.paths["/users/{id}"].get.parameters;
assert.deepEqual(params, [{ $ref: "#/components/parameters/UserId" }]);
assert.equal(doc.components.parameters.UserId.in, "path");
const query = doc.paths["/users"].get.parameters;
const limit = query.find((p: any) => p.name === "limit");
assert.equal(limit.in, "query");
assert.equal(limit.schema.maximum, 100);
assert.deepEqual(doc.components.securitySchemes.bearerAuth, { type: "http", scheme: "bearer", bearerFormat: "JWT" });
});
test("the same schema still validates at runtime and YAML round-trips", () => {
assert.equal(CreateUser.safeParse({ email: "not-an-email", name: "x" }).success, false);
assert.deepEqual(CreateUser.parse({ email: "[email protected]", name: "Ada" }), { email: "[email protected]", name: "Ada", role: "member" });
assert.deepEqual(parse(toYaml()), JSON.parse(JSON.stringify(doc)));
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Anthropic TypeScript SDK tool use loop, tested with a mocked fetch
Run a Claude tool_use loop with the official Anthropic SDK and Zod-validated tool inputs, tested offline with an injected fetch.
better-sqlite3 transactions with Zod-validated inputs
A small ledger on better-sqlite3 using db.transaction for atomic transfers, nested transactions as savepoints for all-or-nothing batches, and Zod 4 to validate inputs and rows, tested on in-memory SQLite.
Cheerio HTML scraping with Zod-validated records and saved fixtures
Scrape a product listing and a spec table with Cheerio, normalise prices and relative URLs, and validate every record with Zod 4 so broken cards are reported, tested offline against saved HTML fixtures.
Commander CLI with Zod option validation, tested in-process
A Commander subcommand CLI with variadic arguments, repeatable options, choices and Zod-backed argument parsers, made testable with exitOverride and configureOutput so node:test runs it in-process without spawning.
All Zod recipesAll @asteasolutions/zod-to-openapi recipesAll yaml recipes