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.

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

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.