199 lines
6.9 KiB
Python
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
|