Files
Aurora-Core/app/main.py
T

198 lines
6.6 KiB
Python
Raw Normal View History

2026-09-04 17:34:03 -05:00
"""Aurora Core FastAPI application factory.
Stateless by design (section 1): nothing here holds request-scoped state in
2026-09-06 00:05:10 -05:00
process memory, so any number of API replicas can run behind a reverse
proxy without sticky sessions.
2026-09-04 17:34:03 -05:00
"""
from __future__ import annotations
import time
import uuid
from collections.abc import Awaitable, Callable
from contextlib import asynccontextmanager
from pathlib import Path
2026-09-04 17:34:03 -05:00
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
2026-09-04 17:34:03 -05:00
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.api.v1 import (
2026-09-06 10:47:41 -05:00
account,
2026-09-04 17:34:03 -05:00
admin,
auth,
2026-09-04 17:34:03 -05:00
collections,
companies,
episodes,
2026-09-06 00:05:10 -05:00
genres,
2026-09-04 17:34:03 -05:00
health,
keywords,
2026-09-04 17:34:03 -05:00
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",
)
2026-09-06 06:04:59 -05:00
# Public API documentation (mkdocs, built from docs/ -- see
# docker/api.Dockerfile's docs-builder stage and mkdocs.yml). Unlike /docs
# (the auto-generated OpenAPI schema, gated by AURORA_API_DOCS_ENABLED
# since it can reveal more raw internal detail), this is curated,
# intentionally public content -- always mounted regardless of that flag.
# The directory only exists in the built Docker image; ensure it's present
# so a local dev server that hasn't run `mkdocs build` doesn't crash on
# startup, same reasoning as MEDIA_ROOT above.
Path("wiki").mkdir(parents=True, exist_ok=True)
app.mount("/wiki", StaticFiles(directory="wiki", html=True), name="wiki")
2026-09-04 17:34:03 -05:00
@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(auth.router, prefix=API_V1_PREFIX)
2026-09-06 10:47:41 -05:00
app.include_router(account.router, prefix=API_V1_PREFIX)
2026-09-04 17:34:03 -05:00
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)
2026-09-06 00:05:10 -05:00
app.include_router(genres.router, prefix=API_V1_PREFIX)
app.include_router(keywords.router, prefix=API_V1_PREFIX)
2026-09-04 17:34:03 -05:00
app.include_router(admin.router, prefix=API_V1_PREFIX)