Skip to main content

Overview

@contract-kit/openapi generates OpenAPI 3.1 specifications from your Contract Kit contracts. It automatically converts your contracts and schemas into comprehensive API documentation that works with tools like Swagger UI, Redoc, and Postman.
Prefer the contract-kit meta package for new projects. It re-exports @contract-kit/openapi along with core, client, application, and more.

Installation

npm install @contract-kit/openapi @contract-kit/core zod

Quick Start

Generating an OpenAPI Spec

import { contractsToOpenAPI } from "@contract-kit/openapi";
import { getTodo, listTodos, createTodo, updateTodo, deleteTodo } from "./contracts/todos";

const spec = contractsToOpenAPI(
  [getTodo, listTodos, createTodo, updateTodo, deleteTodo],
  {
    title: "Todo API",
    version: "1.0.0",
    description: "A simple todo API built with Contract Kit",
    servers: [
      { url: "https://api.example.com", description: "Production" },
      { url: "http://localhost:3000", description: "Development" },
    ],
  }
);

// Write to file
import { writeFileSync } from "fs";
writeFileSync("openapi.json", JSON.stringify(spec, null, 2));

Adding OpenAPI Metadata

Contract Metadata

Use the fluent builder methods to add OpenAPI-specific metadata to your contracts:
import { createContractGroup } from "@contract-kit/core";
import { z } from "zod";

const todos = createContractGroup().namespace("todos");

export const getTodo = todos
  .get("/api/todos/:id")
  .path(z.object({ id: z.string().describe("Todo ID") }))
  .response(200, TodoSchema)
  .errors({
    404: z.object({
      code: z.literal("TODO_NOT_FOUND"),
      message: z.string(),
    }).describe("Todo not found"),
  })
  // OpenAPI metadata
  .summary("Get a todo by ID")
  .description("Retrieves a single todo item by its unique identifier")
  .tags("todos")
  .operationId("getTodoById")
  .externalDocs("https://docs.example.com/todos", "Todo documentation")
  .security({ bearerAuth: [] });

Schema Descriptions

Add descriptions to schemas for better documentation:
export const listTodos = todos
  .get("/api/todos")
  .query(z.object({
    completed: z.boolean().optional().describe("Filter by completion status"),
    limit: z.coerce.number().optional().describe("Maximum number of results"),
    offset: z.coerce.number().optional().describe("Pagination offset"),
  }))
  .response(200, z.array(TodoSchema).describe("List of todos"))
  .summary("List all todos")
  .description("Returns a paginated list of todos with optional filtering")
  .tags("todos")
  .operationId("listTodos");

OpenAPI Builder Methods

MethodDescription
.summary(text)Brief summary of the operation
.description(text)Detailed description
.tags(...tags)Tags for grouping (accumulates)
.deprecated(flag)Mark operation as deprecated
.operationId(id)Custom operation ID (defaults to contract name)
.externalDocs(url, description?)Link to external documentation
.security(requirement)Security requirements (accumulates)

Security Schemes

Defining Security Schemes

Define authentication schemes in the generator options:
const spec = contractsToOpenAPI(contracts, {
  title: "Todo API",
  version: "1.0.0",
  securitySchemes: {
    bearerAuth: {
      type: "http",
      scheme: "bearer",
      bearerFormat: "JWT",
      description: "JWT authentication",
    },
    apiKey: {
      type: "apiKey",
      name: "X-API-Key",
      in: "header",
      description: "API key authentication",
    },
  },
  // Global security (applied to all operations by default)
  security: [{ bearerAuth: [] }],
});

Per-Operation Security

Override security for specific operations:
// Public endpoint (no auth)
export const healthCheck = api
  .get("/api/health")
  .response(200, HealthSchema)
  .security({}); // Empty object = no security

// Admin-only endpoint
export const deleteUser = api
  .delete("/api/users/:id")
  .response(204, z.null())
  .security({ bearerAuth: [], apiKey: [] }); // Requires both

Tagging Operations

Use tags to group operations in the generated documentation:
// Add tags to individual operations
export const getTodo = todos
  .get("/api/todos/:id")
  .tags("todos", "read-operations");

// Tags accumulate across the chain
export const createTodo = todos
  .post("/api/todos")
  .tags("todos")
  .tags("write-operations"); // Now has both tags

Generated Schema References

Schemas are automatically extracted and referenced in components/schemas:
{
  "openapi": "3.1.0",
  "paths": {
    "/api/todos/{id}": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/todos_get_response_200" }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "todos_get_response_200": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "title": { "type": "string" },
          "completed": { "type": "boolean" }
        }
      }
    }
  }
}

API Reference

contractsToOpenAPI(contracts, options)

Converts an array of contracts to an OpenAPI 3.1 document.
function contractsToOpenAPI(
  contracts: ContractInput[],
  options: OpenAPIGeneratorOptions
): OpenAPIObject;

OpenAPIGeneratorOptions

