OpenAPI
The @beignet/core/openapi subpath generates an OpenAPI 3.1 document from your contracts.
You need this page when publishing your API to external consumers or generating client SDKs; Beignet's typed client does not need OpenAPI.
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 zodGenerating 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.
Custom schema introspection
Contracts built with non-Zod schemas keep working with the server and client.
To document them, pass contractsToOpenAPI a schemaIntrospector for object
shapes and a schemaConverters entry for JSON Schema conversion, or provide
equivalent Zod schemas for the documented routes when that is simpler:
import type { SchemaConverter, SchemaIntrospector } from "@beignet/core/openapi";
import { contractsToOpenAPI } from "@beignet/core/openapi";
type MySchema = {
description?: string;
fields?: Record<string, MySchema>;
inner?: MySchema;
optional?: boolean;
toJSONSchema(): Record<string, unknown>;
};
function isSchema(schema: unknown): schema is MySchema {
return (
typeof schema === "object" &&
schema !== null &&
"toJSONSchema" in schema
);
}
const schemaIntrospector: SchemaIntrospector = {
getShape(schema) {
return isSchema(schema) ? schema.fields : undefined;
},
getDescription(schema) {
return isSchema(schema) ? schema.description : undefined;
},
isOptional(schema) {
return isSchema(schema) && schema.optional === true;
},
unwrapOptional(schema) {
return isSchema(schema) && schema.inner ? schema.inner : schema;
},
};
const schemaConverter: SchemaConverter = {
name: "my-schema",
canConvert: isSchema,
toJSONSchema(schema) {
return isSchema(schema) ? schema.toJSONSchema() : {};
},
};
const spec = contractsToOpenAPI(contracts, {
title: "Todo API",
version: "1.0.0",
schemaIntrospector,
schemaConverters: [schemaConverter],
});The introspector and converter do not perform runtime validation. They only teach the OpenAPI generator how to inspect schema metadata and emit JSON Schema.
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. Generated SDKs often use
operationId as a method name, so changing it can be a breaking change even
when the HTTP path stays the same.
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.
Compatibility for external clients
Beignet's typed client is the best internal TypeScript client because it calls
the same contract builders your server uses. OpenAPI is the public client
surface for teams that need generated SDKs, API gateways, partner docs, or
non-TypeScript consumers. Keep both surfaces aligned by treating the contract as
the source of truth and .openapi(...) as the place for transport details the
runtime schema cannot describe.
Use stable, explicit operationId values before publishing a route externally.
Prefer versioned paths such as /api/v1/issues for breaking request or
response changes. Mark old operations with .openapi({ deprecated: true })
while they are still served.
Response changes should be additive for existing status codes. Adding optional
fields or new error statuses is usually compatible. Removing fields, changing
field types, changing status codes, or reusing an operationId for a different
shape should be treated as a breaking change and moved to a new route version.
For external SDK generation, export the OpenAPI JSON from the same contract list registered on the server. Beignet does not generate third-party SDKs itself; use your preferred OpenAPI generator against that document.
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.
Non-JSON media
Beignet contracts still own runtime validation for JSON and typed text
responses. Use .openapi(...) overrides when the wire format cannot be
described as ordinary JSON, such as multipart uploads, file downloads, or event
streams.
export const uploadAttachment = files
.post("/api/attachments")
.body(UploadIntentSchema)
.responses({ 201: AttachmentSchema })
.openapi({
requestBody: {
required: true,
content: {
"multipart/form-data": {
schema: {
type: "object",
properties: {
file: { type: "string", format: "binary" },
},
required: ["file"],
},
},
},
},
});
export const downloadAttachment = files
.get("/api/attachments/:id/download")
.pathParams(z.object({ id: z.string() }))
.responses({ 200: null })
.openapi({
responses: {
"200": {
description: "Attachment bytes",
content: {
"application/octet-stream": {
schema: { type: "string", format: "binary" },
},
},
},
},
});Use .responses({ 200: null }) plus a response media override when the route
is transport-owned, such as private downloads, byte streams, Server-Sent
Events, or application/x-ndjson. Runtime handlers should return a native
Response for those cases, and consumers should use platform fetch because
a null response schema means the typed client expects an empty body. For
caller-owned request transports such as FormData, send them with the typed
client's rawBody; see Client for the request body rules.
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. |
schemaConverters | SchemaConverter[]? | Custom schema-to-JSON-Schema converters. Custom converters run before the default Zod converter. |
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.