Avoid optional arrays

This commit is contained in:
jableader 2025-11-01 12:21:12 +11:00
parent 5a9242ccc9
commit 0be2d1a2b0
9 changed files with 404 additions and 62 deletions

View file

@ -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,

View file

@ -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={

View file

@ -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.

View file

@ -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

View file

@ -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(

View file

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

26
main.py
View file

@ -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,

View file

@ -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": {

View file

@ -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" = [