Compare commits

...

4 commits

Author SHA1 Message Date
0be2d1a2b0 Avoid optional arrays 2025-11-01 12:21:12 +11:00
5a9242ccc9 Tighten api endpoints 2025-10-26 15:14:05 +11:00
a6eb819057 cookie_person now mandatory 2025-10-21 19:59:29 +11:00
5a85250574 Remove nulls from public api 2025-10-21 19:42:23 +11:00
12 changed files with 870 additions and 206 deletions

View file

@ -1,9 +1,9 @@
from __future__ import annotations
from typing import Annotated, AsyncGenerator, Optional
from typing import AsyncGenerator, Optional
import aiosqlite
from fastapi import Cookie, Depends, Request
from fastapi import Cookie, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
import db
@ -38,12 +38,31 @@ async def get_db() -> AsyncGenerator[aiosqlite.Connection, None]:
async def cookie_person(
user_id: Optional[int] = Cookie(None, alias="user_id"),
user_id: int = Cookie(..., alias="user_id"),
conn: aiosqlite.Connection = Depends(get_db),
) -> persons.Person:
"""Return the authenticated user from the user_id cookie or raise 401.
When the cookie is missing, FastAPI will raise 422 (validation error).
"""
person = await persons.get_by_id(conn, user_id)
if not person:
raise HTTPException(status_code=401, detail="Unauthorized")
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
return await persons.get_by_id(conn, user_id)
person = await persons.get_by_id(conn, user_id)
return person
def error_response(request: Optional[Request], status_code: int, message: str) -> JSONResponse:

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

