from __future__ import annotations import os import sys import time import uuid from pathlib import Path if __package__ in {None, ""}: sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from starlette.middleware.base import BaseHTTPMiddleware from api.routes import admin, analyses, auth, exercises, health, templates from core.config import get_settings from core.logging import ( bind_request_context, clear_request_context, get_logger, setup_logging, ) from services.bootstrap import initialize_runtime setup_logging() _log = get_logger("app.api") DIAGNOSTIC_RESPONSE_HEADERS = [ "x-request-id", "x-ai-coach-request-body-wait-ms", "x-ai-coach-handler-ms", "x-ai-coach-ingest-mbps-est", "x-ai-coach-upload-mode", ] def _request_metrics(request: Request, request_started_at: float, duration_ms: float) -> dict[str, object]: route_entered_at = getattr(request.state, "route_entered_at", None) file_saved_at = getattr(request.state, "analysis_file_saved_at", None) enqueued_at = getattr(request.state, "analysis_enqueued_at", None) metrics: dict[str, object] = {} if route_entered_at is not None: body_wait_ms = round((route_entered_at - request_started_at) * 1000, 1) metrics["request_body_wait_ms"] = body_wait_ms metrics["request_handler_ms"] = round(max(duration_ms - body_wait_ms, 0), 1) if file_saved_at is not None: metrics["request_file_save_ms"] = round((file_saved_at - route_entered_at) * 1000, 1) if enqueued_at is not None: metrics["request_enqueue_ms"] = round((enqueued_at - route_entered_at) * 1000, 1) content_length_raw = request.headers.get("content-length") if content_length_raw and content_length_raw.isdigit(): content_length = int(content_length_raw) metrics["content_length"] = content_length if body_wait_ms > 0: metrics["ingest_mbps_est"] = round((content_length * 8) / (body_wait_ms / 1000) / 1_000_000, 3) return metrics class RequestContextMiddleware(BaseHTTPMiddleware): """Tag every request with a request_id, log start/end with latency.""" async def dispatch(self, request: Request, call_next): request_id = request.headers.get("x-request-id") or uuid.uuid4().hex[:12] bind_request_context( request_id=request_id, method=request.method, path=request.url.path, ) start = time.perf_counter() request.state.request_started_at = start status_code = 500 try: response = await call_next(request) status_code = response.status_code response.headers["x-request-id"] = request_id # Cache-Control for static uploads — browser cache avoids re-fetch path = request.url.path if path.startswith("/uploads/templates"): response.headers["Cache-Control"] = "public, max-age=2592000" # 30 days elif path.startswith("/uploads/"): response.headers["Cache-Control"] = "public, max-age=604800" # 7 days return response except Exception: _log.exception("request_unhandled_exception") raise finally: duration_ms = round((time.perf_counter() - start) * 1000, 1) # Skip noisy access logs for static + health. path = request.url.path if not (path.startswith("/uploads") or path == "/health"): extra_metrics = _request_metrics(request, start, duration_ms) _log.info( "request_finished", status=status_code, duration_ms=duration_ms, **extra_metrics, ) clear_request_context() def _start_temp_template_cleanup_thread() -> None: """Background thread that removes expired temporary templates every 30 minutes.""" from threading import Thread CLEANUP_INTERVAL = 30 * 60 # 30 minutes def _loop() -> None: while True: time.sleep(CLEANUP_INTERVAL) try: from exercises.push_up.template_registry import cleanup_expired_temporary_templates deleted = cleanup_expired_temporary_templates() if deleted: _log.info("temp_template_cleanup", deleted_count=len(deleted), deleted_ids=deleted) except Exception: _log.exception("temp_template_cleanup_error") Thread(target=_loop, daemon=True, name="temp-template-cleanup").start() def create_app() -> FastAPI: settings = get_settings() initialize_runtime(settings) is_prod = os.getenv("ENV", "dev").lower() == "production" api = FastAPI( title=settings.app_name, version=settings.api_version, description="FastAPI backend for AI Coach Gym video analysis.", docs_url=None if is_prod else "/docs", redoc_url=None if is_prod else "/redoc", openapi_url=None if is_prod else "/openapi.json", ) api.add_middleware(RequestContextMiddleware) api.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], expose_headers=DIAGNOSTIC_RESPONSE_HEADERS, ) api.include_router(health.router) api.include_router(auth.router, prefix="/auth", tags=["auth"]) api.include_router(exercises.router, prefix="/exercises", tags=["exercises"]) api.include_router(templates.router, prefix="/templates", tags=["templates"]) api.include_router(analyses.router, prefix="/analyses", tags=["analyses"]) api.include_router(admin.router, prefix="/admin", tags=["admin"]) api.mount( settings.upload_url_prefix, StaticFiles(directory=str(settings.upload_dir)), name="uploads", ) _start_temp_template_cleanup_thread() return api app = create_app() if __name__ == "__main__": import uvicorn uvicorn.run("api.main:app", host="127.0.0.1", port=8000)