277 lines
10 KiB
Python
Executable File
277 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""API Health Check — enumerates all registered routes and probes GET endpoints.
|
|
|
|
Usage:
|
|
python scripts/ai_health_check.py --base-url http://localhost:8000 --token <session-token>
|
|
python scripts/ai_health_check.py --base-url http://localhost:8000 --token <token> --output csv --report-file report.csv
|
|
|
|
Checks all GET endpoints with an auth token and reports status codes + response times.
|
|
POST/PUT/DELETE endpoints are documented but not executed (safe mode).
|
|
|
|
Exit codes:
|
|
0 — all probed endpoints healthy (2xx/3xx)
|
|
1 — one or more endpoints failed
|
|
2 — script error (misconfiguration, connection failure)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from typing import Any
|
|
|
|
# Add project root to path
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
import httpx
|
|
|
|
# ─── Route Enumeration ────────────────────────────────────────────────
|
|
|
|
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
|
|
|
|
|
def enumerate_routes() -> list[dict[str, Any]]:
|
|
"""Enumerate all registered API routes from the FastAPI app.
|
|
|
|
Imports the app and extracts route metadata without starting a server.
|
|
Returns a list of dicts: {path, methods, name, tags}.
|
|
"""
|
|
routes: list[dict[str, Any]] = []
|
|
try:
|
|
from app.main import app
|
|
|
|
for route in app.routes:
|
|
# Skip static mounts and SPA catch-all
|
|
if not hasattr(route, "methods") or not hasattr(route, "path"):
|
|
continue
|
|
path = route.path
|
|
# Only include API routes
|
|
if not path.startswith("/api/"):
|
|
continue
|
|
methods = sorted(route.methods - {"HEAD", "OPTIONS"}) if route.methods else []
|
|
if not methods:
|
|
continue
|
|
tags = getattr(route, "tags", []) or []
|
|
name = getattr(route, "name", "") or ""
|
|
routes.append({
|
|
"path": path,
|
|
"methods": methods,
|
|
"name": name,
|
|
"tags": tags,
|
|
})
|
|
except Exception as exc:
|
|
print(f"ERROR: Failed to enumerate routes: {exc}", file=sys.stderr)
|
|
# Return empty list — caller can still probe base_url manually
|
|
return routes
|
|
|
|
|
|
# ─── Endpoint Probing ────────────────────────────────────────────────
|
|
|
|
|
|
async def probe_all_endpoints(
|
|
base_url: str,
|
|
token: str,
|
|
routes: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
"""Probe all GET endpoints and document non-GET ones."""
|
|
results: list[dict[str, Any]] = []
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
|
|
async with httpx.AsyncClient(base_url=base_url.rstrip("/")) as client:
|
|
for route in routes:
|
|
path = route["path"]
|
|
for method in route["methods"]:
|
|
if method in SAFE_METHODS:
|
|
# Probe GET endpoints
|
|
start = time.perf_counter()
|
|
try:
|
|
response = await client.get(path, headers=headers, timeout=30.0)
|
|
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
|
status_code = response.status_code
|
|
healthy = status_code < 400
|
|
results.append({
|
|
"endpoint": path,
|
|
"method": method,
|
|
"status": status_code,
|
|
"response_time_ms": elapsed_ms,
|
|
"healthy": healthy,
|
|
"tags": route.get("tags", []),
|
|
"note": "" if healthy else f"HTTP {status_code}",
|
|
})
|
|
except httpx.TimeoutException:
|
|
results.append({
|
|
"endpoint": path,
|
|
"method": method,
|
|
"status": "timeout",
|
|
"response_time_ms": round((time.perf_counter() - start) * 1000, 2),
|
|
"healthy": False,
|
|
"tags": route.get("tags", []),
|
|
"note": "Request timed out",
|
|
})
|
|
except httpx.ConnectError as exc:
|
|
results.append({
|
|
"endpoint": path,
|
|
"method": method,
|
|
"status": "connection_error",
|
|
"response_time_ms": round((time.perf_counter() - start) * 1000, 2),
|
|
"healthy": False,
|
|
"tags": route.get("tags", []),
|
|
"note": str(exc),
|
|
})
|
|
except Exception as exc:
|
|
results.append({
|
|
"endpoint": path,
|
|
"method": method,
|
|
"status": "error",
|
|
"response_time_ms": round((time.perf_counter() - start) * 1000, 2),
|
|
"healthy": False,
|
|
"tags": route.get("tags", []),
|
|
"note": str(exc),
|
|
})
|
|
else:
|
|
# Document POST/PUT/DELETE — do not execute
|
|
results.append({
|
|
"endpoint": path,
|
|
"method": method,
|
|
"status": "skipped",
|
|
"response_time_ms": 0,
|
|
"healthy": True,
|
|
"tags": route.get("tags", []),
|
|
"note": "Non-GET method — documented only",
|
|
})
|
|
|
|
return results
|
|
|
|
|
|
# ─── Report Formatting ───────────────────────────────────────────────
|
|
|
|
|
|
def format_json_report(results: list[dict[str, Any]]) -> str:
|
|
"""Format results as structured JSON report."""
|
|
total = len(results)
|
|
healthy = sum(1 for r in results if r["healthy"] and r["status"] != "skipped")
|
|
failed = sum(1 for r in results if not r["healthy"])
|
|
skipped = sum(1 for r in results if r["status"] == "skipped")
|
|
|
|
report = {
|
|
"summary": {
|
|
"total_endpoints": total,
|
|
"healthy": healthy,
|
|
"failed": failed,
|
|
"skipped": skipped,
|
|
"all_healthy": failed == 0,
|
|
},
|
|
"endpoints": results,
|
|
}
|
|
return json.dumps(report, indent=2, ensure_ascii=False)
|
|
|
|
|
|
def format_csv_report(results: list[dict[str, Any]]) -> str:
|
|
"""Format results as CSV."""
|
|
output = io.StringIO()
|
|
writer = csv.DictWriter(
|
|
output,
|
|
fieldnames=["endpoint", "method", "status", "response_time_ms", "healthy", "tags", "note"],
|
|
)
|
|
writer.writeheader()
|
|
for r in results:
|
|
writer.writerow({
|
|
"endpoint": r["endpoint"],
|
|
"method": r["method"],
|
|
"status": r["status"],
|
|
"response_time_ms": r["response_time_ms"],
|
|
"healthy": r["healthy"],
|
|
"tags": ",".join(r.get("tags", [])),
|
|
"note": r.get("note", ""),
|
|
})
|
|
return output.getvalue()
|
|
|
|
|
|
# ─── CLI Entry Point ─────────────────────────────────────────────────
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="LeoCRM API Health Check — probes all GET endpoints and reports status."
|
|
)
|
|
parser.add_argument(
|
|
"--base-url",
|
|
default=os.environ.get("LEOCRM_BASE_URL", "http://localhost:8000"),
|
|
help="Base URL of the LeoCRM API (default: %(default)s)",
|
|
)
|
|
parser.add_argument(
|
|
"--token",
|
|
default=os.environ.get("LEOCRM_AUTH_TOKEN", ""),
|
|
help="Auth token (Bearer token) for authenticated requests",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
choices=["json", "csv"],
|
|
default="json",
|
|
help="Output format (default: json)",
|
|
)
|
|
parser.add_argument(
|
|
"--report-file",
|
|
default="-",
|
|
help="Output file path (default: stdout)",
|
|
)
|
|
parser.add_argument(
|
|
"--enumerate-only",
|
|
action="store_true",
|
|
help="Only enumerate routes without probing (list all endpoints)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
# Step 1: Enumerate routes
|
|
print(f"Enumerating routes from FastAPI app...", file=sys.stderr)
|
|
routes = enumerate_routes()
|
|
print(f"Found {len(routes)} API route(s)", file=sys.stderr)
|
|
|
|
if args.enumerate_only:
|
|
for route in routes:
|
|
methods = ", ".join(route["methods"])
|
|
tags = ", ".join(route.get("tags", []))
|
|
print(f" [{methods}] {route['path']} tags=[{tags}]")
|
|
return 0
|
|
|
|
if not args.token:
|
|
print("ERROR: --token is required for probing (or set LEOCRM_AUTH_TOKEN env)", file=sys.stderr)
|
|
return 2
|
|
|
|
# Step 2: Probe endpoints
|
|
import asyncio
|
|
|
|
print(f"Probing GET endpoints against {args.base_url}...", file=sys.stderr)
|
|
results = asyncio.run(probe_all_endpoints(args.base_url, args.token, routes))
|
|
|
|
# Step 3: Format and output report
|
|
if args.output == "json":
|
|
report = format_json_report(results)
|
|
else:
|
|
report = format_csv_report(results)
|
|
|
|
if args.report_file == "-":
|
|
print(report)
|
|
else:
|
|
with open(args.report_file, "w", encoding="utf-8") as f:
|
|
f.write(report)
|
|
print(f"Report written to {args.report_file}", file=sys.stderr)
|
|
|
|
# Step 4: Determine exit code
|
|
failed = sum(1 for r in results if not r["healthy"])
|
|
if failed > 0:
|
|
print(f"\n{failed} endpoint(s) failed!", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"\nAll endpoints healthy.", file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|