Files
RelayOps/backend/app/main.py
2026-06-07 02:32:28 +03:00

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}