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.
An OpenAPI document intentionally enumerates the contract surface, including routes whose runtime handlers require authentication or API keys. For sensitive or internal APIs, protect the spec and documentation endpoints with app-owned authorization or do not expose them publicly. Beignet does not infer endpoint access control from contract metadata.
The @beignet/next Swagger convenience handler loads version-pinned
Swagger UI assets from unpkg.com with subresource-integrity checks. Static and
request-derived spec URLs are serialized with HTML-safe JSON escaping before
entering the inline script. A strict
Content Security Policy or network-isolated deployment should use a custom,
self-hosted CSP-compatible documentation UI instead. The built-in page also
emits inline script and style blocks, so allowing the pinned asset host alone
is not sufficient for a strict policy.
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.
OpenAPI generation also rejects duplicate method and normalized path
combinations instead of replacing an earlier operation. Path parameters written
as :id and [id] both normalize to {id}, so they identify the same OpenAPI
operation when the HTTP method also matches. Parameter names must also be
consistent for paths with the same hierarchy: /items/{id} and
/items/{slug} cannot coexist in one OpenAPI document, even when they use
different methods.
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().
Query parameters include explicit OpenAPI serialization metadata. Primitive
and scalar-array fields use style: "form" with explode: true; flat object
fields use style: "deepObject" with explode: true. Arrays therefore repeat
their parameter name, and object properties use bracket names such as
filter[owner]=user_1. This metadata comes from the explicit query transport
passed to .query(schema, transport), which also drives Beignet's typed client
and server. Query arrays may contain scalars, and deepObject values must be
flat; Beignet rejects shapes that OpenAPI cannot represent consistently.
The schema introspector still supplies requiredness, descriptions, and validation constraints. Its top-level query fields must exactly match the transport fields. OpenAPI generation fails when the query schema is opaque because silently omitting those parameters would publish a document that does not match the endpoint. Provide a schema introspector for non-Zod query schemas.
Empty arrays and objects are omitted by default. A transport configured with
{ empty: "preserve" } adds x-beignet-empty-query: "v1" and uses Beignet's
versioned typed-client extension. Generated OpenAPI clients still treat empty
and omitted collections as the same value unless they implement that extension.
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.
Generate an external TypeScript client
Use Beignet's contract-aware client inside the application that owns the contracts. For a separate TypeScript application, generate types from the published OpenAPI document and pair them with an OpenAPI client:
bun add openapi-fetch
bun add --dev openapi-typescript typescript
bunx openapi-typescript https://api.example.com/api/openapi --output src/generated/api.tsCreate the client with the generated paths type:
import createClient from "openapi-fetch";
import type { paths } from "./generated/api";
export const api = createClient<paths>({
baseUrl: "https://api.example.com",
});
const { data, error, response } = await api.GET("/api/issues/{id}", {
params: { path: { id: "issue_123" } },
headers: { authorization: "Bearer replace-with-access-token" },
});
if (error) {
console.error(response.status, error.code, error.message);
} else {
console.log(data.title);
}Paths, path and query parameters, JSON bodies, success responses, and declared catalog errors are inferred from the generated document. Authentication credentials remain request configuration: an OpenAPI security scheme tells tools which authentication mechanism an operation uses, but it does not supply the credential.
Generate upload and download types
OpenAPI represents uploaded and downloaded bytes as string schemas with
format: "binary". Map that format to Blob when the generated client should
accept files directly:
// scripts/generate-api-types.ts
import { mkdir, writeFile } from "node:fs/promises";
import openapiTS, { astToString } from "openapi-typescript";
import ts from "typescript";
const schemaUrl =
process.env.OPENAPI_URL ?? "https://api.example.com/api/openapi";
const ast = await openapiTS(new URL(schemaUrl), {
transform(schema) {
if (schema.format === "binary") {
return ts.factory.createTypeReferenceNode("Blob");
}
},
});
await mkdir("src/generated", { recursive: true });
await writeFile("src/generated/api.ts", astToString(ast));Serialize a typed upload body as FormData and select the parser for a binary
response:
const file = new File(["example attachment"], "notes.txt", {
type: "text/plain",
});
const upload = await api.POST("/api/issues/{id}/attachments", {
params: { path: { id: "issue_123" } },
body: { file },
bodySerializer(body) {
const formData = new FormData();
formData.set("file", body.file, file.name);
return formData;
},
});
if (upload.error) {
throw new Error(`Upload failed with ${upload.response.status}`);
}
const download = await api.GET(
"/api/issues/{id}/attachments/{attachmentId}/download",
{
params: {
path: {
id: "issue_123",
attachmentId: upload.data.attachmentId,
},
},
parseAs: "arrayBuffer",
},
);Use parseAs: "text" for documented text responses. The default parser is
JSON.
Detect contract drift in CI
Commit generated client types when consumers should review API changes in the same pull request as their code. Regenerate them and fail on a diff:
{
"scripts": {
"api:generate": "bun scripts/generate-api-types.ts",
"api:check": "bun run api:generate && git diff --exit-code -- src/generated/api.ts",
"typecheck": "tsc --noEmit"
}
}Run both bun run api:check and bun run typecheck in CI. Point
OPENAPI_URL at a versioned schema artifact or the deployment being promoted,
not an unrelated mutable production deployment. A generated diff is an API
review signal; classify it using the compatibility rules above before
accepting it.
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.