"""FastAPI application entrypoint.""" from __future__ import annotations import uuid from contextlib import asynccontextmanager from fastapi import FastAPI, Request, status from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, Response from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from slowapi.middleware import SlowAPIMiddleware from structlog.contextvars import bound_contextvars from app.api.v1.router import api_v1_router from app.core.config import get_settings from app.core.errors import ( AppError, AuthenticationError, ConflictError, ForbiddenError, NotFoundError, RateLimitedError, ThrottledError, ValidationAppError, ) from app.core.logging import configure_logging, get_logger from app.core.rate_limit import limiter settings = get_settings() configure_logging(settings) logger = get_logger(__name__) @asynccontextmanager async def lifespan(_app: FastAPI): logger.info( "startup", app_env=settings.app_env, auth_mode=settings.auth_mode, llm_provider=settings.llm_provider, ) yield logger.info("shutdown") app = FastAPI( title=settings.app_name, version="0.1.0", lifespan=lifespan, docs_url="/docs", redoc_url="/redoc", openapi_url="/openapi.json", ) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_middleware(SlowAPIMiddleware) app.add_middleware( CORSMiddleware, # FRONTEND_URL may be a comma-separated list (e.g. localhost plus a LAN # address) so the same API can serve a browser on this machine and one # elsewhere on the network at once. allow_origins=[origin.strip() for origin in settings.frontend_url.split(",") if origin.strip()], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.middleware("http") async def correlation_id_middleware(request: Request, call_next) -> Response: """Every log line emitted while handling this request carries the same request_id (structlog contextvars, merged in automatically - see core/logging.py), and the id is echoed back so a client/proxy log can be cross-referenced with ours. Reuses an inbound X-Request-ID if a gateway already set one, rather than always minting a fresh id.""" request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) with bound_contextvars(request_id=request_id): response = await call_next(request) response.headers["X-Request-ID"] = request_id return response _APP_ERROR_STATUS_CODES: dict[type[AppError], int] = { NotFoundError: status.HTTP_404_NOT_FOUND, ConflictError: status.HTTP_409_CONFLICT, AuthenticationError: status.HTTP_401_UNAUTHORIZED, ForbiddenError: status.HTTP_403_FORBIDDEN, ValidationAppError: status.HTTP_400_BAD_REQUEST, RateLimitedError: status.HTTP_429_TOO_MANY_REQUESTS, ThrottledError: status.HTTP_429_TOO_MANY_REQUESTS, } @app.exception_handler(AppError) async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse: status_code = _APP_ERROR_STATUS_CODES.get(type(exc), status.HTTP_400_BAD_REQUEST) content: dict = {"detail": str(exc)} headers: dict[str, str] = {} if isinstance(exc, ThrottledError) and exc.retry_after_seconds is not None: content["retry_after_seconds"] = exc.retry_after_seconds headers["Retry-After"] = str(exc.retry_after_seconds) return JSONResponse(status_code=status_code, content=content, headers=headers) @app.exception_handler(RequestValidationError) async def validation_exception_handler( _request: Request, exc: RequestValidationError ) -> JSONResponse: return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"detail": "Invalid request", "errors": jsonable_encoder(exc.errors())}, ) @app.exception_handler(Exception) async def unhandled_exception_handler(_request: Request, exc: Exception) -> JSONResponse: logger.error("unhandled_exception", error=str(exc), error_type=type(exc).__name__) return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"detail": "Internal server error"}, ) @app.get("/health", include_in_schema=False) async def root_health() -> dict[str, str]: """Plain root-level liveness probe for infra tooling (Docker, etc.).""" return {"status": "ok"} app.include_router(api_v1_router)