from contextlib import asynccontextmanager from typing import Any, Dict from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from fastapi.routing import APIRoute from pydantic import ValidationError from starlette.exceptions import HTTPException as StarletteHTTPException from api import ( auth as auth_router, recipes as recipes_router, meals as meals_router, shopping as shopping_router, households as households_router, ) from api.deps import ( error_response as error_response, # noqa: F401 - re-exported for completeness get_db as get_db, # noqa: F401 - re-exported for tests dependency overrides ) from api.openapi import extend_with_problem_and_cookie_auth from common import ApiModel, ProblemDetails from settings import settings class CamelCaseRoute(APIRoute): def __init__(self, *args, **kwargs): kwargs.setdefault("response_model_by_alias", True) kwargs.setdefault("response_model_exclude_none", True) super().__init__(*args, **kwargs) @asynccontextmanager async def app_lifespan(app: FastAPI): client = None if not settings.prod: import httpx client = httpx.AsyncClient(base_url=settings.frontend_dev_url) app.state.proxy_client = client try: yield finally: if client is not None: await client.aclose() # RFC7807 Problem Details handlers (standalone functions, registered in factory) async def http_exc_handler(request: Request, exc: Exception): # Narrow to StarletteHTTPException at runtime assert isinstance(exc, StarletteHTTPException) body = ProblemDetails( title=str(exc.detail) if exc.detail else "HTTP Error", status=exc.status_code, type=f"https://httpstatuses.com/{exc.status_code}", instance=str(request.url), ) return JSONResponse( content=body.model_dump(by_alias=True), status_code=exc.status_code, media_type="application/problem+json", ) async def validation_exc_handler(request: Request, exc: Exception): assert isinstance(exc, ValidationError) errors: Dict[str, Any] = {} for e in exc.errors(): loc = ".".join([str(p) for p in e.get("loc", [])]) errors.setdefault(loc, []).append(e.get("msg")) body = ProblemDetails( title="Validation Error", status=422, type="https://datatracker.ietf.org/doc/html/rfc7807", instance=str(request.url), errors=errors, ) return JSONResponse( content=body.model_dump(by_alias=True), status_code=422, media_type="application/problem+json", ) async def request_validation_exc_handler(request: Request, exc: Exception): assert isinstance(exc, RequestValidationError) errors: Dict[str, Any] = {} for e in exc.errors(): loc = ".".join([str(p) for p in e.get("loc", [])]) errors.setdefault(loc, []).append(e.get("msg")) # Legacy cookie-based auth behavior removed; standard 422 for validation errors body = ProblemDetails( title="Validation Error", status=422, type="https://datatracker.ietf.org/doc/html/rfc7807", instance=str(request.url), errors=errors, ) return JSONResponse( content=body.model_dump(by_alias=True), status_code=422, media_type="application/problem+json", ) class HealthStatus(ApiModel): status: str = "ok" async def healthz() -> HealthStatus: return HealthStatus() def create_app() -> FastAPI: app = FastAPI( title="Doof API", version="1.0.0", description="Doof Backend API", lifespan=app_lifespan ) # OpenAPI augmentation extend_with_problem_and_cookie_auth(app) # Exception handlers app.add_exception_handler(StarletteHTTPException, http_exc_handler) app.add_exception_handler(ValidationError, validation_exc_handler) app.add_exception_handler(RequestValidationError, request_validation_exc_handler) # Routers # v1 routers removed; v2 household-scoped and JWT-only API below # v2 JWT auth and households app.include_router(auth_router.router, prefix="/api/v1", tags=["auth"]) # canonical app.include_router(households_router.router, prefix="/api/v1", tags=["households"]) # canonical # Mount household-scoped endpoints try: app.include_router( households_router.scoped, prefix="/api/v1", tags=["households"] ) # scoped except Exception: pass app.include_router(recipes_router.router, prefix="/api/v1", tags=["recipes"]) # canonical app.include_router(recipes_router.public, prefix="/api/v1", tags=["recipes"]) # public utils from api import ingredients as ingredients_router app.include_router(ingredients_router.router, prefix="/api/v1", tags=["ingredients"]) # scoped app.include_router(meals_router.router, prefix="/api/v1", tags=["meals"]) # canonical app.include_router(shopping_router.router, prefix="/api/v1", tags=["shopping"]) # canonical # Routes app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus) # Static/proxy if settings.prod: from fastapi.staticfiles import StaticFiles app.mount("/", StaticFiles(directory="./front-dist", html=True), name="front-dist") else: # Proxy the request to the frontend development server from starlette.background import BackgroundTask from starlette.requests import Request as StarletteRequest from starlette.responses import StreamingResponse async def _reverse_proxy(request: StarletteRequest): # Do not proxy API routes; return 404 to let API clients fail fast in dev if str(request.url.path).startswith("/api/"): from starlette.exceptions import HTTPException as StarletteHTTPException raise StarletteHTTPException(status_code=404) import httpx url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8")) client = getattr(app.state, "proxy_client", None) if client is None: from starlette.exceptions import HTTPException as StarletteHTTPException raise StarletteHTTPException(status_code=503, detail="Proxy not configured") rp_req = client.build_request( request.method, url, headers=request.headers.raw, content=request.stream() ) rp_resp = await client.send(rp_req, stream=True) return StreamingResponse( rp_resp.aiter_raw(), status_code=rp_resp.status_code, headers=rp_resp.headers, background=BackgroundTask(rp_resp.aclose), ) app.add_route("/{path:path}", _reverse_proxy, ["GET", "POST"]) return app # Module-level app for uvicorn app = create_app() DATABASE_PATH = settings.database_path # get_db and error_response are imported from api.deps