Terminal.skills
Use Cases/Build an Automated API Documentation Generator

Build an Automated API Documentation Generator

Build a system that auto-generates OpenAPI documentation from TypeScript route handlers, validates examples against schemas, and publishes an interactive API reference that stays in sync with code.

#web-framework#edge#cloudflare#bun#deno
Works with:claude-codeopenai-codexgemini-clicursor
$

The Problem

Raj leads developer relations at a 30-person API company. Documentation is written manually in a Notion doc and is perpetually outdated — 40% of endpoints have incorrect request/response examples. Customers file support tickets saying "the API doesn't match the docs." New hires spend days figuring out which parameters are required. When developers change an endpoint, they forget to update docs. They need documentation that's generated from the actual code, validated automatically, and published with every deployment.

Step 1: Build the Schema-to-OpenAPI Generator

typescript
// src/docs/openapi-generator.ts — Generate OpenAPI spec from Zod schemas and route metadata
import { z, ZodType, ZodObject, ZodString, ZodNumber, ZodBoolean, ZodEnum, ZodArray, ZodOptional } from "zod";

interface RouteMetadata {
  method: string;
  path: string;
  summary: string;
  description?: string;
  tags: string[];
  requestBody?: ZodType;
  queryParams?: ZodType;
  pathParams?: ZodType;
  responseBody: ZodType;
  responseCode?: number;
  auth?: "bearer" | "apiKey" | "none";
  deprecated?: boolean;
  examples?: {
    request?: any;
    response?: any;
  };
}

export function generateOpenAPI(routes: RouteMetadata[], info: {
  title: string;
  version: string;
  description: string;
  serverUrl: string;
}): Record<string, any> {
  const spec: any = {
    openapi: "3.1.0",
    info: {
      title: info.title,
      version: info.version,
      description: info.description,
    },
    servers: [{ url: info.serverUrl }],
    paths: {},
    components: {
      schemas: {},
      securitySchemes: {
        bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
        apiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" },
      },
    },
  };

  for (const route of routes) {
    const pathKey = route.path.replace(/:(\w+)/g, "{$1}");
    if (!spec.paths[pathKey]) spec.paths[pathKey] = {};

    const operation: any = {
      summary: route.summary,
      description: route.description,
      tags: route.tags,
      deprecated: route.deprecated || false,
      responses: {
        [route.responseCode || 200]: {
          description: "Successful response",
          content: {
            "application/json": {
              schema: zodToJsonSchema(route.responseBody),
              ...(route.examples?.response ? { example: route.examples.response } : {}),
            },
          },
        },
        401: { description: "Unauthorized" },
        422: { description: "Validation error" },
      },
    };

    if (route.auth && route.auth !== "none") {
      operation.security = [{ [route.auth === "bearer" ? "bearerAuth" : "apiKeyAuth"]: [] }];
    }

    if (route.requestBody) {
      operation.requestBody = {
        required: true,
        content: {
          "application/json": {
            schema: zodToJsonSchema(route.requestBody),
            ...(route.examples?.request ? { example: route.examples.request } : {}),
          },
        },
      };
    }

    if (route.queryParams) {
      operation.parameters = [
        ...(operation.parameters || []),
        ...zodToQueryParams(route.queryParams),
      ];
    }

    if (route.pathParams) {
      operation.parameters = [
        ...(operation.parameters || []),
        ...zodToPathParams(route.pathParams),
      ];
    }

    spec.paths[pathKey][route.method.toLowerCase()] = operation;
  }

  return spec;
}

