Spaces:
Running
Running
deploy: feature/audiobook@f1c0d35 (fix batch β models/providers, TTS $0, grants, DB admins, delete-hardening)
833ba13 verified | """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 -------------------------------------------- | |
| 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, | |
| ) | |
| 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 -------------------------------------------------- | |
| 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() | |