Skip to content
rest-rpc
Esc
↑↓navigate↵open⌘Jpreview
On this page

OpenAPI

Generate an OpenAPI document from HTTP route declarations.

createOpenApiDocument() generates an OpenAPI document from server routes or a separately shared contract. HTTP methods, paths, schemas, and operation metadata come from the same route builder used by the server and client.

Declare output schemas with .output(), .response(), .streamOutput(), or .streamResponse() to include responses in OpenAPI. Outputs inferred from handlers have no schema, so they produce no documented responses.

import { route, createOpenApiDocument } from "@rest-rpc/express";
import { z } from "zod";

const list = route
	.get("/todos")
	.output(z.array(z.object({ id: z.string(), title: z.string() })))
	.openAPI({ summary: "List todos" })
	.handler(() => [{ id: "todo_1", title: "Write docs" }]);

const routes = { todos: { list } };

const document = createOpenApiDocument(routes, {
	info: {
		title: "Todo API",
		version: "1.0.0",
	},
	servers: [{ url: "https://api.example.com" }],
});

What Is Included

Every route in the tree becomes an operation, including routes whose method and path are derived from their tree keys. The document includes:

  • route methods and paths
  • path, query, and header parameters
  • request bodies with their content types
  • status-keyed responses with their content types and declared headers
  • streams as text/event-stream responses
  • route .openAPI() metadata

The document targets OpenAPI 3.1.0 unless the openapi option sets another version. servers, components, and tags options are copied to the document.

Schema Conversion

Standard Schema defines validation behavior, not JSON Schema conversion. When schemaConverter is omitted, or when it returns undefined for a particular schema, rest-rpc emits an empty OpenAPI Schema Object for that schema.

import { createOpenApiDocument } from "@rest-rpc/core";
import { z } from "zod";

const document = createOpenApiDocument(routes, {
	info: {
		title: "Todo API",
		version: "1.0.0",
	},
	schemaConverter: (schema, mode) => {
		if (schema["~standard"].vendor === "zod") {
			return z.toJSONSchema(schema as z.ZodType, { io: mode });
		}
	},
});

For object-shaped path and query schemas, rest-rpc reads top-level properties and required from the converted JSON Schema to create OpenAPI parameters. Schema records and headers do not always carry parameter-level requiredness in the converted schema. Use transformParameter when generated parameters need project-specific metadata.

const search = route
	.get("/search")
	.query(
		z.object({
			q: z
				.string()
				.min(1)
				.meta({ openApi: { required: true } }),
			cursor: z
				.string()
				.optional()
				.meta({ openApi: { required: false } }),
		}),
	)
	.response(200, z.array(todoSchema));

const routes = { search };

const document = createOpenApiDocument(routes, {
	info: {
		title: "Todo API",
		version: "1.0.0",
	},
	schemaConverter: (schema, mode) =>
		z.toJSONSchema(schema as z.ZodType, { io: mode }),
	transformParameter: ({ parameter }) => {
		const metadata = parameter.schema as
			{ openApi?: { required?: boolean } } | undefined;
		const required = metadata?.openApi?.required;

		return typeof required === "boolean"
			? { ...parameter, required }
			: parameter;
	},
});

Route Metadata

Use .openAPI() on a route builder for operation metadata. It accepts summary, description, operationId, tags, deprecated, security, externalDocs, responses, and extensions. extensions keys must start with x- and are added to the operation.

const create = route
	.post("/todos")
	.body(z.object({ title: z.string().min(1) }))
	.openAPI({
		summary: "Create a todo",
		operationId: "createTodo",
		responses: {
			201: {
				description: "Todo created.",
			},
		},
	})
	.response(201, todoSchema);

Each .response() call produces a status-keyed OpenAPI response.

const create = route
	.post("/todos")
	.body(z.object({ title: z.string().min(1) }))
	.openAPI({
		summary: "Create a todo",
		operationId: "createTodo",
		responses: {
			201: {
				description: "Todo created.",
			},
			409: {
				description: "A todo with the same title already exists.",
			},
		},
	})
	.response(201, todoSchema)
	.response(
		409,
		z.object({
			code: z.literal("TODO_ALREADY_EXISTS"),
		}),
	);

openApi.responses only affects generated OpenAPI output. It does not change rest-rpc runtime behavior, handler return types, client response types, validation, or server headers. Metadata for statuses that a route does not declare is skipped.

const get = route
	.get("/todos/:id")
	.params(z.object({ id: z.string() }))
	.openAPI({
		responses: {
			200: {
				headers: {
					"x-request-id": {
						description: "Request correlation id.",
						schema: z.string(),
					},
					"x-rate-limit": z.number(),
				},
			},
		},
	})
	.response(200, todoSchema);

Use route response headers instead when a handler-owned header is part of the typed rest-rpc contract.

Typed headers declared on a route response override OpenAPI-only headers with the same name in generated OpenAPI. .openAPI() can appear anywhere before .handler(). Its position among request and output declarations does not affect the generated operation. Calling it more than once merges the details, so a shared base builder can set common tags or security.

Transform Hooks

Use transformParameter and transformOperation when generated output needs project-specific changes. Both transform contexts include routePath, the route key path from the contract tree.

const document = createOpenApiDocument(routes, {
	info: {
		title: "Todo API",
		version: "1.0.0",
	},
	schemaConverter,
	transformParameter: ({ parameter }) =>
		parameter.name === "preview"
			? { ...parameter, required: false }
			: parameter,
	transformOperation: ({ route, routePath, operation }) => {
		if (route.metadata?.auth === "required") {
			return {
				...operation,
				operationId: routePath.join("."),
				security: [{ bearerAuth: [] }],
			};
		}

		return operation;
	},
});

Was this page helpful?