162 lines
4.7 KiB
Python
162 lines
4.7 KiB
Python
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}
|