Files
govoplan-core/src/govoplan_core/server/fastapi.py
2026-07-14 13:22:10 +02:00

117 lines
4.3 KiB
Python

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