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
171 lines
5.9 KiB
Python
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
|