217 lines
7.6 KiB
Python
217 lines
7.6 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,
|
|
auth_v2 as auth_v2_router,
|
|
meals as meals_router,
|
|
persons as persons_router,
|
|
products as products_router,
|
|
recipes as recipes_router,
|
|
recipes_v2 as recipes_v2_router,
|
|
meals_v2 as meals_v2_router,
|
|
shopping as shopping_router,
|
|
households as households_router,
|
|
)
|
|
from api.deps import (
|
|
cookie_person as cookie_person, # noqa: F401 - re-exported for tests
|
|
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"))
|
|
# Special-case: Missing user_id cookie on POST /api/v1/shopping should be 401
|
|
try:
|
|
is_shopping_post = request.method.upper() == "POST" and request.url.path == "/api/v1/shopping"
|
|
except Exception:
|
|
is_shopping_post = False
|
|
if is_shopping_post:
|
|
if any(
|
|
isinstance(e.get("loc"), (list, tuple))
|
|
and len(e.get("loc")) >= 2
|
|
and e.get("loc")[0] == "cookie"
|
|
and e.get("loc")[1] == "user_id"
|
|
for e in exc.errors()
|
|
):
|
|
body = ProblemDetails(
|
|
title="Unauthorized",
|
|
status=401,
|
|
type="https://httpstatuses.com/401",
|
|
instance=str(request.url),
|
|
errors=errors,
|
|
)
|
|
return JSONResponse(
|
|
content=body.model_dump(by_alias=True),
|
|
status_code=401,
|
|
media_type="application/problem+json",
|
|
)
|
|
|
|
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
|
|
app.include_router(products_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
|
app.include_router(recipes_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
|
app.include_router(meals_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
|
app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
|
app.include_router(persons_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
|
app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
|
# Experimental v2 auth endpoints (JWT to be implemented). Kept alongside v1 during transition.
|
|
app.include_router(auth_v2_router.router, prefix="/api/v1", tags=["v2"])
|
|
app.include_router(households_router.router, prefix="/api/v1", tags=["v2"]) # new
|
|
# Mount household-scoped endpoints
|
|
try:
|
|
app.include_router(households_router.scoped, prefix="/api/v1", tags=["v2"]) # type: ignore[attr-defined]
|
|
except Exception:
|
|
pass
|
|
app.include_router(recipes_v2_router.router, prefix="/api/v1", tags=["v2"]) # new
|
|
app.include_router(meals_v2_router.router, prefix="/api/v1", tags=["v2"]) # new
|
|
|
|
# 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):
|
|
import httpx
|
|
|
|
url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8"))
|
|
client = app.state.proxy_client
|
|
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, cookie_person, and error_response are imported from api.deps
|