munch-ease-backend/main.py
jableader 4c3f370ddc Squashed commit of the following:
commit 4189d9f824f681b480f797b109e963762eb22e9c
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:41:57 2025 +1100

    Openapi complete

commit bebf8c30cba0b85a889198fe44879614065a0c34
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:35:39 2025 +1100

    Removed unversioned api

commit dd9cc2eae75d66fceebe14c918c3ed8498376ee6
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:07:03 2025 +1100

    Spec updates

commit b993c4530688f79ea983278283f984e9d8e83860
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:01:50 2025 +1100

    docs(spec): update doof-back-spec with v1 RFC7807 422, reusable Problem* responses, and shopping/current aliasing; tests passing; openapi.json refreshed

commit 30bac7e57367b14ce924667a7955845de949d779
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:00:13 2025 +1100

    openapi polish

commit eb7f7f224f7085fa5b3fadc7716db0ebb7f47eb0
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:54:52 2025 +1100

    OpenAPI reusable responses: Added components.responses for `Problem400`, `Problem404`, and `Problem422`; v1 routes reference these consistently.
     - Units enum: Exposed advisory enum in schema for `Ingredient.unit` using existing units list (no runtime enforcement).

commit 037037e17d684a89b264f2406377970a0de7ec99
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:43:01 2025 +1100

    Add `total` counts to v1 page responses for recipes/persons; push persons name filter into SQL for v1 when `q` is provided.

commit 07e7735076aae8cbd04bb10f9aa324c1a3d80ae4
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:40:17 2025 +1100

    Add parameter descriptions for `cursor`, `limit`, and `q` on v1 list endpoints; include example `Page` envelopes in 200 responses.

commit e5bf9396b0fe870501d1b4712cba2555dd2ef9b1
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:36:27 2025 +1100

    DB pagination

commit 782315cd2a0c18cc50e4deaf28e7ec6e4b58c6e2
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:29:38 2025 +1100

    camelcase tests

commit b207c33e2844c00fc9e531fb9cf8c07a3f5cd543
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:24:57 2025 +1100

    OpenAPI enrichment, Error responses

commit dc84681ab743008e5cd8bec7f3ccb0d05b557518
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:16:39 2025 +1100

    v1 tests: Added basic tests to assert `Page` envelopes and RFC7807 responses for v1 endpoints without affecting legacy tests.

commit 1524b7a98ffe04af11c2731c8125ac9348751cff
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:14:51 2025 +1100

    Pagination

commit 92e91d7acf15c09b14bc76f7b16a7a47e65129ec
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:03:50 2025 +1100

    Use middleware for naming case changes

commit c68f964f9b8e3d8f8ef020661e747e0990459c81
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:00:09 2025 +1100

    Openapi gen
2025-10-18 16:44:36 +11:00

