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: ) -> 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,

View file

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

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) # 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.

View file

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

View file

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

View file

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

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

View file

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

View file

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