extract recipes

This commit is contained in:
jableader 2025-10-18 16:54:35 +11:00
parent 3d7afa3765
commit edfc341b4b
3 changed files with 234 additions and 273 deletions

View file

@ -8,70 +8,254 @@ from fastapi import APIRouter, Depends, Query, Request
import ingredients import ingredients
import recipes import recipes
import persons import persons
from common import Page, ProblemDetails from common import Page, ProblemDetails, ApiModel
from main import get_db, cookie_person, error_response # temporary imports during extraction from pydantic import Field
from main import get_db, cookie_person, error_response
router = APIRouter(prefix="/recipes", tags=["recipes"]) router = APIRouter(prefix="/recipes", tags=["recipes"])
@router.get("", response_model=Page[recipes.Recipe], operation_id="listRecipes", summary="List recipes (paginated)") class ProductUrl(ApiModel):
url: str
tags: List[str] = Field(default_factory=list)
@router.get(
"/parse",
response_model=None,
operation_id="parseRecipe",
summary="Parse a recipe from a URL",
responses={
400: {
"model": ProblemDetails,
"description": "Recipe not found",
"content": {"application/problem+json": {}},
}
},
)
async def parse_recipe_handler(
url: str, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person), request: Request | None = None
) -> recipes.Recipe | ProblemDetails:
parsed = await recipes.parse_recipe(conn, person, url)
if not parsed:
return error_response(request, 400, "Recipe not found")
return parsed
@router.get(
"/ingredients/parse",
operation_id="parseIngredients",
summary="Parse raw ingredient lines",
)
async def parse_ingredients(
lines: List[str] = Query(alias="ingredients", title="Array of ingredients to parse"),
conn: aiosqlite.Connection = Depends(get_db),
) -> List[ingredients.Ingredient]:
had_links = False
result = []
for line in lines:
ingredient = await ingredients.parse_ingredient_from_link(conn, line)
if ingredient:
result.append(ingredient)
had_links = True
continue
ingredient = ingredients.parse_ingredient_from_nlp(line)
if ingredient:
result.append(ingredient)
continue
if had_links:
await conn.commit()
await ingredients.match_existing_products(conn, result)
return result
async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[recipes.Recipe]:
r = await recipes.find_recipe_by_id(conn, id)
if not r:
return None
r.ingredients = []
async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, id):
r.ingredients.append(ingredient)
if r.created_by_id is not None:
r.created_by = await persons.get_by_id(conn, r.created_by_id)
return r
@router.get(
"",
operation_id="listRecipes",
response_model=Page[recipes.Recipe],
summary="List recipes (paginated)",
responses={
200: {
"description": "A page of recipes",
"content": {
"application/json": {
"example": {
"items": [
{
"id": 1,
"name": "Example Recipe",
"link": "https://example.com/recipes/1",
"serves": 4,
"imageUrls": [],
"ingredients": []
}
],
"nextCursor": "2",
"prevCursor": "0",
"total": 1
}
}
},
}
},
)
async def list_recipes( async def list_recipes(
q: Optional[str] = Query(default=None, description="Optional case-insensitive name filter (matches recipe name with SQL LIKE)."), q: Optional[str] = Query(
cursor: Optional[str] = Query(default=None, description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page."), default=None,
limit: int = Query(50, ge=1, le=200, description="Maximum number of items to return (1-200)."), description="Optional case-insensitive name filter (matches recipe name with SQL LIKE).",
),
cursor: Optional[str] = Query(
default=None,
description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.",
),
limit: int = Query(
50,
ge=1,
le=200,
description="Maximum number of items to return (1-200).",
),
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
request: Request | None = None, request: Request | None = None,
) -> Page[recipes.Recipe]: ) -> Page[recipes.Recipe]:
# Placeholder: implementation will be moved from main.get_recipes in a later step. last_id = None
raise NotImplementedError("list_recipes extraction pending") if cursor:
try:
last_id = int(cursor)
except ValueError:
last_id = None
fetch_limit = limit + 1
paged: List[recipes.Recipe] = []
if q:
async for r in recipes.find_recipes_by_name_paged(conn, q, last_id, fetch_limit):
paged.append(r)
else:
async for r in recipes.get_all_paged(conn, last_id, fetch_limit):
paged.append(r)
has_more = len(paged) > limit
items = paged[:limit]
# load ingredients for items
for r in items:
r.ingredients = []
async for ing in ingredients.find_ingredients_by_recipe_id(conn, r.id):
r.ingredients.append(ing)
next_cursor = str(items[-1].id) if has_more and items else None
# Compute prevCursor via DB helper
prev_cursor: Optional[str] = None
if items:
first_id = items[0].id
prev_cursor = await recipes.compute_prev_cursor(conn, first_id, limit, q)
total = await (recipes.count_by_name(conn, q) if q else recipes.count_all(conn))
return Page(items=items, nextCursor=next_cursor, prevCursor=prev_cursor, total=total)
@router.get("/{recipe_id}", response_model=recipes.Recipe, operation_id="getRecipe", summary="Get a single recipe", @router.get(
responses={404: {"model": ProblemDetails, "description": "Recipe not found", "content": {"application/problem+json": {}}}}) "/{recipe_id}",
response_model=None,
operation_id="getRecipe",
summary="Get a single recipe",
responses={
404: {
"model": ProblemDetails,
"description": "Recipe not found",
"content": {"application/problem+json": {}},
}
},
)
async def get_recipe( async def get_recipe(
recipe_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None recipe_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None
) -> recipes.Recipe: ) -> recipes.Recipe | ProblemDetails:
# Placeholder: implementation will be moved from main.get_recipe in a later step. r = await load_full_recipe(conn, recipe_id)
raise NotImplementedError("get_recipe extraction pending") if not r:
return error_response(request, 404, "Recipe not found")
return r
@router.post("", response_model=recipes.Recipe, operation_id="createRecipe", summary="Create a new recipe (versioning semantics applied)", @router.post(
responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}}) "",
response_model=None,
operation_id="createRecipe",
summary="Create a new recipe (versioning semantics applied)",
responses={
400: {
"model": ProblemDetails,
"description": "Validation error",
"content": {"application/problem+json": {}},
}
},
)
async def create_recipe( async def create_recipe(
recipe: recipes.Recipe, recipe: recipes.Recipe,
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
user: persons.Person = Depends(cookie_person), user: persons.Person = Depends(cookie_person),
request: Request | None = None, request: Request | None = None,
) -> recipes.Recipe: ) -> recipes.Recipe | ProblemDetails:
# Placeholder: implementation will be moved from main.create_recipe in a later step. if not recipe.ingredients:
raise NotImplementedError("create_recipe extraction pending") return error_response(request, 400, "Recipe must have at least one ingredient")
if recipe.id >= 0:
await recipes.hide_recipe(conn, recipe.id, user)
recipe.based_on_recipe = recipe.id
recipe.id = 0
recipe.created_by_id = user.id
await recipes.insert_recipe(conn, recipe)
for ingredient in recipe.ingredients:
ingredient.recipe_id = recipe.id
if ingredient.product:
ingredient.product_id = ingredient.product.id
await ingredients.insert_ingredient(conn, ingredient)
await conn.commit()
return recipe
@router.delete("/{recipe_id}", response_model=recipes.Recipe, operation_id="deleteRecipe", summary="Soft-delete (hide) a recipe", @router.delete(
responses={404: {"model": ProblemDetails, "description": "Recipe not found", "content": {"application/problem+json": {}}}}) "/{recipe_id}",
response_model=None,
operation_id="deleteRecipe",
summary="Soft-delete (hide) a recipe",
responses={
404: {
"model": ProblemDetails,
"description": "Recipe not found",
"content": {"application/problem+json": {}},
}
},
)
async def delete_recipe( async def delete_recipe(
recipe_id: int, recipe_id: int,
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
user: persons.Person = Depends(cookie_person), user: persons.Person = Depends(cookie_person),
request: Request | None = None, request: Request | None = None,
) -> recipes.Recipe: ) -> recipes.Recipe | ProblemDetails:
# Placeholder: implementation will be moved from main.delete_recipe in a later step. recipe = await recipes.find_recipe_by_id(conn, recipe_id)
raise NotImplementedError("delete_recipe extraction pending") if not recipe:
return error_response(request, 404, "Recipe not found")
await recipes.hide_recipe(conn, recipe_id, user)
@router.get("/parse", response_model=None, operation_id="parseRecipe", summary="Parse a recipe from a URL", await conn.commit()
responses={400: {"model": ProblemDetails, "description": "Recipe not found", "content": {"application/problem+json": {}}}}) return recipe
async def parse_recipe_handler(
url: str, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person), request: Request | None = None
) -> recipes.Recipe:
# Placeholder: implementation will be moved from main.parse_recipe_handler in a later step.
raise NotImplementedError("parse_recipe_handler extraction pending")
@router.get("/ingredients/parse", operation_id="parseIngredients", summary="Parse raw ingredient lines")
async def parse_ingredients(
lines: List[str] = Query(alias="ingredients", title="Array of ingredients to parse"),
conn: aiosqlite.Connection = Depends(get_db),
) -> List[ingredients.Ingredient]:
# Placeholder: implementation will be moved from main.parse_ingredients in a later step.
raise NotImplementedError("parse_ingredients extraction pending")