// Convert Zod schema to JSON Schema
function zodToJsonSchema(schema: ZodType): Record<string, any> {
  if (schema instanceof ZodObject) {
    const shape = schema.shape;
    const properties: Record<string, any> = {};
    const required: string[] = [];

    for (const [key, value] of Object.entries(shape)) {
      properties[key] = zodToJsonSchema(value as ZodType);
      if (!(value instanceof ZodOptional)) required.push(key);
    }

    return { type: "object", properties, ...(required.length > 0 ? { required } : {}) };
  }

  if (schema instanceof ZodString) return { type: "string" };
  if (schema instanceof ZodNumber) return { type: "number" };
  if (schema instanceof ZodBoolean) return { type: "boolean" };
  if (schema instanceof ZodEnum) return { type: "string", enum: schema.options };
  if (schema instanceof ZodArray) return { type: "array", items: zodToJsonSchema((schema as any)._def.type) };
  if (schema instanceof ZodOptional) return zodToJsonSchema((schema as any)._def.innerType);

  return { type: "object" };
}

function zodToQueryParams(schema: ZodType): any[] {
  if (!(schema instanceof ZodObject)) return [];
  const params = [];
  for (const [key, value] of Object.entries(schema.shape)) {
    params.push({
      name: key,
      in: "query",
      required: !(value instanceof ZodOptional),
      schema: zodToJsonSchema(value as ZodType),
    });
  }
  return params;
}

function zodToPathParams(schema: ZodType): any[] {
  if (!(schema instanceof ZodObject)) return [];
  const params = [];
  for (const [key, value] of Object.entries(schema.shape)) {
    params.push({
      name: key,
      in: "path",
      required: true,
      schema: zodToJsonSchema(value as ZodType),
    });
  }
  return params;
}

// Validate examples against schemas
export function validateExamples(routes: RouteMetadata[]): Array<{ path: string; error: string }> {
  const errors = [];

  for (const route of routes) {
    if (route.examples?.request && route.requestBody) {
      const result = route.requestBody.safeParse(route.examples.request);
      if (!result.success) {
        errors.push({
          path: `${route.method} ${route.path} (request)`,
          error: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", "),
        });
      }
    }

    if (route.examples?.response && route.responseBody) {
      const result = route.responseBody.safeParse(route.examples.response);
      if (!result.success) {
        errors.push({
          path: `${route.method} ${route.path} (response)`,
          error: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", "),
        });
      }
    }
  }

  return errors;
}

Step 2: Build the Documentation Server

typescript
// src/docs/server.ts — Serve interactive API documentation
import { Hono } from "hono";
import { generateOpenAPI, validateExamples } from "./openapi-generator";
import { routes } from "../routes/registry";

const app = new Hono();

// Serve OpenAPI spec
app.get("/docs/openapi.json", (c) => {
  const spec = generateOpenAPI(routes, {
    title: "Platform API",
    version: "2.1.0",
    description: "API reference — auto-generated from source code",
    serverUrl: process.env.API_URL || "https://api.example.com",
  });
  return c.json(spec);
});

// Serve Scalar UI (modern API docs viewer)
app.get("/docs", (c) => {
  return c.html(`
    <!DOCTYPE html>
    <html>
    <head><title>API Documentation</title></head>
    <body>
      <script id="api-reference" data-url="/docs/openapi.json"></script>
      <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
    </body>
    </html>
  `);
});

// CI endpoint: validate docs are correct
app.get("/docs/validate", (c) => {
  const errors = validateExamples(routes);
  if (errors.length > 0) {
    return c.json({ valid: false, errors }, 422);
  }
  return c.json({ valid: true, routeCount: routes.length });
});

export default app;

Results

  • Documentation is always in sync — generated from the same Zod schemas that validate requests; changing an endpoint automatically updates docs
  • 40% of support tickets eliminated — "API doesn't match docs" tickets dropped to zero; examples are validated against schemas in CI
  • New developer onboarding cut from 3 days to 4 hours — interactive docs with "Try It" functionality let developers explore the API immediately
  • CI catches doc drift — the validation endpoint runs in CI; if an example no longer matches its schema, the build fails before merging
  • Zero maintenance effort — no manual Notion docs to maintain; the code IS the documentation