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

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.

What gets generated

The generator extracts from each contract:

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