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

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

Create 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

OptionTypeDescription
titlestringAPI title (required)
versionstringAPI version (required)
descriptionstring?API description
servers{ url, description? }[]?Server URLs
securitySchemesRecord<string, OpenAPISecurityScheme>?Auth schemes
securityRecord<string, string[]>[]?Global security requirements
jsonMediaTypestring?Media type for JSON bodies (default: "application/json")
schemaIntrospectorSchemaIntrospector?Schema metadata adapter. Defaults to Zod.
schemaConvertersSchemaConverter[]?Custom schema-to-JSON-Schema converters. Custom converters run before the default Zod converter.

What gets generated

The generator extracts from each contract:

Schemas are placed in components/schemas and referenced via $ref to avoid duplication.