patristic-be / src /api /app.py
Mario33333's picture
deploy: feature/audiobook@f1c0d35 (fix batch β€” models/providers, TTS $0, grants, DB admins, delete-hardening)
833ba13 verified
Raw
History Blame Contribute Delete
10.7 kB
"""FastAPI app factory + middleware/exception-handler wiring.
Mount point: this app runs at the root path. The ``/api/*`` prefix the
frontend uses is added by the Vite/Express proxy
(``CONTRACT.md Β§2``) β€” so endpoints declared with prefix ``/system`` are
reachable from the browser at ``/api/system/...`` and from the server at
``/system/...``. Don't add an ``/api`` prefix on this side; that would
double-prefix everything.
No ``CORSMiddleware`` in v1 (CONTRACT.md Β§2): the proxy keeps the
browser's origin same as the FE's, so cookies travel and no preflight is
needed. When we split-host later, revisit.
Stage 1 included the ``system`` router + global ApiError handler. Stage 2
added the ``auth`` router + CSRF middleware. Stage 3 wires in the read-
only routers: books, history, costs, tools, indexes, processing_log,
labels, pdf (book assets). It also installs a Pydantic-validation
handler so 422s come back as canonical ``BAD_REQUEST`` envelopes (per
CONTRACT.md Β§8) instead of FastAPI's bespoke error shape.
Stage 4 adds the ``query`` + ``jobs`` routers (the FE's search page now
works end-to-end against the real backend) and a startup hook that runs
``src/api/jobs/reconcile.py`` so orphaned ``running`` rows from a previous
boot get marked ``failed`` before the API accepts new requests.
"""
from __future__ import annotations
import os
from fastapi import FastAPI, Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from src.api.auth import router as auth_router
from src.api.dto.common import ErrorResponse
from src.api.errors import ApiError, RateLimited
from src.api.middleware.csrf import CsrfASGIMiddleware
from src.api.jobs.reconcile import reconcile_orphaned_jobs
from src.api.routers import (
addbook as addbook_router,
admin_users as admin_users_router,
audio as audio_router,
books as books_router,
config as config_router,
costs as costs_router,
evaluation as evaluation_router,
exports as exports_router,
history as history_router,
indexes as indexes_router,
ingestion as ingestion_router,
jobs as jobs_router,
labels as labels_router,
pdf as pdf_router,
processing_log as processing_log_router,
provider_keys as provider_keys_router,
query as query_router,
roadmap as roadmap_router,
samples as samples_router,
system as system_router,
tools as tools_router,
uploads as uploads_router,
)
# On the public cloud Space, hide the interactive docs + the raw OpenAPI
# schema: they enumerate every route and DTO to an unauthenticated visitor,
# which is needless attack-surface disclosure. Local dev keeps them for
# convenience. Toggle is RUNTIME=cloud (same signal the storage/inventory
# backends auto-promote on).
_CLOUD = os.environ.get("RUNTIME", "").strip().lower() == "cloud"
app = FastAPI(
title="Patristic Arabic Library API",
description=(
"HTTP + SSE layer in front of the patristic-arabic-library RAG "
"pipeline. See CONTRACT.md for the wire contract and "
"BACKEND_BUILD.md for the build brief."
),
version="0.1.0",
docs_url=None if _CLOUD else "/docs",
redoc_url=None if _CLOUD else "/redoc",
openapi_url=None if _CLOUD else "/openapi.json",
)
# ---------- Exception handlers --------------------------------------------
@app.exception_handler(ApiError)
async def _api_error_handler(_request: Request, exc: ApiError) -> JSONResponse:
"""Serialize typed API errors to the canonical envelope.
Routers ``raise BookNotFound(book_id=...)`` and the handler builds
the response β€” that keeps router bodies focused on the happy path
and centralises the contract. The Pydantic-422 handler below ships
its own ``BAD_REQUEST`` envelope so the wire shape is uniform across
every error class.
"""
envelope = exc.to_envelope()
# On a rate-limit refusal, set the standard Retry-After header so clients
# (and any intermediary) back off correctly β€” the value mirrors
# details.retryAfterSeconds.
headers: dict[str, str] | None = None
if isinstance(exc, RateLimited):
retry = (exc.details or {}).get("retryAfterSeconds")
if retry is not None:
headers = {"Retry-After": str(int(retry))}
# by_alias=True so the wire form is camelCase per ApiModel's
# alias_generator. exclude_none keeps optional fields out when unset
# so the wire payload doesn't carry "details: null" noise.
return JSONResponse(
status_code=exc.status_code,
content=jsonable_encoder(envelope, by_alias=True, exclude_none=True),
headers=headers,
)
@app.exception_handler(RequestValidationError)
async def _validation_error_handler(
_request: Request, exc: RequestValidationError
) -> JSONResponse:
"""Re-shape Pydantic 422s into the canonical ``BAD_REQUEST`` envelope.
Default FastAPI behaviour is a 422 with ``{detail: [{loc, msg, ...}]}``,
which doesn't match the ``{code, message, details}`` shape the FE's
typed client expects (CONTRACT.md Β§8). We keep the validation errors
under ``details.errors`` so the FE can show field-level messages
when it wants to; otherwise it just reads ``message``.
"""
envelope = ErrorResponse(
code="BAD_REQUEST",
message="Request validation failed.",
details={"errors": jsonable_encoder(exc.errors())},
)
return JSONResponse(
status_code=400,
content=jsonable_encoder(envelope, by_alias=True, exclude_none=True),
)
# ---------- Middleware ----------------------------------------------------
# CSRF runs before route matching so the rule covers every mutating route
# that lands in later stages by default β€” see CONTRACT.md Β§3.
# Pure-ASGI implementation (not BaseHTTPMiddleware) so SSE responses
# stream chunks through without buffering β€” required for /system/events
# and /jobs/{id}/events to deliver the first frame immediately rather
# than holding it until BaseHTTPMiddleware's accumulator flushes (which
# can take 15+ seconds for low-volume streams).
app.add_middleware(CsrfASGIMiddleware)
# ---------- Routers -------------------------------------------------------
app.include_router(system_router.router)
app.include_router(auth_router.router)
# Stage 3 β€” read-only routers. Order matters only for OpenAPI tag
# grouping in the docs; the path layout is disjoint.
app.include_router(books_router.router)
app.include_router(history_router.router)
app.include_router(costs_router.router)
app.include_router(tools_router.router)
app.include_router(indexes_router.router)
app.include_router(processing_log_router.router)
app.include_router(labels_router.router)
app.include_router(pdf_router.router)
# Stage 4 β€” query + jobs (SSE). The query router includes a side-effect
# import that registers the "query" handler with the job registry, so
# enqueue() can dispatch the moment the first POST lands.
app.include_router(query_router.router)
app.include_router(jobs_router.router)
# Stage 5 β€” ingest jobs. POST /books/{bookId}/ingest. Like query.py,
# this router does a side-effect import of its job-type module so the
# "ingest" handler is in the registry by the time the first POST lands.
app.include_router(ingestion_router.router)
# Clean-OCR export jobs. POST /books/{bookId}/exports/clean-ocr returns 202
# jobId/sseUrl; GET .../clean-ocr.pdf streams the cached build. The router
# side-effect-imports its "export_clean_ocr" job-type module so the handler
# is registered before the first POST lands.
app.include_router(exports_router.router)
# Audiobook (pre-rendered narration). GET /books/{id}/audio/{manifest|status|
# estimate|spanId} (reads) + POST /books/{id}/audio:generate (202 jobId/sseUrl).
# The router side-effect-imports its "generate_audiobook" job-type module so the
# handler is registered before the first POST lands.
app.include_router(audio_router.router)
# Stage 6 β€” Add Book writes. The addbook router shares the /books
# prefix with books_router but mounts the /probe path; FastAPI dispatch
# matches longest-prefix so /books/probe lands here and /books/{bookId}
# stays on books_router. The uploads router has its own /uploads prefix.
app.include_router(uploads_router.router)
app.include_router(addbook_router.router)
# OCR-sample preview for the Add Book wizard. POST /books/ocr-sample returns
# 202 jobId/sseUrl; GET /uploads/{id}/(pages/{n}/image|ocr-sample) read the
# sample output. Side-effect-imports its "ocr_sample" job type.
app.include_router(samples_router.router)
# Stage 7 β€” Evaluation runs. POST /eval/runs returns 202 jobId/sseUrl;
# the eval_run router imports its job-type module as a side effect so the
# "eval_run" handler is registered before the first POST lands.
app.include_router(evaluation_router.router)
# Stage 7 β€” Backend configuration + migration. GET/PUT /config are sync;
# POST /config/migrate returns 202 + SSE. The config router imports the
# migrate job-type module as a side effect so the "backend_migrate"
# handler is registered before the first POST lands.
app.include_router(config_router.router)
# /roadmap β€” proxies GitHub Issues with a 5-min cache so the FE roadmap page
# has a single source of truth (GH issues) without exposing the GH PAT (if
# any) to the browser.
app.include_router(roadmap_router.router)
# Admin: DB-backed reader-account management (/admin/users). All endpoints are
# require_admin; the store is app_users (the preview / Reading-Room logins the
# admin creates). Admins themselves stay in env/TOML (the bootstrap layer).
app.include_router(admin_users_router.router)
# Admin: LLM provider activation keys (/admin/provider-keys). All endpoints are
# require_admin; the store is provider_keys (admin-pasteable API keys that
# activate a provider whose key isn't in the environment). Never returns keys.
app.include_router(provider_keys_router.router)
# ---------- Startup hook --------------------------------------------------
@app.on_event("startup")
def _on_startup() -> None:
"""Run the job-reconciler before the API accepts requests.
Without this, a ``running`` row left behind by a previous boot would
forever show as a ghost in flight in the UI (BACKEND_BUILD.md Β§5.5 +
Β§12.4 β€” the --reload dev server kills workers on every file save).
"""
reconcile_orphaned_jobs()