"""Aurora Core FastAPI application factory. Stateless by design (section 1): nothing here holds request-scoped state in process memory, so any number of API replicas can run behind Traefik. """ from __future__ import annotations import time import uuid from collections.abc import Awaitable, Callable from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request, Response from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest from starlette.exceptions import HTTPException as StarletteHTTPException from app.api.v1 import ( admin, collections, companies, episodes, health, movies, people, search, seasons, series, ) from app.core.config import get_settings from app.core.database import dispose_engine from app.core.logging import configure_logging, get_logger, request_id_ctx from app.core.queue import close_arq_pool from app.core.redis import close_redis settings = get_settings() REQUEST_COUNT = Counter( "aurora_http_requests_total", "HTTP requests", ["method", "path", "status"] ) REQUEST_LATENCY = Histogram( "aurora_http_request_duration_seconds", "HTTP request latency", ["method", "path"] ) MAX_REQUEST_BODY_BYTES = 2 * 1024 * 1024 # 2 MiB @asynccontextmanager async def lifespan(app: FastAPI): configure_logging() logger = get_logger(__name__) logger.info("aurora_core_starting", env=settings.AURORA_ENV) yield logger.info("aurora_core_stopping") await dispose_engine() await close_redis() await close_arq_pool() app = FastAPI( title="Aurora Core", description="Aurora Dragon Studio's media metadata, discovery, and intelligence backend.", version="0.1.0", docs_url=settings.docs_url, redoc_url=None, openapi_url=settings.openapi_url, lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins_list, allow_credentials=True, allow_methods=["GET", "POST"], allow_headers=["*"], ) # Serves what app/services/artwork/ downloads (section 10: self-hosted # artwork). The directory must exist before StaticFiles will mount — the # worker creates subdirectories as it writes files, but the root itself # needs to be there even before the first download. Path(settings.MEDIA_ROOT).mkdir(parents=True, exist_ok=True) app.mount( settings.MEDIA_URL_PREFIX, StaticFiles(directory=settings.MEDIA_ROOT), name="media", ) @app.middleware("http") async def request_context_middleware( request: Request, call_next: Callable[[Request], Awaitable[Response]] ) -> Response: request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())) token = request_id_ctx.set(request_id) start = time.perf_counter() try: response = await call_next(request) finally: request_id_ctx.reset(token) duration = time.perf_counter() - start route = request.scope.get("route") path_template = route.path if route else request.url.path REQUEST_COUNT.labels(request.method, path_template, "n/a").inc() REQUEST_LATENCY.labels(request.method, path_template).observe(duration) response.headers["X-Request-ID"] = request_id response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "no-referrer" return response @app.middleware("http") async def request_size_limit_middleware( request: Request, call_next: Callable[[Request], Awaitable[Response]] ) -> Response: content_length = request.headers.get("content-length") if content_length and int(content_length) > MAX_REQUEST_BODY_BYTES: return JSONResponse( status_code=413, content={"error": {"code": "payload_too_large", "message": "Request body too large."}}, ) return await call_next(request) @app.exception_handler(StarletteHTTPException) async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: detail = exc.detail if isinstance(detail, dict) and "code" in detail: error = detail else: error = {"code": "http_error", "message": str(detail)} return JSONResponse(status_code=exc.status_code, content={"error": error}) @app.exception_handler(RequestValidationError) async def validation_exception_handler( request: Request, exc: RequestValidationError ) -> JSONResponse: return JSONResponse( status_code=422, content={ "error": { "code": "validation_error", "message": "Request validation failed.", "details": exc.errors(), } }, ) @app.get("/metrics", include_in_schema=False) async def metrics() -> Response: return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) # Unversioned liveness/readiness for infra probes (Docker/Traefik healthchecks). app.include_router(health.router) API_V1_PREFIX = "/api/v1" app.include_router(health.router, prefix=API_V1_PREFIX) app.include_router(search.router, prefix=API_V1_PREFIX) app.include_router(movies.router, prefix=API_V1_PREFIX) app.include_router(series.router, prefix=API_V1_PREFIX) app.include_router(seasons.router, prefix=API_V1_PREFIX) app.include_router(episodes.router, prefix=API_V1_PREFIX) app.include_router(people.router, prefix=API_V1_PREFIX) app.include_router(collections.router, prefix=API_V1_PREFIX) app.include_router(companies.router, prefix=API_V1_PREFIX) app.include_router(admin.router, prefix=API_V1_PREFIX)