OpenAPI
The @beignet/core/openapi subpath generates an OpenAPI 3.1 document from your contracts.
Core contracts, the server, and the client work with any Standard
Schema-compatible library for runtime validation. OpenAPI generation needs a
schema introspector so it can read object shapes, descriptions, and optional
fields. Zod v4 is supported by default through createZodIntrospector().
bun add @beignet/core zod
Generating a spec
Pass your contracts and metadata to contractsToOpenAPI:
import { contractsToOpenAPI } from "@beignet/core/openapi";
import { getTodo, createTodo, listTodos } from "./contracts";
const spec = contractsToOpenAPI(
[getTodo, createTodo, listTodos],
{
title: "Todo API",
version: "1.0.0",
description: "A simple todo API",
},
);
The result is a plain JavaScript object conforming to the OpenAPI 3.1 specification. Serialize it to JSON or YAML as needed.
Pass the same contract builders used by the server and client. You normally do
not need to extract .config; OpenAPI generation accepts Beignet's
contract-like builder shape directly.
If a contract uses non-Zod schemas, keep using it with the server and client,
then either leave it out of OpenAPI generation, provide equivalent Zod schemas
for documented HTTP routes, or pass a custom schemaIntrospector.
Custom schema introspection
contractsToOpenAPI accepts schemaIntrospector when your app has another
schema system that can expose the metadata OpenAPI needs:
import type { SchemaIntrospector } from "@beignet/core/openapi";
import { contractsToOpenAPI } from "@beignet/core/openapi";
const schemaIntrospector: SchemaIntrospector = {
getShape(schema) {
return isObjectSchema(schema) ? schema.fields : undefined;
},
getDescription(schema) {
return isSchema(schema) ? schema.description : undefined;
},
isOptional(schema) {
return isSchema(schema) && schema.optional === true;
},
unwrapOptional(schema) {
return isOptionalSchema(schema) ? schema.inner : schema;
},
};
const spec = contractsToOpenAPI(contracts, {
title: "Todo API",
version: "1.0.0",
schemaIntrospector,
});
The introspector does not perform runtime validation. It only teaches the OpenAPI generator how to inspect schema metadata.
Serving from a route
Expose the spec as a JSON endpoint:
// app/api/openapi/route.ts
import { createOpenAPIHandler } from "@beignet/next";
import { server } from "@/server";
export const GET = createOpenAPIHandler(server.contracts, {
title: "My API",
version: "1.0.0",
});
server.contracts is populated from the route list passed to
createNextServer({ routes }). If your app uses per-file Next route handlers
with server.route(contract).handle(...), those files are not imported by the
server automatically. In that style, export an explicit contract list or an
importable route registry and pass that to createOpenAPIHandler(...).
Operation metadata
Use .openapi(...) on contracts to customize generated operation metadata.
export const getTodo = todos
.get("/api/todos/:id")
.pathParams(z.object({ id: z.string() }))
.responses({ 200: TodoSchema })
.errors({ TodoNotFound: errors.TodoNotFound })
.openapi({
summary: "Get a todo",
description: "Fetch one todo by ID.",
tags: ["todos"],
operationId: "getTodo",
});
The generated operationId defaults to the contract name. Set it explicitly when external clients need a stable identifier.
Path template parameters are emitted as required string parameters by default.
Add .pathParams(...) when you want specific schemas, descriptions, runtime
validation, or coercion. If .pathParams(...) is present, its keys must match
the path template exactly.
Request bodies are supported for POST, PUT, and PATCH contracts only. OpenAPI generation rejects body schemas on other methods.
Request headers declared with .headers(...) are generated as OpenAPI
parameters with in: "header". Header names should be declared in lowercase in
contracts; HTTP header matching remains case-insensitive.
Catalog errors declared with .errors(...) use the standard error envelope in
OpenAPI. The generator emits catalog code values as literal schemas, includes
declared details schemas, uses catalog messages as response descriptions, and
adds named examples with each catalog code and message.
Security
Pass global security schemes to contractsToOpenAPI, then attach operation-level security with .openapi(...).
const spec = contractsToOpenAPI(contracts, {
title: "Todo API",
version: "1.0.0",
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
},
security: [{ bearerAuth: [] }],
});
export const publicHealth = system
.get("/api/health")
.responses({ 200: HealthSchema })
.openapi({
summary: "Health check",
security: [{}],
});
Use security: [{}] to mark a route as public when the document has global security.
Deprecated operations
export const oldGetTodo = todos
.get("/api/v1/todos/:id")
.pathParams(z.object({ id: z.string() }))
.responses({ 200: TodoSchema })
.openapi({
summary: "Get a todo using the old route",
deprecated: true,
});
Options
| Option | Type | Description |
|---|---|---|
title | string | API title (required) |
version | string | API version (required) |
description | string? | API description |
servers | { url, description? }[]? | Server URLs |
securitySchemes | Record<string, OpenAPISecurityScheme>? | Auth schemes |
security | Record<string, string[]>[]? | Global security requirements |
jsonMediaType | string? | Media type for JSON bodies (default: "application/json") |
schemaIntrospector | SchemaIntrospector? | Schema metadata adapter. Defaults to Zod. |
What gets generated
The generator extracts from each contract:
- Path parameters →
parameterswithin: "path" - Query parameters →
parameterswithin: "query" - Request headers →
parameterswithin: "header" - Request body →
requestBodywith JSON schema - Responses → status codes with JSON schema (or empty for 204)
- Metadata →
tags,summary,description,operationIdfrom contract metadata
Schemas are placed in components/schemas and referenced via $ref to avoid duplication.