PawTrace / backend /app /main.py
Elliott Duke
Docs: make the repo describe the system that actually shipped
79688a8
Raw
History Blame Contribute Delete
5.4 kB
"""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")