---
title: OpenAPI
description: 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.

```ts
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" }],
});
```

:::tip[Using a Shared Contract?]
The `createOpenApiDocument()` function is also exported from the `@rest-rpc/core` package,
and works identically with a shared contract too.
:::

## 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()`](#route-metadata) 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.

```ts
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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.

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