from __future__ import annotations import logging import os import time from collections.abc import AsyncIterator, Callable, Iterable from contextlib import AbstractAsyncContextManager from typing import Any from fastapi import APIRouter, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id from govoplan_core.core.registry import PlatformRegistry from govoplan_core.db.query_metrics import collect_query_metrics from govoplan_core.server.conditional_requests import conditional_json_get_middleware LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]] logger = logging.getLogger("govoplan.request") def _slow_request_threshold_ms() -> float: raw = os.getenv("GOVOPLAN_SLOW_REQUEST_MS", "500").strip() try: return float(raw) except ValueError: return 500.0 def create_govoplan_app( *, title: str, version: str, registry: PlatformRegistry, api_router: APIRouter | None = None, lifespan: LifespanFactory | None = None, cors_origins: Iterable[str] = (), health_payload: dict[str, Any] | None = None, ) -> FastAPI: app = FastAPI(title=title, version=version, lifespan=lifespan) app.state.govoplan_registry = registry slow_request_threshold_ms = _slow_request_threshold_ms() @app.middleware("http") async def request_correlation_context(request: Request, call_next): correlation_id = ( normalize_trace_id(request.headers.get("x-correlation-id")) or normalize_trace_id(request.headers.get("x-request-id")) or new_event_id() ) with event_context(correlation_id=correlation_id): response = await call_next(request) response.headers["X-Correlation-ID"] = correlation_id return response @app.middleware("http") async def slow_request_logging(request: Request, call_next): started_at = time.perf_counter() with collect_query_metrics() as db_metrics: try: response = await call_next(request) except Exception: elapsed_ms = (time.perf_counter() - started_at) * 1000 if slow_request_threshold_ms > 0 and elapsed_ms >= slow_request_threshold_ms: logger.warning( "slow request failed method=%s path=%s duration_ms=%.1f db_query_count=%s db_time_ms=%.1f db_slowest_ms=%.1f db_error_count=%s", request.method, request.url.path, elapsed_ms, db_metrics.query_count, db_metrics.total_ms, db_metrics.slowest_ms, db_metrics.error_count, exc_info=True, ) raise elapsed_ms = (time.perf_counter() - started_at) * 1000 if slow_request_threshold_ms > 0 and elapsed_ms >= slow_request_threshold_ms: logger.warning( "slow request method=%s path=%s status=%s duration_ms=%.1f db_query_count=%s db_time_ms=%.1f db_slowest_ms=%.1f db_error_count=%s", request.method, request.url.path, response.status_code, elapsed_ms, db_metrics.query_count, db_metrics.total_ms, db_metrics.slowest_ms, db_metrics.error_count, ) return response app.middleware("http")(conditional_json_get_middleware) origins = [item.strip() for item in cors_origins if item.strip()] if origins: if "*" in origins: raise ValueError("CORS wildcard origin '*' cannot be used with credentialed requests; configure explicit origins.") app.add_middleware( CORSMiddleware, allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) if api_router is not None: app.include_router(api_router) @app.get("/health") def health(): payload = {"status": "ok"} if health_payload: payload.update(health_payload) return payload return app