from __future__ import annotations from typing import List, Optional import aiosqlite from fastapi import APIRouter, Depends, Query, Request, Response import ingredients import recipes import persons from common import Page, ProblemDetails from api.deps import get_db, cookie_person, error_response router = APIRouter(prefix="/recipes", tags=["recipes"]) @router.get( "/parse", response_model=recipes.Recipe, 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, request: Request, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person) ) -> 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: # Transaction will commit at end of request pass 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( request: Request, 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), ) -> Page[recipes.Recipe]: 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] # Batch-load ingredients for the page to avoid N+1 queries if items: recipe_ids = [r.id for r in items] by_recipe = await ingredients.find_ingredients_by_recipe_ids(conn, recipe_ids) for r in items: r.ingredients = by_recipe.get(r.id, []) 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": {}}, } }, ) async def get_recipe( recipe_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db) ) -> 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": {}}, } }, ) async def create_recipe( recipe: recipes.Recipe, request: Request, response: Response, conn: aiosqlite.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person), ) -> 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) # Transaction will commit at end of request # Set Location to the new resource response.headers["Location"] = f"/api/v1/recipes/{recipe.id}" 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": {}}, } }, ) async def delete_recipe( recipe_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person), ) -> recipes.Recipe | ProblemDetails: 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) return recipe