355 lines
13 KiB
Python
355 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Trace API Contracts — Find Frontend↔Backend Verkabelungsfehler.
|
|
|
|
Compares every frontend API call with the backend response fields.
|
|
Mismatches = Verkabelungsfehler.
|
|
|
|
Usage: python3 scripts/test_suite/trace_api_contracts.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
# Project root
|
|
ROOT = Path(__file__).resolve().parent.parent.parent
|
|
FRONTEND_SRC = ROOT / "frontend" / "src"
|
|
BACKEND_APP = ROOT / "app"
|
|
|
|
|
|
def find_frontend_api_calls() -> list[dict]:
|
|
"""Find all API calls in frontend source."""
|
|
calls = []
|
|
api_dir = FRONTEND_SRC / "api"
|
|
if not api_dir.exists():
|
|
return calls
|
|
|
|
for ts_file in api_dir.rglob("*.ts"):
|
|
try:
|
|
content = ts_file.read_text(encoding="utf-8")
|
|
except Exception:
|
|
continue
|
|
|
|
rel_path = str(ts_file.relative_to(FRONTEND_SRC))
|
|
|
|
# Find apiGet/apiPost/apiPut/apiPatch/apiDelete calls with URL patterns
|
|
patterns = [
|
|
(r"apiGet(?:<[^>]+>)?\s*\(\s*[`'\"]([^`'\"]+)[`'\"]\s*", "GET"),
|
|
(r"apiPost(?:<[^>]+>)?\s*\(\s*[`'\"]([^`'\"]+)[`'\"]\s*", "POST"),
|
|
(r"apiPut(?:<[^>]+>)?\s*\(\s*[`'\"]([^`'\"]+)[`'\"]\s*", "PUT"),
|
|
(r"apiPatch(?:<[^>]+>)?\s*\(\s*[`'\"]([^`'\"]+)[`'\"]\s*", "PATCH"),
|
|
(r"apiDelete(?:<[^>]+>)?\s*\(\s*[`'\"]([^`'\"]+)[`'\"]\s*", "DELETE"),
|
|
(r"\bM\s*\(\s*[`'\"]([^`'\"]+)[`'\"]\s*", "GET"),
|
|
(r"\bj\s*\(\s*[`'\"]([^`'\"]+)[`'\"]\s*", "POST"),
|
|
(r"\bSt\s*\(\s*[`'\"]([^`'\"]+)[`'\"]\s*", "PUT"),
|
|
(r"\byt\s*\(\s*[`'\"]([^`'\"]+)[`'\"]\s*", "PATCH"),
|
|
(r"\bxt\s*\(\s*[`'\"]([^`'\"]+)[`'\"]\s*", "DELETE"),
|
|
]
|
|
|
|
for pattern, method in patterns:
|
|
for match in re.finditer(pattern, content):
|
|
url = match.group(1)
|
|
# Normalize URL — remove template literals, query params
|
|
url_clean = re.sub(r"\$\{[^}]+\}", "{param}", url)
|
|
url_clean = url_clean.split("?")[0].split("#")[0]
|
|
|
|
calls.append({
|
|
"file": rel_path,
|
|
"method": method,
|
|
"url": url_clean,
|
|
"raw_url": url,
|
|
})
|
|
|
|
return calls
|
|
|
|
|
|
def find_frontend_expected_fields() -> dict[str, list[str]]:
|
|
"""Find TypeScript interfaces/types that define expected API response fields."""
|
|
fields_by_url = defaultdict(list)
|
|
api_dir = FRONTEND_SRC / "api"
|
|
if not api_dir.exists():
|
|
return fields_by_url
|
|
|
|
for ts_file in api_dir.rglob("*.ts"):
|
|
try:
|
|
content = ts_file.read_text(encoding="utf-8")
|
|
except Exception:
|
|
continue
|
|
|
|
# Find interface definitions
|
|
for match in re.finditer(r"interface\s+(\w+)\s*\{([^}]+)\}", content):
|
|
iface_name = match.group(1)
|
|
body = match.group(2)
|
|
field_names = re.findall(r"(\w+)\s*[?:]", body)
|
|
fields_by_url[iface_name] = field_names
|
|
|
|
return fields_by_url
|
|
|
|
|
|
def find_backend_response_fields() -> list[dict]:
|
|
"""Find backend response fields from routes."""
|
|
responses = []
|
|
routes_dir = BACKEND_APP / "routes"
|
|
if not routes_dir.exists():
|
|
return responses
|
|
|
|
for py_file in routes_dir.rglob("*.py"):
|
|
try:
|
|
content = py_file.read_text(encoding="utf-8")
|
|
except Exception:
|
|
continue
|
|
|
|
rel_path = str(py_file.relative_to(BACKEND_APP))
|
|
|
|
# Find response_model definitions
|
|
for match in re.finditer(r"response_model\s*=\s*(\w+)", content):
|
|
responses.append({
|
|
"file": rel_path,
|
|
"response_model": match.group(1),
|
|
})
|
|
|
|
# Find content= dict definitions (inline responses)
|
|
for match in re.finditer(r"content\s*=\s*\{([^}]+)\}", content, re.DOTALL):
|
|
body = match.group(1)
|
|
fields = re.findall(r"['\"](\w+)['\"]\s*:", body)
|
|
if fields:
|
|
responses.append({
|
|
"file": rel_path,
|
|
"fields": fields,
|
|
"type": "inline",
|
|
})
|
|
|
|
# Find return dict patterns
|
|
for match in re.finditer(r"return\s*\{([^}]+)\}", content, re.DOTALL):
|
|
body = match.group(1)
|
|
fields = re.findall(r"['\"](\w+)['\"]\s*:", body)
|
|
if fields:
|
|
responses.append({
|
|
"file": rel_path,
|
|
"fields": fields,
|
|
"type": "return_dict",
|
|
})
|
|
|
|
return responses
|
|
|
|
|
|
def _router_prefixes(content: str) -> dict[str, str]:
|
|
"""Extract all router variable definitions with their APIRouter prefixes.
|
|
|
|
Modules may define multiple routers (e.g. ``router``, ``calendar_router``,
|
|
``resource_router``) each with its own prefix.
|
|
"""
|
|
prefixes: dict[str, str] = {}
|
|
for m in re.finditer(
|
|
r"(\w+)\s*=\s*APIRouter\([^)]*?prefix\s*=\s*[\"']([^\"']+)[\"']", content
|
|
):
|
|
prefixes[m.group(1)] = m.group(2).rstrip("/")
|
|
return prefixes
|
|
|
|
|
|
def find_backend_endpoints() -> list[dict]:
|
|
"""Find all backend endpoints from route decorators.
|
|
|
|
Includes the module's APIRouter prefix so paths are comparable with
|
|
frontend calls (which use full /api/v1/... URLs).
|
|
"""
|
|
endpoints = []
|
|
scan_dirs = [
|
|
BACKEND_APP / "routes",
|
|
BACKEND_APP / "plugins" / "builtins",
|
|
]
|
|
|
|
for scan_dir in scan_dirs:
|
|
if not scan_dir.exists():
|
|
continue
|
|
is_plugin_dir = scan_dir.name == "builtins"
|
|
for py_file in scan_dir.rglob("*.py"):
|
|
try:
|
|
content = py_file.read_text(encoding="utf-8")
|
|
except Exception:
|
|
continue
|
|
|
|
rel_path = str(py_file.relative_to(BACKEND_APP))
|
|
prefixes = _router_prefixes(content)
|
|
|
|
# Variable-agnostic with per-variable prefix mapping: modules may
|
|
# define multiple routers (e.g. ``calendar_router``) each with its
|
|
# own prefix. Path may be an empty string for collection routes
|
|
# (e.g. ``@router.get("")``).
|
|
for match in re.finditer(r"@(\w+)\.(get|post|put|patch|delete)\s*\(\s*[\"']([^\"']*)[\"']", content):
|
|
var_name = match.group(1)
|
|
method = match.group(2).upper()
|
|
prefix = prefixes.get(var_name, "")
|
|
path = prefix + match.group(3)
|
|
ep = {
|
|
"file": rel_path,
|
|
"method": method,
|
|
"path": path,
|
|
}
|
|
if is_plugin_dir:
|
|
ep["is_plugin"] = True
|
|
endpoints.append(ep)
|
|
|
|
return endpoints
|
|
|
|
|
|
def normalize_path(path: str) -> str:
|
|
"""Normalize a path for comparison."""
|
|
# Remove leading /api/v1 if present
|
|
path = re.sub(r"^/api/v1/", "/", path)
|
|
# Replace JS template-literal interpolations (${qs}, ${id}, ...) like params;
|
|
# ensure a '/' separator so '/webhooks${params}' -> '/webhooks/{param}'
|
|
path = re.sub(r"([^/])\$\{[^}]*\}", r"\1/{param}", path)
|
|
path = re.sub(r"^\$\{[^}]*\}", "{param}", path)
|
|
# Replace {param} patterns
|
|
path = re.sub(r"\{[^}]+\}", "{param}", path)
|
|
# Collapse duplicate slashes and remove trailing slash
|
|
path = re.sub(r"//+", "/", path)
|
|
path = path.rstrip("/")
|
|
return path
|
|
|
|
|
|
def check_frontend_calls_have_backend(frontend_calls, backend_endpoints) -> list[dict]:
|
|
"""Check if every frontend API call has a matching backend endpoint."""
|
|
backend_paths = set()
|
|
for ep in backend_endpoints:
|
|
normalized = normalize_path(ep["path"])
|
|
backend_paths.add((ep["method"], normalized))
|
|
|
|
mismatches = []
|
|
for call in frontend_calls:
|
|
url = call["url"]
|
|
# Remove /api/v1 prefix if present
|
|
url_normalized = normalize_path(url)
|
|
key = (call["method"], url_normalized)
|
|
|
|
if key not in backend_paths:
|
|
# Try without method (some endpoints might have different method mapping)
|
|
found = False
|
|
for bm, bp in backend_paths:
|
|
if bp == url_normalized:
|
|
found = True
|
|
break
|
|
if not found:
|
|
mismatches.append({
|
|
"type": "frontend_call_no_backend",
|
|
"method": call["method"],
|
|
"url": url,
|
|
"file": call["file"],
|
|
"severity": "HIGH",
|
|
"message": f"Frontend calls {call['method']} {url} but no matching backend endpoint found",
|
|
})
|
|
|
|
return mismatches
|
|
|
|
|
|
def check_backend_endpoints_have_frontend(frontend_calls, backend_endpoints) -> list[dict]:
|
|
"""Check if every backend endpoint is called by the frontend."""
|
|
frontend_paths = set()
|
|
for call in frontend_calls:
|
|
url_normalized = normalize_path(call["url"])
|
|
frontend_paths.add((call["method"], url_normalized))
|
|
|
|
mismatches = []
|
|
for ep in backend_endpoints:
|
|
normalized = normalize_path(ep["path"])
|
|
key = (ep["method"], normalized)
|
|
|
|
if key not in frontend_paths:
|
|
mismatches.append({
|
|
"type": "backend_endpoint_no_frontend",
|
|
"method": ep["method"],
|
|
"path": ep["path"],
|
|
"file": ep["file"],
|
|
"severity": "LOW",
|
|
"message": f"Backend endpoint {ep['method']} {ep['path']} is never called by frontend",
|
|
})
|
|
|
|
return mismatches
|
|
|
|
|
|
def main():
|
|
print("=" * 70)
|
|
print("TRACE API CONTRACTS — Frontend ↔ Backend Verkabelungsfehler")
|
|
print("=" * 70)
|
|
|
|
print("\n[1] Scanning frontend API calls...")
|
|
frontend_calls = find_frontend_api_calls()
|
|
print(f" Found {len(frontend_calls)} API calls in frontend")
|
|
|
|
print("\n[2] Scanning backend endpoints...")
|
|
backend_endpoints = find_backend_endpoints()
|
|
print(f" Found {len(backend_endpoints)} endpoints in backend")
|
|
|
|
print("\n[3] Scanning backend response fields...")
|
|
backend_responses = find_backend_response_fields()
|
|
print(f" Found {len(backend_responses)} response definitions")
|
|
|
|
print("\n[4] Scanning frontend expected fields...")
|
|
frontend_fields = find_frontend_expected_fields()
|
|
print(f" Found {len(frontend_fields)} TypeScript interfaces")
|
|
|
|
print("\n[5] Checking frontend calls have matching backend...")
|
|
missing_backend = check_frontend_calls_have_backend(frontend_calls, backend_endpoints)
|
|
print(f" Found {len(missing_backend)} frontend calls without backend endpoint")
|
|
|
|
print("\n[6] Checking backend endpoints are called by frontend...")
|
|
missing_frontend = check_backend_endpoints_have_frontend(frontend_calls, backend_endpoints)
|
|
print(f" Found {len(missing_frontend)} backend endpoints never called by frontend")
|
|
|
|
# Summary
|
|
all_issues = missing_backend + missing_frontend
|
|
|
|
print("\n" + "=" * 70)
|
|
print("SUMMARY")
|
|
print("=" * 70)
|
|
print(f"Frontend API calls: {len(frontend_calls)}")
|
|
print(f"Backend endpoints: {len(backend_endpoints)}")
|
|
print(f"Response definitions: {len(backend_responses)}")
|
|
print(f"TS interfaces: {len(frontend_fields)}")
|
|
print(f"\nISSUES FOUND: {len(all_issues)}")
|
|
print(f" HIGH (frontend calls missing backend): {len(missing_backend)}")
|
|
print(f" LOW (backend endpoints not called): {len(missing_frontend)}")
|
|
|
|
if missing_backend:
|
|
print("\n--- HIGH SEVERITY: Frontend calls without backend ---")
|
|
for m in missing_backend[:30]:
|
|
print(f" [{m['method']}] {m['url']} (in {m['file']})")
|
|
if len(missing_backend) > 30:
|
|
print(f" ... and {len(missing_backend) - 30} more")
|
|
|
|
if missing_frontend:
|
|
print("\n--- LOW SEVERITY: Backend endpoints never called by frontend ---")
|
|
for m in missing_frontend[:30]:
|
|
print(f" [{m['method']}] {m['path']} (in {m['file']})")
|
|
if len(missing_frontend) > 30:
|
|
print(f" ... and {len(missing_frontend) - 30} more")
|
|
|
|
# Write results to JSON
|
|
results_file = ROOT / "scripts" / "test_suite" / "results_trace_api_contracts.json"
|
|
with open(results_file, "w") as f:
|
|
json.dump({
|
|
"frontend_calls": frontend_calls,
|
|
"backend_endpoints": backend_endpoints,
|
|
"issues": all_issues,
|
|
"summary": {
|
|
"total_frontend_calls": len(frontend_calls),
|
|
"total_backend_endpoints": len(backend_endpoints),
|
|
"total_issues": len(all_issues),
|
|
"high_severity": len(missing_backend),
|
|
"low_severity": len(missing_frontend),
|
|
},
|
|
}, f, indent=2, default=str)
|
|
print(f"\nResults written to {results_file}")
|
|
|
|
return len(all_issues)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|