"""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()