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"])