diff --git a/api/auth.py b/api/auth.py index 42cb365..c86e0a3 100644 --- a/api/auth.py +++ b/api/auth.py @@ -25,7 +25,7 @@ class LoginBody(ApiModel): 404: {"model": ProblemDetails, "description": "Person not found", "content": {"application/problem+json": {}}} }, ) -async def login(data: LoginBody, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None) -> persons.Person | JSONResponse: +async def login(request: Request, data: LoginBody, conn: aiosqlite.Connection = Depends(get_db)) -> persons.Person | JSONResponse: person = await persons.get_by_name(conn, data.username) if not person: return error_response(request, 404, "Person not found") diff --git a/api/meals.py b/api/meals.py index f675442..9e63cfc 100644 --- a/api/meals.py +++ b/api/meals.py @@ -37,7 +37,7 @@ async def get_upcoming_meals( @router.get("/{meal_id}", response_model=meals.Meal, operation_id="getMeal", summary="Get a meal by id", responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}}) async def get_meal( - meal_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None + meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db) ) -> meals.Meal: meal = await meals.find_meal_by_id(conn, meal_id) if not meal: @@ -49,7 +49,7 @@ async def get_meal( @router.post("", response_model=meals.Meal, operation_id="createMeal", summary="Create a new meal", responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}}) async def create_meal( - meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None + meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db) ) -> meals.Meal: validation_response = validate_meal(meal, request) if validation_response: @@ -66,7 +66,7 @@ async def create_meal( 404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}, }) async def update_meal( - meal_id: int, meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None + meal_id: int, meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db) ) -> meals.Meal: if meal.id != meal_id: return error_response(request, 400, "Meal ID in URL does not match meal ID in body") @@ -92,10 +92,10 @@ async def update_meal( }) async def mark_consumed( meal_id: int, + request: Request, consumed_date: Optional[datetime.datetime] = None, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), - request: Request | None = None, ) -> meals.Meal: if consumed_date and not consumed_date.tzinfo: return error_response(request, 400, "Consumed date must include timezone") @@ -115,9 +115,9 @@ async def mark_consumed( responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}}) async def delete_meal( meal_id: int, + request: Request, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), - request: Request | None = None, ) -> meals.Meal: meal = await meals.find_meal_by_id(conn, meal_id) if not meal: diff --git a/api/persons.py b/api/persons.py index 297a40e..2420e8e 100644 --- a/api/persons.py +++ b/api/persons.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import List, Optional import aiosqlite -from fastapi import APIRouter, Depends, Query, Request +from fastapi import APIRouter, Depends, Query import persons from common import Page @@ -54,7 +54,6 @@ async def list_persons( description="Maximum number of items to return (1-200).", ), conn: aiosqlite.Connection = Depends(get_db), - request: Request | None = None, ) -> Page[persons.Person]: # v1: DB-backed pagination last_id = None diff --git a/api/recipes.py b/api/recipes.py index 8f7aad0..4190c46 100644 --- a/api/recipes.py +++ b/api/recipes.py @@ -8,19 +8,12 @@ from fastapi import APIRouter, Depends, Query, Request import ingredients import recipes import persons -from common import Page, ProblemDetails, ApiModel -from pydantic import Field +from common import Page, ProblemDetails from api.deps import get_db, cookie_person, error_response - router = APIRouter(prefix="/recipes", tags=["recipes"]) -class ProductUrl(ApiModel): - url: str - tags: List[str] = Field(default_factory=list) - - @router.get( "/parse", response_model=None, @@ -35,7 +28,7 @@ class ProductUrl(ApiModel): }, ) async def parse_recipe_handler( - url: str, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person), request: Request | None = None + 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: @@ -119,6 +112,7 @@ async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[reci }, ) async def list_recipes( + request: Request, q: Optional[str] = Query( default=None, description="Optional case-insensitive name filter (matches recipe name with SQL LIKE).", @@ -134,7 +128,6 @@ async def list_recipes( description="Maximum number of items to return (1-200).", ), conn: aiosqlite.Connection = Depends(get_db), - request: Request | None = None, ) -> Page[recipes.Recipe]: last_id = None if cursor: @@ -183,7 +176,7 @@ async def list_recipes( }, ) async def get_recipe( - recipe_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None + 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: @@ -207,9 +200,9 @@ async def get_recipe( ) async def create_recipe( recipe: recipes.Recipe, + request: Request, conn: aiosqlite.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person), - request: Request | None = None, ) -> recipes.Recipe | ProblemDetails: if not recipe.ingredients: return error_response(request, 400, "Recipe must have at least one ingredient") @@ -248,9 +241,9 @@ async def create_recipe( ) async def delete_recipe( recipe_id: int, + request: Request, conn: aiosqlite.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person), - request: Request | None = None, ) -> recipes.Recipe | ProblemDetails: recipe = await recipes.find_recipe_by_id(conn, recipe_id) if not recipe: diff --git a/api/shopping.py b/api/shopping.py index c6b0965..362ab37 100644 --- a/api/shopping.py +++ b/api/shopping.py @@ -16,7 +16,6 @@ from api.deps import get_db, cookie_person, error_response router = APIRouter(prefix="/shopping", tags=["shopping"]) from common import ApiModel, Field -from typing import Optional class CurrentShoppingList(ApiModel): @@ -81,7 +80,7 @@ class PurchasedShoppingList(ApiModel): @router.get("/{list_id}", response_model=PurchasedShoppingList, operation_id="getShoppingList", summary="Get a purchased shopping list by id", responses={404: {"model": ProblemDetails, "description": "Shopping list not found", "content": {"application/problem+json": {}}}}) -async def get_shopping_list(list_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None) -> PurchasedShoppingList | ProblemDetails: +async def get_shopping_list(list_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)) -> PurchasedShoppingList | ProblemDetails: shopping_list = await shopping.load_shopping_list(conn, list_id) if not shopping_list: return error_response(request, 404, "Shopping list not found") @@ -149,7 +148,7 @@ class MealIdWrapper(ApiModel): @router.post("/current/meals/me", response_model=None, operation_id="requestMeal", summary="Request a meal for shopping", responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}}) -async def request_meal(r: MealIdWrapper, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), request: Request | None = None) -> shopping.ShoppingListItem | ProblemDetails: +async def request_meal(r: MealIdWrapper, request: Request, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> shopping.ShoppingListItem | ProblemDetails: meal = await meals.find_meal_by_id(conn, r.meal_id) if not meal: return error_response(request, 404, "Meal not found") @@ -161,7 +160,7 @@ async def request_meal(r: MealIdWrapper, conn: aiosqlite.Connection = Depends(ge @router.delete("/current/meals/{meal_id}", response_model=None, operation_id="unrequestMeal", summary="Remove a meal request", responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}}) -async def unrequest_meal(meal_id: int, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), request: Request | None = None) -> dict | ProblemDetails: +async def unrequest_meal(meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> dict | ProblemDetails: meal = await meals.find_meal_by_id(conn, meal_id) if not meal: return error_response(request, 404, "Meal not found") @@ -171,63 +170,4 @@ async def unrequest_meal(meal_id: int, conn: aiosqlite.Connection = Depends(get_ return {} -@router.get("/current", operation_id="getCurrentShoppingList", summary="Get the current aggregated shopping list") -async def get_current_shopping_list( - conn: aiosqlite.Connection = Depends(get_db), -): - raise NotImplementedError("get_current_shopping_list extraction pending") - - -@router.get("/{list_id}", operation_id="getShoppingList", summary="Get a purchased shopping list by id", - responses={404: {"model": ProblemDetails, "description": "Shopping list not found", "content": {"application/problem+json": {}}}}) -async def get_shopping_list( - list_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None -): - raise NotImplementedError("get_shopping_list extraction pending") - - -@router.post("", operation_id="purchaseIngredients", summary="Purchase ingredients for a shopping list") -async def purchase_ingredients( - shopping_list: shopping.ShoppingList, - conn: aiosqlite.Connection = Depends(get_db), - person: persons.Person = Depends(cookie_person), -): - raise NotImplementedError("purchase_ingredients extraction pending") - - -@router.get("/current/me/ingredients", operation_id="getMyShoppingList", summary="Get my outstanding ingredient requests") -async def get_my_shopping_list( - conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person) -) -> List[ingredients.Ingredient]: - raise NotImplementedError("get_my_shopping_list extraction pending") - - -@router.post("/current/me/ingredients", operation_id="syncMyShoppingList", summary="Sync my outstanding ingredient requests") -async def sync_my_shopping_list( - requests: List[ingredients.Ingredient], - conn: aiosqlite.Connection = Depends(get_db), - person: persons.Person = Depends(cookie_person), -) -> List[ingredients.Ingredient]: - raise NotImplementedError("sync_my_shopping_list extraction pending") - - -@router.post("/current/meals/{meal_id}", operation_id="requestMeal", summary="Request a meal for shopping", - responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}}) -async def request_meal( - meal_id: int, - conn: aiosqlite.Connection = Depends(get_db), - person: persons.Person = Depends(cookie_person), - request: Request | None = None, -): - raise NotImplementedError("request_meal extraction pending") - - -@router.delete("/current/meals/{meal_id}", operation_id="unrequestMeal", summary="Remove a meal request", - responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}}) -async def unrequest_meal( - meal_id: int, - conn: aiosqlite.Connection = Depends(get_db), - person: persons.Person = Depends(cookie_person), - request: Request | None = None, -): - raise NotImplementedError("unrequest_meal extraction pending") +# Removed duplicate placeholder endpoints left over from earlier scaffolding diff --git a/main.py b/main.py index 3c137b9..96b773b 100644 --- a/main.py +++ b/main.py @@ -1,20 +1,16 @@ import datetime import os from typing import Annotated, Dict, List, Optional, Any +from contextlib import asynccontextmanager import aiosqlite -from fastapi import Cookie, Depends, FastAPI, Query, APIRouter, Request +from fastapi import Depends, FastAPI, APIRouter, Request from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse -from pydantic import BaseModel, Field +from pydantic import Field import db -import ingredients -import meals -import persons import products -import recipes -import shopping from fastapi.routing import APIRoute from common import ProblemDetails, Page, ApiModel @@ -29,7 +25,22 @@ class CamelCaseRoute(APIRoute): super().__init__(*args, **kwargs) -app = FastAPI(title="Doof API", version="1.0.0", description="Doof Backend API") +@asynccontextmanager +async def app_lifespan(app: FastAPI): + client = None + if not settings.prod: + import httpx + + client = httpx.AsyncClient(base_url=settings.frontend_dev_url) + app.state.proxy_client = client + try: + yield + finally: + if client is not None: + await client.aclose() + + +app = FastAPI(title="Doof API", version="1.0.0", description="Doof Backend API", lifespan=app_lifespan) api_v1 = APIRouter(route_class=CamelCaseRoute) DATABASE_PATH = settings.database_path @@ -127,530 +138,21 @@ async def create_product( return await products.get_or_create(conn, url.url, url.tags) -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 + from api import recipes as recipes_router # type: ignore -@api_v1.get( - "/meals/upcoming", - operation_id="getUpcomingMeals", - tags=["meals"], - summary="List upcoming meals in a date range", -) -async def get_upcoming_meals( - date_from: Annotated[datetime.datetime, Query(alias="from")], - to: datetime.datetime, - conn: aiosqlite.Connection = Depends(get_db), -) -> List[meals.Meal]: - result = [] - async for meal in meals.find_upcoming_meals_by_date_range(conn, date_from, to): - await meals.load_recipes(conn, meal) - await meals.load_extra_ingredients(conn, meal) - await meals.load_participants(conn, meal) - result.append(meal) - - return result - - -@api_v1.get( - "/meals/{meal_id}", - response_model=None, - operation_id="getMeal", - tags=["meals"], - summary="Get a meal by id", - responses={ - 404: { - "model": ProblemDetails, - "description": "Meal not found", - "content": {"application/problem+json": {}}, - } - }, -) -async def get_meal( - meal_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request = None -) -> meals.Meal | JSONResponse: - meal = await meals.find_meal_by_id(conn, meal_id) - if not meal: - return error_response(request, 404, "Meal not found") - - return meal - - -def get_duplicates(items: List[meals.Person]) -> set[str]: - seen: set[int] = set() - duplicates: set[str] = set() - for item in items: - if item.id in seen: - duplicates.add(item.name) - seen.add(item.id) - return duplicates - - -def validate_meal(meal: meals.Meal, request: Optional[Request] = None) -> Optional[JSONResponse]: - if not meal.chefs: - return error_response(request, 400, "Meal must have at least one chef") - - if not meal.cleanup: - return error_response(request, 400, "Meal must have at least one cleanup person") - - if not meal.consumers: - return error_response(request, 400, "Meal must have at least one consumer") - - if len(meal.recipes) == 0 and len(meal.extra_ingredients) == 0: - return error_response(request, 400, "Meal must have at least one recipe or ingredient") - - duplicates = get_duplicates(meal.chefs) - if duplicates: - return error_response(request, 400, f'Duplicate chef: {", ".join(duplicates)}') - - duplicates = get_duplicates(meal.cleanup) - if duplicates: - return error_response(request, 400, f'Duplicate cleanup person: {", ".join(duplicates)}') - - duplicates = get_duplicates(meal.consumers) - if duplicates: - return error_response(request, 400, f'Duplicate consumer: {", ".join(duplicates)}') - - zero_servings = [r for r in meal.recipes if r.servings == 0] - if zero_servings: - return error_response(request, 400, "Recipe servings must be greater than 0") - - return None - - -@api_v1.post( - "/meals", - response_model=None, - operation_id="createMeal", - tags=["meals"], - summary="Create a new meal", - responses={ - 400: { - "model": ProblemDetails, - "description": "Validation error", - "content": {"application/problem+json": {}}, - } - }, -) -async def create_meal( - meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request = None -) -> meals.Meal | JSONResponse: - validation_response = validate_meal(meal, request) - if validation_response: - return validation_response - - await meals.insert_meal(conn, meal) - await conn.commit() - return meal - - -@api_v1.put( - "/meals/{meal_id}", - response_model=None, - operation_id="updateMeal", - tags=["meals"], - summary="Update an existing meal", - responses={ - 400: { - "model": ProblemDetails, - "description": "Validation error", - "content": {"application/problem+json": {}}, - }, - 404: { - "model": ProblemDetails, - "description": "Meal not found", - "content": {"application/problem+json": {}}, - }, - }, -) -async def update_meal( - meal_id: int, meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request = None -) -> meals.Meal | JSONResponse: - if meal.id != meal_id: - return error_response(request, 400, "Meal ID in URL does not match meal ID in body") - - existing = await meals.find_meal_by_id(conn, meal_id) - if not existing: - return error_response(request, 404, "Meal not found") - - validation_response = validate_meal(meal, request) - if validation_response: - return validation_response - - await meals.update_meal(conn, meal) - await conn.commit() - - return await get_meal(meal_id, conn) - - -@api_v1.post( - "/meals/{meal_id}/consumed", - response_model=None, - operation_id="markMealConsumed", - tags=["meals"], - summary="Mark a meal as consumed", - responses={ - 400: { - "model": ProblemDetails, - "description": "Validation error", - "content": {"application/problem+json": {}}, - }, - 404: { - "model": ProblemDetails, - "description": "Meal not found", - "content": {"application/problem+json": {}}, - }, - }, -) -async def mark_consumed( - meal_id: int, - consumed_date: Optional[datetime.datetime] = None, - conn: aiosqlite.Connection = Depends(get_db), - person: persons.Person = Depends(cookie_person), - request: Request = None, -) -> meals.Meal | JSONResponse: - if consumed_date and not consumed_date.tzinfo: - return error_response(request, 400, "Consumed date must include timezone") - - meal = await meals.find_meal_by_id(conn, meal_id) - if not meal: - return error_response(request, 404, "Meal not found") - - await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone()) - await shopping.remove_request(conn, person, meal=meal) - - await conn.commit() - return meal - - -@api_v1.delete( - "/meals/{meal_id}", - response_model=None, - operation_id="deleteMeal", - tags=["meals"], - summary="Delete a meal", - responses={ - 404: { - "model": ProblemDetails, - "description": "Meal not found", - "content": {"application/problem+json": {}}, - } - }, -) -async def delete_meal( - meal_id: int, - conn: aiosqlite.Connection = Depends(get_db), - person: persons.Person = Depends(cookie_person), - request: Request = None, -) -> meals.Meal | JSONResponse: - meal = await meals.find_meal_by_id(conn, meal_id) - if not meal: - return error_response(request, 404, "Meal not found") - - await shopping.remove_request(conn, person, meal=meal) - await meals.delete_meal(conn, meal.id) - - await conn.commit() - return meal - from api import meals as meals_router # type: ignore -app.include_router(meals_router.router, prefix="/api/v1", tags=["v1"]) # extracted - - -class CurrentShoppingList(ApiModel): - outstanding_items: List[shopping.ShoppingListItem] - requested_meals: List[shopping.ShoppingListItem] - purchased_items: List[shopping.ShoppingListItem] = Field(default_factory=list) - - ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict) - meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict) - shopping_list_lookup: Dict[int, shopping.ShoppingList] = Field(default_factory=dict) - recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict) - - -@api_v1.get( - "/shopping/current", - response_model=CurrentShoppingList, - operation_id="getCurrentShoppingList", - tags=["shopping"], - summary="Get the current aggregated shopping list", -) -async def get_current_shopping_list( - conn: aiosqlite.Connection = Depends(get_db), -) -> CurrentShoppingList: - ( - outstanding_requests, - purchased_requests, - meal_requests, - meals_lookup, - recipes_lookup, - ingredients_lookup, - ) = await shopping.get_outstanding_requests(conn) - other_shopping_list_ids = {item.list_id for item in purchased_requests} - - shopping_list_lookup = {} - for list_id in other_shopping_list_ids: - if list_id is not None: - sl = await shopping.load_shopping_list(conn, list_id) - if sl is not None: - shopping_list_lookup[list_id] = sl - - # Add any additional items from shopping lists to the existing lookups - additional_items = [item for sl in shopping_list_lookup.values() for item in sl.items] - if additional_items: - await shopping.to_lookups( - conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup - ) - - return CurrentShoppingList( - outstanding_items=outstanding_requests, - requested_meals=meal_requests, - purchased_items=purchased_requests, - meals_lookup=meals_lookup, - shopping_list_lookup=shopping_list_lookup, - ingredients_lookup=ingredients_lookup, - recipes_lookup=recipes_lookup, - ) - - -class PurchasedShoppingList(ApiModel): - list: shopping.ShoppingList - meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict) - ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict) - recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict) - - -@api_v1.get( - "/shopping/{list_id}", - response_model=PurchasedShoppingList, - operation_id="getShoppingList", - tags=["shopping"], - summary="Get a purchased shopping list by id", - responses={ - 404: { - "model": ProblemDetails, - "description": "Shopping list not found", - "content": {"application/problem+json": {}}, - } - }, -) -async def get_shopping_list( - list_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request = None -) -> PurchasedShoppingList | JSONResponse: - shopping_list = await shopping.load_shopping_list(conn, list_id) - if not shopping_list: - return error_response(request, 404, "Shopping list not found") - - meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups( - conn, shopping_list.items - ) - return PurchasedShoppingList( - list=shopping_list, - meals_lookup=meals_lookup, - recipes_lookup=recipes_lookup, - ingredients_lookup=ingredients_lookup, - ) - - -@api_v1.post( - "/shopping/", - operation_id="purchaseIngredients", - tags=["shopping"], - summary="Purchase ingredients for a shopping list", -) -async def purchase_ingredients( - shopping_list: shopping.ShoppingList, - conn: aiosqlite.Connection = Depends(get_db), - person: persons.Person = Depends(cookie_person), -) -> PurchasedShoppingList: - shopping_list = shopping.ShoppingList( - purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name - ) - - await shopping.purchase(conn, shopping_list) - await conn.commit() - - result = PurchasedShoppingList(list=shopping_list) - await shopping.to_lookups( - conn, - shopping_list.items, - result.meals_lookup, - result.recipes_lookup, - result.ingredients_lookup, - ) - return result - - -@api_v1.get( - "/shopping/current/me/ingredients", - operation_id="getMyShoppingList", - tags=["shopping"], - summary="Get my outstanding ingredient requests", -) -async def get_my_shopping_list( - conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person) -) -> List[ingredients.Ingredient]: - return await shopping.get_persons_requests(conn, person.id) - - -@api_v1.post( - "/shopping/current/me/ingredients", - operation_id="syncMyShoppingList", - tags=["shopping"], - summary="Sync my outstanding ingredient requests", -) -async def sync_my_shopping_list( - requests: List[ingredients.Ingredient], - conn: aiosqlite.Connection = Depends(get_db), - person: persons.Person = Depends(cookie_person), -) -> List[ingredients.Ingredient]: - def isMatching(a: ingredients.Ingredient, b: ingredients.Ingredient) -> bool: - return a.id == b.id or a.line == b.line - - my_shopping_list = await shopping.get_persons_requests(conn, person.id) - to_remove = [r for r in my_shopping_list if not any(isMatching(r, req) for req in requests)] - to_add = [req for req in requests if not any(isMatching(req, r) for r in my_shopping_list)] - - for r in to_remove: - await shopping.remove_request(conn, person, ingredient=r) - - for r in to_add: - if r.id < 0: - await ingredients.insert_ingredient(conn, r) - await shopping.request(conn, person, ingredient=r) - - await conn.commit() - return await get_my_shopping_list(conn, person) - - -class MealIdWrapper(ApiModel): - meal_id: int - - -@api_v1.post( - "/shopping/current/meals/me", - response_model=None, - operation_id="requestMeal", - tags=["shopping"], - summary="Request a meal for shopping", - responses={ - 404: { - "model": ProblemDetails, - "description": "Meal not found", - "content": {"application/problem+json": {}}, - } - }, -) -async def request_meal( - r: MealIdWrapper, - conn: aiosqlite.Connection = Depends(get_db), - person: persons.Person = Depends(cookie_person), - request: Request = None, -) -> shopping.ShoppingListItem | JSONResponse: - meal = await meals.find_meal_by_id(conn, r.meal_id) - if not meal: - return error_response(request, 404, "Meal not found") - - response = await shopping.request(conn, person, meal=meal) - await conn.commit() - return response - - -@api_v1.delete( - "/shopping/current/meals/{meal_id}", - response_model=None, - operation_id="unrequestMeal", - tags=["shopping"], - summary="Remove a meal request", - responses={ - 404: { - "model": ProblemDetails, - "description": "Meal not found", - "content": {"application/problem+json": {}}, - } - }, -) -async def unrequest_meal( - meal_id: int, - conn: aiosqlite.Connection = Depends(get_db), - person: persons.Person = Depends(cookie_person), - request: Request = None, -) -> dict | JSONResponse: - meal = await meals.find_meal_by_id(conn, meal_id) - if not meal: - return error_response(request, 404, "Meal not found") - - await shopping.remove_request(conn, person, meal=meal) - await conn.commit() - return {} - from api import shopping as shopping_router # type: ignore -app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted - from api import persons as persons_router # type: ignore - - -class LoginBody(ApiModel): - username: str - - -@api_v1.post( - "/auth/login", - response_model=None, - operation_id="login", - tags=["auth"], - summary="Login and set user_id cookie", - responses={ - 404: { - "model": ProblemDetails, - "description": "Person not found", - "content": {"application/problem+json": {}}, - } - }, -) -async def login( - data: LoginBody, conn: aiosqlite.Connection = Depends(get_db), request: Request = None -) -> persons.Person | JSONResponse: - person = await persons.get_by_name(conn, data.username) - if not person: - return error_response(request, 404, "Person not found") - - response = JSONResponse(content=jsonable_encoder(person)) - response.set_cookie(key="user_id", value=str(person.id)) - return response - - -@api_v1.post( - "/auth/refresh", - operation_id="refresh", - tags=["auth"], - summary="Refresh current user from cookie", -) -async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person: - return user - from api import auth as auth_router # type: ignore -app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted # RFC7807 Problem Details handlers from starlette.exceptions import HTTPException as StarletteHTTPException from pydantic import ValidationError from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse @app.exception_handler(StarletteHTTPException) @@ -707,7 +209,10 @@ 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.include_router(meals_router.router, prefix="/api/v1", tags=["v1"]) # extracted +app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted app.include_router(persons_router.router, prefix="/api/v1", tags=["v1"]) # extracted +app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted @app.get("/healthz") @@ -715,21 +220,20 @@ async def healthz(): return {"status": "ok"} -if os.environ.get("DOOF_PROD", False): +if settings.prod: from fastapi.staticfiles import StaticFiles app.mount("/", StaticFiles(directory="./front-dist", html=True), name="front-dist") else: # Proxy the request to the frontend development server - import httpx from starlette.background import BackgroundTask from starlette.requests import Request from starlette.responses import StreamingResponse - client = httpx.AsyncClient(base_url="http://localhost:8080/") - async def _reverse_proxy(request: Request): + import httpx url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8")) + client = app.state.proxy_client rp_req = client.build_request( request.method, url, headers=request.headers.raw, content=request.stream() ) diff --git a/refactor-project-strategy.md b/refactor-project-strategy.md index 0b04bd1..463aa13 100644 --- a/refactor-project-strategy.md +++ b/refactor-project-strategy.md @@ -37,7 +37,7 @@ Acceptance criteria - [x] api/shopping.py - [x] api/auth.py - [x] Wire routers in main with minimal app code -- [ ] Move dev reverse proxy setup into a lifespan handler and ensure httpx client is closed +- [x] Move dev reverse proxy setup into a lifespan handler and ensure httpx client is closed - [x] Add /healthz endpoint (simple JSON: {"status": "ok"}) - [x] Create api/deps module for get_db, cookie_person, and error_response - [x] Use settings.py (DOOF_DB) for DB path in main and deps @@ -47,6 +47,13 @@ Acceptance criteria - Reverse proxy cleaned up; no stray global clients - Health check available and excluded from auth/security +Status: Complete + +Notes +- Lifespan handler initializes a dev httpx.AsyncClient using settings.frontend_dev_url and stores it on app.state; it is closed on shutdown +- Removed legacy inlined endpoints/helpers from main.py after extraction +- Kept product creation endpoint in main pending Phase 4 move + --- ## Phase 2 — API design and OpenAPI @@ -148,11 +155,11 @@ Note: We can adopt this structure gradually without moving DB code immediately; - 2025-10-18: Extracted recipes routes to api/recipes.py and wired router; added /healthz - 2025-10-18: Extracted persons routes to api/persons.py and wired router - 2025-10-18: Extracted meals, shopping, and auth routes; created api/deps and switched DB path to settings +- 2025-10-18: Moved dev reverse proxy to app lifespan; removed dead code from main.py; deduplicated models; tests all passing --- ## Next actions -- Phase 1: Move dev reverse proxy into a lifespan handler (startup/shutdown) and close AsyncClient cleanly - Phase 2: Add OpenAPI cookie security scheme and normalize 201 Created + Location - Phase 3: Plan DB PRAGMAs and indexes; add transaction scoping per request - Phase 5: Add fixtures for DB/auth and tests for health + 201 Location