Skip to main content

Overview

Contract Kit automatically generates OpenAPI 3.1 specifications from your contracts, providing comprehensive API documentation that works with tools like Swagger UI, Redoc, and Postman. This guide walks you through setting up OpenAPI generation, adding metadata, and serving interactive documentation.

Prerequisites

  • Existing Contract Kit project
  • Contracts defined with @contract-kit/core
  • Basic understanding of OpenAPI/Swagger

Installation

If you’re using the contract-kit meta package, OpenAPI generation is already included. Otherwise, install it separately:
npm install @contract-kit/openapi

Step 1: Create an OpenAPI Endpoint

First, create an API route that generates and serves your OpenAPI specification:
// app/api/openapi/route.ts
import { contractsToOpenAPI } from "contract-kit";
import { allContracts } from "@/contracts";

export async function GET(request: Request) {
  // Get the base URL from the request
  const url = new URL(request.url);
  const baseUrl = `${url.protocol}//${url.host}`;

  const spec = contractsToOpenAPI(allContracts, {
    title: "My API",
    version: "1.0.0",
    description: "A comprehensive API built with Contract Kit",
    servers: [
      {
        url: baseUrl,
        description: "Current server",
      },
      {
        url: "https://api.example.com",
        description: "Production",
      },
    ],
  });

  return Response.json(spec);
}

Collecting All Contracts

Create an index file that exports all your contracts:
// contracts/index.ts
export * from "./todos";
export * from "./users";

import { todoContracts } from "./todos";
import { userContracts } from "./users";

// Export as a single array for OpenAPI generation
export const allContracts = [...todoContracts, ...userContracts];

Step 2: Add OpenAPI Metadata to Contracts

Enhance your contracts with OpenAPI-specific metadata using builder methods:
// contracts/todos.ts
import { createContractGroup } from "contract-kit";
import { z } from "zod";

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

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

