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", "/health/live"), ("GET", "/health/ready"), ("GET", "/api/v1/exports/{export_id}/download"), ("GET", "/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/image"), } 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 schema = create_app().openapi() routes: set[tuple[str, str]] = set() for path, path_item in schema.get("paths", {}).items(): if path in IGNORED_OPENAPI_PATHS or not isinstance(path_item, dict): continue for method in path_item: normalized_method = method.upper() if normalized_method in {"GET", "POST", "PATCH", "DELETE"}: routes.add((normalized_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()