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-streamresponses - 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;
},
});