FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
137 lines
4.5 KiB
Python
137 lines
4.5 KiB
Python
"""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)
|