@ -103,8 +103,43 @@ def extend_with_problem_and_cookie_auth(app: FastAPI) -> None:
if not any(isinstance(s, dict) and "cookieAuth" in s for s in security):
security.append({"cookieAuth": []})
# Ensure the cookie parameter is documented as required integer (non-null)
params = op.get("parameters")
if isinstance(params, list):
for p in params:
if not isinstance(p, dict):
continue
if p.get("in") == "cookie" and p.get("name") == "user_id":
p["required"] = True
schema = p.setdefault("schema", {})
if isinstance(schema, dict):
schema.clear()
schema.update({"type": "integer", "title": "User Id"})
# 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,11 +96,10 @@ 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)
if r.created_by_id is not None:
r.created_by = await persons.get_by_id(conn, r.created_by_id)
r.created_by = await persons.get_by_id(conn, r.created_by_id)
return r
@ -88,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: {
@ -154,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
@ -169,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={
@ -192,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={
@ -224,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,8 @@
from __future__ import annotations
from typing import Dict, List
from datetime import datetime
from enum import Enum
from typing import Dict, List, Literal
import aiosqlite
from fastapi import APIRouter, Depends, Request, Response
@ -10,21 +12,112 @@ import meals
import persons
import recipes
import shopping
from shopping.models import StoreEnum
from api.deps import cookie_person, error_response, get_db
from common import ApiModel, Field, ProblemDetails
# Outward-facing models to reduce unnecessary nulls in API responses
class ListIngredientItem(ApiModel):
kind: Literal["ingredient"] = "ingredient"
id: int = -1
ingredient_id: int
person_id: int
created_date: datetime
# These may be present when the ingredient is part of a requested meal
list_id: int | None = None
meal_id: int | None = None
recipe_id: int | None = None
class RequestedMealItem(ApiModel):
kind: Literal["requestedMeal"] = "requestedMeal"
id: int = -1
person_id: int
meal_id: int
created_date: datetime
# Input DTOs (separate from internal DB/domain models)
class IngredientPurchaseItemIn(ApiModel):
ingredient_id: int
person_id: int
created_date: datetime | None = None
meal_id: int | None = None
recipe_id: int | None = None
class PurchaseListIn(ApiModel):
store_name: StoreEnum
items: List[IngredientPurchaseItemIn]
# Output DTOs for purchased lists
class StoreNameOut(str, Enum):
woolworths = "woolworths"
coles = "coles"
home = "home"
class ShoppingListOut(ApiModel):
id: int
created_date: datetime
# 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
# 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[shopping.ShoppingListItem]
requested_meals: List[shopping.ShoppingListItem]
purchased_items: List[shopping.ShoppingListItem] = 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, shopping.ShoppingList] = 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
def _to_ingredient_item(item: shopping.ShoppingListItem) -> ListIngredientItem:
return ListIngredientItem(
id=item.id,
ingredient_id=item.ingredient_id if item.ingredient_id is not None else -1,
person_id=item.person_id,
created_date=item.created_date,
list_id=item.list_id,
meal_id=item.meal_id,
recipe_id=item.recipe_id,
)
def _to_meal_item(item: shopping.ShoppingListItem) -> RequestedMealItem:
return RequestedMealItem(
id=item.id,
person_id=item.person_id,
meal_id=item.meal_id if item.meal_id is not None else -1,
created_date=item.created_date,
)
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=outward_store,
purchased_by_id=sl.purchased_by_id,
purchased_by=sl.purchased_by,
items=[_to_ingredient_item(i) for i in sl.items],
)
@router.get(
@ -46,24 +139,30 @@ async def get_current_shopping_list(
) = await shopping.get_outstanding_requests(conn)
other_shopping_list_ids = {item.list_id for item in purchased_requests}
shopping_list_lookup = {}
# Load full lists for additional lookups
other_lists_domain: Dict[int, shopping.ShoppingList] = {}
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
other_lists_domain[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]
additional_items = [item for sl in other_lists_domain.values() for item in sl.items]
if additional_items:
await shopping.to_lookups(
conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup
)
# Convert domain shopping lists to outward form for response
shopping_list_lookup: Dict[int, ShoppingListOut] = {
k: _to_shopping_list_out(v) for k, v in other_lists_domain.items()
}
return CurrentShoppingList(
outstanding_items=outstanding_requests,
requested_meals=meal_requests,
purchased_items=purchased_requests,
outstanding_items=[_to_ingredient_item(i) for i in outstanding_requests],
requested_meals=[_to_meal_item(i) for i in meal_requests],
purchased_items=[_to_ingredient_item(i) for i in purchased_requests],
meals_lookup=meals_lookup,
shopping_list_lookup=shopping_list_lookup,
ingredients_lookup=ingredients_lookup,
@ -72,10 +171,11 @@ async def get_current_shopping_list(
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)
list: ShoppingListOut
# 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(
@ -102,7 +202,7 @@ async def get_shopping_list(
conn, shopping_list.items
)
return PurchasedShoppingList(
list=shopping_list,
list=_to_shopping_list_out(shopping_list),
meals_lookup=meals_lookup,
recipes_lookup=recipes_lookup,
ingredients_lookup=ingredients_lookup,
@ -128,7 +228,7 @@ async def get_shopping_list(
},
)
async def purchase_ingredients(
shopping_list: shopping.ShoppingList,
shopping_list: PurchaseListIn,
request: Request,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
@ -137,24 +237,41 @@ async def purchase_ingredients(
if not person:
return error_response(request, 401, "Unauthorized")
# Attach purchaser to ensure purchased_by_id is set via BaseLinkedModel
shopping_list = shopping.ShoppingList(
purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name
# Map outward input DTO to domain model
domain_items: List[shopping.ShoppingListItem] = []
for it in shopping_list.items:
created = it.created_date or datetime.now().astimezone()
domain_items.append(
shopping.ShoppingListItem(
ingredient_id=it.ingredient_id,
person_id=it.person_id,
meal_id=it.meal_id,
recipe_id=it.recipe_id,
created_date=created,
)
)
domain_list = shopping.ShoppingList(
purchased_by=person, items=domain_items, store_name=shopping_list.store_name
)
try:
await shopping.purchase(conn, shopping_list)
await shopping.purchase(conn, domain_list)
except ValueError as e:
# Map domain validation errors to a proper Problem Details response
return error_response(request, 400, str(e))
result = PurchasedShoppingList(list=shopping_list)
await shopping.to_lookups(
conn,
shopping_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(
@ -202,7 +319,7 @@ class MealIdWrapper(ApiModel):
@router.post(
"/current/meals/me",
response_model=shopping.ShoppingListItem,
response_model=RequestedMealItem,
operation_id="requestMeal",
summary="Request a meal for shopping",
responses={
@ -218,13 +335,13 @@ async def request_meal(
request: Request,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
) -> shopping.ShoppingListItem | Response:
) -> RequestedMealItem | Response:
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)
return response
return _to_meal_item(response)
class Ok(ApiModel):

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: Optional[int] = Field(default=None, description="Optional total count")
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"
}
}
}
@ -859,7 +859,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ShoppingList"
"$ref": "#/components/schemas/PurchaseListIn"
}
}
}
@ -1058,7 +1058,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ShoppingListItem"
"$ref": "#/components/schemas/RequestedMealItem"
}
}
}
@ -1397,23 +1397,26 @@
"properties": {
"outstandingItems": {
"items": {
"$ref": "#/components/schemas/ShoppingListItem"
"$ref": "#/components/schemas/ListIngredientItem"
},
"type": "array",
"minItems": 0,
"title": "Outstandingitems"
},
"requestedMeals": {
"items": {
"$ref": "#/components/schemas/ShoppingListItem"
"$ref": "#/components/schemas/RequestedMealItem"
},
"type": "array",
"minItems": 0,
"title": "Requestedmeals"
},
"purchasedItems": {
"items": {
"$ref": "#/components/schemas/ShoppingListItem"
"$ref": "#/components/schemas/ListIngredientItem"
},
"type": "array",
"minItems": 0,
"title": "Purchaseditems"
},
"ingredientsLookup": {
@ -1432,7 +1435,7 @@
},
"shoppingListLookup": {
"additionalProperties": {
"$ref": "#/components/schemas/ShoppingList"
"$ref": "#/components/schemas/ShoppingListOut"
},
"type": "object",
"title": "Shoppinglistlookup"
@ -1448,7 +1451,12 @@
"type": "object",
"required": [
"outstandingItems",
"requestedMeals"
"requestedMeals",
"purchasedItems",
"ingredientsLookup",
"mealsLookup",
"shoppingListLookup",
"recipesLookup"
],
"title": "CurrentShoppingList"
},
@ -1575,6 +1583,129 @@
],
"title": "Ingredient"
},
"IngredientPurchaseItemIn": {
"properties": {
"ingredientId": {
"type": "integer",
"title": "Ingredientid"
},
"personId": {
"type": "integer",
"title": "Personid"
},
"createdDate": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Createddate"
},
"mealId": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Mealid"
},
"recipeId": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Recipeid"
}
},
"type": "object",
"required": [
"ingredientId",
"personId"
],
"title": "IngredientPurchaseItemIn"
},
"ListIngredientItem": {
"properties": {
"kind": {
"type": "string",
"enum": [
"ingredient"
],
"const": "ingredient",
"title": "Kind",
"default": "ingredient"
},
"id": {
"type": "integer",
"title": "Id",
"default": -1
},
"ingredientId": {
"type": "integer",
"title": "Ingredientid"
},
"personId": {
"type": "integer",
"title": "Personid"
},
"createdDate": {
"type": "string",
"format": "date-time",
"title": "Createddate"
},
"listId": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Listid"
},
"mealId": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Mealid"
},
"recipeId": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Recipeid"
}
},
"type": "object",
"required": [
"ingredientId",
"personId",
"createdDate"
],
"title": "ListIngredientItem"
},
"LoginBody": {
"properties": {
"username": {
@ -1757,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": {
@ -1841,6 +2060,7 @@
"$ref": "#/components/schemas/Person"
},
"type": "array",
"minItems": 0,
"title": "Items"
},
"nextCursor": {
@ -1866,16 +2086,10 @@
"title": "Prevcursor"
},
"total": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"type": "integer",
"title": "Total",
"description": "Optional total count"
"description": "Total count",
"default": 0
}
},
"type": "object",
@ -1884,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": {
@ -1916,23 +2131,17 @@
"title": "Prevcursor"
},
"total": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"type": "integer",
"title": "Total",
"description": "Optional total count"
"description": "Total count",
"default": 0
}
},
"type": "object",
"required": [
"items"
],
"title": "Page[Recipe]"
"title": "Page[RecipeOut]"
},
"Person": {
"properties": {
@ -2046,17 +2255,6 @@
"imgLarge": {
"type": "string",
"title": "Imglarge"
},
"rawData": {
"anyOf": [
{
"type": "object"
},
{
"type": "null"
}
],
"title": "Rawdata"
}
},
"type": "object",
@ -2092,10 +2290,30 @@
],
"title": "ProductUrl"
},
"PurchaseListIn": {
"properties": {
"storeName": {
"$ref": "#/components/schemas/StoreNameOut"
},
"items": {
"items": {
"$ref": "#/components/schemas/IngredientPurchaseItemIn"
},
"type": "array",
"title": "Items"
}
},
"type": "object",
"required": [
"storeName",
"items"
],
"title": "PurchaseListIn"
},
"PurchasedShoppingList": {
"properties": {
"list": {
"$ref": "#/components/schemas/ShoppingList"
"$ref": "#/components/schemas/ShoppingListOut"
},
"mealsLookup": {
"additionalProperties": {
@ -2121,7 +2339,10 @@
},
"type": "object",
"required": [
"list"
"list",
"mealsLookup",
"ingredientsLookup",
"recipesLookup"
],
"title": "PurchasedShoppingList"
},
@ -2175,14 +2396,7 @@
"title": "Datecreated"
},
"createdById": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"type": "integer",
"title": "Createdbyid"
},
"createdBy": {
@ -2288,14 +2502,7 @@
"title": "Datecreated"
},
"createdById": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"type": "integer",
"title": "Createdbyid"
},
"createdBy": {
@ -2351,26 +2558,178 @@
],
"title": "Recipe"
},
"ShoppingList": {
"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": {
"type": "string",
"enum": [
"requestedMeal"
],
"const": "requestedMeal",
"title": "Kind",
"default": "requestedMeal"
},
"id": {
"type": "integer",
"title": "Id",
"default": -1
},
"personId": {
"type": "integer",
"title": "Personid"
},
"mealId": {
"type": "integer",
"title": "Mealid"
},
"createdDate": {
"type": "string",
"format": "date-time",
"title": "Createddate"
}
},
"type": "object",
"required": [
"personId",
"mealId",
"createdDate"
],
"title": "RequestedMealItem"
},
"ShoppingListOut": {
"properties": {
"id": {
"type": "integer",
"title": "Id"
},
"createdDate": {
"type": "string",
"format": "date-time",
"title": "Createddate"
},
"storeName": {
"$ref": "#/components/schemas/StoreEnum",
"default": ""
"type": "string",
"enum": [
"woolworths",
"coles",
"home"
],
"title": "Storename"
},
"purchasedById": {
"type": "integer",
"title": "Purchasedbyid",
"default": -1
"title": "Purchasedbyid"
},
"purchasedBy": {
"anyOf": [
@ -2384,79 +2743,22 @@
},
"items": {
"items": {
"$ref": "#/components/schemas/ShoppingListItem"
"$ref": "#/components/schemas/ListIngredientItem"
},
"type": "array",
"minItems": 0,
"title": "Items"
}
},
"type": "object",
"title": "ShoppingList"
},
"ShoppingListItem": {
"properties": {
"id": {
"type": "integer",
"title": "Id",
"default": -1
},
"listId": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Listid"
},
"personId": {
"type": "integer",
"title": "Personid",
"default": -1
},
"ingredientId": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Ingredientid"
},
"recipeId": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Recipeid"
},
"mealId": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Mealid"
},
"createdDate": {
"type": "string",
"format": "date-time",
"title": "Createddate"
}
},
"type": "object",
"title": "ShoppingListItem"
"required": [
"id",
"createdDate",
"storeName",
"purchasedById",
"items"
],
"title": "ShoppingListOut"
},
"StoreEnum": {
"type": "string",
@ -2499,6 +2801,15 @@
"type"
],
"title": "ValidationError"
},
"StoreNameOut": {
"type": "string",
"enum": [
"woolworths",
"coles",
"home"
],
"title": "StoreNameOut"
}
},
"responses": {

View file

@ -2,6 +2,8 @@ from __future__ import annotations
from typing import ClassVar, List, Optional
from pydantic import PrivateAttr
from common import ApiModel
@ -28,5 +30,13 @@ class Product(ApiModel):
unit: str
img_small: str
img_large: str
# Non-persisted field used in tests and insert helper
raw_data: Optional[dict] = None
# Non-persisted field used in tests and insert helper (not part of public schema)
_raw_data: Optional[dict] = PrivateAttr(default=None)
@property
def raw_data(self) -> Optional[dict]:
return self._raw_data
@raw_data.setter
def raw_data(self, value: Optional[dict]) -> None:
self._raw_data = value

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

View file

@ -36,7 +36,7 @@ class Recipe(ApiModel):
date_created: datetime.datetime = Field(
default_factory=lambda: datetime.datetime.now().astimezone()
)
created_by_id: Optional[int]
created_by_id: int
created_by: Optional[Person] = None
date_hidden: Optional[datetime.datetime] = None

113
tighten-api-spec.md Normal file
View file

@ -0,0 +1,113 @@
# Tighten Public API Nullability
Make the external API more consistent and predictable by eliminating unnecessary nulls (nullable fields) in models and responses. This plan lists concrete, low-risk changes, their rationale, and exact files/lines to modify. Each task is checkable and includes verification steps.
Date: 2025-10-21
## Principles
- Prefer non-nullable types where domain requires a value (DB constraints, logic always sets it).
- Keep optional only when a field truly may be absent by design (e.g., hiddenBy, prevCursor when first page).
- Preserve backward compatibility where feasible. When changing response shapes, update tests and OpenAPI examples.
- Pydantic already excludes None on serialization in some places; we still tighten model types to improve OpenAPI and client SDKs.
## Quick wins (low risk)
- [x] Page.total is non-nullable with default 0
- Why: Pagination always returns a number. Current `Optional[int]` leads to `null` in schema and potential nulls in responses.
- Change: in `common.py`, change `total: Optional[int]` to `total: int = Field(default=0, description="Total count")`.
- Verify:
- [ ] mypy/pyright/ruff pass.
- [ ] Tests for persons/recipes list remain green.
- [ ] OpenAPI shows `total` as `integer` (no anyOf null).
- [x] Recipe.created_by_id non-nullable
- Why: DB enforces NOT NULL and creation flow always sets it.
- Change: in `recipes/models.py` set `created_by_id: int` (remove Optional). Keep `created_by: Optional[Person]` (hydrated field).
- Knock-on: `api/recipes.load_full_recipe` can drop the `if r.created_by_id is not None` guard.
- Verify:
- [ ] All recipe-related tests green.
- [ ] OpenAPI for Recipe shows `createdById` required.
- [x] Product.raw_data excluded from public schema
- Why: Internal/testing helper currently typed as `Optional[dict]` -> visible as nullable in OpenAPI.
- Change: in `products/models.py` use a `PrivateAttr` (with a `raw_data` property) so it stays out of the schema without creating Input/Output variants.
- Verify:
- [ ] Product schema in OpenAPI does not include `rawData`.
- [ ] Tests referencing raw_data still pass (field remains available in code, excluded from schema/response).
## Shopping models and endpoints
`ShoppingListItem` currently represents two cases (ingredient request vs meal request), so several linking fields are nullable. We can reduce nulls in the public API by introducing outward-facing variants while keeping the DB model as-is.
- [ ] Optional: Introduce discriminated union for API returns (medium change)
- Rationale: Return `oneOf` in OpenAPI with variant-specific required fields; eliminates irrelevant nullable properties for each variant.
- Approach (sketch):
- Define `ListIngredientItem` and `RequestedMealItem` pydantic models with a `kind` discriminator.
- Update `api/shopping.py` response models (CurrentShoppingList and PurchasedShoppingList) to use `Union[ListIngredientItem, RequestedMealItem]` for item arrays.
- Conversion helpers in `shopping` module to map from `ShoppingListItem` DB model to the outward union.
- Verify:
- [ ] Update tests in `tests/test_shopping_api.py` to accept the new shape while preserving field meanings.
- [ ] OpenAPI shows `oneOf` for shopping list items.
- [ ] Tighten invariants without breaking shape (keep for now)
- Keep model but document invariants (only one of ingredient_id/meal_id required; recipe_id optional when meal request). Repository already validates; consider pydantic validators later.
## Persons and Recipes listings
- [ ] Ensure total is populated for Person and Recipe lists
- Already implemented in `api/persons.py` and `api/recipes.py` using repository `count_*` helpers. After making `Page.total` non-nullable, nothing else required.
## Authentication dependency
- [x] Provide strict non-null person dependency for protected endpoints
- Why: Many endpoints assume an authenticated user; typing as non-null simplifies signatures and docs.
- Change:
- Consolidated on a single dependency `cookie_person` (strict): `Cookie(..., alias="user_id")` and raises 401 if missing/unknown.
- Removed `require_cookie_person` and switched usages to `cookie_person`.
- Verify:
- [x] Endpoint signatures updated.
- [x] Unauthorized behavior covered by handlers; overall tests still pass.
## File-by-file checklist (edits)
- [x] `common.py`
- [x] Page.total -> `int = Field(default=0, ...)`
- [x] `recipes/models.py`
- [x] `created_by_id: int`
- [x] `api/recipes.py`
- [x] In `load_full_recipe`, set `r.created_by = await persons.get_by_id(conn, r.created_by_id)` unconditionally.
- [x] `products/models.py`
- [x] `raw_data` moved to `PrivateAttr` with property; kept out of schema.
- [x] `api/deps.py`
- [x] Added `require_cookie_person(...) -> persons.Person` that raises 401.
- [x] Updated protected endpoints to depend on `require_cookie_person`.
- [ ] (Optional) Shopping API union types
- [x] Add outward-facing union models and mapping helpers.
- [x] Update `api/shopping.py` response models to use union.
## Tests and validation
- [x] Run format/lint/typecheck
- make format && make lint && make typecheck
- [x] Run tests
- make test
- [x] Export OpenAPI and inspect schema
- make openapi (confirm: Recipe.createdById required; Product has single schema; Page.total non-nullable; cookie param required on protected ops.)
## Rollout notes
- API change in OpenAPI (non-null total; createdById required). Client SDKs generated from the spec may need re-gen. Runtime remains compatible because server fills these fields.
- Even without the optional union refactor, the quick wins remove several unnecessary nulls.
## Acceptance criteria
- OpenAPI no longer marks Page.total and Recipe.createdById as nullable.
- Product.rawData does not appear in the public schema.
- All tests pass; no runtime regressions.
- Optional: Shopping list items use `oneOf` variants.