export const getTodo = todos
  .get("/api/todos/:id")
  .path(z.object({ 
    id: z.string().uuid().describe("Todo ID") 
  }))
  .response(200, TodoSchema)
  .errors({
    404: z.object({ 
      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("getTodo");

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("Maximum results per page"),
    offset: z.coerce.number().min(0).optional().describe("Pagination offset"),
  }))
  .response(200, z.object({
    todos: z.array(TodoSchema),
    total: z.number(),
  }))
  .summary("List all todos")
  .description("Returns a paginated list of todos with optional filtering")
  .tags("todos")
  .operationId("listTodos");

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

Available OpenAPI Builder Methods

MethodDescriptionExample
.summary(text)Brief operation summary.summary("Get a todo")
.description(text)Detailed description.description("Retrieves a single todo by ID")
.tags(...tags)Group operations (accumulates).tags("todos", "read-operations")
.operationId(id)Custom operation ID.operationId("getTodoById")
.deprecated(flag)Mark as deprecated.deprecated(true)
.externalDocs(url, desc?)Link to external docs.externalDocs("https://docs.example.com/todos")
.security(requirement)Security requirements.security({ bearerAuth: [] })
Use .describe() on all Zod schema fields for comprehensive field-level documentation in your OpenAPI spec.

Step 3: Configure Security Schemes

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

Per-Operation Security

Override security for specific operations:
// Public endpoint (no authentication required)
export const healthCheck = api
  .get("/api/health")
  .response(200, z.object({ status: z.string() }))
  .summary("Health check")
  .security({}); // Empty object = no security

// Endpoint requiring both bearer token and API key
export const deleteUser = api
  .delete("/api/users/:id")
  .path(z.object({ id: z.string() }))
  .response(204, z.void())
  .summary("Delete user")
  .security({ bearerAuth: [], apiKey: [] }); // Requires both

Step 4: Serve Interactive Documentation

Option A: Swagger UI

Create a route that serves Swagger UI for interactive documentation:
// app/api/docs/route.ts
export function GET() {
  const html = `
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8">
        <title>API Documentation</title>
        <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",
            deepLinking: true,
            presets: [
              SwaggerUIBundle.presets.apis,
              SwaggerUIBundle.SwaggerUIStandalonePreset
            ],
          });
        </script>
      </body>
    </html>
  `;
  
  return new Response(html, {
    headers: { "Content-Type": "text/html" },
  });
}

Option B: Redoc

Alternatively, use Redoc for a different documentation style:
// app/api/docs/route.ts
export function GET() {
  const html = `
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8">
        <title>API Documentation</title>
        <style>
          body { margin: 0; padding: 0; }
        </style>
      </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" },
  });
}
Now visit http://localhost:3000/api/docs to view your interactive API documentation!

Step 5: Organize with Tags

Use tags to group related operations in the documentation:
// Group by resource
export const listUsers = api
  .get("/api/users")
  .tags("users")
  .summary("List users");

export const getUser = api
  .get("/api/users/:id")
  .tags("users")
  .summary("Get user");

// Tags accumulate, allowing multiple categorizations
export const createUser = api
  .post("/api/users")
  .tags("users")        // By resource
  .tags("write-operations") // By operation type
  .summary("Create user");

Advanced Configuration

Server Variables

Define dynamic server URLs with variables:
const spec = contractsToOpenAPI(allContracts, {
  title: "My API",
  version: "1.0.0",
  servers: [
    {
      url: "https://{environment}.example.com",
      description: "API server",
      variables: {
        environment: {
          default: "api",
          enum: ["api", "staging", "dev"],
          description: "Environment name",
        },
      },
    },
  ],
});

Custom Media Types

Change the default JSON media type:
const spec = contractsToOpenAPI(allContracts, {
  title: "My API",
  version: "1.0.0",
  jsonMediaType: "application/vnd.api+json", // Custom media type
});

External Documentation

Link to external documentation for operations:
export const complexOperation = api
  .post("/api/complex")
  .body(ComplexSchema)
  .response(200, ResultSchema)
  .summary("Complex operation")
  .externalDocs(
    "https://docs.example.com/complex-operation",
    "Detailed guide on complex operations"
  );

Complete Example

Here’s a complete example bringing everything together:
// contracts/todos.ts
import { createContractGroup } from "contract-kit";
import { z } from "zod";

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

const TodoSchema = z.object({
  id: z.string().uuid().describe("Unique identifier"),
  title: z.string().min(1).max(200).describe("Todo title"),
  description: z.string().optional().describe("Optional description"),
  completed: z.boolean().describe("Completion status"),
  createdAt: z.string().datetime().describe("Creation timestamp"),
  updatedAt: z.string().datetime().describe("Last update 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(),
    limit: z.number(),
    offset: 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().uuid().describe("Todo ID") }))
  .response(200, TodoSchema)
  .errors({ 404: z.object({ message: z.string() }).describe("Todo not found") })
  .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(200).describe("Todo title"),
    description: z.string().optional().describe("Optional description"),
    completed: z.boolean().optional().describe("Initial completion status"),
  }))
  .response(201, TodoSchema)
  .response(400, z.object({ 
    message: z.string(),
    errors: z.array(z.string()).optional() 
  }))
  .summary("Create a todo")
  .description("Creates a new todo item")
  .tags("todos")
  .operationId("createTodo");

export const todoContracts = [listTodos, getTodo, createTodo];

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

export async function GET(request: Request) {
  const url = new URL(request.url);
  const baseUrl = `${url.protocol}//${url.host}`;

  const spec = contractsToOpenAPI(allContracts, {
    title: "Todo API",
    version: "1.0.0",
    description: "A simple todo management API built with Contract Kit",
    servers: [
      { url: baseUrl, description: "Current server" },
      { url: "https://api.example.com", description: "Production" },
    ],
    securitySchemes: {
      bearerAuth: {
        type: "http",
        scheme: "bearer",
        bearerFormat: "JWT",
        description: "JWT authentication token",
      },
    },
    security: [{ bearerAuth: [] }],
  });

  return Response.json(spec);
}

Testing Your OpenAPI Spec

Manual Testing

  1. Start your development server:
    npm run dev
    
  2. View the raw OpenAPI spec:
    curl http://localhost:3000/api/openapi | jq
    
  3. Visit your documentation page:
    • Swagger UI: http://localhost:3000/api/docs
    • Or use external tools like Swagger Editor by pasting your spec

Validate Your Spec

Use the OpenAPI validator to check your spec:
npx @apidevtools/swagger-cli validate http://localhost:3000/api/openapi

Best Practices

Use .describe() on all schema fields and add .summary() and .description() to all operations. This creates comprehensive, self-documenting API specifications that are invaluable for API consumers.
Set custom operation IDs with .operationId() for better code generation in client SDKs. Use consistent naming conventions like listTodos, getTodo, createTodo.
Use tags to organize your API endpoints into logical groups. This makes navigation easier in documentation tools and helps consumers understand your API structure.
Define comprehensive error schemas in .errors() to document all possible error responses. This helps API consumers handle errors properly.
Regenerate your OpenAPI spec whenever contracts change. Consider adding automated tests to ensure your spec stays in sync with your implementation.

Limitations

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

Troubleshooting

Ensure your schemas are properly referenced in contracts. The generator automatically extracts schemas, but they must be valid Zod schemas.
Verify that security scheme names match between the securitySchemes definition and .security() calls on contracts.
Make sure path parameters use the :param syntax in contract paths and are defined in .path() schemas.

Next Steps