238
main.py
View file

@ -134,61 +134,6 @@ def _extend_openapi_with_problem_responses(app: FastAPI) -> None:
_extend_openapi_with_problem_responses(app) _extend_openapi_with_problem_responses(app)
@api_v1.get(
"/recipes/parse",
response_model=None,
operation_id="parseRecipe",
tags=["recipes"],
summary="Parse a recipe from a URL",
responses={
400: {
"model": ProblemDetails,
"description": "Recipe not found",
"content": {"application/problem+json": {}},
}
},
)
async def parse_recipe_handler(
url: str, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person), request: Request = None
) -> recipes.Recipe | JSONResponse:
parsed = await recipes.parse_recipe(conn, person, url)
if not parsed:
return error_response(request, 400, "Recipe not found")
return parsed
@api_v1.get(
"/recipes/ingredients/parse",
operation_id="parseIngredients",
tags=["ingredients"],
summary="Parse raw ingredient lines",
)
async def parse_ingredients(
lines: Annotated[List[str], Query(alias="ingredients", title="Array of ingredients to parse")],
conn: aiosqlite.Connection = Depends(get_db),
) -> List[ingredients.Ingredient]:
had_links = False
result = []
for line in lines:
ingredient = await ingredients.parse_ingredient_from_link(conn, line)
if ingredient:
result.append(ingredient)
had_links = True
continue
ingredient = ingredients.parse_ingredient_from_nlp(line)
if ingredient:
result.append(ingredient)
continue
if had_links:
await conn.commit()
await ingredients.match_existing_products(conn, result)
return result
class ProductUrl(ApiModel): class ProductUrl(ApiModel):
url: str url: str
tags: List[str] = Field(default_factory=list) tags: List[str] = Field(default_factory=list)
@ -221,182 +166,7 @@ async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[reci
return r return r
@api_v1.get( from api import recipes as recipes_router # type: ignore
"/recipes",
operation_id="listRecipes",
response_model=Page[recipes.Recipe],
tags=["recipes"],
summary="List recipes (paginated)",
responses={
200: {
"description": "A page of recipes",
"content": {
"application/json": {
"example": {
"items": [
{
"id": 1,
"name": "Example Recipe",
"link": "https://example.com/recipes/1",
"serves": 4,
"imageUrls": [],
"ingredients": []
}
],
"nextCursor": "2",
"prevCursor": "0",
"total": 1
}
}
},
}
},
)
async def get_recipes(
q: Optional[str] = Query(
default=None,
description="Optional case-insensitive name filter (matches recipe name with SQL LIKE).",
),
cursor: Optional[str] = Query(
default=None,
description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.",
),
limit: int = Query(
50,
ge=1,
le=200,
description="Maximum number of items to return (1-200).",
),
conn: aiosqlite.Connection = Depends(get_db),
request: Request = None,
) -> List[recipes.Recipe] | Page[recipes.Recipe]:
# v1: DB-backed pagination using limit+1 strategy
last_id = None
if cursor:
try:
last_id = int(cursor)
except ValueError:
last_id = None
fetch_limit = limit + 1
paged: List[recipes.Recipe] = []
if q:
async for r in recipes.find_recipes_by_name_paged(conn, q, last_id, fetch_limit):
paged.append(r)
else:
async for r in recipes.get_all_paged(conn, last_id, fetch_limit):
paged.append(r)
has_more = len(paged) > limit
items = paged[:limit]
# load ingredients for items
for r in items:
r.ingredients = []
async for ing in ingredients.find_ingredients_by_recipe_id(conn, r.id):
r.ingredients.append(ing)
next_cursor = str(items[-1].id) if has_more and items else None
# Compute prevCursor via DB helper
prev_cursor: Optional[str] = None
if items:
first_id = items[0].id
prev_cursor = await recipes.compute_prev_cursor(conn, first_id, limit, q)
total = await (recipes.count_by_name(conn, q) if q else recipes.count_all(conn))
return Page(items=items, nextCursor=next_cursor, prevCursor=prev_cursor, total=total)
@api_v1.get(
"/recipes/{recipe_id}",
response_model=None,
operation_id="getRecipe",
tags=["recipes"],
summary="Get a single recipe",
responses={
404: {
"model": ProblemDetails,
"description": "Recipe not found",
"content": {"application/problem+json": {}},
}
},
)
async def get_recipe(
recipe_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request = None
) -> recipes.Recipe | JSONResponse:
r = await load_full_recipe(conn, recipe_id)
if not r:
return error_response(request, 404, "Recipe not found")
return r
@api_v1.post(
"/recipes",
response_model=None,
operation_id="createRecipe",
tags=["recipes"],
summary="Create a new recipe (versioning semantics applied)",
responses={
400: {
"model": ProblemDetails,
"description": "Validation error",
"content": {"application/problem+json": {}},
}
},
)
async def create_recipe(
recipe: recipes.Recipe,
conn: aiosqlite.Connection = Depends(get_db),
user: persons.Person = Depends(cookie_person),
request: Request = None,
) -> recipes.Recipe | JSONResponse:
if not recipe.ingredients:
return error_response(request, 400, "Recipe must have at least one ingredient")
if recipe.id >= 0:
await recipes.hide_recipe(conn, recipe.id, user)
recipe.based_on_recipe = recipe.id
recipe.id = 0
recipe.created_by_id = user.id
await recipes.insert_recipe(conn, recipe)
for ingredient in recipe.ingredients:
ingredient.recipe_id = recipe.id
if ingredient.product:
ingredient.product_id = ingredient.product.id
await ingredients.insert_ingredient(conn, ingredient)
await conn.commit()
return recipe
@api_v1.delete(
"/recipes/{recipe_id}",
response_model=None,
operation_id="deleteRecipe",
tags=["recipes"],
summary="Soft-delete (hide) a recipe",
responses={
404: {
"model": ProblemDetails,
"description": "Recipe not found",
"content": {"application/problem+json": {}},
}
},
)
async def delete_recipe(
recipe_id: int,
conn: aiosqlite.Connection = Depends(get_db),
user: persons.Person = Depends(cookie_person),
request: Request = None,
) -> recipes.Recipe | JSONResponse:
recipe = await recipes.find_recipe_by_id(conn, recipe_id)
if not recipe:
return error_response(request, 404, "Recipe not found")
await recipes.hide_recipe(conn, recipe_id, user)
await conn.commit()
return recipe
@api_v1.get( @api_v1.get(
@ -1038,6 +808,12 @@ async def request_validation_exc_handler(request: Request, exc: RequestValidatio
# Mount versioned API router # Mount versioned API router
app.include_router(api_v1, prefix="/api/v1", tags=["v1"]) app.include_router(api_v1, prefix="/api/v1", tags=["v1"])
app.include_router(recipes_router.router, prefix="/api/v1", tags=["v1"]) # extracted
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
if os.environ.get("DOOF_PROD", False): if os.environ.get("DOOF_PROD", False):

View file

@ -31,14 +31,14 @@ Acceptance criteria
## Phase 1 — API structure and lifecycle ## Phase 1 — API structure and lifecycle
- [ ] Extract routers by feature - [ ] Extract routers by feature
- [ ] api/recipes.py - [x] api/recipes.py
- [ ] api/meals.py - [ ] api/meals.py
- [ ] api/persons.py - [ ] api/persons.py
- [ ] api/shopping.py - [ ] api/shopping.py
- [ ] api/auth.py - [ ] api/auth.py
- [ ] Wire routers in main with minimal app code - [ ] Wire routers in main with minimal app code
- [ ] Move dev reverse proxy setup into a lifespan handler and ensure httpx client is closed - [ ] Move dev reverse proxy setup into a lifespan handler and ensure httpx client is closed
- [ ] Add /healthz endpoint (simple JSON: {"status": "ok"}) - [x] Add /healthz endpoint (simple JSON: {"status": "ok"})
Acceptance criteria Acceptance criteria
- main.py primarily wires app, routers, settings, and lifespan - main.py primarily wires app, routers, settings, and lifespan
@ -143,6 +143,7 @@ Note: We can adopt this structure gradually without moving DB code immediately;
- 2025-10-18: Created strategy document and added settings.py (not yet wired) - 2025-10-18: Created strategy document and added settings.py (not yet wired)
- 2025-10-18: Finalized router layout and health endpoint plan — Phase 0 complete - 2025-10-18: Finalized router layout and health endpoint plan — Phase 0 complete
- 2025-10-18: Created api package and scaffolded routers (recipes, meals, persons, shopping, auth) with placeholders - 2025-10-18: Created api package and scaffolded routers (recipes, meals, persons, shopping, auth) with placeholders
- 2025-10-18: Extracted recipes routes to api/recipes.py and wired router; added /healthz
--- ---