from __future__ import annotations import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] BACKEND = ROOT / "backend" DOCS = ROOT / "docs" / "API_CONTRACTS.md" # docs/API_CONTRACTS.md ALLOWED_NON_ENVELOPE_ENDPOINTS = { ("GET", "/health"), ("GET", "/api/v1/exports/{export_id}/download"), } IGNORED_OPENAPI_PATHS = { "/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc", } def _load_app_routes() -> set[tuple[str, str]]: sys.path.insert(0, str(BACKEND)) from app.main import create_app app = create_app() routes: set[tuple[str, str]] = set() for route in app.routes: path = getattr(route, "path", None) methods = getattr(route, "methods", set()) if not path or path in IGNORED_OPENAPI_PATHS: continue for method in sorted(methods - {"HEAD", "OPTIONS"}): routes.add((method, path)) return routes def _load_documented_routes() -> set[tuple[str, str]]: docs = DOCS.read_text(encoding="utf-8") return set(re.findall(r"###\s+(GET|POST|PATCH|DELETE)\s+`([^`]+)`", docs)) def main() -> None: implemented_routes = _load_app_routes() documented_routes = _load_documented_routes() missing_docs = sorted(implemented_routes - documented_routes) stale_docs = sorted(documented_routes - implemented_routes) missing_non_envelope = sorted(ALLOWED_NON_ENVELOPE_ENDPOINTS - implemented_routes) errors: list[str] = [] for method, path in missing_docs: errors.append(f"Missing documented API route: {method} {path}") for method, path in stale_docs: errors.append(f"Documented API route is not implemented: {method} {path}") for method, path in missing_non_envelope: errors.append(f"Allowed non-envelope endpoint is not implemented: {method} {path}") if errors: raise SystemExit("\n".join(errors)) print( "API contract audit OK: " f"{len(implemented_routes)} implemented routes match docs; " f"{len(ALLOWED_NON_ENVELOPE_ENDPOINTS)} explicit non-envelope endpoints tracked." ) if __name__ == "__main__": main()