| """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): |
| |
| settings.media_path.mkdir(parents=True, exist_ok=True) |
| Base.metadata.create_all(bind=engine) |
| if settings.seed_demo: |
| |
| from .db import SessionLocal |
| from .services.demo_seed import seed_demo |
|
|
| db = SessionLocal() |
| try: |
| seed_demo(db) |
| except Exception: |
| 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=["*"], |
| ) |
|
|
|
|
| |
| @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()}}, |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
| 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) |
|
|
|
|
| |
| 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) |
|
|
| |
| settings.media_path.mkdir(parents=True, exist_ok=True) |
| app.mount("/media", StaticFiles(directory=str(settings.media_path)), name="media") |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _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) |
|
|
| |
| app.mount("/", StaticFiles(directory=str(_frontend_dist), html=True), name="spa") |
|
|