Added missing methods and return types

This commit is contained in:
jableader 2025-11-01 23:36:46 +11:00
parent 5c70a9faf0
commit 29ebbe7a37
8 changed files with 178 additions and 70 deletions

26
api/ingredients.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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