ProvenPin Install

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
pinned versions5
zod@4.6.7@asteasolutions/zod-to-openapi@9.1.0yaml@2.9.1tsx@4.23.15@types/node@26.6.5
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
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.

package.json
{
  "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"
  }
}
src/openapi.ts
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());
test/openapi.test.ts
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.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.