1069 lines
33 KiB
Python

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
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 = os.environ.get("DOOF_DB", "./data/doof.sqlite")
# Dependency to create SQLite connection
async def get_db():
sql_db = await db.connect(DATABASE_PATH)
try:
yield sql_db
finally:
await sql_db.close()
async def cookie_person(
user_id: Annotated[int, Cookie(alias="user_id")], conn: aiosqlite.Connection = Depends(get_db)
) -> Optional[persons.Person]:
return await persons.get_by_id(conn, user_id)
def error_response(request: Optional[Request], status_code: int, message: str) -> JSONResponse:
body = ProblemDetails(
title=message,
status=status_code,
type=f"https://httpstatuses.com/{status_code}",
instance=str(request.url) if request else None,
)
return JSONResponse(
content=body.model_dump(by_alias=True),
status_code=status_code,
media_type="application/problem+json",
)
# 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)
@api_v1.get(
"/recipes/parse",
response_model=None,
operation_id="parseRecipe",
tags=["recipes"],
summary="Parse a recipe from a URL",
responses={
400: {
"model": ProblemDetails,
"description": "Recipe not found",
"content": {"application/problem+json": {}},
}
},
)
async def parse_recipe_handler(
url: str, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person), request: Request = None
) -> recipes.Recipe | JSONResponse:
parsed = await recipes.parse_recipe(conn, person, url)
if not parsed:
return error_response(request, 400, "Recipe not found")
return parsed
@api_v1.get(
"/recipes/ingredients/parse",
operation_id="parseIngredients",
tags=["ingredients"],
summary="Parse raw ingredient lines",
)
async def parse_ingredients(
lines: Annotated[List[str], Query(alias="ingredients", title="Array of ingredients to parse")],
conn: aiosqlite.Connection = Depends(get_db),
) -> List[ingredients.Ingredient]:
had_links = False
result = []
for line in lines:
ingredient = await ingredients.parse_ingredient_from_link(conn, line)
if ingredient:
result.append(ingredient)
had_links = True
continue
ingredient = ingredients.parse_ingredient_from_nlp(line)
if ingredient:
result.append(ingredient)
continue
if had_links:
await conn.commit()
await ingredients.match_existing_products(conn, result)
return result
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
@api_v1.get(
"/recipes",
operation_id="listRecipes",
response_model=Page[recipes.Recipe],
tags=["recipes"],
summary="List recipes (paginated)",
responses={
200: {
"description": "A page of recipes",
"content": {
"application/json": {
"example": {
"items": [
{
"id": 1,
"name": "Example Recipe",
"link": "https://example.com/recipes/1",
"serves": 4,
"imageUrls": [],
"ingredients": []
}
],
"nextCursor": "2",
"prevCursor": "0",
"total": 1
}
}
},
}
},
)
async def get_recipes(
q: Optional[str] = Query(
default=None,
description="Optional case-insensitive name filter (matches recipe name with SQL LIKE).",
),
cursor: Optional[str] = Query(
default=None,
description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.",
),
limit: int = Query(
50,
ge=1,
le=200,
description="Maximum number of items to return (1-200).",
),
conn: aiosqlite.Connection = Depends(get_db),
request: Request = None,
) -> List[recipes.Recipe] | Page[recipes.Recipe]:
# v1: DB-backed pagination using limit+1 strategy
last_id = None
if cursor:
try:
last_id = int(cursor)
except ValueError:
last_id = None
fetch_limit = limit + 1
paged: List[recipes.Recipe] = []
if q:
async for r in recipes.find_recipes_by_name_paged(conn, q, last_id, fetch_limit):
paged.append(r)
else:
async for r in recipes.get_all_paged(conn, last_id, fetch_limit):
paged.append(r)
has_more = len(paged) > limit
items = paged[:limit]
# load ingredients for items
for r in items:
r.ingredients = []
async for ing in ingredients.find_ingredients_by_recipe_id(conn, r.id):
r.ingredients.append(ing)
next_cursor = str(items[-1].id) if has_more and items else None
# Compute prevCursor via DB helper
prev_cursor: Optional[str] = None
if items:
first_id = items[0].id
prev_cursor = await recipes.compute_prev_cursor(conn, first_id, limit, q)
total = await (recipes.count_by_name(conn, q) if q else recipes.count_all(conn))
return Page(items=items, nextCursor=next_cursor, prevCursor=prev_cursor, total=total)
@api_v1.get(
"/recipes/{recipe_id}",
response_model=None,
operation_id="getRecipe",
tags=["recipes"],
summary="Get a single recipe",
responses={
404: {
"model": ProblemDetails,
"description": "Recipe not found",
"content": {"application/problem+json": {}},
}
},
)
async def get_recipe(
recipe_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request = None
) -> recipes.Recipe | JSONResponse:
r = await load_full_recipe(conn, recipe_id)
if not r:
return error_response(request, 404, "Recipe not found")
return r
@api_v1.post(
"/recipes",
response_model=None,
operation_id="createRecipe",
tags=["recipes"],
summary="Create a new recipe (versioning semantics applied)",
responses={
400: {
"model": ProblemDetails,
"description": "Validation error",
"content": {"application/problem+json": {}},
}
},
)
async def create_recipe(
recipe: recipes.Recipe,
conn: aiosqlite.Connection = Depends(get_db),
user: persons.Person = Depends(cookie_person),
request: Request = None,
) -> recipes.Recipe | JSONResponse:
if not recipe.ingredients:
return error_response(request, 400, "Recipe must have at least one ingredient")
if recipe.id >= 0:
await recipes.hide_recipe(conn, recipe.id, user)
recipe.based_on_recipe = recipe.id
recipe.id = 0
recipe.created_by_id = user.id
await recipes.insert_recipe(conn, recipe)
for ingredient in recipe.ingredients:
ingredient.recipe_id = recipe.id
if ingredient.product:
ingredient.product_id = ingredient.product.id
await ingredients.insert_ingredient(conn, ingredient)
await conn.commit()
return recipe
@api_v1.delete(
"/recipes/{recipe_id}",
response_model=None,
operation_id="deleteRecipe",
tags=["recipes"],
summary="Soft-delete (hide) a recipe",
responses={
404: {
"model": ProblemDetails,
"description": "Recipe not found",
"content": {"application/problem+json": {}},
}
},
)
async def delete_recipe(
recipe_id: int,
conn: aiosqlite.Connection = Depends(get_db),
user: persons.Person = Depends(cookie_person),
request: Request = None,
) -> recipes.Recipe | JSONResponse:
recipe = await recipes.find_recipe_by_id(conn, recipe_id)
if not recipe:
return error_response(request, 404, "Recipe not found")
await recipes.hide_recipe(conn, recipe_id, user)
await conn.commit()
return recipe
@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
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 {}
@api_v1.get(
"/persons",
operation_id="listPersons",
response_model=Page[persons.Person],
tags=["persons"],
summary="List persons (paginated)",
responses={
200: {
"description": "A page of persons",
"content": {
"application/json": {
"example": {
"items": [
{
"id": 1,
"name": "Ada Lovelace"
}
],
"nextCursor": "2",
"prevCursor": "0",
"total": 1
}
}
},
}
},
)
async def get_persons(
q: Optional[str] = Query(
default=None,
description="Optional case-insensitive name filter (applied after page read; may be pushed to SQL in the future).",
),
cursor: Optional[str] = Query(
default=None,
description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.",
),
limit: int = Query(
50,
ge=1,
le=200,
description="Maximum number of items to return (1-200).",
),
conn: aiosqlite.Connection = Depends(get_db),
request: Request = None,
) -> List[persons.Person] | Page[persons.Person]:
# v1: DB-backed pagination
last_id = None
if cursor:
try:
last_id = int(cursor)
except ValueError:
last_id = None
fetch_limit = limit + 1
paged: List[persons.Person] = []
if q:
async for p in persons.search_by_name_paged(conn, q, last_id, fetch_limit):
paged.append(p)
else:
async for p in persons.get_all_paged(conn, last_id, fetch_limit):
paged.append(p)
has_more = len(paged) > limit
items = paged[:limit]
next_cursor = str(items[-1].id) if has_more and items else None
# Compute prevCursor via DB helper
prev_cursor: Optional[str] = None
if items:
first_id = items[0].id
prev_cursor = await persons.compute_prev_cursor(conn, first_id, limit, q)
total = await (persons.count_by_name(conn, q) if q else persons.count_all(conn))
return Page(items=items, nextCursor=next_cursor, prevCursor=prev_cursor, total=total)
@api_v1.post(
"/persons",
operation_id="createPerson",
tags=["persons"],
summary="Create a person",
)
async def create_person(
person: persons.Person, conn: aiosqlite.Connection = Depends(get_db)
) -> persons.Person:
await persons.insert_person(conn, person)
await conn.commit()
return person
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
# 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"])
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"])