munch-ease-backend/main.py
2025-11-02 21:33:23 +11:00

199 lines
6.9 KiB
Python

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"))
# Standard 422 for request 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