- Move config loading to dedicated ConfigLoader class with validation - Add DATABASE_MIGRATIONS.md content to TROUBLESHOOTING.md - Add API_STATUS_CODES.md documenting all API response codes - Update runner.csx to use new config structure - Add check_responses.py validation script - Update config tests for new structure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
91 lines
2.6 KiB
Python
91 lines
2.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate that every API router endpoint has an explicit `responses={}` dict.
|
|
|
|
This script runs in CI to ensure no endpoint is merged without OpenAPI
|
|
response documentation. An endpoint without `responses={}` makes status-code
|
|
branching impossible for frontend clients.
|
|
|
|
Exit codes:
|
|
0 — all endpoints documented
|
|
1 — one or more endpoints missing responses={}
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROUTES = {"get", "post", "put", "delete", "patch", "options", "head"}
|
|
ROUTER_DIR = Path(__file__).parent / "app" / "routers"
|
|
|
|
|
|
class EndpointVisitor(ast.NodeVisitor):
|
|
"""Walk router files and collect endpoints lacking `responses={}`."""
|
|
|
|
def __init__(self) -> None:
|
|
self.errors: list[str] = []
|
|
self._current_path = ""
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
for decorator in node.decorator_list:
|
|
if self._is_router_decorator(decorator):
|
|
self._check_decorator(decorator, node)
|
|
self.generic_visit(node)
|
|
|
|
def _is_router_decorator(self, node: ast.AST) -> bool:
|
|
match node:
|
|
case ast.Name():
|
|
return node.id in ROUTES
|
|
case ast.Attribute():
|
|
return node.attr in ROUTES
|
|
return False
|
|
|
|
def _check_decorator(self, decorator: ast.AST, node: ast.FunctionDef) -> None:
|
|
found_responses = False
|
|
for child in ast.walk(decorator):
|
|
if isinstance(child, ast.keyword) and child.arg == "responses":
|
|
found_responses = True
|
|
break
|
|
|
|
if not found_responses:
|
|
lineno = node.lineno
|
|
self.errors.append(
|
|
f"{self._current_path}:{lineno} — "
|
|
f"endpoint in {node.name}() lacks `responses={{}}`"
|
|
)
|
|
|
|
|
|
def check_file(path: Path) -> list[str]:
|
|
"""Return list of errors for one router file."""
|
|
source = path.read_text()
|
|
tree = ast.parse(source, filename=str(path))
|
|
|
|
visitor = EndpointVisitor()
|
|
visitor._current_path = str(path)
|
|
visitor.visit(tree)
|
|
return visitor.errors
|
|
|
|
|
|
def main() -> int:
|
|
errors: list[str] = []
|
|
|
|
for py_file in sorted(ROUTER_DIR.glob("*.py")):
|
|
if py_file.name.startswith("_"):
|
|
continue
|
|
errors.extend(check_file(py_file))
|
|
|
|
if errors:
|
|
print("ERRORS: Endpoints missing `responses={}`:")
|
|
for e in errors:
|
|
print(f" {e}")
|
|
print(f"\n{len(errors)} endpoint(s) lack response documentation.")
|
|
return 1
|
|
|
|
print("OK: all router endpoints have `responses={}`")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|