Added missing methods and return types
This commit is contained in:
parent
5c70a9faf0
commit
29ebbe7a37
8 changed files with 178 additions and 70 deletions
26
api/ingredients.py
Normal file
26
api/ingredients.py
Normal 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
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
"""
|
||||
|
|
|
|||
2
main.py
2
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
|
||||
|
||||
|
|
|
|||
86
openapi.json
86
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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
lines = [
|
||||
"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)
|
||||
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
|
||||
arr = r.json()
|
||||
assert isinstance(arr, list)
|
||||
assert len(arr) == 4
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue