Builds on the accounts foundation: PUT/DELETE a rating (0.5-10.0) or a
watchlist entry on any movie or series, all scoped to the bearer token's
own user, plus GET /account/ratings|watchlist/{movies,series} to list
them back. Both are polymorphic entity_type/entity_id tables, same shape
as watch_provider_offerings and trending_entries, so movie and series
share one repository/router each rather than duplicating per type.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
198 lines
6.6 KiB
Python
198 lines
6.6 KiB
Python
"""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 a reverse
|
|
proxy without sticky sessions.
|
|
"""
|
|
|
|
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 (
|
|
account,
|
|
admin,
|
|
auth,
|
|
collections,
|
|
companies,
|
|
episodes,
|
|
genres,
|
|
health,
|
|
keywords,
|
|
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",
|
|
)
|
|
|
|
# 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")
|
|
|
|
|
|
@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)
|
|
app.include_router(account.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(genres.router, prefix=API_V1_PREFIX)
|
|
app.include_router(keywords.router, prefix=API_V1_PREFIX)
|
|
app.include_router(admin.router, prefix=API_V1_PREFIX)
|