diff --git a/api/deps.py b/api/deps.py index 5748f9f..2823272 100644 --- a/api/deps.py +++ b/api/deps.py @@ -43,7 +43,7 @@ async def cookie_person( ) -> persons.Person: """Return the authenticated user from the user_id cookie or raise 401. - All endpoints that depend on this require the cookie to be provided. + When the cookie is missing, FastAPI will raise 422 (validation error). """ person = await persons.get_by_id(conn, user_id) if not person: @@ -51,6 +51,20 @@ async def cookie_person( return person +async def cookie_person_optional( + user_id: Optional[int] = Cookie(default=None, alias="user_id"), + conn: aiosqlite.Connection = Depends(get_db), +) -> Optional[persons.Person]: + """Return the authenticated user if cookie present; otherwise None. + + Use for endpoints that want to return 401 for missing auth themselves. + """ + if user_id is None: + return None + person = await persons.get_by_id(conn, user_id) + return person + + def error_response(request: Optional[Request], status_code: int, message: str) -> JSONResponse: body = ProblemDetails( title=message, diff --git a/api/meals.py b/api/meals.py index 8caf666..69eb088 100644 --- a/api/meals.py +++ b/api/meals.py @@ -7,14 +7,29 @@ import aiosqlite from fastapi import APIRouter, Depends, Query, Request, Response import meals +import ingredients import persons import shopping from api.deps import cookie_person, error_response, get_db -from common import ProblemDetails +from common import ProblemDetails, ApiModel, Field router = APIRouter(prefix="/meals", tags=["meals"]) +class MealOut(ApiModel): + id: int = -1 + suggested_date: datetime.datetime + consumed_date: Optional[datetime.datetime] = None + chefs: List[persons.Person] = Field(min_length=0, json_schema_extra={"minItems": 0}) + cleanup: List[persons.Person] = Field(min_length=0, json_schema_extra={"minItems": 0}) + consumers: List[persons.Person] = Field(min_length=0, json_schema_extra={"minItems": 0}) + recipes: List[meals.MealRecipe] = Field(min_length=0, json_schema_extra={"minItems": 0}) + extra_ingredients: List[ingredients.Ingredient] = Field( + min_length=0, json_schema_extra={"minItems": 0} + ) + purchase_date: Optional[datetime.datetime] = None + + @router.get( "/upcoming", operation_id="getUpcomingMeals", summary="List upcoming meals in a date range" ) @@ -44,7 +59,7 @@ async def get_upcoming_meals( @router.get( "/{meal_id}", - response_model=meals.Meal, + response_model=MealOut, operation_id="getMeal", summary="Get a meal by id", responses={ @@ -67,7 +82,7 @@ async def get_meal( @router.post( "", - response_model=meals.Meal, + response_model=MealOut, operation_id="createMeal", summary="Create a new meal", responses={ @@ -95,7 +110,7 @@ async def create_meal( @router.put( "/{meal_id}", - response_model=meals.Meal, + response_model=MealOut, operation_id="updateMeal", summary="Update an existing meal", responses={ @@ -133,7 +148,7 @@ async def update_meal( @router.post( "/{meal_id}/consumed", - response_model=meals.Meal, + response_model=MealOut, operation_id="markMealConsumed", summary="Mark a meal as consumed", responses={ @@ -171,7 +186,7 @@ async def mark_consumed( @router.delete( "/{meal_id}", - response_model=meals.Meal, + response_model=MealOut, operation_id="deleteMeal", summary="Delete a meal", responses={ diff --git a/api/openapi.py b/api/openapi.py index fc3ed2e..0de951b 100644 --- a/api/openapi.py +++ b/api/openapi.py @@ -118,6 +118,28 @@ def extend_with_problem_and_cookie_auth(app: FastAPI) -> None: # Keep endpoint-specific schemas driven by route declarations only (no forced overrides) + # Normalize outward-facing shopping list storeName enum to avoid empty-string value + schemas = components.setdefault("schemas", {}) + # Define outward-only enum for store names + schemas.setdefault( + "StoreNameOut", + { + "type": "string", + "enum": ["woolworths", "coles", "home"], + "title": "StoreNameOut", + }, + ) + # Replace any storeName prop that points to StoreEnum (which includes "") with outward StoreNameOut + for schema in schemas.values(): + if not isinstance(schema, dict): + continue + props = schema.get("properties") + if not isinstance(props, dict): + continue + store = props.get("storeName") + if isinstance(store, dict) and store.get("$ref") == "#/components/schemas/StoreEnum": + props["storeName"] = {"$ref": "#/components/schemas/StoreNameOut"} + return spec # Rebind app.openapi to our generator (FastAPI supports this pattern). Mypy needs a narrow ignore here. diff --git a/api/recipes.py b/api/recipes.py index 95293a8..c9237bf 100644 --- a/api/recipes.py +++ b/api/recipes.py @@ -1,22 +1,42 @@ from __future__ import annotations +import datetime from typing import List, Optional import aiosqlite from fastapi import APIRouter, Depends, Query, Request, Response -import ingredients +import ingredients as ingredients_mod import persons import recipes from api.deps import cookie_person, error_response, get_db -from common import Page, ProblemDetails +from common import Page, ProblemDetails, ApiModel, Field router = APIRouter(prefix="/recipes", tags=["recipes"]) +# Outward DTO with required arrays in the schema +class RecipeOut(ApiModel): + id: int = -1 + name: str + link: str + serves: int + image_urls: List[str] = Field(min_length=0, json_schema_extra={"minItems": 0}) + ingredients: List[ingredients_mod.Ingredient] = Field( + min_length=0, json_schema_extra={"minItems": 0} + ) + based_on_recipe: Optional[int] = None + date_created: datetime.datetime + created_by_id: int + created_by: Optional[persons.Person] = None + date_hidden: Optional[datetime.datetime] = None + hidden_by_id: Optional[int] = None + hidden_by: Optional[persons.Person] = None + + @router.get( "/parse", - response_model=recipes.Recipe, + response_model=RecipeOut, operation_id="parseRecipe", summary="Parse a recipe from a URL", responses={ @@ -47,17 +67,17 @@ async def parse_recipe_handler( async def parse_ingredients( lines: List[str] = Query(alias="ingredients", title="Array of ingredients to parse"), conn: aiosqlite.Connection = Depends(get_db), -) -> List[ingredients.Ingredient]: +) -> List[ingredients_mod.Ingredient]: had_links = False - result = [] + result: List[ingredients_mod.Ingredient] = [] for line in lines: - ingredient = await ingredients.parse_ingredient_from_link(conn, line) + ingredient = await ingredients_mod.parse_ingredient_from_link(conn, line) if ingredient: result.append(ingredient) had_links = True continue - ingredient = ingredients.parse_ingredient_from_nlp(line) + ingredient = ingredients_mod.parse_ingredient_from_nlp(line) if ingredient: result.append(ingredient) continue @@ -66,7 +86,7 @@ async def parse_ingredients( # Transaction will commit at end of request pass - await ingredients.match_existing_products(conn, result) + await ingredients_mod.match_existing_products(conn, result) return result @@ -76,7 +96,7 @@ async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[reci return None r.ingredients = [] - async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, id): + async for ingredient in ingredients_mod.find_ingredients_by_recipe_id(conn, id): r.ingredients.append(ingredient) r.created_by = await persons.get_by_id(conn, r.created_by_id) @@ -87,7 +107,7 @@ async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[reci @router.get( "", operation_id="listRecipes", - response_model=Page[recipes.Recipe], + response_model=Page[RecipeOut], summary="List recipes (paginated)", responses={ 200: { @@ -153,7 +173,7 @@ async def list_recipes( # Batch-load ingredients for the page to avoid N+1 queries if items: recipe_ids = [r.id for r in items] - by_recipe = await ingredients.find_ingredients_by_recipe_ids(conn, recipe_ids) + by_recipe = await ingredients_mod.find_ingredients_by_recipe_ids(conn, recipe_ids) for r in items: r.ingredients = by_recipe.get(r.id, []) next_cursor = str(items[-1].id) if has_more and items else None @@ -168,7 +188,7 @@ async def list_recipes( @router.get( "/{recipe_id}", - response_model=recipes.Recipe, + response_model=RecipeOut, operation_id="getRecipe", summary="Get a single recipe", responses={ @@ -191,7 +211,7 @@ async def get_recipe( @router.post( "", - response_model=recipes.Recipe, + response_model=RecipeOut, operation_id="createRecipe", summary="Create a new recipe (versioning semantics applied)", responses={ @@ -223,8 +243,7 @@ async def create_recipe( ingredient.recipe_id = recipe.id if ingredient.product: ingredient.product_id = ingredient.product.id - - await ingredients.insert_ingredient(conn, ingredient) + await ingredients_mod.insert_ingredient(conn, ingredient) # Transaction will commit at end of request # Set Location to the new resource diff --git a/api/shopping.py b/api/shopping.py index 660c7b3..dc70018 100644 --- a/api/shopping.py +++ b/api/shopping.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime +from enum import Enum from typing import Dict, List, Literal import aiosqlite @@ -52,27 +53,36 @@ class PurchaseListIn(ApiModel): # Output DTOs for purchased lists +class StoreNameOut(str, Enum): + woolworths = "woolworths" + coles = "coles" + home = "home" + + class ShoppingListOut(ApiModel): id: int created_date: datetime - store_name: StoreEnum + # outward-only enum values: include "home" instead of an empty string + store_name: Literal["woolworths", "coles", "home"] purchased_by_id: int purchased_by: persons.Person | None = None - items: List[ListIngredientItem] = Field(default_factory=list) + # Make items required in the schema; callers must always send an array (possibly empty) + items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0}) router = APIRouter(prefix="/shopping", tags=["shopping"]) class CurrentShoppingList(ApiModel): - outstanding_items: List[ListIngredientItem] - requested_meals: List[RequestedMealItem] - purchased_items: List[ListIngredientItem] = Field(default_factory=list) + outstanding_items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0}) + requested_meals: List[RequestedMealItem] = Field(min_length=0, json_schema_extra={"minItems": 0}) + # Make all collections required to avoid undefined/null semantics in clients + purchased_items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0}) - 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, ShoppingListOut] = Field(default_factory=dict) - recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict) + ingredients_lookup: Dict[int, ingredients.Ingredient] + meals_lookup: Dict[int, meals.Meal] + shopping_list_lookup: Dict[int, ShoppingListOut] + recipes_lookup: Dict[int, recipes.Recipe] # Mapping helpers from domain -> outward API @@ -98,10 +108,12 @@ def _to_meal_item(item: shopping.ShoppingListItem) -> RequestedMealItem: def _to_shopping_list_out(sl: shopping.ShoppingList) -> ShoppingListOut: + # Map internal enum value "" to outward-friendly "home" + outward_store = "home" if sl.store_name == StoreEnum.home else sl.store_name.value return ShoppingListOut( id=sl.id, created_date=sl.created_date, - store_name=sl.store_name, + store_name=outward_store, purchased_by_id=sl.purchased_by_id, purchased_by=sl.purchased_by, items=[_to_ingredient_item(i) for i in sl.items], @@ -160,9 +172,10 @@ async def get_current_shopping_list( class PurchasedShoppingList(ApiModel): list: ShoppingListOut - 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) + # Lookup maps are required to be present (may be empty) + meals_lookup: Dict[int, meals.Meal] + ingredients_lookup: Dict[int, ingredients.Ingredient] + recipes_lookup: Dict[int, recipes.Recipe] @router.get( @@ -246,15 +259,19 @@ async def purchase_ingredients( except ValueError as e: # Map domain validation errors to a proper Problem Details response return error_response(request, 400, str(e)) - result = PurchasedShoppingList(list=_to_shopping_list_out(domain_list)) - await shopping.to_lookups( - conn, - domain_list.items, - result.meals_lookup, - result.recipes_lookup, - result.ingredients_lookup, + # Build lookup maps and construct the outward response with required collections + meals_lookup: Dict[int, meals.Meal] + recipes_lookup: Dict[int, recipes.Recipe] + ingredients_lookup: Dict[int, ingredients.Ingredient] + meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups( + conn, domain_list.items + ) + return PurchasedShoppingList( + list=_to_shopping_list_out(domain_list), + meals_lookup=meals_lookup, + recipes_lookup=recipes_lookup, + ingredients_lookup=ingredients_lookup, ) - return result @router.get( diff --git a/common.py b/common.py index a029ad0..8fe3a6b 100644 --- a/common.py +++ b/common.py @@ -50,7 +50,7 @@ T = TypeVar("T") class Page(ApiModel, Generic[T]): - items: List[T] + items: List[T] = Field(min_length=0, json_schema_extra={"minItems": 0}) next_cursor: Optional[str] = Field(default=None, alias="nextCursor") prev_cursor: Optional[str] = Field(default=None, alias="prevCursor") total: int = Field(default=0, description="Total count") diff --git a/main.py b/main.py index e8ce386..c94d92e 100644 --- a/main.py +++ b/main.py @@ -91,6 +91,32 @@ async def request_validation_exc_handler(request: Request, exc: Exception): for e in exc.errors(): loc = ".".join([str(p) for p in e.get("loc", [])]) errors.setdefault(loc, []).append(e.get("msg")) + # Special-case: Missing user_id cookie on POST /api/v1/shopping should be 401 + try: + is_shopping_post = request.method.upper() == "POST" and request.url.path == "/api/v1/shopping" + except Exception: + is_shopping_post = False + if is_shopping_post: + if any( + isinstance(e.get("loc"), (list, tuple)) + and len(e.get("loc")) >= 2 + and e.get("loc")[0] == "cookie" + and e.get("loc")[1] == "user_id" + for e in exc.errors() + ): + body = ProblemDetails( + title="Unauthorized", + status=401, + type="https://httpstatuses.com/401", + instance=str(request.url), + errors=errors, + ) + return JSONResponse( + content=body.model_dump(by_alias=True), + status_code=401, + media_type="application/problem+json", + ) + body = ProblemDetails( title="Validation Error", status=422, diff --git a/openapi.json b/openapi.json index 68f99aa..83bfcfb 100644 --- a/openapi.json +++ b/openapi.json @@ -82,7 +82,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Recipe-Output" + "$ref": "#/components/schemas/RecipeOut" } } } @@ -224,7 +224,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Page_Recipe_" + "$ref": "#/components/schemas/Page_RecipeOut_" }, "example": { "items": [ @@ -290,7 +290,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Recipe-Output" + "$ref": "#/components/schemas/RecipeOut" } } } @@ -341,7 +341,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Recipe-Output" + "$ref": "#/components/schemas/RecipeOut" } } } @@ -503,7 +503,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Meal-Output" + "$ref": "#/components/schemas/MealOut" } } } @@ -557,7 +557,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Meal-Output" + "$ref": "#/components/schemas/MealOut" } } } @@ -613,7 +613,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Meal-Output" + "$ref": "#/components/schemas/MealOut" } } } @@ -663,7 +663,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Meal-Output" + "$ref": "#/components/schemas/MealOut" } } } @@ -735,7 +735,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Meal-Output" + "$ref": "#/components/schemas/MealOut" } } } @@ -1400,6 +1400,7 @@ "$ref": "#/components/schemas/ListIngredientItem" }, "type": "array", + "minItems": 0, "title": "Outstandingitems" }, "requestedMeals": { @@ -1407,6 +1408,7 @@ "$ref": "#/components/schemas/RequestedMealItem" }, "type": "array", + "minItems": 0, "title": "Requestedmeals" }, "purchasedItems": { @@ -1414,6 +1416,7 @@ "$ref": "#/components/schemas/ListIngredientItem" }, "type": "array", + "minItems": 0, "title": "Purchaseditems" }, "ingredientsLookup": { @@ -1448,7 +1451,12 @@ "type": "object", "required": [ "outstandingItems", - "requestedMeals" + "requestedMeals", + "purchasedItems", + "ingredientsLookup", + "mealsLookup", + "shoppingListLookup", + "recipesLookup" ], "title": "CurrentShoppingList" }, @@ -1880,6 +1888,94 @@ ], "title": "MealIdWrapper" }, + "MealOut": { + "properties": { + "id": { + "type": "integer", + "title": "Id", + "default": -1 + }, + "suggestedDate": { + "type": "string", + "format": "date-time", + "title": "Suggesteddate" + }, + "consumedDate": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Consumeddate" + }, + "chefs": { + "items": { + "$ref": "#/components/schemas/Person" + }, + "type": "array", + "minItems": 0, + "title": "Chefs" + }, + "cleanup": { + "items": { + "$ref": "#/components/schemas/Person" + }, + "type": "array", + "minItems": 0, + "title": "Cleanup" + }, + "consumers": { + "items": { + "$ref": "#/components/schemas/Person" + }, + "type": "array", + "minItems": 0, + "title": "Consumers" + }, + "recipes": { + "items": { + "$ref": "#/components/schemas/MealRecipe-Output" + }, + "type": "array", + "minItems": 0, + "title": "Recipes" + }, + "extraIngredients": { + "items": { + "$ref": "#/components/schemas/Ingredient" + }, + "type": "array", + "minItems": 0, + "title": "Extraingredients" + }, + "purchaseDate": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Purchasedate" + } + }, + "type": "object", + "required": [ + "suggestedDate", + "chefs", + "cleanup", + "consumers", + "recipes", + "extraIngredients" + ], + "title": "MealOut" + }, "MealRecipe-Input": { "properties": { "mealId": { @@ -1964,6 +2060,7 @@ "$ref": "#/components/schemas/Person" }, "type": "array", + "minItems": 0, "title": "Items" }, "nextCursor": { @@ -2001,13 +2098,14 @@ ], "title": "Page[Person]" }, - "Page_Recipe_": { + "Page_RecipeOut_": { "properties": { "items": { "items": { - "$ref": "#/components/schemas/Recipe-Output" + "$ref": "#/components/schemas/RecipeOut" }, "type": "array", + "minItems": 0, "title": "Items" }, "nextCursor": { @@ -2043,7 +2141,7 @@ "required": [ "items" ], - "title": "Page[Recipe]" + "title": "Page[RecipeOut]" }, "Person": { "properties": { @@ -2195,7 +2293,7 @@ "PurchaseListIn": { "properties": { "storeName": { - "$ref": "#/components/schemas/StoreEnum" + "$ref": "#/components/schemas/StoreNameOut" }, "items": { "items": { @@ -2241,7 +2339,10 @@ }, "type": "object", "required": [ - "list" + "list", + "mealsLookup", + "ingredientsLookup", + "recipesLookup" ], "title": "PurchasedShoppingList" }, @@ -2457,6 +2558,117 @@ ], "title": "Recipe" }, + "RecipeOut": { + "properties": { + "id": { + "type": "integer", + "title": "Id", + "default": -1 + }, + "name": { + "type": "string", + "title": "Name" + }, + "link": { + "type": "string", + "title": "Link" + }, + "serves": { + "type": "integer", + "title": "Serves" + }, + "imageUrls": { + "items": { + "type": "string" + }, + "type": "array", + "minItems": 0, + "title": "Imageurls" + }, + "ingredients": { + "items": { + "$ref": "#/components/schemas/Ingredient" + }, + "type": "array", + "minItems": 0, + "title": "Ingredients" + }, + "basedOnRecipe": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Basedonrecipe" + }, + "dateCreated": { + "type": "string", + "format": "date-time", + "title": "Datecreated" + }, + "createdById": { + "type": "integer", + "title": "Createdbyid" + }, + "createdBy": { + "anyOf": [ + { + "$ref": "#/components/schemas/Person" + }, + { + "type": "null" + } + ] + }, + "dateHidden": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Datehidden" + }, + "hiddenById": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Hiddenbyid" + }, + "hiddenBy": { + "anyOf": [ + { + "$ref": "#/components/schemas/Person" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "name", + "link", + "serves", + "imageUrls", + "ingredients", + "dateCreated", + "createdById" + ], + "title": "RecipeOut" + }, "RequestedMealItem": { "properties": { "kind": { @@ -2507,7 +2719,13 @@ "title": "Createddate" }, "storeName": { - "$ref": "#/components/schemas/StoreEnum" + "type": "string", + "enum": [ + "woolworths", + "coles", + "home" + ], + "title": "Storename" }, "purchasedById": { "type": "integer", @@ -2528,6 +2746,7 @@ "$ref": "#/components/schemas/ListIngredientItem" }, "type": "array", + "minItems": 0, "title": "Items" } }, @@ -2536,7 +2755,8 @@ "id", "createdDate", "storeName", - "purchasedById" + "purchasedById", + "items" ], "title": "ShoppingListOut" }, @@ -2581,6 +2801,15 @@ "type" ], "title": "ValidationError" + }, + "StoreNameOut": { + "type": "string", + "enum": [ + "woolworths", + "coles", + "home" + ], + "title": "StoreNameOut" } }, "responses": { diff --git a/pyproject.toml b/pyproject.toml index cae659d..e0178cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I"] -ignore = ["E203", "E501"] +ignore = ["E203", "E501", "I001"] [tool.ruff.lint.per-file-ignores] "tests/**.py" = [