"""PawTrace FastAPI application entrypoint (spec §6, §12).""" from __future__ import annotations import logging import os from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from starlette.exceptions import HTTPException as StarletteHTTPException from .api import admin, auth, cases, datasets, dogs, geo, jobs, matches, search from .config import settings from .db import engine from .models import Base logging.basicConfig(level=logging.INFO) @asynccontextmanager async def lifespan(_: FastAPI): # MVP: create tables directly. Alembic migrations are provided for the production path. settings.media_path.mkdir(parents=True, exist_ok=True) Base.metadata.create_all(bind=engine) if settings.seed_demo: # Optional: fill an empty DB with a few demo dogs (SEED_DEMO=1). Never aborts startup. from .db import SessionLocal from .services.demo_seed import seed_demo db = SessionLocal() try: seed_demo(db) except Exception: # noqa: BLE001 logging.getLogger("pawtrace").warning("demo seed failed", exc_info=True) finally: db.close() yield app = FastAPI( title="PawTrace API", version="0.1.0", description="Image-based dog re-identification: match a photo of a dog against a database of found dogs.", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ---- Consistent error envelopes (spec §12) ---- @app.exception_handler(StarletteHTTPException) async def _http_exc_handler(_: Request, exc: StarletteHTTPException) -> JSONResponse: return JSONResponse( status_code=exc.status_code, content={"error": {"code": exc.status_code, "message": exc.detail}}, ) @app.exception_handler(RequestValidationError) async def _validation_exc_handler(_: Request, exc: RequestValidationError) -> JSONResponse: return JSONResponse( status_code=422, content={"error": {"code": 422, "message": "Validation error", "details": exc.errors()}}, ) # ---- Demo mode: hard, server-side read-only guard ---- # When DEMO_MODE=1 the app is a public showcase. The ONLY writes allowed are the two transient # photo-search endpoints (which persist nothing). Every other mutating request is refused here — so # the database can never be modified from the demo UI, a direct API call, or curl. Safety is enforced # on the server, not by hiding buttons in the frontend. if settings.demo_mode: _DEMO_WRITE_ALLOWLIST = {"/search/by-photo", "/search/breed"} _MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} @app.middleware("http") async def _demo_readonly_guard(request: Request, call_next): if request.method in _MUTATING_METHODS and request.url.path not in _DEMO_WRITE_ALLOWLIST: return JSONResponse( status_code=403, content={"error": {"code": 403, "message": "This is a read-only demo — changes are disabled."}}, ) return await call_next(request) # ---- Routers ---- app.include_router(auth.router) app.include_router(dogs.router) app.include_router(cases.router) app.include_router(matches.router) app.include_router(search.router) app.include_router(admin.router) app.include_router(datasets.router) app.include_router(jobs.router) app.include_router(geo.router) # ---- Static media (local StorageBackend) ---- settings.media_path.mkdir(parents=True, exist_ok=True) app.mount("/media", StaticFiles(directory=str(settings.media_path)), name="media") # ---- Serve the built frontend (single-service production deploy) ---- # The Docker image builds the React app to ./frontend_dist and the API serves it, so the whole app # is one origin (no CORS needed). Skipped entirely when no build is present, so local dev and the # test suite are unaffected. # # The API is not under an "/api" prefix, and several client-side routes share a path with an API # route (the page /cases/5 vs. the API GET /cases/{id}). So a middleware serves the SPA shell for # browser PAGE navigations (Accept: text/html) — making deep links and hard refreshes work — while # the app's own data fetches (Accept: */*) fall through untouched to the API. _frontend_dist = Path(os.environ.get("FRONTEND_DIST", "frontend_dist")).resolve() if (_frontend_dist / "index.html").is_file(): _spa_index = _frontend_dist / "index.html" _spa_skip = ("/media", "/assets", "/healthz", "/docs", "/redoc", "/openapi") @app.middleware("http") async def _spa_navigation(request: Request, call_next): if ( request.method == "GET" and "text/html" in request.headers.get("accept", "") and not request.url.path.startswith(_spa_skip) ): return FileResponse(_spa_index) return await call_next(request) # Real static files: hashed JS/CSS in /assets, favicon, images, and index.html at "/". app.mount("/", StaticFiles(directory=str(_frontend_dist), html=True), name="spa")