from contextlib import asynccontextmanager import logging from time import perf_counter from uuid import uuid4 from fastapi import FastAPI, HTTPException, Request from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from app.api.router import api_router from app.core.config import settings from app.core.logging import configure_logging from app.core.runtime import validate_runtime_settings from app.db.session import init_db configure_logging() logger = logging.getLogger("server_panel.api") @asynccontextmanager async def lifespan(_: FastAPI): validate_runtime_settings(settings) init_db() logger.info("Application startup app_env=%s action_mode=%s", settings.app_env, settings.action_execution_mode) yield logger.info("Application shutdown") app = FastAPI( title=settings.app_name, version="0.1.0", lifespan=lifespan, ) if settings.cors_allowed_origins_list: app.add_middleware( CORSMiddleware, allow_origins=settings.cors_allowed_origins_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(api_router) def _get_request_id(request: Request) -> str: return getattr(request.state, "request_id", "unknown") @app.middleware("http") async def log_requests(request: Request, call_next): # Reuse an incoming request ID when present so reverse proxies and the API # can be correlated in the same trace during troubleshooting. request_id = request.headers.get("X-Request-ID") or uuid4().hex request.state.request_id = request_id started_at = perf_counter() try: response = await call_next(request) except Exception: duration_ms = (perf_counter() - started_at) * 1000 logger.exception( "request_failed request_id=%s method=%s path=%s duration_ms=%.2f", request_id, request.method, request.url.path, duration_ms, ) raise duration_ms = (perf_counter() - started_at) * 1000 response.headers["X-Request-ID"] = request_id logger.info( "request_completed request_id=%s method=%s path=%s status_code=%s duration_ms=%.2f", request_id, request.method, request.url.path, response.status_code, duration_ms, ) return response @app.exception_handler(RequestValidationError) async def handle_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse: field_errors = jsonable_encoder(exc.errors()) # Keep validation failures in the same error envelope as domain errors so # the frontend can handle both paths consistently. logger.warning( "validation_error request_id=%s method=%s path=%s errors=%s", _get_request_id(request), request.method, request.url.path, field_errors, ) return JSONResponse( status_code=422, content={ "detail": { "code": "validation_error", "message": "Invalid request payload.", "fields": field_errors, "request_id": _get_request_id(request), } }, ) @app.exception_handler(HTTPException) async def handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse: if isinstance(exc.detail, dict): detail = { **exc.detail, "request_id": exc.detail.get("request_id", _get_request_id(request)), } else: detail = { "code": "http_error", "message": str(exc.detail), "request_id": _get_request_id(request), } logger.warning( "http_error request_id=%s method=%s path=%s status_code=%s code=%s", detail["request_id"], request.method, request.url.path, exc.status_code, detail.get("code"), ) return JSONResponse(status_code=exc.status_code, content={"detail": detail}) @app.exception_handler(Exception) async def handle_unexpected_exception(request: Request, exc: Exception) -> JSONResponse: request_id = _get_request_id(request) logger.exception( "unhandled_exception request_id=%s method=%s path=%s", request_id, request.method, request.url.path, ) return JSONResponse( status_code=500, content={ "detail": { "code": "internal_server_error", "message": "Internal server error.", "request_id": request_id, } }, ) @app.get("/health", tags=["health"]) def healthcheck() -> dict[str, str]: return {"status": "ok", "service": settings.app_name}