Avoid optional arrays
This commit is contained in:
parent
5a9242ccc9
commit
0be2d1a2b0
9 changed files with 404 additions and 62 deletions
16
api/deps.py
16
api/deps.py
|
|
@ -43,7 +43,7 @@ async def cookie_person(
|
||||||
) -> persons.Person:
|
) -> persons.Person:
|
||||||
"""Return the authenticated user from the user_id cookie or raise 401.
|
"""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)
|
person = await persons.get_by_id(conn, user_id)
|
||||||
if not person:
|
if not person:
|
||||||
|
|
@ -51,6 +51,20 @@ async def cookie_person(
|
||||||
return 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:
|
def error_response(request: Optional[Request], status_code: int, message: str) -> JSONResponse:
|
||||||
body = ProblemDetails(
|
body = ProblemDetails(
|
||||||
title=message,
|
title=message,
|
||||||
|
|
|
||||||
27
api/meals.py
27
api/meals.py
|
|
@ -7,14 +7,29 @@ import aiosqlite
|
||||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||||
|
|
||||||
import meals
|
import meals
|
||||||
|
import ingredients
|
||||||
import persons
|
import persons
|
||||||
import shopping
|
import shopping
|
||||||
from api.deps import cookie_person, error_response, get_db
|
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"])
|
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(
|
@router.get(
|
||||||
"/upcoming", operation_id="getUpcomingMeals", summary="List upcoming meals in a date range"
|
"/upcoming", operation_id="getUpcomingMeals", summary="List upcoming meals in a date range"
|
||||||
)
|
)
|
||||||
|
|
@ -44,7 +59,7 @@ async def get_upcoming_meals(
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{meal_id}",
|
"/{meal_id}",
|
||||||
response_model=meals.Meal,
|
response_model=MealOut,
|
||||||
operation_id="getMeal",
|
operation_id="getMeal",
|
||||||
summary="Get a meal by id",
|
summary="Get a meal by id",
|
||||||
responses={
|
responses={
|
||||||
|
|
@ -67,7 +82,7 @@ async def get_meal(
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=meals.Meal,
|
response_model=MealOut,
|
||||||
operation_id="createMeal",
|
operation_id="createMeal",
|
||||||
summary="Create a new meal",
|
summary="Create a new meal",
|
||||||
responses={
|
responses={
|
||||||
|
|
@ -95,7 +110,7 @@ async def create_meal(
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/{meal_id}",
|
"/{meal_id}",
|
||||||
response_model=meals.Meal,
|
response_model=MealOut,
|
||||||
operation_id="updateMeal",
|
operation_id="updateMeal",
|
||||||
summary="Update an existing meal",
|
summary="Update an existing meal",
|
||||||
responses={
|
responses={
|
||||||
|
|
@ -133,7 +148,7 @@ async def update_meal(
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{meal_id}/consumed",
|
"/{meal_id}/consumed",
|
||||||
response_model=meals.Meal,
|
response_model=MealOut,
|
||||||
operation_id="markMealConsumed",
|
operation_id="markMealConsumed",
|
||||||
summary="Mark a meal as consumed",
|
summary="Mark a meal as consumed",
|
||||||
responses={
|
responses={
|
||||||
|
|
@ -171,7 +186,7 @@ async def mark_consumed(
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/{meal_id}",
|
"/{meal_id}",
|
||||||
response_model=meals.Meal,
|
response_model=MealOut,
|
||||||
operation_id="deleteMeal",
|
operation_id="deleteMeal",
|
||||||
summary="Delete a meal",
|
summary="Delete a meal",
|
||||||
responses={
|
responses={
|
||||||
|
|
|
||||||
|
|
@ -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)
|
# 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
|
return spec
|
||||||
|
|
||||||
# Rebind app.openapi to our generator (FastAPI supports this pattern). Mypy needs a narrow ignore here.
|
# Rebind app.openapi to our generator (FastAPI supports this pattern). Mypy needs a narrow ignore here.
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,42 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||||
|
|
||||||
import ingredients
|
import ingredients as ingredients_mod
|
||||||
import persons
|
import persons
|
||||||
import recipes
|
import recipes
|
||||||
from api.deps import cookie_person, error_response, get_db
|
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"])
|
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(
|
@router.get(
|
||||||
"/parse",
|
"/parse",
|
||||||
response_model=recipes.Recipe,
|
response_model=RecipeOut,
|
||||||
operation_id="parseRecipe",
|
operation_id="parseRecipe",
|
||||||
summary="Parse a recipe from a URL",
|
summary="Parse a recipe from a URL",
|
||||||
responses={
|
responses={
|
||||||
|
|
@ -47,17 +67,17 @@ async def parse_recipe_handler(
|
||||||
async def parse_ingredients(
|
async def parse_ingredients(
|
||||||
lines: List[str] = Query(alias="ingredients", title="Array of ingredients to parse"),
|
lines: List[str] = Query(alias="ingredients", title="Array of ingredients to parse"),
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
) -> List[ingredients.Ingredient]:
|
) -> List[ingredients_mod.Ingredient]:
|
||||||
had_links = False
|
had_links = False
|
||||||
result = []
|
result: List[ingredients_mod.Ingredient] = []
|
||||||
for line in lines:
|
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:
|
if ingredient:
|
||||||
result.append(ingredient)
|
result.append(ingredient)
|
||||||
had_links = True
|
had_links = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ingredient = ingredients.parse_ingredient_from_nlp(line)
|
ingredient = ingredients_mod.parse_ingredient_from_nlp(line)
|
||||||
if ingredient:
|
if ingredient:
|
||||||
result.append(ingredient)
|
result.append(ingredient)
|
||||||
continue
|
continue
|
||||||
|
|
@ -66,7 +86,7 @@ async def parse_ingredients(
|
||||||
# Transaction will commit at end of request
|
# Transaction will commit at end of request
|
||||||
pass
|
pass
|
||||||
|
|
||||||
await ingredients.match_existing_products(conn, result)
|
await ingredients_mod.match_existing_products(conn, result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -76,7 +96,7 @@ async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[reci
|
||||||
return None
|
return None
|
||||||
|
|
||||||
r.ingredients = []
|
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.ingredients.append(ingredient)
|
||||||
|
|
||||||
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)
|
||||||
|
|
@ -87,7 +107,7 @@ async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[reci
|
||||||
@router.get(
|
@router.get(
|
||||||
"",
|
"",
|
||||||
operation_id="listRecipes",
|
operation_id="listRecipes",
|
||||||
response_model=Page[recipes.Recipe],
|
response_model=Page[RecipeOut],
|
||||||
summary="List recipes (paginated)",
|
summary="List recipes (paginated)",
|
||||||
responses={
|
responses={
|
||||||
200: {
|
200: {
|
||||||
|
|
@ -153,7 +173,7 @@ async def list_recipes(
|
||||||
# Batch-load ingredients for the page to avoid N+1 queries
|
# Batch-load ingredients for the page to avoid N+1 queries
|
||||||
if items:
|
if items:
|
||||||
recipe_ids = [r.id for r in 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:
|
for r in items:
|
||||||
r.ingredients = by_recipe.get(r.id, [])
|
r.ingredients = by_recipe.get(r.id, [])
|
||||||
next_cursor = str(items[-1].id) if has_more and items else None
|
next_cursor = str(items[-1].id) if has_more and items else None
|
||||||
|
|
@ -168,7 +188,7 @@ async def list_recipes(
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{recipe_id}",
|
"/{recipe_id}",
|
||||||
response_model=recipes.Recipe,
|
response_model=RecipeOut,
|
||||||
operation_id="getRecipe",
|
operation_id="getRecipe",
|
||||||
summary="Get a single recipe",
|
summary="Get a single recipe",
|
||||||
responses={
|
responses={
|
||||||
|
|
@ -191,7 +211,7 @@ async def get_recipe(
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=recipes.Recipe,
|
response_model=RecipeOut,
|
||||||
operation_id="createRecipe",
|
operation_id="createRecipe",
|
||||||
summary="Create a new recipe (versioning semantics applied)",
|
summary="Create a new recipe (versioning semantics applied)",
|
||||||
responses={
|
responses={
|
||||||
|
|
@ -223,8 +243,7 @@ async def create_recipe(
|
||||||
ingredient.recipe_id = recipe.id
|
ingredient.recipe_id = recipe.id
|
||||||
if ingredient.product:
|
if ingredient.product:
|
||||||
ingredient.product_id = ingredient.product.id
|
ingredient.product_id = ingredient.product.id
|
||||||
|
await ingredients_mod.insert_ingredient(conn, ingredient)
|
||||||
await ingredients.insert_ingredient(conn, ingredient)
|
|
||||||
|
|
||||||
# Transaction will commit at end of request
|
# Transaction will commit at end of request
|
||||||
# Set Location to the new resource
|
# Set Location to the new resource
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
from typing import Dict, List, Literal
|
from typing import Dict, List, Literal
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
@ -52,27 +53,36 @@ class PurchaseListIn(ApiModel):
|
||||||
|
|
||||||
|
|
||||||
# Output DTOs for purchased lists
|
# Output DTOs for purchased lists
|
||||||
|
class StoreNameOut(str, Enum):
|
||||||
|
woolworths = "woolworths"
|
||||||
|
coles = "coles"
|
||||||
|
home = "home"
|
||||||
|
|
||||||
|
|
||||||
class ShoppingListOut(ApiModel):
|
class ShoppingListOut(ApiModel):
|
||||||
id: int
|
id: int
|
||||||
created_date: datetime
|
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_id: int
|
||||||
purchased_by: persons.Person | None = None
|
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"])
|
router = APIRouter(prefix="/shopping", tags=["shopping"])
|
||||||
|
|
||||||
|
|
||||||
class CurrentShoppingList(ApiModel):
|
class CurrentShoppingList(ApiModel):
|
||||||
outstanding_items: List[ListIngredientItem]
|
outstanding_items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
||||||
requested_meals: List[RequestedMealItem]
|
requested_meals: List[RequestedMealItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
||||||
purchased_items: List[ListIngredientItem] = Field(default_factory=list)
|
# 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)
|
ingredients_lookup: Dict[int, ingredients.Ingredient]
|
||||||
meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict)
|
meals_lookup: Dict[int, meals.Meal]
|
||||||
shopping_list_lookup: Dict[int, ShoppingListOut] = Field(default_factory=dict)
|
shopping_list_lookup: Dict[int, ShoppingListOut]
|
||||||
recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict)
|
recipes_lookup: Dict[int, recipes.Recipe]
|
||||||
|
|
||||||
|
|
||||||
# Mapping helpers from domain -> outward API
|
# 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:
|
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(
|
return ShoppingListOut(
|
||||||
id=sl.id,
|
id=sl.id,
|
||||||
created_date=sl.created_date,
|
created_date=sl.created_date,
|
||||||
store_name=sl.store_name,
|
store_name=outward_store,
|
||||||
purchased_by_id=sl.purchased_by_id,
|
purchased_by_id=sl.purchased_by_id,
|
||||||
purchased_by=sl.purchased_by,
|
purchased_by=sl.purchased_by,
|
||||||
items=[_to_ingredient_item(i) for i in sl.items],
|
items=[_to_ingredient_item(i) for i in sl.items],
|
||||||
|
|
@ -160,9 +172,10 @@ async def get_current_shopping_list(
|
||||||
|
|
||||||
class PurchasedShoppingList(ApiModel):
|
class PurchasedShoppingList(ApiModel):
|
||||||
list: ShoppingListOut
|
list: ShoppingListOut
|
||||||
meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict)
|
# Lookup maps are required to be present (may be empty)
|
||||||
ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict)
|
meals_lookup: Dict[int, meals.Meal]
|
||||||
recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict)
|
ingredients_lookup: Dict[int, ingredients.Ingredient]
|
||||||
|
recipes_lookup: Dict[int, recipes.Recipe]
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
@ -246,15 +259,19 @@ async def purchase_ingredients(
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# Map domain validation errors to a proper Problem Details response
|
# Map domain validation errors to a proper Problem Details response
|
||||||
return error_response(request, 400, str(e))
|
return error_response(request, 400, str(e))
|
||||||
result = PurchasedShoppingList(list=_to_shopping_list_out(domain_list))
|
# Build lookup maps and construct the outward response with required collections
|
||||||
await shopping.to_lookups(
|
meals_lookup: Dict[int, meals.Meal]
|
||||||
conn,
|
recipes_lookup: Dict[int, recipes.Recipe]
|
||||||
domain_list.items,
|
ingredients_lookup: Dict[int, ingredients.Ingredient]
|
||||||
result.meals_lookup,
|
meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(
|
||||||
result.recipes_lookup,
|
conn, domain_list.items
|
||||||
result.ingredients_lookup,
|
)
|
||||||
|
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(
|
@router.get(
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
class Page(ApiModel, Generic[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")
|
next_cursor: Optional[str] = Field(default=None, alias="nextCursor")
|
||||||
prev_cursor: Optional[str] = Field(default=None, alias="prevCursor")
|
prev_cursor: Optional[str] = Field(default=None, alias="prevCursor")
|
||||||
total: int = Field(default=0, description="Total count")
|
total: int = Field(default=0, description="Total count")
|
||||||
|
|
|
||||||
26
main.py
26
main.py
|
|
@ -91,6 +91,32 @@ async def request_validation_exc_handler(request: Request, exc: Exception):
|
||||||
for e in exc.errors():
|
for e in exc.errors():
|
||||||
loc = ".".join([str(p) for p in e.get("loc", [])])
|
loc = ".".join([str(p) for p in e.get("loc", [])])
|
||||||
errors.setdefault(loc, []).append(e.get("msg"))
|
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(
|
body = ProblemDetails(
|
||||||
title="Validation Error",
|
title="Validation Error",
|
||||||
status=422,
|
status=422,
|
||||||
|
|
|
||||||
263
openapi.json
263
openapi.json
|
|
@ -82,7 +82,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/Recipe-Output"
|
"$ref": "#/components/schemas/RecipeOut"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -224,7 +224,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/Page_Recipe_"
|
"$ref": "#/components/schemas/Page_RecipeOut_"
|
||||||
},
|
},
|
||||||
"example": {
|
"example": {
|
||||||
"items": [
|
"items": [
|
||||||
|
|
@ -290,7 +290,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/Recipe-Output"
|
"$ref": "#/components/schemas/RecipeOut"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -341,7 +341,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/Recipe-Output"
|
"$ref": "#/components/schemas/RecipeOut"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -503,7 +503,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/Meal-Output"
|
"$ref": "#/components/schemas/MealOut"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -557,7 +557,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/Meal-Output"
|
"$ref": "#/components/schemas/MealOut"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -613,7 +613,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/Meal-Output"
|
"$ref": "#/components/schemas/MealOut"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -663,7 +663,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/Meal-Output"
|
"$ref": "#/components/schemas/MealOut"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -735,7 +735,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/Meal-Output"
|
"$ref": "#/components/schemas/MealOut"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1400,6 +1400,7 @@
|
||||||
"$ref": "#/components/schemas/ListIngredientItem"
|
"$ref": "#/components/schemas/ListIngredientItem"
|
||||||
},
|
},
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
"minItems": 0,
|
||||||
"title": "Outstandingitems"
|
"title": "Outstandingitems"
|
||||||
},
|
},
|
||||||
"requestedMeals": {
|
"requestedMeals": {
|
||||||
|
|
@ -1407,6 +1408,7 @@
|
||||||
"$ref": "#/components/schemas/RequestedMealItem"
|
"$ref": "#/components/schemas/RequestedMealItem"
|
||||||
},
|
},
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
"minItems": 0,
|
||||||
"title": "Requestedmeals"
|
"title": "Requestedmeals"
|
||||||
},
|
},
|
||||||
"purchasedItems": {
|
"purchasedItems": {
|
||||||
|
|
@ -1414,6 +1416,7 @@
|
||||||
"$ref": "#/components/schemas/ListIngredientItem"
|
"$ref": "#/components/schemas/ListIngredientItem"
|
||||||
},
|
},
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
"minItems": 0,
|
||||||
"title": "Purchaseditems"
|
"title": "Purchaseditems"
|
||||||
},
|
},
|
||||||
"ingredientsLookup": {
|
"ingredientsLookup": {
|
||||||
|
|
@ -1448,7 +1451,12 @@
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"outstandingItems",
|
"outstandingItems",
|
||||||
"requestedMeals"
|
"requestedMeals",
|
||||||
|
"purchasedItems",
|
||||||
|
"ingredientsLookup",
|
||||||
|
"mealsLookup",
|
||||||
|
"shoppingListLookup",
|
||||||
|
"recipesLookup"
|
||||||
],
|
],
|
||||||
"title": "CurrentShoppingList"
|
"title": "CurrentShoppingList"
|
||||||
},
|
},
|
||||||
|
|
@ -1880,6 +1888,94 @@
|
||||||
],
|
],
|
||||||
"title": "MealIdWrapper"
|
"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": {
|
"MealRecipe-Input": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"mealId": {
|
"mealId": {
|
||||||
|
|
@ -1964,6 +2060,7 @@
|
||||||
"$ref": "#/components/schemas/Person"
|
"$ref": "#/components/schemas/Person"
|
||||||
},
|
},
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
"minItems": 0,
|
||||||
"title": "Items"
|
"title": "Items"
|
||||||
},
|
},
|
||||||
"nextCursor": {
|
"nextCursor": {
|
||||||
|
|
@ -2001,13 +2098,14 @@
|
||||||
],
|
],
|
||||||
"title": "Page[Person]"
|
"title": "Page[Person]"
|
||||||
},
|
},
|
||||||
"Page_Recipe_": {
|
"Page_RecipeOut_": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"items": {
|
"items": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/Recipe-Output"
|
"$ref": "#/components/schemas/RecipeOut"
|
||||||
},
|
},
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
"minItems": 0,
|
||||||
"title": "Items"
|
"title": "Items"
|
||||||
},
|
},
|
||||||
"nextCursor": {
|
"nextCursor": {
|
||||||
|
|
@ -2043,7 +2141,7 @@
|
||||||
"required": [
|
"required": [
|
||||||
"items"
|
"items"
|
||||||
],
|
],
|
||||||
"title": "Page[Recipe]"
|
"title": "Page[RecipeOut]"
|
||||||
},
|
},
|
||||||
"Person": {
|
"Person": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -2195,7 +2293,7 @@
|
||||||
"PurchaseListIn": {
|
"PurchaseListIn": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"storeName": {
|
"storeName": {
|
||||||
"$ref": "#/components/schemas/StoreEnum"
|
"$ref": "#/components/schemas/StoreNameOut"
|
||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"items": {
|
"items": {
|
||||||
|
|
@ -2241,7 +2339,10 @@
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"list"
|
"list",
|
||||||
|
"mealsLookup",
|
||||||
|
"ingredientsLookup",
|
||||||
|
"recipesLookup"
|
||||||
],
|
],
|
||||||
"title": "PurchasedShoppingList"
|
"title": "PurchasedShoppingList"
|
||||||
},
|
},
|
||||||
|
|
@ -2457,6 +2558,117 @@
|
||||||
],
|
],
|
||||||
"title": "Recipe"
|
"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": {
|
"RequestedMealItem": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"kind": {
|
"kind": {
|
||||||
|
|
@ -2507,7 +2719,13 @@
|
||||||
"title": "Createddate"
|
"title": "Createddate"
|
||||||
},
|
},
|
||||||
"storeName": {
|
"storeName": {
|
||||||
"$ref": "#/components/schemas/StoreEnum"
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"woolworths",
|
||||||
|
"coles",
|
||||||
|
"home"
|
||||||
|
],
|
||||||
|
"title": "Storename"
|
||||||
},
|
},
|
||||||
"purchasedById": {
|
"purchasedById": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
|
|
@ -2528,6 +2746,7 @@
|
||||||
"$ref": "#/components/schemas/ListIngredientItem"
|
"$ref": "#/components/schemas/ListIngredientItem"
|
||||||
},
|
},
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
"minItems": 0,
|
||||||
"title": "Items"
|
"title": "Items"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -2536,7 +2755,8 @@
|
||||||
"id",
|
"id",
|
||||||
"createdDate",
|
"createdDate",
|
||||||
"storeName",
|
"storeName",
|
||||||
"purchasedById"
|
"purchasedById",
|
||||||
|
"items"
|
||||||
],
|
],
|
||||||
"title": "ShoppingListOut"
|
"title": "ShoppingListOut"
|
||||||
},
|
},
|
||||||
|
|
@ -2581,6 +2801,15 @@
|
||||||
"type"
|
"type"
|
||||||
],
|
],
|
||||||
"title": "ValidationError"
|
"title": "ValidationError"
|
||||||
|
},
|
||||||
|
"StoreNameOut": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"woolworths",
|
||||||
|
"coles",
|
||||||
|
"home"
|
||||||
|
],
|
||||||
|
"title": "StoreNameOut"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ target-version = "py311"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I"]
|
select = ["E", "F", "I"]
|
||||||
ignore = ["E203", "E501"]
|
ignore = ["E203", "E501", "I001"]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"tests/**.py" = [
|
"tests/**.py" = [
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue