Using LLMs to auto-generate API documentation from source code

By Mei-Ling Zhou · 16 July 2026125 views
Using LLMs to auto-generate API documentation from source code

The documentation gap in API development

API documentation degrades the moment code changes. A team that maintains its OpenAPI spec manually will inevitably let it drift: a new query parameter goes undocumented, a deprecated field lingers in the spec, error responses are incomplete. The result is developers guessing at API behavior, integration bugs that take hours to debug, and support burden that falls on the team that built the API.

The traditional solutions are all imperfect. Decorators-based generation (Swagger annotations, NestJS decorators) couples documentation to implementation in ways that add noise to the codebase. Spec-first development is disciplined but requires a workflow change that many teams don't adopt. Manual documentation doesn't scale.

LLM-based documentation generation offers a different path: parse the actual source code, send it through a model that understands code semantics, and generate structured documentation that accurately reflects what the code does. When wired into CI, it keeps documentation synchronized with implementation automatically.

This article builds a complete pipeline: route extraction, parameter inference, example generation, OpenAPI spec output, and CI integration.

Extracting routes from TypeScript/Node.js code

The first step is getting structured route information out of source code. The approach depends on your framework. Here's a general-purpose TypeScript parser using the @typescript-eslint/parser AST:

import * as parser from "@typescript-eslint/parser";
import * as fs from "fs/promises";
import * as path from "path";
import { glob } from "glob";

interface RouteParam {
  name: string;
  location: "path" | "query" | "body" | "header";
  type: string;
  required: boolean;
  description?: string;
}

interface ExtractedRoute {
  method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
  path: string;
  handlerName: string;
  handlerCode: string;
  filePath: string;
  params: RouteParam[];
  responseTypes: string[];
}

