#!/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 python scripts/ai_health_check.py --base-url http://localhost:8000 --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())