diff --git a/api/ingredients.py b/api/ingredients.py new file mode 100644 index 0000000..3b5c025 --- /dev/null +++ b/api/ingredients.py @@ -0,0 +1,26 @@ +from __future__ import annotations +from typing import List + +from fastapi import APIRouter, Depends, Query +import aiosqlite + +import ingredients as ingredients_mod +from api.deps import get_db, get_household_from_slug + + +router = APIRouter(prefix="/households/{householdSlug}/ingredients", tags=["ingredients"]) + + +@router.get("/parse", response_model=list[ingredients_mod.Ingredient], summary="Parse an ingredient line from a string") +async def parse_ingredient( + lines: List[str] = Query(..., description="Multiple ingredient lines to parse"), + household=Depends(get_household_from_slug), + conn: aiosqlite.Connection = Depends(get_db), +): + # NLP parse + best-effort product match + parsed = [ingredients_mod.parse_ingredient_from_nlp(line) for line in lines] + matched = await ingredients_mod.match_existing_products(conn, parsed) + + return matched + + diff --git a/api/recipes.py b/api/recipes.py index 4675934..017c95b 100644 --- a/api/recipes.py +++ b/api/recipes.py @@ -208,7 +208,7 @@ async def create_recipe( await ingredients_mod.insert_ingredient(conn, ingredient) response.headers["Location"] = f"/api/v1/households/{household['slug']}/recipes/{r.id}" created = MemberRef(id=user.id, display_name=user.display_name) - return RecipeOut( + out = RecipeOut( id=r.id, name=r.name, link=r.link, @@ -218,28 +218,30 @@ async def create_recipe( created_by_id=r.created_by_id, created_by=created, ) + return out.model_dump(by_alias=False) -@router.post("/parse-from-url") +@router.post("/parse-from-url", response_model=RecipeCreate) async def parse_from_url( body: ParseUrlIn, household=Depends(get_household_from_slug), -): - # Household dependency enforces access; parsing is stateless - from recipes.scraping import scrape_recipe_ldata - - data = await scrape_recipe_ldata(body.url) - if not data: - return error_response(None, 404, "Recipe data not found at URL") - return data - - -@public.get("/ingredients/parse", response_model=List[ingredients_mod.Ingredient]) -async def parse_ingredients( - ingredients: List[str] = Query(..., description="Array of ingredients to parse"), + user=Depends(get_current_user), conn: aiosqlite.Connection = Depends(get_db), ): - # Stateless NLP parsing; attempt to match existing products for convenience - parsed = [ingredients_mod.parse_ingredient_from_nlp(s) for s in ingredients] - matched = await ingredients_mod.match_existing_products(conn, parsed) - return matched + # Build an unsaved Recipe object using NLP parsing and product matching; do not insert + r = await recipes.parse_recipe(conn, user, body.url) + if not r: + return error_response(None, 404, "Recipe data not found at URL") + # Return the same shape a client would POST to create + return RecipeCreate( + name=r.name, + link=r.link, + serves=r.serves, + image_urls=r.image_urls, + ingredients=r.ingredients, + ) + + +""" +Note: public parse endpoint moved to /api/v1/ingredients/parse (see api/ingredients.py) +""" diff --git a/main.py b/main.py index e9a6eb0..5c73b6f 100644 --- a/main.py +++ b/main.py @@ -140,6 +140,8 @@ def create_app() -> FastAPI: pass app.include_router(recipes_router.router, prefix="/api/v1", tags=["recipes"]) # canonical app.include_router(recipes_router.public, prefix="/api/v1", tags=["recipes"]) # public utils + from api import ingredients as ingredients_router + app.include_router(ingredients_router.router, prefix="/api/v1", tags=["ingredients"]) # scoped app.include_router(meals_router.router, prefix="/api/v1", tags=["meals"]) # canonical app.include_router(shopping_router.router, prefix="/api/v1", tags=["shopping"]) # canonical diff --git a/openapi.json b/openapi.json index 4b1131e..758be59 100644 --- a/openapi.json +++ b/openapi.json @@ -540,7 +540,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecipeCreate" + "$ref": "#/components/schemas/RecipeCreate-Input" } } } @@ -738,7 +738,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/RecipeCreate-Output" + } } } }, @@ -763,17 +765,26 @@ ] } }, - "/api/v1/recipes/ingredients/parse": { + "/api/v1/households/{householdSlug}/ingredients/parse": { "get": { "tags": [ - "recipes", - "recipes" + "ingredients", + "ingredients" ], - "summary": "Parse Ingredients", - "operationId": "parse_ingredients_api_v1_recipes_ingredients_parse_get", + "summary": "Parse an ingredient line from a string", + "operationId": "parse_ingredient_api_v1_households__householdSlug__ingredients_parse_get", "parameters": [ { - "name": "ingredients", + "name": "householdSlug", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Householdslug" + } + }, + { + "name": "lines", "in": "query", "required": true, "schema": { @@ -781,10 +792,10 @@ "items": { "type": "string" }, - "description": "Array of ingredients to parse", - "title": "Ingredients" + "description": "Multiple ingredient lines to parse", + "title": "Lines" }, - "description": "Array of ingredients to parse" + "description": "Multiple ingredient lines to parse" } ], "responses": { @@ -797,7 +808,7 @@ "items": { "$ref": "#/components/schemas/Ingredient" }, - "title": "Response Parse Ingredients Api V1 Recipes Ingredients Parse Get" + "title": "Response Parse Ingredient Api V1 Households Householdslug Ingredients Parse Get" } } } @@ -811,8 +822,16 @@ } } } + }, + "403": { + "$ref": "#/components/responses/Problem403" } - } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "/api/v1/households/{householdSlug}/meals/upcoming": { @@ -2811,7 +2830,48 @@ ], "title": "Recipe" }, - "RecipeCreate": { + "RecipeCreate-Input": { + "properties": { + "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" + } + }, + "type": "object", + "required": [ + "name", + "link", + "serves", + "imageUrls", + "ingredients" + ], + "title": "RecipeCreate" + }, + "RecipeCreate-Output": { "properties": { "name": { "type": "string", diff --git a/recipes/__init__.py b/recipes/__init__.py index 517de5c..1d630fb 100644 --- a/recipes/__init__.py +++ b/recipes/__init__.py @@ -2,6 +2,7 @@ import re from typing import Optional from ingredients import match_existing_products, parse_ingredient_from_nlp +from api.dtos import MemberRef from recipes.models import Recipe as Recipe from recipes.repository import ( compute_prev_cursor as compute_prev_cursor, @@ -70,6 +71,12 @@ async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by) -> Rec if isinstance(images, str): images = [images] + # Ensure created_by is a MemberRef (not a full User) to satisfy model typing + mref = ( + MemberRef(id=created_by.id, display_name=created_by.display_name) + if created_by is not None + else None + ) return Recipe( id=-1, name=name, @@ -77,6 +84,6 @@ async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by) -> Rec serves=serves, image_urls=images, ingredients=ingredients, - created_by=created_by, - created_by_id=created_by.id, + created_by=mref, + created_by_id=created_by.id if created_by is not None else -1, ) diff --git a/tests/test_ingredients_parse_api_v2.py b/tests/test_ingredients_parse_api_v2.py index 6a70f4b..eded656 100644 --- a/tests/test_ingredients_parse_api_v2.py +++ b/tests/test_ingredients_parse_api_v2.py @@ -3,12 +3,14 @@ from fastapi.testclient import TestClient import main from db import connect, create +from scripts.migration_to_households import run_migration class TestIngredientsParseApiV2(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.conn = await connect(":memory:") await create(self.conn) + await run_migration(self.conn) async def override_get_db(): try: @@ -19,27 +21,42 @@ class TestIngredientsParseApiV2(unittest.IsolatedAsyncioTestCase): main.app.dependency_overrides[main.get_db] = override_get_db self.client = TestClient(main.app) + # Register user and create household + r = self.client.post( + "/api/v1/auth/register", + json={"email": "ing@test.com", "password": "pw", "displayName": "Ing"}, + ) + assert r.status_code == 200, r.text + token = r.json()["accessToken"] + self.headers = {"Authorization": f"Bearer {token}"} + + r2 = self.client.post("/api/v1/households", headers=self.headers, json={"name": "H"}) + assert r2.status_code == 200, r2.text + self.slug = r2.json()["slug"] + async def asyncTearDown(self): await self.conn.close() main.app.dependency_overrides.clear() def test_parse_multiple_ingredients(self): - params = { - "ingredients": [ - "14oz milk powder", - "2 cups flour", - "1 tsp salt", - "egg", # defaults to 1 Items - ] - } - r = self.client.get("/api/v1/recipes/ingredients/parse", params=params) - assert r.status_code == 200, r.text - arr = r.json() - assert isinstance(arr, list) - assert len(arr) == 4 + lines = [ + "14oz milk powder", + "2 cups flour", + "1 tsp salt", + "egg", # defaults to 1 Items + ] + parsed = [] + for line in lines: + r = self.client.get( + f"/api/v1/households/{self.slug}/ingredients/parse", + params={"line": line}, + headers=self.headers, + ) + assert r.status_code == 200, r.text + parsed.append(r.json()) # Basic shape checks - for item in arr: + for item in parsed: assert "name" in item and isinstance(item["name"], str) assert "line" in item and isinstance(item["line"], str) assert "quantity" in item @@ -51,22 +68,19 @@ class TestIngredientsParseApiV2(unittest.IsolatedAsyncioTestCase): # Spot checks for unit/quantity normalization # 14oz milk powder - oz = arr[0] + oz, cups, tsp, egg = parsed assert float(oz["quantity"]) == 14.0 assert oz["unit"] == "Ounce" assert "milk" in oz["name"].lower() - cups = arr[1] assert float(cups["quantity"]) == 2.0 assert cups["unit"] == "Cup" assert cups["name"].lower() == "flour" - tsp = arr[2] assert float(tsp["quantity"]) == 1.0 assert tsp["unit"] == "Teaspoon" assert tsp["name"].lower() == "salt" - egg = arr[3] assert float(egg["quantity"]) == 1.0 assert egg["unit"] == "Items" assert egg["name"].lower() == "egg" diff --git a/tests/test_recipes_parse_from_url_integration_v2.py b/tests/test_recipes_parse_from_url_integration_v2.py index 219bf96..f96b0ce 100644 --- a/tests/test_recipes_parse_from_url_integration_v2.py +++ b/tests/test_recipes_parse_from_url_integration_v2.py @@ -72,17 +72,11 @@ class TestRecipesParseFromUrlIntegrationV2(unittest.IsolatedAsyncioTestCase): ) assert r.status_code == 200, r.text data = r.json() - assert data.get("@type") in ("Recipe", ["Recipe"]) # accept single or list - # JSON-LD should contain ingredients and instructions - assert isinstance(data.get("recipeIngredient"), list) - assert len(data["recipeIngredient"]) >= 1 - # The title should mention omelette + # Now returns RecipeCreate shape + assert "id" not in data assert "name" in data and "omelette" in data["name"].lower() - # Optional: if author or image present, assert types - if "image" in data: - assert isinstance(data["image"], (str, list, dict)) - if "author" in data: - assert isinstance(data["author"], (str, list, dict)) + assert isinstance(data.get("ingredients"), list) + assert isinstance(data.get("imageUrls"), list) finally: scraping.httpx.AsyncClient = orig_client diff --git a/tests/test_recipes_parse_from_url_v2.py b/tests/test_recipes_parse_from_url_v2.py index 7b6bf66..2b1a629 100644 --- a/tests/test_recipes_parse_from_url_v2.py +++ b/tests/test_recipes_parse_from_url_v2.py @@ -44,14 +44,15 @@ class TestRecipesParseFromUrlV2(unittest.IsolatedAsyncioTestCase): async def fake_scrape(url: str): assert url == "https://example.com/recipe" - return {"@type": "Recipe", "name": "Example"} + return {"@type": "Recipe", "name": "Example", "recipeIngredient": ["2 eggs"]} async def fake_scrape_none(url: str): return None # Success case - orig = scraping.scrape_recipe_ldata - scraping.scrape_recipe_ldata = fake_scrape + import recipes as recipes_pkg + orig = recipes_pkg._scrape_recipe_ldata + recipes_pkg._scrape_recipe_ldata = fake_scrape try: r = self.client.post( f"/api/v1/households/{self.slug}/recipes/parse-from-url", @@ -60,13 +61,15 @@ class TestRecipesParseFromUrlV2(unittest.IsolatedAsyncioTestCase): ) assert r.status_code == 200, r.text body = r.json() - assert body.get("@type") == "Recipe" + # Returns the same shape as create (RecipeCreate) + assert "id" not in body assert body.get("name") == "Example" + assert "createdBy" not in body finally: - scraping.scrape_recipe_ldata = orig + recipes_pkg._scrape_recipe_ldata = orig # Not found case - scraping.scrape_recipe_ldata = fake_scrape_none + recipes_pkg._scrape_recipe_ldata = fake_scrape_none try: r2 = self.client.post( f"/api/v1/households/{self.slug}/recipes/parse-from-url", @@ -77,4 +80,4 @@ class TestRecipesParseFromUrlV2(unittest.IsolatedAsyncioTestCase): pb = r2.json() assert pb.get("status") == 404 finally: - scraping.scrape_recipe_ldata = orig + recipes_pkg._scrape_recipe_ldata = orig