Files
2026-07-13 21:35:40 +08:00

760 lines
26 KiB
Python

#!/usr/bin/env python3
"""Generate OpenAPI 3.0 spec from source code route definitions.
Supported frameworks:
Python — Flask, FastAPI, Django REST Framework
JS/TS — Express.js
Go — Gin, Echo
"""
import ast
import json
import os
import re
import sys
import argparse
from pathlib import Path
PYTHON_TYPE_MAP = {
"int": {"type": "integer"},
"float": {"type": "number", "format": "float"},
"str": {"type": "string"},
"bool": {"type": "boolean"},
"bytes": {"type": "string", "format": "binary"},
"date": {"type": "string", "format": "date"},
"datetime": {"type": "string", "format": "date-time"},
"UUID": {"type": "string", "format": "uuid"},
"uuid": {"type": "string", "format": "uuid"},
}
HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head", "options"}
FLASK_DECORATORS = {"route", "get", "post", "put", "delete", "patch", "head", "options"}
FASTAPI_DECORATORS = {"get", "post", "put", "delete", "patch", "head", "options", "api_route"}
SKIP_DIRS = {
"node_modules", "__pycache__", "venv", ".venv", "env", ".env",
".git", ".hg", "dist", "build", "vendor", ".tox", ".mypy_cache",
}
# ── Helpers ────────────────────────────────────────────────────────────────
def convert_path_params(path, framework):
"""Convert framework path-param syntax to OpenAPI {param} format."""
params = []
if framework == "flask":
def _replace(m):
type_str, name = m.group(1), m.group(2)
ptype = {"int": "integer", "float": "number", "string": "string",
"path": "string"}.get(type_str, "string") if type_str else "string"
params.append({"name": name, "type": ptype})
return "{" + name + "}"
path = re.sub(r"<(?:(\w+):)?(\w+)>", _replace, path)
elif framework in ("express", "gin", "echo"):
def _replace(m):
name = m.group(1)
params.append({"name": name, "type": "string"})
return "{" + name + "}"
path = re.sub(r":(\w+)", _replace, path)
elif framework == "fastapi":
for m in re.finditer(r"\{(\w+)\}", path):
params.append({"name": m.group(1), "type": "string"})
return path, params
def extract_docstring(node):
"""Extract docstring from a Python function AST node."""
if (node.body
and isinstance(node.body[0], ast.Expr)
and isinstance(node.body[0].value, ast.Constant)
and isinstance(node.body[0].value.value, str)):
return node.body[0].value.value
return ""
def annotation_to_str(node):
"""Convert an AST annotation node to its string representation."""
if node is None:
return None
if isinstance(node, ast.Constant):
return str(node.value)
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
base = annotation_to_str(node.value)
return f"{base}.{node.attr}" if base else node.attr
if isinstance(node, ast.Subscript):
base = annotation_to_str(node.value)
sl = annotation_to_str(node.slice)
return f"{base}[{sl}]" if base and sl else base
if isinstance(node, ast.Tuple):
parts = [annotation_to_str(e) for e in node.elts]
return ", ".join(p for p in parts if p)
return None
def python_type_to_schema(type_str):
"""Map a Python type-annotation string to an OpenAPI schema dict."""
if type_str in PYTHON_TYPE_MAP:
return dict(PYTHON_TYPE_MAP[type_str])
m = re.match(r"(?:List|list)\[(\w+)]", type_str)
if m:
return {"type": "array", "items": python_type_to_schema(m.group(1))}
m = re.match(r"Optional\[(\w+)]", type_str)
if m:
schema = python_type_to_schema(m.group(1))
schema["nullable"] = True
return schema
if type_str.startswith(("Dict[", "dict[")):
return {"type": "object"}
if type_str and type_str[0].isupper():
return {"$ref": f"#/components/schemas/{type_str}"}
return {"type": "string"}
def _should_skip(path):
"""Return True if *path* is inside a directory we never want to scan."""
return any(part in SKIP_DIRS for part in path.parts)
# ── Python Extractor (Flask / FastAPI / Django) ───────────────────────────
class PythonExtractor:
"""Extract API routes from Python source files via AST."""
def extract(self, filepath, framework=None):
try:
source = Path(filepath).read_text(encoding="utf-8")
tree = ast.parse(source, filename=filepath)
except (SyntaxError, UnicodeDecodeError, OSError):
return []
fw = framework or self._detect(tree)
if fw is None:
return []
tag = Path(filepath).stem
routes = []
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for deco in node.decorator_list:
parsed = self._parse_decorator(deco, node, fw, tag)
if parsed:
routes.extend(parsed)
return routes
# ── private ──
@staticmethod
def _detect(tree):
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
low = alias.name.lower()
if "fastapi" in low:
return "fastapi"
if "flask" in low:
return "flask"
if "django" in low:
return "django"
elif isinstance(node, ast.ImportFrom) and node.module:
mod = node.module.lower()
if "fastapi" in mod:
return "fastapi"
if "flask" in mod:
return "flask"
if "rest_framework" in mod or "django" in mod:
return "django"
return None
def _parse_decorator(self, deco, func, fw, tag):
if not isinstance(deco, ast.Call):
return None
attr = None
if isinstance(deco.func, ast.Attribute):
attr = deco.func.attr
elif isinstance(deco.func, ast.Name):
attr = deco.func.id
if attr is None:
return None
if fw in ("flask", "fastapi"):
return self._parse_flask_fastapi(deco, func, attr, fw, tag)
if fw == "django":
return self._parse_django(deco, func, attr, tag)
return None
def _parse_flask_fastapi(self, deco, func, attr, fw, tag):
allowed = FLASK_DECORATORS if fw == "flask" else FASTAPI_DECORATORS
if attr not in allowed:
return None
if not deco.args:
return None
path_node = deco.args[0]
if not isinstance(path_node, ast.Constant) or not isinstance(path_node.value, str):
return None
path = path_node.value
methods = []
if attr in ("route", "api_route"):
for kw in deco.keywords:
if kw.arg == "methods" and isinstance(kw.value, (ast.List, ast.Tuple)):
for elt in kw.value.elts:
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
methods.append(elt.value.lower())
if not methods:
methods = ["get"]
else:
methods = [attr.lower()]
openapi_path, path_params = convert_path_params(path, fw)
# Enrich path param types from function annotations
arg_types = {}
for arg in func.args.args:
if arg.annotation:
ts = annotation_to_str(arg.annotation)
if ts:
arg_types[arg.arg] = ts
for pp in path_params:
ts = arg_types.get(pp["name"])
if ts and ts in PYTHON_TYPE_MAP:
pp["type"] = PYTHON_TYPE_MAP[ts].get("type", pp["type"])
docstring = extract_docstring(func)
summary = docstring.split("\n")[0].strip() if docstring else ""
description = docstring.strip() if docstring else ""
query_params, body_schema = self._func_params(func, fw, path_params)
response_schema = None
status_code = None
for kw in deco.keywords:
if kw.arg == "response_model":
ts = annotation_to_str(kw.value)
if ts:
response_schema = python_type_to_schema(ts)
elif kw.arg == "status_code" and isinstance(kw.value, ast.Constant):
status_code = kw.value.value
routes = []
for method in methods:
if method not in HTTP_METHODS:
continue
route = {
"method": method,
"path": openapi_path,
"summary": summary,
"description": description,
"operation_id": func.name,
"tags": [tag],
"parameters": [],
"responses": {},
}
for pp in path_params:
route["parameters"].append({
"name": pp["name"], "in": "path",
"required": True,
"schema": {"type": pp["type"]},
})
for qp in query_params:
entry = {
"name": qp["name"], "in": "query",
"required": qp.get("required", False),
"schema": qp.get("schema", {"type": "string"}),
}
if qp.get("description"):
entry["description"] = qp["description"]
route["parameters"].append(entry)
if body_schema and method in ("post", "put", "patch"):
route["request_body"] = {
"required": True,
"content": {"application/json": {"schema": body_schema}},
}
sc = str(status_code or (201 if method == "post" else 200))
if response_schema:
route["responses"][sc] = {
"description": "Successful response",
"content": {"application/json": {"schema": response_schema}},
}
else:
route["responses"][sc] = {"description": "Successful response"}
routes.append(route)
return routes
@staticmethod
def _parse_django(deco, func, attr, tag):
if attr != "api_view":
return None
methods = []
if deco.args and isinstance(deco.args[0], (ast.List, ast.Tuple)):
for elt in deco.args[0].elts:
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
methods.append(elt.value.lower())
if not methods:
methods = ["get"]
docstring = extract_docstring(func)
summary = docstring.split("\n")[0].strip() if docstring else ""
routes = []
for method in methods:
routes.append({
"method": method,
"path": "/" + func.name.replace("_", "-"),
"summary": summary,
"description": docstring.strip() if docstring else "",
"operation_id": func.name,
"tags": [tag],
"parameters": [],
"responses": {"200": {"description": "Successful response"}},
})
return routes
@staticmethod
def _func_params(func, fw, path_params):
"""Extract query params and body schema from function signature."""
query_params = []
body_schema = None
path_names = {p["name"] for p in path_params}
skip = {"self", "cls", "request", "req", "res", "db", "session", "background_tasks"}
args = func.args
num_defaults = len(args.defaults)
num_args = len(args.args)
for idx, arg in enumerate(args.args):
name = arg.arg
if name in skip or name in path_names:
continue
type_str = annotation_to_str(arg.annotation) if arg.annotation else None
if fw == "fastapi" and type_str:
schema = python_type_to_schema(type_str)
if "$ref" in schema or schema.get("type") == "object":
body_schema = schema
continue
has_default = idx >= (num_args - num_defaults)
schema = python_type_to_schema(type_str) if type_str else {"type": "string"}
query_params.append({
"name": name,
"schema": schema,
"required": not has_default,
})
return query_params, body_schema
# ── JavaScript / TypeScript Extractor (Express) ──────────────────────────
class JSExtractor:
"""Extract API routes from JS/TS files (Express.js)."""
_ROUTE_RE = re.compile(
r"""(\w+)\s*\.\s*(get|post|put|delete|patch|head|options)\s*\(\s*['"]([^'"]+)['"]""",
re.IGNORECASE,
)
_JSDOC_RE = re.compile(r"/\*\*([\s\S]*?)\*/\s*$")
def extract(self, filepath):
try:
content = Path(filepath).read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
return []
tag = Path(filepath).stem
routes = []
for m in self._ROUTE_RE.finditer(content):
method = m.group(2).lower()
path = m.group(3)
jsdoc_match = self._JSDOC_RE.search(content[:m.start()])
summary = self._jsdoc_summary(jsdoc_match.group(1)) if jsdoc_match else ""
openapi_path, path_params = convert_path_params(path, "express")
op_id = method + "_" + re.sub(r"[{}]", "", openapi_path).strip("/").replace("/", "_")
route = {
"method": method,
"path": openapi_path,
"summary": summary,
"description": "",
"operation_id": op_id,
"tags": [tag],
"parameters": [
{"name": p["name"], "in": "path", "required": True,
"schema": {"type": p["type"]}}
for p in path_params
],
"responses": {"200": {"description": "Successful response"}},
}
if method in ("post", "put", "patch"):
route["request_body"] = {
"required": True,
"content": {"application/json": {"schema": {"type": "object"}}},
}
routes.append(route)
return routes
@staticmethod
def _jsdoc_summary(text):
for line in text.strip().split("\n"):
line = line.strip().lstrip("* ").strip()
if line and not line.startswith("@"):
return line
return ""
# ── Go Extractor (Gin / Echo) ────────────────────────────────────────────
class GoExtractor:
"""Extract API routes from Go files (Gin, Echo)."""
_ROUTE_RE = re.compile(
r"""(?://\s*(.*?)\n\s*)?"""
r"""(\w+)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|Any)\s*\(\s*"([^"]+)"\s*""",
re.VERBOSE,
)
_GROUP_RE = re.compile(
r"""(\w+)\s*:?=\s*\w+\.Group\s*\(\s*"([^"]+)"\s*\)""",
)
def extract(self, filepath):
try:
content = Path(filepath).read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
return []
tag = Path(filepath).stem
groups = {}
for m in self._GROUP_RE.finditer(content):
groups[m.group(1)] = m.group(2).rstrip("/")
routes = []
for m in self._ROUTE_RE.finditer(content):
comment = (m.group(1) or "").strip()
var = m.group(2)
raw_method = m.group(3).lower()
path = m.group(4)
prefix = groups.get(var, "")
full_path = prefix + path
methods = (["get", "post", "put", "delete", "patch"]
if raw_method == "any" else [raw_method])
openapi_path, path_params = convert_path_params(full_path, "gin")
for method in methods:
op_id = method + "_" + re.sub(r"[{}:]", "", full_path).strip("/").replace("/", "_")
route = {
"method": method,
"path": openapi_path,
"summary": comment,
"description": "",
"operation_id": op_id,
"tags": [tag],
"parameters": [
{"name": p["name"], "in": "path", "required": True,
"schema": {"type": p["type"]}}
for p in path_params
],
"responses": {"200": {"description": "Successful response"}},
}
routes.append(route)
return routes
# ── OpenAPI Spec Builder ──────────────────────────────────────────────────
def build_openapi_spec(routes, title="API Documentation", version="1.0.0",
description="", servers=None):
spec = {
"openapi": "3.0.3",
"info": {"title": title, "version": version},
"paths": {},
}
if description:
spec["info"]["description"] = description
if servers:
spec["servers"] = [{"url": s} for s in servers]
all_tags = set()
ref_schemas = {}
for route in routes:
path = route["path"]
method = route["method"]
if path not in spec["paths"]:
spec["paths"][path] = {}
op = {}
if route.get("summary"):
op["summary"] = route["summary"]
if route.get("description") and route["description"] != route.get("summary"):
op["description"] = route["description"]
if route.get("operation_id"):
op["operationId"] = route["operation_id"]
if route.get("tags"):
op["tags"] = route["tags"]
all_tags.update(route["tags"])
if route.get("parameters"):
op["parameters"] = route["parameters"]
if route.get("request_body"):
op["requestBody"] = route["request_body"]
op["responses"] = route.get("responses", {"200": {"description": "Successful response"}})
_collect_refs(op, ref_schemas)
spec["paths"][path][method] = op
if all_tags:
spec["tags"] = [{"name": t} for t in sorted(all_tags)]
if ref_schemas:
spec.setdefault("components", {})["schemas"] = {
name: {"type": "object", "description": f"{name} model"}
for name in sorted(ref_schemas)
}
return spec
def _collect_refs(obj, out):
if isinstance(obj, dict):
ref = obj.get("$ref")
if ref and ref.startswith("#/components/schemas/"):
out[ref.split("/")[-1]] = True
for v in obj.values():
_collect_refs(v, out)
elif isinstance(obj, list):
for item in obj:
_collect_refs(item, out)
# ── Framework Detection ───────────────────────────────────────────────────
def detect_framework(source_dir):
source_dir = Path(source_dir)
for py in source_dir.rglob("*.py"):
if _should_skip(py):
continue
try:
head = py.read_text(encoding="utf-8", errors="ignore")[:4096]
except OSError:
continue
if re.search(r"from\s+fastapi\s+import|import\s+fastapi", head):
return "fastapi"
if re.search(r"from\s+flask\s+import|import\s+flask", head):
return "flask"
if re.search(r"from\s+(?:rest_framework|django)", head):
return "django"
for ext in ("*.js", "*.ts", "*.mjs"):
for f in source_dir.rglob(ext):
if _should_skip(f):
continue
try:
head = f.read_text(encoding="utf-8", errors="ignore")[:4096]
except OSError:
continue
if re.search(r"""require\s*\(\s*['"]express['"]\)|from\s+['"]express['"]""", head):
return "express"
for f in source_dir.rglob("*.go"):
if _should_skip(f):
continue
try:
head = f.read_text(encoding="utf-8", errors="ignore")[:4096]
except OSError:
continue
if '"github.com/gin-gonic/gin"' in head:
return "gin"
if '"github.com/labstack/echo' in head:
return "echo"
return None
# ── Scan & Extract ────────────────────────────────────────────────────────
def scan_and_extract(source_dir, framework=None):
source_dir = Path(source_dir)
if not source_dir.is_dir():
print(f"Error: '{source_dir}' is not a directory.", file=sys.stderr)
sys.exit(1)
detected = framework or detect_framework(source_dir)
if detected:
print(f"Detected framework: {detected}", file=sys.stderr)
py_ext = PythonExtractor()
js_ext = JSExtractor()
go_ext = GoExtractor()
routes = []
for py in sorted(source_dir.rglob("*.py")):
if _should_skip(py):
continue
fw = detected if detected in ("flask", "fastapi", "django") else None
routes.extend(py_ext.extract(str(py), framework=fw))
for ext in ("*.js", "*.ts", "*.mjs"):
for f in sorted(source_dir.rglob(ext)):
if _should_skip(f):
continue
routes.extend(js_ext.extract(str(f)))
for f in sorted(source_dir.rglob("*.go")):
if _should_skip(f):
continue
routes.extend(go_ext.extract(str(f)))
return routes, detected
# ── YAML Serializer (stdlib-only) ─────────────────────────────────────────
def to_yaml(obj, indent=0):
"""Minimal YAML serializer sufficient for OpenAPI specs."""
sp = " " * indent
if obj is None:
return "null"
if isinstance(obj, bool):
return "true" if obj else "false"
if isinstance(obj, (int, float)):
return str(obj)
if isinstance(obj, str):
if not obj:
return '""'
if any(c in obj for c in ':#{}[]|>&*!%@`"\'\n') or obj.strip() != obj:
return json.dumps(obj, ensure_ascii=False)
return obj
if isinstance(obj, list):
if not obj:
return "[]"
lines = []
for item in obj:
if isinstance(item, dict):
first = True
for k, v in item.items():
vs = to_yaml(v, indent + 2)
if isinstance(v, (dict, list)) and v:
lines.append(f"{sp}{'- ' if first else ' '}{k}:")
lines.append(vs)
else:
lines.append(f"{sp}{'- ' if first else ' '}{k}: {vs}")
first = False
else:
lines.append(f"{sp}- {to_yaml(item, indent + 1)}")
return "\n".join(lines)
if isinstance(obj, dict):
if not obj:
return "{}"
lines = []
for k, v in obj.items():
vs = to_yaml(v, indent + 1)
if isinstance(v, (dict, list)) and v:
lines.append(f"{sp}{k}:")
lines.append(vs)
else:
lines.append(f"{sp}{k}: {vs}")
return "\n".join(lines)
return str(obj)
# ── CLI ───────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Generate OpenAPI 3.0 spec from source code route definitions.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
examples:
%(prog)s ./src
%(prog)s ./src --format yaml --output api-spec.yaml
%(prog)s ./app --framework flask --title "My API" --version 2.0.0
%(prog)s ./routes --server http://localhost:3000
""",
)
parser.add_argument("source_dir",
help="Source directory to scan for route definitions")
parser.add_argument("-f", "--format", choices=["json", "yaml"],
default="json", help="Output format (default: json)")
parser.add_argument("-o", "--output",
help="Output file path (default: stdout)")
parser.add_argument("--framework",
choices=["flask", "fastapi", "django", "express", "gin", "echo"],
help="Force framework detection (auto-detected by default)")
parser.add_argument("--title", default="API Documentation",
help="API title (default: API Documentation)")
parser.add_argument("--version", default="1.0.0",
help="API version (default: 1.0.0)")
parser.add_argument("--description", default="",
help="API description")
parser.add_argument("--server", action="append", dest="servers",
help="Server URL (repeatable)")
args = parser.parse_args()
routes, _ = scan_and_extract(args.source_dir, framework=args.framework)
if not routes:
print("Warning: no routes found. Verify the source directory contains "
"supported framework code.", file=sys.stderr)
spec = build_openapi_spec(
routes,
title=args.title,
version=args.version,
description=args.description,
servers=args.servers,
)
if args.format == "yaml":
output_text = to_yaml(spec)
else:
output_text = json.dumps(spec, indent=2, ensure_ascii=False)
if args.output:
Path(args.output).write_text(output_text + "\n", encoding="utf-8")
print(f"OpenAPI spec written to {args.output}", file=sys.stderr)
else:
print(output_text)
n_routes = len(routes)
n_paths = len(spec["paths"])
print(f"Found {n_routes} endpoint(s) across {n_paths} path(s).", file=sys.stderr)
if __name__ == "__main__":
main()