type OpenAPIGeneratorOptions = {
  // Required
  title: string;
  version: string;
  
  // Optional
  description?: string;
  servers?: OpenAPIServer[];
  securitySchemes?: Record<string, OpenAPISecurityScheme>;
  security?: Array<Record<string, string[]>>;
  jsonMediaType?: string; // default: "application/json"
};

OpenAPIServer

type OpenAPIServer = {
  url: string;
  description?: string;
  variables?: Record<string, {
    default: string;
    enum?: string[];
    description?: string;
  }>;
};

OpenAPISecurityScheme

type OpenAPISecurityScheme =
  | { type: "apiKey"; name: string; in: "query" | "header" | "cookie" }
  | { type: "http"; scheme: string; bearerFormat?: string }
  | { type: "oauth2"; flows: Record<string, unknown> }
  | { type: "openIdConnect"; openIdConnectUrl: string };

Serving the Spec

With Next.js

// app/api/openapi/route.ts
import { contractsToOpenAPI } from "@contract-kit/openapi";
import { allContracts } from "@/contracts";

const spec = contractsToOpenAPI(allContracts, {
  title: "My API",
  version: "1.0.0",
});

export function GET() {
  return Response.json(spec);
}

With Swagger UI

// app/api/docs/route.ts
export function GET() {
  const html = `
    <!DOCTYPE html>
    <html>
      <head>
        <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
      </head>
      <body>
        <div id="swagger-ui"></div>
        <script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
        <script>
          SwaggerUIBundle({
            url: "/api/openapi",
            dom_id: "#swagger-ui",
          });
        </script>
      </body>
    </html>
  `;
  return new Response(html, {
    headers: { "Content-Type": "text/html" },
  });
}

With Redoc

// app/api/docs/route.ts
export function GET() {
  const html = `
    <!DOCTYPE html>
    <html>
      <head>
        <title>API Documentation</title>
      </head>
      <body>
        <redoc spec-url="/api/openapi"></redoc>
        <script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
      </body>
    </html>
  `;
  return new Response(html, {
    headers: { "Content-Type": "text/html" },
  });
}

Complete Example

// contracts/todos.ts
import { createContractGroup } from "@contract-kit/core";
import { z } from "zod";

const todos = createContractGroup()
  .namespace("todos")
  .errors({
    401: z.object({ message: z.string() }),
    500: z.object({ message: z.string() }),
  });

const TodoSchema = z.object({
  id: z.string().describe("Unique identifier"),
  title: z.string().describe("Todo title"),
  completed: z.boolean().describe("Completion status"),
  createdAt: z.string().datetime().describe("Creation timestamp"),
});

export const listTodos = todos
  .get("/api/todos")
  .query(z.object({
    completed: z.boolean().optional().describe("Filter by completion status"),
    limit: z.coerce.number().min(1).max(100).optional().describe("Page size"),
    offset: z.coerce.number().min(0).optional().describe("Pagination offset"),
  }))
  .response(200, z.object({
    todos: z.array(TodoSchema),
    total: z.number(),
  }))
  .summary("List todos")
  .description("Returns a paginated list of todos with optional filtering by completion status")
  .tags("todos")
  .operationId("listTodos");

export const getTodo = todos
  .get("/api/todos/:id")
  .path(z.object({ id: z.string().describe("Todo ID") }))
  .response(200, TodoSchema)
  .errors({
    404: z.object({ message: z.string() }),
  })
  .summary("Get a todo")
  .description("Retrieves a single todo by its ID")
  .tags("todos")
  .operationId("getTodo");

export const createTodo = todos
  .post("/api/todos")
  .body(z.object({
    title: z.string().min(1).max(100).describe("Todo title"),
    completed: z.boolean().optional().describe("Initial completion status"),
  }))
  .response(201, TodoSchema)
  .summary("Create a todo")
  .description("Creates a new todo item")
  .tags("todos")
  .operationId("createTodo");

// app/api/openapi/route.ts
import { contractsToOpenAPI } from "@contract-kit/openapi";
import * as todoContracts from "@/contracts/todos";

const spec = contractsToOpenAPI(
  [
    todoContracts.listTodos,
    todoContracts.getTodo,
    todoContracts.createTodo,
  ],
  {
    title: "Todo API",
    version: "1.0.0",
    description: "A simple todo management API",
    servers: [
      { url: "https://api.example.com", description: "Production" },
      { url: "http://localhost:3000", description: "Development" },
    ],
    securitySchemes: {
      bearerAuth: {
        type: "http",
        scheme: "bearer",
        bearerFormat: "JWT",
      },
    },
    security: [{ bearerAuth: [] }],
  }
);

export function GET() {
  return Response.json(spec);
}

Limitations

  • Currently requires Zod schemas (uses zod-to-json-schema for conversion)
  • Other Standard Schema libraries will be supported in future versions

Best Practices

Use .describe() on all schema fields and add summaries/descriptions to all operations for comprehensive documentation.
Set custom operation IDs with .operationId() for better code generation in client SDKs.
Use tags to organize your API endpoints into logical groups in the documentation.
Define error schemas in .errors() to document all possible error responses.

Next Steps