function extractRoutesFromAST(sourceCode: string, filePath: string): Partial<ExtractedRoute>[] {
  const ast = parser.parse(sourceCode, {
    jsx: false,
    range: true,
    loc: true,
  });

  const routes: Partial<ExtractedRoute>[] = [];

  function visit(node: any): void {
    if (
      node.type === "CallExpression" &&
      node.callee?.type === "MemberExpression"
    ) {
      const method = node.callee.property?.name?.toUpperCase();
      if (
        ["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method) &&
        node.arguments?.length >= 2
      ) {
        const pathArg = node.arguments[0];
        const handlerArg = node.arguments[node.arguments.length - 1];

        if (pathArg?.type === "Literal" && typeof pathArg.value === "string") {
          const route: Partial<ExtractedRoute> = {
            method: method as ExtractedRoute["method"],
            path: pathArg.value,
            filePath,
          };

          if (handlerArg?.type === "ArrowFunctionExpression" || handlerArg?.type === "FunctionExpression") {
            // Extract the handler source
            route.handlerCode = sourceCode.slice(handlerArg.range[0], handlerArg.range[1]);
          }

          routes.push(route);
        }
      }
    }

    for (const key of Object.keys(node)) {
      if (key === "parent") continue;
      const child = node[key];
      if (child && typeof child === "object") {
        if (Array.isArray(child)) {
          child.forEach((c) => c && typeof c.type === "string" && visit(c));
        } else if (typeof child.type === "string") {
          visit(child);
        }
      }
    }
  }

  visit(ast);
  return routes;
}

async function extractAllRoutes(rootDir: string): Promise<Partial<ExtractedRoute>[]> {
  const files = await glob("**/*.{ts,js}", {
    cwd: rootDir,
    ignore: ["node_modules/**", "**/*.test.*", "**/*.spec.*", "dist/**"],
    absolute: true,
  });

  const allRoutes: Partial<ExtractedRoute>[] = [];

  for (const file of files) {
    const source = await fs.readFile(file, "utf8");
    // Quick check before parsing
    if (!/\.(get|post|put|patch|delete)\s*\(/.test(source)) continue;

    try {
      const routes = extractRoutesFromAST(source, file);
      allRoutes.push(...routes);
    } catch (err) {
      console.warn(`Failed to parse ${file}: ${err}`);
    }
  }

  return allRoutes;
}

For Hono, Express, or Fastify, the pattern is the same — look for method call expressions. For NestJS or other decorator-based frameworks, you'd parse decorators instead of function calls.

LLM-based documentation generation per route

With route information extracted, send each route's handler code to the LLM for documentation. The model analyzes what the code does, infers parameters from validation middleware and type annotations, and generates structured documentation:

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

interface RouteDocumentation {
  summary: string;
  description: string;
  parameters: Array<{
    name: string;
    in: "path" | "query" | "header" | "cookie";
    description: string;
    required: boolean;
    schema: {
      type: string;
      format?: string;
      enum?: string[];
      minimum?: number;
      maximum?: number;
    };
  }>;
  requestBody?: {
    description: string;
    required: boolean;
    content: Record<
      string,
      {
        schema: {
          type: string;
          properties: Record<string, unknown>;
          required?: string[];
        };
        example?: unknown;
      }
    >;
  };
  responses: Record<
    string,
    {
      description: string;
      content?: Record<string, { schema: unknown; example?: unknown }>;
    }
  >;
  tags: string[];
  deprecated?: boolean;
}

async function documentRoute(
  route: Partial<ExtractedRoute>,
  typeDefinitions?: string
): Promise<RouteDocumentation> {
  const contextBlock = typeDefinitions
    ? `\nRelevant TypeScript types:\n\`\`\`typescript\n${typeDefinitions}\n\`\`\``
    : "";

  const prompt = `Analyze this API route handler and generate accurate OpenAPI 3.0 documentation.

METHOD: ${route.method}
PATH: ${route.path}
FILE: ${path.basename(route.filePath ?? "")}
${contextBlock}

HANDLER CODE:
\`\`\`typescript
${route.handlerCode ?? "// Handler code not available"}
\`\`\`

Generate documentation that:
1. Accurately reflects what the code ACTUALLY does, not what it should do
2. Documents ALL parameters visible in the code (path params, query params, body fields)
3. Lists ALL response codes the code can return (look for status() calls, throw statements, error handlers)
4. Includes realistic examples based on the field names and types
5. Notes if the endpoint appears to require authentication (look for auth middleware, token checks)
6. Marks fields as required/optional based on code validation

Return valid JSON matching the OpenAPI 3.0 operation object schema.`;

  const response = await client.messages.create({
    model: "claude-opus-4-5",
    max_tokens: 2048,
    system:
      "You are an API documentation generator. Analyze code precisely and generate accurate OpenAPI 3.0 documentation. Return only valid JSON — no markdown, no explanation.",
    messages: [{ role: "user", content: prompt }],
  });

  const text =
    response.content[0].type === "text" ? response.content[0].text : "";

  // Extract JSON from response
  const jsonMatch = text.match(/\{[\s\S]*\}/);
  if (!jsonMatch) throw new Error(`No JSON found in response for ${route.path}`);

  return JSON.parse(jsonMatch[0]);
}

Extracting type definitions to improve accuracy

The handler code alone often lacks full type information. Query parameter types may be defined elsewhere; request body shapes are TypeScript interfaces. Extracting relevant type definitions significantly improves documentation quality:

async function extractRelevantTypes(
  handlerCode: string,
  rootDir: string
): Promise<string> {
  // Find type names referenced in the handler
  const typeNames = new Set<string>();
  const typeRefPattern = /:\s*([A-Z][a-zA-Z]+)/g;
  let match;
  while ((match = typeRefPattern.exec(handlerCode)) !== null) {
    typeNames.add(match[1]);
  }

  if (typeNames.size === 0) return "";

  // Search for type definitions in the codebase
  const typeFiles = await glob("**/*.{ts,d.ts}", {
    cwd: rootDir,
    ignore: ["node_modules/**", "dist/**"],
    absolute: true,
  });

  const relevantTypes: string[] = [];

  for (const file of typeFiles) {
    const source = await fs.readFile(file, "utf8");
    for (const typeName of typeNames) {
      const typePattern = new RegExp(
        `(interface|type)\\s+${typeName}[\\s<{=]`,
        "g"
      );
      if (typePattern.test(source)) {
        // Extract the full type definition
        const typeMatch = source.match(
          new RegExp(
            `(interface|type)\\s+${typeName}[\\s<][\\s\\S]*?(?=\\n(?:interface|type|export|import|const|let|var)|$)`,
            "m"
          )
        );
        if (typeMatch) {
          relevantTypes.push(typeMatch[0].trim());
        }
      }
    }
  }

  return relevantTypes.join("\n\n");
}

Assembling the full OpenAPI specification

Once you have per-route documentation, assemble it into a complete OpenAPI 3.0 spec:

interface APIMetadata {
  title: string;
  version: string;
  description: string;
  serverUrl: string;
}

function assembleOpenAPISpec(
  routes: Array<{ route: Partial<ExtractedRoute>; docs: RouteDocumentation }>,
  metadata: APIMetadata
): object {
  const paths: Record<string, Record<string, unknown>> = {};

  for (const { route, docs } of routes) {
    if (!route.path || !route.method) continue;

    // Convert Express path params to OpenAPI format
    const openAPIPath = route.path.replace(/:([^/]+)/g, "{$1}");

    if (!paths[openAPIPath]) {
      paths[openAPIPath] = {};
    }

    paths[openAPIPath][route.method.toLowerCase()] = {
      summary: docs.summary,
      description: docs.description,
      tags: docs.tags,
      parameters: docs.parameters,
      ...(docs.requestBody ? { requestBody: docs.requestBody } : {}),
      responses: docs.responses,
      ...(docs.deprecated ? { deprecated: true } : {}),
    };
  }

  return {
    openapi: "3.0.3",
    info: {
      title: metadata.title,
      version: metadata.version,
      description: metadata.description,
    },
    servers: [{ url: metadata.serverUrl }],
    paths,
  };
}

async function generateOpenAPISpec(
  rootDir: string,
  metadata: APIMetadata
): Promise<object> {
  console.log("Extracting routes...");
  const routes = await extractAllRoutes(rootDir);
  console.log(`Found ${routes.length} routes`);

  const documented: Array<{
    route: Partial<ExtractedRoute>;
    docs: RouteDocumentation;
  }> = [];

  // Process routes with concurrency limit
  const CONCURRENCY = 5;
  const chunks = [];
  for (let i = 0; i < routes.length; i += CONCURRENCY) {
    chunks.push(routes.slice(i, i + CONCURRENCY));
  }

  for (const chunk of chunks) {
    const results = await Promise.all(
      chunk.map(async (route) => {
        if (!route.handlerCode) return null;

        try {
          const typeContext = await extractRelevantTypes(
            route.handlerCode,
            rootDir
          );
          const docs = await documentRoute(route, typeContext);
          return { route, docs };
        } catch (err) {
          console.warn(`Failed to document ${route.method} ${route.path}: ${err}`);
          return null;
        }
      })
    );

    documented.push(...results.filter(Boolean) as typeof documented);
  }

  console.log(`Successfully documented ${documented.length}/${routes.length} routes`);

  return assembleOpenAPISpec(documented, metadata);
}

Detecting documentation drift in CI

The most valuable use of this pipeline is automated drift detection: generate docs on every PR and fail the build if the generated spec differs meaningfully from the committed one.

import * as yaml from "js-yaml";
import * as crypto from "crypto";

function normalizeSpec(spec: object): string {
  // Sort keys for deterministic comparison
  return JSON.stringify(spec, Object.keys(spec).sort());
}

function specHash(spec: object): string {
  return crypto
    .createHash("sha256")
    .update(normalizeSpec(spec))
    .digest("hex");
}

async function checkDocumentationDrift(
  rootDir: string,
  committedSpecPath: string,
  metadata: APIMetadata
): Promise<{
  hasDrift: boolean;
  newPaths: string[];
  removedPaths: string[];
  changedPaths: string[];
}> {
  const [generatedSpec, committedRaw] = await Promise.all([
    generateOpenAPISpec(rootDir, metadata),
    fs.readFile(committedSpecPath, "utf8"),
  ]);

  const committedSpec = yaml.load(committedRaw) as any;
  const generated = generatedSpec as any;

  const generatedPaths = new Set(Object.keys(generated.paths ?? {}));
  const committedPaths = new Set(Object.keys(committedSpec.paths ?? {}));

  const newPaths = [...generatedPaths].filter((p) => !committedPaths.has(p));
  const removedPaths = [...committedPaths].filter(
    (p) => !generatedPaths.has(p)
  );
  const changedPaths = [...generatedPaths]
    .filter((p) => committedPaths.has(p))
    .filter(
      (p) =>
        JSON.stringify(generated.paths[p]) !==
        JSON.stringify(committedSpec.paths[p])
    );

  const hasDrift =
    newPaths.length > 0 ||
    removedPaths.length > 0 ||
    changedPaths.length > 0;

  return { hasDrift, newPaths, removedPaths, changedPaths };
}

The CI script ties this together:

#!/bin/bash
set -e

echo "Checking API documentation drift..."

node -e "
const { checkDocumentationDrift } = require('./scripts/doc-gen');

checkDocumentationDrift(
  './src',
  './docs/openapi.yaml',
  {
    title: 'My API',
    version: '1.0.0',
    description: 'API documentation',
    serverUrl: 'https://api.example.com'
  }
).then(result => {
  if (result.hasDrift) {
    console.error('Documentation is out of date!');
    if (result.newPaths.length) console.error('New undocumented paths:', result.newPaths.join(', '));
    if (result.removedPaths.length) console.error('Removed paths still in docs:', result.removedPaths.join(', '));
    if (result.changedPaths.length) console.error('Changed paths:', result.changedPaths.join(', '));
    console.error('Run: npm run docs:generate');
    process.exit(1);
  }
  console.log('Documentation is up to date.');
});
"

Generating human-readable prose documentation

OpenAPI specs are machine-readable but not always developer-friendly. Generate a companion Markdown document that reads naturally:

async function generateMarkdownDocs(
  spec: any,
  routes: Array<{ route: Partial<ExtractedRoute>; docs: RouteDocumentation }>
): Promise<string> {
  const sections = await Promise.all(
    routes.map(async ({ route, docs }) => {
      const response = await client.messages.create({
        model: "claude-haiku-4-5",
        max_tokens: 1024,
        messages: [
          {
            role: "user",
            content: `Write a concise Markdown documentation section for this API endpoint.

${route.method} ${route.path}

Documentation object: ${JSON.stringify(docs, null, 2)}

Write it for developers who are integrating with this API. Include:
- A clear description of what it does
- Parameter table if there are parameters
- Request body example if applicable
- Response examples for success and error cases
- Any authentication requirements

Use Markdown formatting. Start with ### ${route.method} \`${route.path}\``,
          },
        ],
      });

      return response.content[0].type === "text"
        ? response.content[0].text
        : "";
    })
  );

  const header = `# API Reference

${spec.info.description}

**Version:** ${spec.info.version}  
**Base URL:** ${spec.servers?.[0]?.url ?? ""}

---

`;

  return header + sections.join("\n\n---\n\n");
}

Handling authentication and middleware documentation

Routes often rely on middleware for authentication, rate limiting, and request validation. The handler code alone doesn't show this context. Augment your extraction with middleware analysis:

async function extractMiddlewareContext(
  filePath: string,
  routePath: string
): Promise<string[]> {
  const source = await fs.readFile(filePath, "utf8");

  const middlewareNotes: string[] = [];

  // Look for authentication middleware patterns
  if (/authenticate|requireAuth|verifyToken|isAuthenticated/i.test(source)) {
    middlewareNotes.push("Requires authentication (Bearer token)");
  }

  if (/rateLimit|throttle/i.test(source)) {
    middlewareNotes.push("Rate limited");
  }

  if (/validateBody|validate\(|z\.parse|yup\./i.test(source)) {
    middlewareNotes.push("Request body is validated");
  }

  return middlewareNotes;
}

Pass these notes to the documentation generation prompt as additional context. The resulting documentation will correctly show the Authorization header as required for protected endpoints.

Cost and performance considerations

Documentation generation makes one LLM call per route. For an API with 50 routes, that's 50 calls at roughly 1000-2000 input tokens each. At Claude Haiku pricing, the full generation costs well under $0.10. For larger APIs (500+ routes), consider:

  • Caching: Store generated docs keyed by a hash of the handler code. Only regenerate when the handler changes.
  • Incremental updates: In CI, only regenerate docs for routes whose files changed in the PR (use git diff --name-only).
  • Parallelism: Use the concurrency-limited Promise.all pattern to process 10-20 routes simultaneously.

With caching and incremental updates, steady-state CI cost is minimal — you're only paying for routes that actually changed. The initial generation is a one-time investment.

The compounding benefit of LLM-generated documentation is that it improves over time. As you refine your generation prompts and extract more context from the codebase, every subsequent run produces better output. After a few iterations of tuning, the documentation quality typically exceeds what developers would write manually — without any of the maintenance burden.

Comments

No comments yet. Be the first!

Sign in to leave a comment.