munch-ease-backend/main.py

300 lines
11 KiB
Python

import datetime
import os
from typing import Annotated, Dict, List, Optional, Any
from contextlib import asynccontextmanager
import aiosqlite
from fastapi import Depends, FastAPI, APIRouter, Request, Response
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from pydantic import Field
import db
import products
from fastapi.routing import APIRoute
from common import ProblemDetails, Page, ApiModel
from settings import settings
from api.deps import get_db, cookie_person, error_response
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()
app = FastAPI(title="Doof API", version="1.0.0", description="Doof Backend API", lifespan=app_lifespan)
api_v1 = APIRouter(route_class=CamelCaseRoute)
DATABASE_PATH = settings.database_path
# get_db, cookie_person, and error_response are imported from api.deps
# OpenAPI reusable responses for ProblemDetails
def _extend_openapi_with_problem_responses(app: FastAPI) -> None:
# Attach a custom openapi generation that injects reusable responses
original_openapi = app.openapi
def custom_openapi():
spec = original_openapi()
components = spec.setdefault("components", {})
responses = components.setdefault("responses", {})
security_schemes = components.setdefault("securitySchemes", {})
# Standard ProblemDetails responses
responses.setdefault(
"Problem400",
{
"description": "Bad Request",
"content": {
"application/problem+json": {},
"application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
},
},
)
responses.setdefault(
"Problem404",
{
"description": "Not Found",
"content": {
"application/problem+json": {},
"application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
},
},
)
responses.setdefault(
"Problem422",
{
"description": "Validation Error",
"content": {
"application/problem+json": {
"schema": {"$ref": "#/components/schemas/ProblemDetails"}
},
# Some clients may still expect FastAPI's default error; keep schema available
"application/json": {
"schema": {"$ref": "#/components/schemas/ProblemDetails"}
},
},
},
)
# Define cookie-based auth for documentation (does not enforce at runtime)
security_schemes.setdefault(
"cookieAuth",
{
"type": "apiKey",
"in": "cookie",
"name": "user_id",
"description": "Authentication via user_id cookie (session-style).",
},
)
# Normalize v1 responses to reference reusable ProblemDetails where appropriate
paths = spec.get("paths", {})
# Known operationIds that require cookie_person dependency
protected_ops: set[str] = {
# recipes
"parseRecipe", # GET /recipes/parse
"createRecipe", # POST /recipes
"deleteRecipe", # DELETE /recipes/{recipe_id}
# meals
"markMealConsumed", # POST /meals/{meal_id}/consumed
"deleteMeal", # DELETE /meals/{meal_id}
# shopping
"purchaseIngredients", # POST /shopping
"getMyShoppingList", # GET /shopping/current/me/ingredients
"syncMyShoppingList", # POST /shopping/current/me/ingredients
"requestMeal", # POST /shopping/current/meals/me
"unrequestMeal", # DELETE /shopping/current/meals/{meal_id}
# auth
"refresh",
}
for path, ops in paths.items():
if not isinstance(path, str) or not path.startswith("/api/v1/"):
continue
if not isinstance(ops, dict):
continue
for method, op in ops.items():
if not isinstance(op, dict):
continue
# Add ProblemDetails response references and cookie security if required
resp = op.get("responses")
if not isinstance(resp, dict):
continue
# Map 400/404 to reusable references; ensure 422 exists
if "400" in resp:
resp["400"] = {"$ref": "#/components/responses/Problem400"}
if "404" in resp:
resp["404"] = {"$ref": "#/components/responses/Problem404"}
# Only add 422 if not already present
if "422" not in resp:
resp["422"] = {"$ref": "#/components/responses/Problem422"}
op_id = op.get("operationId")
if isinstance(op_id, str) and op_id in protected_ops:
# Merge/append cookieAuth security requirement
security = op.setdefault("security", [])
# Avoid duplicating if already present
if not any(isinstance(s, dict) and "cookieAuth" in s for s in security):
security.append({"cookieAuth": []})
return spec
app.openapi = custom_openapi # type: ignore[assignment]
_extend_openapi_with_problem_responses(app)
class ProductUrl(ApiModel):
url: str
tags: List[str] = Field(default_factory=list)
@api_v1.post(
"/products",
operation_id="createProduct",
tags=["products"],
summary="Create or fetch a product from a URL",
response_model=products.Product,
)
async def create_product(
url: ProductUrl, response: Response, conn: aiosqlite.Connection = Depends(get_db)
) -> Optional[products.Product]:
product = await products.get_or_create(conn, url.url, url.tags)
if product:
response.headers["Location"] = f"/api/v1/products/{product.id}"
return product
from api import recipes as recipes_router # type: ignore
from api import meals as meals_router # type: ignore
from api.meals import validate_meal as _validate_meal, get_duplicates as _get_duplicates
from api import shopping as shopping_router # type: ignore
from api import persons as persons_router # type: ignore
from api import auth as auth_router # type: ignore
# RFC7807 Problem Details handlers
from starlette.exceptions import HTTPException as StarletteHTTPException
from pydantic import ValidationError
from fastapi.exceptions import RequestValidationError
@app.exception_handler(StarletteHTTPException)
async def http_exc_handler(request: Request, 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",
)
@app.exception_handler(ValidationError)
async def validation_exc_handler(request: Request, 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"
)
@app.exception_handler(RequestValidationError)
async def request_validation_exc_handler(request: Request, 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"
)
# Mount versioned API router
app.include_router(api_v1, prefix="/api/v1", tags=["v1"])
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
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
# Backward-compatibility: expose helper functions expected by tests in main
def get_duplicates(items):
return _get_duplicates(items)
def validate_meal(meal, request: Request | None = None):
return _validate_meal(meal, request)
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
from starlette.responses import StreamingResponse
async def _reverse_proxy(request: Request):
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"])