From be7f6987799e5fd29b2bf67fd5bf62e0a32d6746 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 5 May 2026 20:50:07 -0700 Subject: [PATCH] feat: add RecipeCreate/Update/Read schemas with canonical ingredient refs --- backend/app/schemas/recipe.py | 75 +++++++++++++++++++++++++++++ backend/tests/test_recipe_schema.py | 41 ++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 backend/app/schemas/recipe.py create mode 100644 backend/tests/test_recipe_schema.py diff --git a/backend/app/schemas/recipe.py b/backend/app/schemas/recipe.py new file mode 100644 index 0000000..faae3e7 --- /dev/null +++ b/backend/app/schemas/recipe.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import List, Optional +from uuid import UUID + +from pydantic import BaseModel, Field, field_validator + + +class RecipeIngredientRef(BaseModel): + ingredient_id: UUID + qty: float = Field(gt=0) + unit: Optional[str] = Field(default=None, max_length=50) + notes: Optional[str] = None + + +class RecipeBase(BaseModel): + name: str = Field(min_length=1, max_length=300) + description: Optional[str] = None + image_url: Optional[str] = None + prep_time_minutes: int = Field(ge=0) + cook_time_minutes: int = Field(ge=0) + servings: int = Field(gt=0) + cuisine_tags: List[str] = Field(default_factory=list) + dietary_tags: List[str] = Field(default_factory=list) + protein_type: str = Field(min_length=1, max_length=50) + spice_level: Optional[int] = Field(default=None, ge=0, le=5) + calories_per_serving: Optional[int] = Field(default=None, ge=0) + ingredients: List[RecipeIngredientRef] = Field(min_length=1) + instructions: List[str] = Field(min_length=1) + source_url: Optional[str] = None + + +class RecipeCreate(RecipeBase): + pass + + +class RecipeUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + image_url: Optional[str] = None + prep_time_minutes: Optional[int] = Field(default=None, ge=0) + cook_time_minutes: Optional[int] = Field(default=None, ge=0) + servings: Optional[int] = Field(default=None, gt=0) + cuisine_tags: Optional[List[str]] = None + dietary_tags: Optional[List[str]] = None + protein_type: Optional[str] = None + spice_level: Optional[int] = None + calories_per_serving: Optional[int] = None + ingredients: Optional[List[RecipeIngredientRef]] = None + instructions: Optional[List[str]] = None + + +class RecipeRead(RecipeBase): + id: UUID + + model_config = {"from_attributes": True} + + +class ResolveIngredientRequest(BaseModel): + text: str = Field(min_length=1) + + +class ResolveIngredientCandidate(BaseModel): + ingredient_id: UUID + name: str + score: float + aisle: Optional[str] = None + + +class ResolveIngredientResponse(BaseModel): + parsed_qty: Optional[float] = None + parsed_unit: Optional[str] = None + parsed_text: str + candidates: List[ResolveIngredientCandidate] diff --git a/backend/tests/test_recipe_schema.py b/backend/tests/test_recipe_schema.py new file mode 100644 index 0000000..8b230b4 --- /dev/null +++ b/backend/tests/test_recipe_schema.py @@ -0,0 +1,41 @@ +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +from app.schemas.recipe import RecipeCreate, RecipeIngredientRef, ResolveIngredientRequest + + +def test_recipe_create_requires_canonical_ingredient_ids() -> None: + iid = uuid4() + payload = RecipeCreate( + name="Sheet-Pan Chicken Thighs", + servings=4, + prep_time_minutes=10, + cook_time_minutes=30, + cuisine_tags=["american"], + protein_type="chicken", + ingredients=[RecipeIngredientRef(ingredient_id=iid, qty=2.0, unit="lb")], + instructions=["Preheat oven", "Roast"], + ) + assert payload.ingredients[0].ingredient_id == iid + assert payload.servings == 4 + + +def test_recipe_create_rejects_empty_ingredients() -> None: + with pytest.raises(ValidationError): + RecipeCreate( + name="Empty", + servings=4, + prep_time_minutes=5, + cook_time_minutes=5, + cuisine_tags=[], + protein_type="vegetarian", + ingredients=[], + instructions=["nope"], + ) + + +def test_resolve_ingredient_request_round_trip() -> None: + req = ResolveIngredientRequest(text="1 lb chicken thighs") + assert req.text == "1 lb chicken thighs"