API documentation is the thing everyone agrees is important and nobody wants to write. Routes get added, parameters change, response shapes evolve β and the docs stay frozen in time, three sprints behind reality.
What if you could point a script at your codebase and get an OpenAPI spec back? Not a perfect one β but a solid first draft that captures every route, its method, path parameters, and likely request/response shapes.
Thatβs what weβre building: a Python CLI that scans Express or FastAPI codebases, extracts route signatures, sends them to a local LLM via Ollama, and outputs valid OpenAPI 3.0 YAML. Your code never leaves your machine.
What Weβre Building
A single Python script β docgen.py β that:
- Recursively scans a directory for
.pyand.js/.tsfiles - Extracts REST API route definitions using regex
- Sends the extracted routes to a local Ollama model
- Outputs a complete OpenAPI 3.0 YAML spec
Prerequisites
- Python 3.10+
- Ollama installed and running
- A code-capable model β
codellama:13borqwen2.5-coder:14bwork well (see best models for coding locally)
ollama pull codellama:13b
Install dependencies:
pip install requests pyyaml
Step 1 β Extract Routes From Source Files
The first job is finding route definitions. Express uses patterns like app.get('/users', ...) or router.post('/items/:id', ...). FastAPI uses decorators like @app.get("/users"). Weβll handle both with regex.
Create docgen.py:
#!/usr/bin/env python3
"""Generate OpenAPI docs from Express/FastAPI routes using Ollama."""
import re
import sys
import os
import requests
import yaml
# Patterns for route extraction
EXPRESS_PATTERN = re.compile(
r'(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*[\'"]([^\'"]+)[\'"]',
re.IGNORECASE,
)
FASTAPI_PATTERN = re.compile(
r'@\w+\.(get|post|put|patch|delete)\s*\(\s*[\'"]([^\'"]+)[\'"]',
re.IGNORECASE,
)
EXTENSIONS = {".py", ".js", ".ts"}
SKIP_DIRS = {"node_modules", ".git", "__pycache__", "venv", ".venv", "dist"}
def find_routes(directory: str) -> list[dict]:
"""Walk a directory tree and extract API route signatures."""
routes = []
for root, dirs, files in os.walk(directory):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for fname in files:
ext = os.path.splitext(fname)[1]
if ext not in EXTENSIONS:
continue
filepath = os.path.join(root, fname)
with open(filepath, "r", errors="ignore") as f:
content = f.read()
for pattern in (EXPRESS_PATTERN, FASTAPI_PATTERN):
for match in pattern.finditer(content):
method, path = match.group(1).upper(), match.group(2)
# Grab surrounding lines for context
start = max(0, match.start() - 200)
end = min(len(content), match.end() + 300)
context = content[start:end].strip()
routes.append({
"method": method,
"path": path,
"file": os.path.relpath(filepath, directory),
"context": context,
})
return routes
The context field captures the code around each route β function body, parameter validation, response calls. This gives the LLM enough signal to infer request/response schemas.
Step 2 β Build the Prompt
The prompt is where the magic happens. We need to be specific about the output format β LLMs are much more reliable when you tell them exactly what structure to produce.
def build_prompt(routes: list[dict]) -> str:
"""Build a prompt that asks the LLM to generate an OpenAPI spec."""
route_descriptions = []
for r in routes:
route_descriptions.append(
f"### {r['method']} {r['path']}\n"
f"File: {r['file']}\n"
f"```\n{r['context']}\n```"
)
routes_block = "\n\n".join(route_descriptions)
return f"""Analyze these API route definitions and generate a complete OpenAPI 3.0 specification in YAML format.
For each route:
- Infer path parameters from the URL (e.g., :id or {{id}})
- Infer request body schema from the code context when visible
- Infer response schema from the code context when visible
- Add a brief description based on the route's purpose
- Use sensible HTTP status codes
Output ONLY valid YAML β no markdown fences, no explanation.
Routes found:
{routes_block}"""
Step 3 β Call Ollama and Generate the Spec
Now we send the prompt to Ollamaβs API and capture the YAML output:
OLLAMA_URL = "http://localhost:11434/api/generate"
def generate_spec(routes: list[dict], model: str = "codellama:13b") -> str:
"""Send routes to Ollama and get back an OpenAPI YAML spec."""
prompt = build_prompt(routes)
response = requests.post(
OLLAMA_URL,
json={
"model": model,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.2, "num_predict": 4096},
},
timeout=120,
)
response.raise_for_status()
return response.json()["response"]
def clean_yaml_output(raw: str) -> str:
"""Strip markdown fences if the model wraps the output."""
raw = raw.strip()
if raw.startswith("```"):
raw = re.sub(r"^```\w*\n?", "", raw)
raw = re.sub(r"\n?```$", "", raw)
return raw.strip()
We set temperature to 0.2 β low enough for consistent, structured output but not so low that the model gets repetitive. The num_predict cap of 4096 tokens is usually enough for 10-20 routes.
Step 4 β Wire It All Together
def main():
if len(sys.argv) < 2:
print("Usage: python docgen.py <project-directory> [--model MODEL] [--output FILE]")
sys.exit(1)
directory = sys.argv[1]
model = "codellama:13b"
output_file = None
args = sys.argv[2:]
for i, arg in enumerate(args):
if arg == "--model" and i + 1 < len(args):
model = args[i + 1]
elif arg == "--output" and i + 1 < len(args):
output_file = args[i + 1]
if not os.path.isdir(directory):
print(f"Error: {directory} is not a directory")
sys.exit(1)
print(f"Scanning {directory} for API routes...")
routes = find_routes(directory)
if not routes:
print("No routes found. Supported frameworks: Express, FastAPI.")
sys.exit(0)
print(f"Found {len(routes)} routes:")
for r in routes:
print(f" {r['method']:6s} {r['path']:30s} ({r['file']})")
print(f"\nGenerating OpenAPI spec with {model}...")
raw_output = generate_spec(routes, model)
spec_yaml = clean_yaml_output(raw_output)
# Validate it parses as YAML
try:
parsed = yaml.safe_load(spec_yaml)
# Re-dump for consistent formatting
spec_yaml = yaml.dump(parsed, default_flow_style=False, sort_keys=False)
except yaml.YAMLError as e:
print(f"Warning: LLM output wasn't valid YAML β saving raw output. Error: {e}")
if output_file:
with open(output_file, "w") as f:
f.write(spec_yaml)
print(f"Spec written to {output_file}")
else:
print("\n---")
print(spec_yaml)
if __name__ == "__main__":
main()
Running It
Point it at any Express or FastAPI project:
# Scan and print to stdout
python docgen.py ./my-express-app
# Save to a file
python docgen.py ./my-fastapi-project --output openapi.yaml
# Use a different model
python docgen.py ./my-app --model qwen2.5-coder:14b --output docs.yaml
Example output for a small Express app:
openapi: "3.0.0"
info:
title: API Documentation
version: "1.0.0"
paths:
/users:
get:
summary: List all users
responses:
"200":
description: Array of user objects
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: integer
name:
type: string
email:
type: string
post:
summary: Create a new user
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
email:
type: string
responses:
"201":
description: User created successfully
/users/{id}:
get:
summary: Get user by ID
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
"200":
description: User object
"404":
description: User not found
Tips for Better Results
Give the model more context. The script captures 200 characters before and 300 after each route match. If your route handlers are in separate files (common in Express), the context window might miss the actual logic. You can increase those numbers or refactor the script to follow imports.
Use a larger model for complex APIs. codellama:13b handles straightforward CRUD routes well. For APIs with nested schemas, authentication middleware, or complex validation (Zod, Pydantic), step up to a 30B+ model if your hardware supports it.
Iterate on the output. Treat the generated spec as a first draft. Load it into Swagger Editor to validate and refine. The LLM gets the structure right 80-90% of the time β youβre filling in the gaps, not starting from scratch.
Run it in CI. Add the script to your pipeline to detect undocumented routes. Compare the generated route list against your existing spec to catch drift.
Why Local?
Running this through Ollama instead of a cloud API means your source code never leaves your machine. That matters when youβre working with proprietary codebases, client projects under NDA, or anything in a regulated industry. For a deeper look at local AI for code tasks, see our local AI code review guide.
The trade-off is speed β a 13B model on a MacBook Pro takes 15-30 seconds per generation. For a one-off documentation task, thatβs fine. If youβre running this on every commit, consider a smaller model or batching routes.
What You Learned
- How to extract API route signatures from Express and FastAPI codebases using regex
- How to structure prompts for consistent, structured YAML output from an LLM
- How to call Ollamaβs API from Python and handle the response
- How to validate and clean LLM-generated YAML with PyYAML
The full script is under 120 lines. You can extend it with support for Hono, Koa, Django REST Framework, or any framework β just add another regex pattern. The LLM doesnβt care where the routes came from; it just needs the method, path, and surrounding code.
Related: Local AI Code Review with Ollama Β· Build a Text-to-SQL Assistant Β· Best AI Models for Coding Locally