extract recipes
This commit is contained in:
parent
3d7afa3765
commit
edfc341b4b
3 changed files with 234 additions and 273 deletions
264
api/recipes.py
264
api/recipes.py
|
|
@ -8,70 +8,254 @@ from fastapi import APIRouter, Depends, Query, Request
|
|||
import ingredients
|
||||
import recipes
|
||||
import persons
|
||||
from common import Page, ProblemDetails
|
||||
from main import get_db, cookie_person, error_response # temporary imports during extraction
|
||||
from common import Page, ProblemDetails, ApiModel
|
||||
from pydantic import Field
|
||||
from main import get_db, cookie_person, error_response
|
||||
|
||||
|
||||
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(
|
||||
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)."),
|
||||
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 = None,
|
||||
) -> Page[recipes.Recipe]:
|
||||
# Placeholder: implementation will be moved from main.get_recipes in a later step.
|
||||
raise NotImplementedError("list_recipes extraction pending")
|
||||
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)
|
||||
|
||||
|
||||
@router.get("/{recipe_id}", response_model=recipes.Recipe, operation_id="getRecipe", summary="Get a single recipe",
|
||||
responses={404: {"model": ProblemDetails, "description": "Recipe not found", "content": {"application/problem+json": {}}}})
|
||||
@router.get(
|
||||
"/{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(
|
||||
recipe_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None
|
||||
) -> recipes.Recipe:
|
||||
# Placeholder: implementation will be moved from main.get_recipe in a later step.
|
||||
raise NotImplementedError("get_recipe extraction pending")
|
||||
) -> recipes.Recipe | ProblemDetails:
|
||||
r = await load_full_recipe(conn, recipe_id)
|
||||
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)",
|
||||
responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}})
|
||||
@router.post(
|
||||
"",
|
||||
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(
|
||||
recipe: recipes.Recipe,
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
user: persons.Person = Depends(cookie_person),
|
||||
request: Request | None = None,
|
||||
) -> recipes.Recipe:
|
||||
# Placeholder: implementation will be moved from main.create_recipe in a later step.
|
||||
raise NotImplementedError("create_recipe extraction pending")
|
||||
) -> recipes.Recipe | ProblemDetails:
|
||||
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
|
||||
|
||||
|
||||
@router.delete("/{recipe_id}", response_model=recipes.Recipe, operation_id="deleteRecipe", summary="Soft-delete (hide) a recipe",
|
||||
responses={404: {"model": ProblemDetails, "description": "Recipe not found", "content": {"application/problem+json": {}}}})
|
||||
@router.delete(
|
||||
"/{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(
|
||||
recipe_id: int,
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
user: persons.Person = Depends(cookie_person),
|
||||
request: Request | None = None,
|
||||
) -> recipes.Recipe:
|
||||
# Placeholder: implementation will be moved from main.delete_recipe in a later step.
|
||||
raise NotImplementedError("delete_recipe extraction pending")
|
||||
) -> recipes.Recipe | ProblemDetails:
|
||||
recipe = await recipes.find_recipe_by_id(conn, recipe_id)
|
||||
if not recipe:
|
||||
return error_response(request, 404, "Recipe not found")
|
||||
|
||||
|
||||
@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:
|
||||
# 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")
|
||||
await recipes.hide_recipe(conn, recipe_id, user)
|
||||
await conn.commit()
|
||||
return recipe
|
||||
|
|
|
|||
238
main.py
238
main.py
|
|
@ -134,61 +134,6 @@ def _extend_openapi_with_problem_responses(app: FastAPI) -> None:
|
|||
_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):
|
||||
url: str
|
||||
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
|
||||
|
||||
|
||||
@api_v1.get(
|
||||
"/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
|
||||
from api import recipes as recipes_router # type: ignore
|
||||
|
||||
|
||||
@api_v1.get(
|
||||
|
|
@ -1038,6 +808,12 @@ async def request_validation_exc_handler(request: Request, exc: RequestValidatio
|
|||
|
||||
# Mount versioned API router
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -31,14 +31,14 @@ Acceptance criteria
|
|||
|
||||
## Phase 1 — API structure and lifecycle
|
||||
- [ ] Extract routers by feature
|
||||
- [ ] api/recipes.py
|
||||
- [x] api/recipes.py
|
||||
- [ ] api/meals.py
|
||||
- [ ] api/persons.py
|
||||
- [ ] api/shopping.py
|
||||
- [ ] api/auth.py
|
||||
- [ ] Wire routers in main with minimal app code
|
||||
- [ ] 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
|
||||
- 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: 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: Extracted recipes routes to api/recipes.py and wired router; added /healthz
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue