import datetime import os from typing import Annotated, Dict, List, Optional, Any import aiosqlite from fastapi import Cookie, Depends, FastAPI, Query, APIRouter, Request from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse from pydantic import BaseModel, Field import db import ingredients import meals import persons import products import recipes import shopping 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) app = FastAPI(title="Doof API", version="1.0.0", description="Doof Backend API") 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", {}) # 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"} }, }, }, ) # Normalize v1 responses to reference reusable ProblemDetails where appropriate paths = spec.get("paths", {}) 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 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"} 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", ) async def create_product( url: ProductUrl, conn: aiosqlite.Connection = Depends(get_db) ) -> Optional[products.Product]: return await products.get_or_create(conn, url.url, url.tags) async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[recipes.Recipe]: r = await recipes.find_recipe_by_id(conn, id) if not r: return None r.ingredients = [] async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, id): r.ingredients.append(ingredient) if r.created_by_id is not None: r.created_by = await persons.get_by_id(conn, r.created_by_id) return r from api import recipes as recipes_router # type: ignore @api_v1.get( "/meals/upcoming", operation_id="getUpcomingMeals", tags=["meals"], summary="List upcoming meals in a date range", ) async def get_upcoming_meals( date_from: Annotated[datetime.datetime, Query(alias="from")], to: datetime.datetime, conn: aiosqlite.Connection = Depends(get_db), ) -> List[meals.Meal]: result = [] async for meal in meals.find_upcoming_meals_by_date_range(conn, date_from, to): await meals.load_recipes(conn, meal) await meals.load_extra_ingredients(conn, meal) await meals.load_participants(conn, meal) result.append(meal) return result @api_v1.get( "/meals/{meal_id}", response_model=None, operation_id="getMeal", tags=["meals"], summary="Get a meal by id", responses={ 404: { "model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}, } }, ) async def get_meal( meal_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request = None ) -> meals.Meal | JSONResponse: meal = await meals.find_meal_by_id(conn, meal_id) if not meal: return error_response(request, 404, "Meal not found") return meal def get_duplicates(items: List[meals.Person]) -> set[str]: seen: set[int] = set() duplicates: set[str] = set() for item in items: if item.id in seen: duplicates.add(item.name) seen.add(item.id) return duplicates def validate_meal(meal: meals.Meal, request: Optional[Request] = None) -> Optional[JSONResponse]: if not meal.chefs: return error_response(request, 400, "Meal must have at least one chef") if not meal.cleanup: return error_response(request, 400, "Meal must have at least one cleanup person") if not meal.consumers: return error_response(request, 400, "Meal must have at least one consumer") if len(meal.recipes) == 0 and len(meal.extra_ingredients) == 0: return error_response(request, 400, "Meal must have at least one recipe or ingredient") duplicates = get_duplicates(meal.chefs) if duplicates: return error_response(request, 400, f'Duplicate chef: {", ".join(duplicates)}') duplicates = get_duplicates(meal.cleanup) if duplicates: return error_response(request, 400, f'Duplicate cleanup person: {", ".join(duplicates)}') duplicates = get_duplicates(meal.consumers) if duplicates: return error_response(request, 400, f'Duplicate consumer: {", ".join(duplicates)}') zero_servings = [r for r in meal.recipes if r.servings == 0] if zero_servings: return error_response(request, 400, "Recipe servings must be greater than 0") return None @api_v1.post( "/meals", response_model=None, operation_id="createMeal", tags=["meals"], summary="Create a new meal", responses={ 400: { "model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}, } }, ) async def create_meal( meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request = None ) -> meals.Meal | JSONResponse: validation_response = validate_meal(meal, request) if validation_response: return validation_response await meals.insert_meal(conn, meal) await conn.commit() return meal @api_v1.put( "/meals/{meal_id}", response_model=None, operation_id="updateMeal", tags=["meals"], summary="Update an existing meal", responses={ 400: { "model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}, }, 404: { "model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}, }, }, ) async def update_meal( meal_id: int, meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request = None ) -> meals.Meal | JSONResponse: if meal.id != meal_id: return error_response(request, 400, "Meal ID in URL does not match meal ID in body") existing = await meals.find_meal_by_id(conn, meal_id) if not existing: return error_response(request, 404, "Meal not found") validation_response = validate_meal(meal, request) if validation_response: return validation_response await meals.update_meal(conn, meal) await conn.commit() return await get_meal(meal_id, conn) @api_v1.post( "/meals/{meal_id}/consumed", response_model=None, operation_id="markMealConsumed", tags=["meals"], summary="Mark a meal as consumed", responses={ 400: { "model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}, }, 404: { "model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}, }, }, ) async def mark_consumed( meal_id: int, consumed_date: Optional[datetime.datetime] = None, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), request: Request = None, ) -> meals.Meal | JSONResponse: if consumed_date and not consumed_date.tzinfo: return error_response(request, 400, "Consumed date must include timezone") meal = await meals.find_meal_by_id(conn, meal_id) if not meal: return error_response(request, 404, "Meal not found") await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone()) await shopping.remove_request(conn, person, meal=meal) await conn.commit() return meal @api_v1.delete( "/meals/{meal_id}", response_model=None, operation_id="deleteMeal", tags=["meals"], summary="Delete a meal", responses={ 404: { "model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}, } }, ) async def delete_meal( meal_id: int, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), request: Request = None, ) -> meals.Meal | JSONResponse: meal = await meals.find_meal_by_id(conn, meal_id) if not meal: return error_response(request, 404, "Meal not found") await shopping.remove_request(conn, person, meal=meal) await meals.delete_meal(conn, meal.id) await conn.commit() return meal from api import meals as meals_router # type: ignore app.include_router(meals_router.router, prefix="/api/v1", tags=["v1"]) # extracted class CurrentShoppingList(ApiModel): outstanding_items: List[shopping.ShoppingListItem] requested_meals: List[shopping.ShoppingListItem] purchased_items: List[shopping.ShoppingListItem] = Field(default_factory=list) ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict) meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict) shopping_list_lookup: Dict[int, shopping.ShoppingList] = Field(default_factory=dict) recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict) @api_v1.get( "/shopping/current", response_model=CurrentShoppingList, operation_id="getCurrentShoppingList", tags=["shopping"], summary="Get the current aggregated shopping list", ) async def get_current_shopping_list( conn: aiosqlite.Connection = Depends(get_db), ) -> CurrentShoppingList: ( outstanding_requests, purchased_requests, meal_requests, meals_lookup, recipes_lookup, ingredients_lookup, ) = await shopping.get_outstanding_requests(conn) other_shopping_list_ids = {item.list_id for item in purchased_requests} shopping_list_lookup = {} for list_id in other_shopping_list_ids: if list_id is not None: sl = await shopping.load_shopping_list(conn, list_id) if sl is not None: shopping_list_lookup[list_id] = sl # Add any additional items from shopping lists to the existing lookups additional_items = [item for sl in shopping_list_lookup.values() for item in sl.items] if additional_items: await shopping.to_lookups( conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup ) return CurrentShoppingList( outstanding_items=outstanding_requests, requested_meals=meal_requests, purchased_items=purchased_requests, meals_lookup=meals_lookup, shopping_list_lookup=shopping_list_lookup, ingredients_lookup=ingredients_lookup, recipes_lookup=recipes_lookup, ) class PurchasedShoppingList(ApiModel): list: shopping.ShoppingList meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict) ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict) recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict) @api_v1.get( "/shopping/{list_id}", response_model=PurchasedShoppingList, operation_id="getShoppingList", tags=["shopping"], summary="Get a purchased shopping list by id", responses={ 404: { "model": ProblemDetails, "description": "Shopping list not found", "content": {"application/problem+json": {}}, } }, ) async def get_shopping_list( list_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request = None ) -> PurchasedShoppingList | JSONResponse: shopping_list = await shopping.load_shopping_list(conn, list_id) if not shopping_list: return error_response(request, 404, "Shopping list not found") meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups( conn, shopping_list.items ) return PurchasedShoppingList( list=shopping_list, meals_lookup=meals_lookup, recipes_lookup=recipes_lookup, ingredients_lookup=ingredients_lookup, ) @api_v1.post( "/shopping/", operation_id="purchaseIngredients", tags=["shopping"], summary="Purchase ingredients for a shopping list", ) async def purchase_ingredients( shopping_list: shopping.ShoppingList, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), ) -> PurchasedShoppingList: shopping_list = shopping.ShoppingList( purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name ) await shopping.purchase(conn, shopping_list) await conn.commit() result = PurchasedShoppingList(list=shopping_list) await shopping.to_lookups( conn, shopping_list.items, result.meals_lookup, result.recipes_lookup, result.ingredients_lookup, ) return result @api_v1.get( "/shopping/current/me/ingredients", operation_id="getMyShoppingList", tags=["shopping"], summary="Get my outstanding ingredient requests", ) async def get_my_shopping_list( conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person) ) -> List[ingredients.Ingredient]: return await shopping.get_persons_requests(conn, person.id) @api_v1.post( "/shopping/current/me/ingredients", operation_id="syncMyShoppingList", tags=["shopping"], summary="Sync my outstanding ingredient requests", ) async def sync_my_shopping_list( requests: List[ingredients.Ingredient], conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), ) -> List[ingredients.Ingredient]: def isMatching(a: ingredients.Ingredient, b: ingredients.Ingredient) -> bool: return a.id == b.id or a.line == b.line my_shopping_list = await shopping.get_persons_requests(conn, person.id) to_remove = [r for r in my_shopping_list if not any(isMatching(r, req) for req in requests)] to_add = [req for req in requests if not any(isMatching(req, r) for r in my_shopping_list)] for r in to_remove: await shopping.remove_request(conn, person, ingredient=r) for r in to_add: if r.id < 0: await ingredients.insert_ingredient(conn, r) await shopping.request(conn, person, ingredient=r) await conn.commit() return await get_my_shopping_list(conn, person) class MealIdWrapper(ApiModel): meal_id: int @api_v1.post( "/shopping/current/meals/me", response_model=None, operation_id="requestMeal", tags=["shopping"], summary="Request a meal for shopping", responses={ 404: { "model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}, } }, ) async def request_meal( r: MealIdWrapper, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), request: Request = None, ) -> shopping.ShoppingListItem | JSONResponse: meal = await meals.find_meal_by_id(conn, r.meal_id) if not meal: return error_response(request, 404, "Meal not found") response = await shopping.request(conn, person, meal=meal) await conn.commit() return response @api_v1.delete( "/shopping/current/meals/{meal_id}", response_model=None, operation_id="unrequestMeal", tags=["shopping"], summary="Remove a meal request", responses={ 404: { "model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}, } }, ) async def unrequest_meal( meal_id: int, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), request: Request = None, ) -> dict | JSONResponse: meal = await meals.find_meal_by_id(conn, meal_id) if not meal: return error_response(request, 404, "Meal not found") await shopping.remove_request(conn, person, meal=meal) await conn.commit() return {} from api import shopping as shopping_router # type: ignore app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted from api import persons as persons_router # type: ignore class LoginBody(ApiModel): username: str @api_v1.post( "/auth/login", response_model=None, operation_id="login", tags=["auth"], summary="Login and set user_id cookie", responses={ 404: { "model": ProblemDetails, "description": "Person not found", "content": {"application/problem+json": {}}, } }, ) async def login( data: LoginBody, conn: aiosqlite.Connection = Depends(get_db), request: Request = None ) -> persons.Person | JSONResponse: person = await persons.get_by_name(conn, data.username) if not person: return error_response(request, 404, "Person not found") response = JSONResponse(content=jsonable_encoder(person)) response.set_cookie(key="user_id", value=str(person.id)) return response @api_v1.post( "/auth/refresh", operation_id="refresh", tags=["auth"], summary="Refresh current user from cookie", ) async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person: return user from api import auth as auth_router # type: ignore app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted # RFC7807 Problem Details handlers from starlette.exceptions import HTTPException as StarletteHTTPException from pydantic import ValidationError from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse @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(persons_router.router, prefix="/api/v1", tags=["v1"]) # extracted @app.get("/healthz") async def healthz(): return {"status": "ok"} if os.environ.get("DOOF_PROD", False): 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 import httpx from starlette.background import BackgroundTask from starlette.requests import Request from starlette.responses import StreamingResponse client = httpx.AsyncClient(base_url="http://localhost:8080/") async def _reverse_proxy(request: Request): url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8")) 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"])