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.
When a pathParams Standard Schema cannot be introspected, Beignet still emits
every parameter from the literal path template as a required string. This
matches server startup, which cannot compare opaque schema keys to the
template. Introspectable object schemas remain strict: missing or extra path
parameter keys fail both server registration and OpenAPI generation. Provide a
custom introspector when the document needs richer path schemas or
descriptions.
Serving from a route
Expose the spec as a JSON endpoint:
// app/api/openapi/route.ts
import { createOpenAPIHandler } from "@beignet/next";
import { env } from "@/lib/env";
import { contracts } from "@/server/routes";
export const GET = createOpenAPIHandler(contracts, {
title: "My API",
version: "1.0.0",
servers: [{ url: env.APP_URL }],
});Export contracts = contractsFromRoutes(routes) from server/routes.ts so the
OpenAPI route can serve the same route list as the runtime without booting
providers during Next build-time route imports. 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, add those contracts to the
explicit contract list you pass to createOpenAPIHandler(...).
Prefer a configured server URL such as env.APP_URL for deployed docs.
inferServersFromRequest: true is available for local or internal routes, but
it reflects the request origin into the OpenAPI document.
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",
});OpenAPI metadata merges one field deep across .openapi(...) and
.meta({ openapi: ... }) calls. This lets a contract group provide shared tags
or security requirements while an individual contract adds its summary and
operation ID. Later calls replace only OpenAPI fields with the same name;
structured fields such as responses are replaced as a whole rather than
deep-merged. Prefer .openapi(...) when setting operation metadata directly.
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. Server registration and OpenAPI generation
reject duplicate operation IDs. Treat a published contract name or explicit
operationId as durable API surface.
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.
Query-field and request-header descriptions are preserved whether
.describe(...) is applied before or after .optional().
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.
OpenAPI describes contract-owned responses. Framework-owned failures added by
runtime composition — for example request validation, authentication hooks,
rate limiting, or adapter limits — are not attached universally because the
active hooks and adapters differ by deployment. Document those shared gateway
responses at the API level when external consumers need them; keep
route-specific business failures in .errors(...) so generated clients can
narrow them precisely.
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.
Contract names already provide stable default operation IDs; set an explicit
operationId when the external SDK method should differ from the internal
contract name. Prefer path-prefixed groups such as /api/v1/issues for
breaking request or response changes. Mark old contracts while they are still
served:
import { defineContractGroup } from "@beignet/core/contracts";
import { z } from "zod";
const v1Issues = defineContractGroup()
.namespace("legacyIssues")
.prefix("/api/v1/issues");
export const listIssuesV1 = v1Issues
.get("/", "list")
.deprecated({
since: "2026-07-11T00:00:00Z",
sunset: "2027-01-01T00:00:00Z",
reason: "Use the current issues collection.",
replacement: "/api/issues",
documentation: "https://docs.example.com/migrations/issues-v1",
})
.responses({
200: z.object({
items: z.array(z.object({ id: z.string(), title: z.string() })),
}),
});OpenAPI emits deprecated: true plus x-beignet-deprecation with the complete
lifecycle metadata. Runtime responses emit the standard Deprecation and
optional Sunset and documentation Link headers. A bare
.openapi({ deprecated: true }) remains available for documentation-only
operations, but it does not opt the runtime into lifecycle headers.
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.