munch-ease-backend/main.py
jableader 5be4e89c4e Squashed commit of the following:
commit fcd005b8624023547f28b7b28e59e6099bcfc7d4
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 20:24:07 2025 +1100

    Openapi tightening

commit f93bd8f641d561052c7bd075bae321b4ff3b676d
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 19:03:52 2025 +1100

    Removed refactor strategy doc

commit 0c5a61092f522be0c47cbbe86917c8a7e4e2d339
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 17:48:33 2025 +1100

    mypy & ruff checks

commit 23d66d6b18984127e17c73c3063f6120385935e9
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 16:49:35 2025 +1100

    Final removal of db.py files

commit f454aed1ca9783cc558cc203f29a7fe31b62a975
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 15:42:31 2025 +1100

    Finalise restructure, remove db.py files

commit 7187f6dd89489521538791c6bdebb426514beb99
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 15:34:54 2025 +1100

commit 6fea227ae20d32b8eb1e7a4885006a620fcc7bb1
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 15:32:53 2025 +1100

commit 27415e7e02d89195ad514cb017a9dbbf84d7a5e4
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 15:31:10 2025 +1100

commit b773428033d855f9ad82005602e049c1a2e3c585
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 15:28:58 2025 +1100

commit 116592c95278d995f4c516e87f2cea43cf5b7735
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 15:25:21 2025 +1100

commit 03ec565faea088971968ee2f9bb83e2de16b21f3
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 15:21:29 2025 +1100

    Plan
2025-10-19 20:24:23 +11:00

171 lines
5.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,
meals as meals_router,
persons as persons_router,
products as products_router,
recipes as recipes_router,
shopping as shopping_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"))
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
# 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