Add backend API contract audit
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user