diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml new file mode 100644 index 0000000..67b5727 --- /dev/null +++ b/.github/workflows/openapi.yml @@ -0,0 +1,50 @@ +name: OpenAPI + +on: + push: + branches: [ main, openapi, '**/openapi' ] + pull_request: + branches: [ main ] + +jobs: + schema: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Export OpenAPI schema + run: | + python scripts/export_openapi.py + + - name: Set up Node for schema tools + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Lint schema with Spectral + run: npx -y @stoplight/spectral-cli lint openapi.json + + - name: Compare with baseline if present + run: | + if [ -f openapi-baseline.json ]; then \ + npx -y openapi-diff --fail-on-changed --fail-on-incompatible openapi-baseline.json openapi.json; \ + else \ + echo "No baseline file found. Skipping diff."; \ + fi + + - name: Upload schema artifact + uses: actions/upload-artifact@v4 + with: + name: openapi-schema + path: openapi.json diff --git a/README.md b/README.md index 7ed78a9..0caaf89 100644 --- a/README.md +++ b/README.md @@ -43,3 +43,27 @@ ruff check . black . mypy . ``` + +## Environment variables + +- DOOF_DB: Path to sqlite database (default: `./data/doof.sqlite`) +- DOOF_PORT: Port the server listens on when containerized; align Dockerfile `EXPOSE` accordingly. + +## OpenAPI schema + +- Generate the schema artifact used by the frontend and CI checks: +``` +python scripts/export_openapi.py +``` + +This writes `openapi.json` to the repo root. Versioned endpoints live under `/api/v1`, legacy under `/api` (deprecated with `Deprecation` header). + +## Schema lint/diff (manual) + +Optionally, lint and compare schemas locally using Node tools: +``` +npx -y @stoplight/spectral-cli lint openapi.json +npx -y openapi-diff --fail-on-changed --fail-on-incompatible path/to/baseline.json openapi.json +``` + +Keep a `baseline.json` on release branches to detect breaking changes. diff --git a/common.py b/common.py index dd9535c..c7f71fb 100644 --- a/common.py +++ b/common.py @@ -1,11 +1,23 @@ -from typing import Any +from typing import Any, Dict, Generic, List, Optional, TypeVar -from pydantic import BaseModel, model_validator +from pydantic import BaseModel, Field, ConfigDict, model_validator -class BaseLinkedModel(BaseModel): - model_config = dict(arbitrary_types_allowed=True) +def to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + +class ApiModel(BaseModel): + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + ser_json_inf_nan="null", + arbitrary_types_allowed=True, + ) + + +class BaseLinkedModel(ApiModel): @model_validator(mode="before") @classmethod def auto_populate_ids(cls, data: dict[str, Any]) -> dict[str, Any]: @@ -17,9 +29,30 @@ class BaseLinkedModel(BaseModel): if id_key in data: # If the id_key already exists, ensure it matches the value's id if data[id_key] != value.id: - raise ValueError(f"ID mismatch for {key}: {data[id_key]} != {value.id}") + raise ValueError( + f"ID mismatch for {key}: {data[id_key]} != {value.id}" + ) else: # If the id_key does not exist, set it to the value's id data[id_key] = value.id return data + + +class ProblemDetails(ApiModel): + type: str = Field(default="about:blank") + title: str + status: int + detail: Optional[str] = None + instance: Optional[str] = None + errors: Optional[Dict[str, Any]] = None + + +T = TypeVar("T") + + +class Page(ApiModel, Generic[T]): + items: List[T] + next_cursor: Optional[str] = Field(default=None, alias="nextCursor") + prev_cursor: Optional[str] = Field(default=None, alias="prevCursor") + total: Optional[int] = Field(default=None, description="Optional total count") diff --git a/ingredients/db.py b/ingredients/db.py index fc8c146..5b8bfba 100644 --- a/ingredients/db.py +++ b/ingredients/db.py @@ -1,11 +1,13 @@ from typing import Any, AsyncIterator, ClassVar, List, Optional -from pydantic import BaseModel, field_validator +from pydantic import field_validator, Field +from common import ApiModel from products import Product +from units import ALL_UNITS -class Ingredient(BaseModel): +class Ingredient(ApiModel): KEYS: ClassVar[List[str]] = [ "id", "name", @@ -20,7 +22,11 @@ class Ingredient(BaseModel): id: int = -1 name: str line: str - unit: str + unit: str = Field( + title="Unit", + description="Measurement unit (enum values are advisory; runtime accepts any string)", + json_schema_extra={"enum": [u.name for u in ALL_UNITS]}, + ) quantity: float | str preparation: str product_id: Optional[int] = None diff --git a/main.py b/main.py index fcc93ed..8976979 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,9 @@ import datetime import os -from typing import Annotated, Dict, List, Optional +from typing import Annotated, Dict, List, Optional, Any import aiosqlite -from fastapi import Cookie, Depends, FastAPI, Query +from fastapi import Cookie, Depends, FastAPI, Query, APIRouter, Request from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse from pydantic import BaseModel, Field @@ -16,7 +16,19 @@ import products import recipes import shopping -app = FastAPI() +from fastapi.routing import APIRoute +from common import ProblemDetails, Page, ApiModel + + +class CamelCaseRoute(APIRoute): + def __init__(self, *args, **kwargs): + kwargs.setdefault("response_model_by_alias", True) + kwargs.setdefault("response_model_exclude_none", True) + super().__init__(*args, **kwargs) + + +app = FastAPI(title="Doof API", version="1.0.0", description="Doof Backend API") +api_v1 = APIRouter(route_class=CamelCaseRoute) DATABASE_PATH = os.environ.get("DOOF_DB", "./data/doof.sqlite") # Dependency to create SQLite connection @@ -34,17 +46,123 @@ async def cookie_person( return await persons.get_by_id(conn, user_id) -@app.get("/api/recipes/parse", response_model=None) +def error_response(request: Optional[Request], status_code: int, message: str) -> JSONResponse: + body = ProblemDetails( + title=message, + status=status_code, + type=f"https://httpstatuses.com/{status_code}", + instance=str(request.url) if request else None, + ) + return JSONResponse( + content=body.model_dump(by_alias=True), + status_code=status_code, + media_type="application/problem+json", + ) + + +# OpenAPI reusable responses for ProblemDetails +def _extend_openapi_with_problem_responses(app: FastAPI) -> None: + # Attach a custom openapi generation that injects reusable responses + original_openapi = app.openapi + + def custom_openapi(): + spec = original_openapi() + components = spec.setdefault("components", {}) + responses = components.setdefault("responses", {}) + # Standard ProblemDetails responses + responses.setdefault( + "Problem400", + { + "description": "Bad Request", + "content": { + "application/problem+json": {}, + "application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}}, + }, + }, + ) + responses.setdefault( + "Problem404", + { + "description": "Not Found", + "content": { + "application/problem+json": {}, + "application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}}, + }, + }, + ) + responses.setdefault( + "Problem422", + { + "description": "Validation Error", + "content": { + "application/problem+json": { + "schema": {"$ref": "#/components/schemas/ProblemDetails"} + }, + # Some clients may still expect FastAPI's default error; keep schema available + "application/json": { + "schema": {"$ref": "#/components/schemas/ProblemDetails"} + }, + }, + }, + ) + # Normalize v1 responses to reference reusable ProblemDetails where appropriate + paths = spec.get("paths", {}) + for path, ops in paths.items(): + if not isinstance(path, str) or not path.startswith("/api/v1/"): + continue + if not isinstance(ops, dict): + continue + for method, op in ops.items(): + if not isinstance(op, dict): + continue + resp = op.get("responses") + if not isinstance(resp, dict): + continue + # Map 400/404 to reusable references; ensure 422 exists + if "400" in resp: + resp["400"] = {"$ref": "#/components/responses/Problem400"} + if "404" in resp: + resp["404"] = {"$ref": "#/components/responses/Problem404"} + # Only add 422 if not already present + if "422" not in resp: + resp["422"] = {"$ref": "#/components/responses/Problem422"} + return spec + + app.openapi = custom_openapi # type: ignore[assignment] + + +_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) + 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 JSONResponse(status_code=400, content={"message": "Recipe not found"}) + return error_response(request, 400, "Recipe not found") return parsed -@app.get("/api/recipes/ingredients/parse") +@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), @@ -71,12 +189,17 @@ async def parse_ingredients( return result -class ProductUrl(BaseModel): +class ProductUrl(ApiModel): url: str tags: List[str] = Field(default_factory=list) -@app.post("/api/products") +@api_v1.post( + "/products", + operation_id="createProduct", + tags=["products"], + summary="Create or fetch a product from a URL", +) async def create_product( url: ProductUrl, conn: aiosqlite.Connection = Depends(get_db) ) -> Optional[products.Product]: @@ -98,47 +221,135 @@ async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[reci return r -@app.get("/api/recipes") +@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] = None, conn: aiosqlite.Connection = Depends(get_db) -) -> List[recipes.Recipe]: - result = [] + 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 recipe in recipes.find_recipes_by_name(conn, q): - result.append(recipe) + async for r in recipes.find_recipes_by_name_paged(conn, q, last_id, fetch_limit): + paged.append(r) else: - async for recipe in recipes.get_all(conn): - result.append(recipe) + async for r in recipes.get_all_paged(conn, last_id, fetch_limit): + paged.append(r) - for recipe in result: - recipe.ingredients = [] - async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, recipe.id): - recipe.ingredients.append(ingredient) - - return result + 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) -@app.get("/api/recipes/{recipe_id}", response_model=None) +@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) + 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 JSONResponse(status_code=404, content={"message": "Recipe not found"}) + return error_response(request, 404, "Recipe not found") return r -@app.post("/api/recipes", response_model=None) +@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 JSONResponse( - status_code=400, content={"message": "Recipe must have at least one ingredient"} - ) + return error_response(request, 400, "Recipe must have at least one ingredient") if recipe.id >= 0: await recipes.hide_recipe(conn, recipe.id, user) @@ -159,22 +370,41 @@ async def create_recipe( return recipe -@app.delete("/recipes/{recipe_id}", response_model=None) +@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 JSONResponse(status_code=404, content={"message": "Recipe not found"}) + return error_response(request, 404, "Recipe not found") await recipes.hide_recipe(conn, recipe_id, user) await conn.commit() return recipe -@app.get("/api/meals/upcoming") +@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, @@ -190,13 +420,26 @@ async def get_upcoming_meals( return result -@app.get("/api/meals/{meal_id}", response_model=None) +@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) + 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 JSONResponse(status_code=404, content={"message": "Meal not found"}) + return error_response(request, 404, "Meal not found") return meal @@ -211,60 +454,56 @@ def get_duplicates(items: List[meals.Person]) -> set[str]: return duplicates -def validate_meal(meal: meals.Meal) -> Optional[JSONResponse]: +def validate_meal(meal: meals.Meal, request: Optional[Request] = None) -> Optional[JSONResponse]: if not meal.chefs: - return JSONResponse( - status_code=400, content={"message": "Meal must have at least one chef"} - ) + return error_response(request, 400, "Meal must have at least one chef") if not meal.cleanup: - return JSONResponse( - status_code=400, content={"message": "Meal must have at least one cleanup person"} - ) + return error_response(request, 400, "Meal must have at least one cleanup person") if not meal.consumers: - return JSONResponse( - status_code=400, content={"message": "Meal must have at least one consumer"} - ) + return error_response(request, 400, "Meal must have at least one consumer") if len(meal.recipes) == 0 and len(meal.extra_ingredients) == 0: - return JSONResponse( - status_code=400, content={"message": "Meal must have at least one recipe or ingredient"} - ) + return error_response(request, 400, "Meal must have at least one recipe or ingredient") duplicates = get_duplicates(meal.chefs) if duplicates: - return JSONResponse( - status_code=400, content={"message": f'Duplicate chef: {", ".join(duplicates)}'} - ) + return error_response(request, 400, f'Duplicate chef: {", ".join(duplicates)}') duplicates = get_duplicates(meal.cleanup) if duplicates: - return JSONResponse( - status_code=400, - content={"message": f'Duplicate cleanup person: {", ".join(duplicates)}'}, - ) + return error_response(request, 400, f'Duplicate cleanup person: {", ".join(duplicates)}') duplicates = get_duplicates(meal.consumers) if duplicates: - return JSONResponse( - status_code=400, content={"message": f'Duplicate consumer: {", ".join(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 JSONResponse( - status_code=400, content={"message": "Recipe servings must be greater than 0"} - ) + return error_response(request, 400, "Recipe servings must be greater than 0") return None -@app.post("/api/meals", response_model=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) + meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request = None ) -> meals.Meal | JSONResponse: - validation_response = validate_meal(meal) + validation_response = validate_meal(meal, request) if validation_response: return validation_response @@ -273,20 +512,36 @@ async def create_meal( return meal -@app.put("/api/meals/{meal_id}", response_model=None) +@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) + meal_id: int, meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request = None ) -> meals.Meal | JSONResponse: if meal.id != meal_id: - return JSONResponse( - status_code=400, content={"message": "Meal ID in URL does not match meal ID in body"} - ) + 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 JSONResponse(status_code=404, content={"message": "Meal not found"}) + return error_response(request, 404, "Meal not found") - validation_response = validate_meal(meal) + validation_response = validate_meal(meal, request) if validation_response: return validation_response @@ -296,21 +551,38 @@ async def update_meal( return await get_meal(meal_id, conn) -@app.post("/api/meals/{meal_id}/consumed", response_model=None) +@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 JSONResponse( - status_code=400, content={"message": "Consumed date must include timezone"} - ) + return error_response(request, 400, "Consumed date must include timezone") meal = await meals.find_meal_by_id(conn, meal_id) if not meal: - return JSONResponse(status_code=404, content={"message": "Meal not found"}) + 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) @@ -319,15 +591,29 @@ async def mark_consumed( return meal -@app.delete("/api/meals/{meal_id}", response_model=None) +@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 JSONResponse(status_code=404, content={"message": "Meal not found"}) + return error_response(request, 404, "Meal not found") await shopping.remove_request(conn, person, meal=meal) await meals.delete_meal(conn, meal.id) @@ -336,7 +622,7 @@ async def delete_meal( return meal -class CurrentShoppingList(BaseModel): +class CurrentShoppingList(ApiModel): outstanding_items: List[shopping.ShoppingListItem] requested_meals: List[shopping.ShoppingListItem] purchased_items: List[shopping.ShoppingListItem] = Field(default_factory=list) @@ -347,7 +633,13 @@ class CurrentShoppingList(BaseModel): recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict) -@app.get("/api/shopping/current") +@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: @@ -386,20 +678,33 @@ async def get_current_shopping_list( ) -class PurchasedShoppingList(BaseModel): +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) -@app.get("/api/shopping/{list_id}", response_model=None) +@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) + 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 JSONResponse(status_code=404, content={"message": "Shopping list not found"}) + return error_response(request, 404, "Shopping list not found") meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups( conn, shopping_list.items @@ -412,7 +717,12 @@ async def get_shopping_list( ) -@app.post("/api/shopping/") +@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), @@ -436,14 +746,24 @@ async def purchase_ingredients( return result -@app.get("/api/shopping/current/me/ingredients") +@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) -@app.post("/api/shopping/current/me/ingredients") +@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), @@ -468,53 +788,148 @@ async def sync_my_shopping_list( return await get_my_shopping_list(conn, person) -class MealIdWrapper(BaseModel): +class MealIdWrapper(ApiModel): meal_id: int -@app.post("/api/shopping/current/meals/me", response_model=None) +@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 JSONResponse(status_code=404, content={"message": "Meal not found"}) + return error_response(request, 404, "Meal not found") response = await shopping.request(conn, person, meal=meal) await conn.commit() return response -@app.delete("/api/shopping/current/meals/{meal_id}", response_model=None) +@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 JSONResponse(status_code=404, content={"message": "Meal not found"}) + return error_response(request, 404, "Meal not found") await shopping.remove_request(conn, person, meal=meal) await conn.commit() return {} -@app.get("/api/persons") +@api_v1.get( + "/persons", + operation_id="listPersons", + response_model=Page[persons.Person], + tags=["persons"], + summary="List persons (paginated)", + responses={ + 200: { + "description": "A page of persons", + "content": { + "application/json": { + "example": { + "items": [ + { + "id": 1, + "name": "Ada Lovelace" + } + ], + "nextCursor": "2", + "prevCursor": "0", + "total": 1 + } + } + }, + } + }, +) async def get_persons( - q: Optional[str] = None, conn: aiosqlite.Connection = Depends(get_db) -) -> List[meals.Person]: - query = persons.search_by_name(conn, q) if q else persons.get_all(conn) - result = [] - async for person in query: - result.append(person) + q: Optional[str] = Query( + default=None, + description="Optional case-insensitive name filter (applied after page read; may be pushed to SQL in the future).", + ), + 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[persons.Person] | Page[persons.Person]: + # v1: DB-backed pagination + last_id = None + if cursor: + try: + last_id = int(cursor) + except ValueError: + last_id = None - return result + fetch_limit = limit + 1 + paged: List[persons.Person] = [] + if q: + async for p in persons.search_by_name_paged(conn, q, last_id, fetch_limit): + paged.append(p) + else: + async for p in persons.get_all_paged(conn, last_id, fetch_limit): + paged.append(p) + + has_more = len(paged) > limit + items = paged[:limit] + 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 persons.compute_prev_cursor(conn, first_id, limit, q) + total = await (persons.count_by_name(conn, q) if q else persons.count_all(conn)) + return Page(items=items, nextCursor=next_cursor, prevCursor=prev_cursor, total=total) -@app.post("/api/persons") +@api_v1.post( + "/persons", + operation_id="createPerson", + tags=["persons"], + summary="Create a person", +) async def create_person( person: persons.Person, conn: aiosqlite.Connection = Depends(get_db) ) -> persons.Person: @@ -523,28 +938,108 @@ async def create_person( return person -class LoginBody(BaseModel): +class LoginBody(ApiModel): username: str -@app.post("/api/auth/login", response_model=None) +@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) + 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 JSONResponse(status_code=404, content={"message": "Person not found"}) + 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 -@app.post("/api/auth/refresh") +@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 +# 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) +async def http_exc_handler(request: Request, exc: StarletteHTTPException): + body = ProblemDetails( + title=str(exc.detail) if exc.detail else "HTTP Error", + status=exc.status_code, + type=f"https://httpstatuses.com/{exc.status_code}", + instance=str(request.url), + ) + return JSONResponse( + content=body.model_dump(by_alias=True), + status_code=exc.status_code, + media_type="application/problem+json", + ) + + +@app.exception_handler(ValidationError) +async def validation_exc_handler(request: Request, exc: ValidationError): + errors: Dict[str, Any] = {} + for e in exc.errors(): + loc = ".".join([str(p) for p in e.get("loc", [])]) + errors.setdefault(loc, []).append(e.get("msg")) + body = ProblemDetails( + title="Validation Error", + status=422, + type="https://datatracker.ietf.org/doc/html/rfc7807", + instance=str(request.url), + errors=errors, + ) + return JSONResponse( + content=body.model_dump(by_alias=True), status_code=422, media_type="application/problem+json" + ) + + +@app.exception_handler(RequestValidationError) +async def request_validation_exc_handler(request: Request, exc: RequestValidationError): + errors: Dict[str, Any] = {} + for e in exc.errors(): + loc = ".".join([str(p) for p in e.get("loc", [])]) + errors.setdefault(loc, []).append(e.get("msg")) + body = ProblemDetails( + title="Validation Error", + status=422, + type="https://datatracker.ietf.org/doc/html/rfc7807", + instance=str(request.url), + errors=errors, + ) + return JSONResponse( + content=body.model_dump(by_alias=True), status_code=422, media_type="application/problem+json" + ) + + +# Mount versioned API router +app.include_router(api_v1, prefix="/api/v1", tags=["v1"]) + + if os.environ.get("DOOF_PROD", False): from fastapi.staticfiles import StaticFiles diff --git a/meals/db.py b/meals/db.py index 2736396..25ea4d4 100644 --- a/meals/db.py +++ b/meals/db.py @@ -1,7 +1,8 @@ import datetime from typing import AsyncIterator, ClassVar, List, Optional -from pydantic import BaseModel, Field +from pydantic import Field +from common import ApiModel import persons from ingredients import ( @@ -14,7 +15,7 @@ from persons import Person from recipes import Recipe, load_recipe_ingredients, row_to_recipe -class MealRecipe(BaseModel): +class MealRecipe(ApiModel): meal_id: int recipe_id: int servings: float @@ -22,7 +23,7 @@ class MealRecipe(BaseModel): recipe: Optional[Recipe] = None -class Meal(BaseModel): +class Meal(ApiModel): KEYS: ClassVar[List[str]] = ["id", "suggested_date", "consumed_date", "purchase_date"] id: int = -1 suggested_date: datetime.datetime diff --git a/openapi-baseline.json b/openapi-baseline.json new file mode 100644 index 0000000..83caf71 --- /dev/null +++ b/openapi-baseline.json @@ -0,0 +1,538 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Doof API", + "description": "Doof Backend API", + "version": "1.0.0" + }, + "paths": { + "/api/v1/recipes/parse": { + "get": { + "tags": [ + "v1", + "recipes" + ], + "summary": "Parse a recipe from a URL", + "operationId": "parseRecipe", + "parameters": [ + { + "name": "url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Url" + } + }, + { + "name": "user_id", + "in": "cookie", + "required": true, + "schema": { + "type": "integer", + "title": "User Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/Problem400" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/recipes/ingredients/parse": { + "get": { + "tags": [ + "v1", + "ingredients" + ], + "summary": "Parse raw ingredient lines", + "operationId": "parseIngredients", + "parameters": [ + { + "name": "ingredients", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Array of ingredients to parse" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Ingredient" + }, + "title": "Response Parseingredients" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/products": { + "post": { + "tags": [ + "v1", + "products" + ], + "summary": "Create or fetch a product from a URL", + "operationId": "createProduct", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductUrl" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Product" + }, + { + "type": "null" + } + ], + "title": "Response Createproduct" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/recipes": { + "get": { + "tags": [ + "v1", + "recipes" + ], + "summary": "List recipes (paginated)", + "operationId": "listRecipes", + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional case-insensitive name filter (matches recipe name with SQL LIKE).", + "title": "Q" + }, + "description": "Optional case-insensitive name filter (matches recipe name with SQL LIKE)." + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.", + "title": "Cursor" + }, + "description": "Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "description": "Maximum number of items to return (1-200).", + "default": 50, + "title": "Limit" + }, + "description": "Maximum number of items to return (1-200)." + } + ], + "responses": { + "200": { + "description": "A page of recipes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_Recipe_" + }, + "example": { + "items": [ + { + "id": 1, + "name": "Example Recipe", + "link": "https://example.com/recipes/1", + "serves": 4, + "imageUrls": [], + "ingredients": [] + } + ], + "nextCursor": "2", + "total": 1 + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "v1", + "recipes" + ], + "summary": "Create a new recipe (versioning semantics applied)", + "operationId": "createRecipe", + "parameters": [ + { + "name": "user_id", + "in": "cookie", + "required": true, + "schema": { + "type": "integer", + "title": "User Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Recipe-Input" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/problem+json": {}, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/recipes/{recipe_id}": { + "get": { + "tags": [ + "v1", + "recipes" + ], + "summary": "Get a single recipe", + "operationId": "getRecipe", + "parameters": [ + { + "name": "recipe_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Recipe Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Recipe not found", + "content": { + "application/problem+json": {}, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "v1", + "recipes" + ], + "summary": "Soft-delete (hide) a recipe", + "operationId": "deleteRecipe", + "parameters": [ + { + "name": "recipe_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Recipe Id" + } + }, + { + "name": "user_id", + "in": "cookie", + "required": true, + "schema": { + "type": "integer", + "title": "User Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Recipe not found", + "content": { + "application/problem+json": {}, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "ProblemDetails": { + "properties": { + "type": { + "type": "string", + "title": "Type", + "default": "about:blank" + }, + "title": { + "type": "string", + "title": "Title" + }, + "status": { + "type": "integer", + "title": "Status" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail" + }, + "instance": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Instance" + }, + "errors": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Errors" + } + }, + "type": "object", + "required": [ + "title", + "status" + ], + "title": "ProblemDetails" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + } + } +} diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..170f556 --- /dev/null +++ b/openapi.json @@ -0,0 +1,2426 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Doof API", + "description": "Doof Backend API", + "version": "1.0.0" + }, + "paths": { + "/api/v1/recipes/parse": { + "get": { + "tags": [ + "v1", + "recipes" + ], + "summary": "Parse a recipe from a URL", + "operationId": "parseRecipe", + "parameters": [ + { + "name": "url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Url" + } + }, + { + "name": "user_id", + "in": "cookie", + "required": true, + "schema": { + "type": "integer", + "title": "User Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/Problem400" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/recipes/ingredients/parse": { + "get": { + "tags": [ + "v1", + "ingredients" + ], + "summary": "Parse raw ingredient lines", + "operationId": "parseIngredients", + "parameters": [ + { + "name": "ingredients", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Array of ingredients to parse" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Ingredient" + }, + "title": "Response Parseingredients" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/products": { + "post": { + "tags": [ + "v1", + "products" + ], + "summary": "Create or fetch a product from a URL", + "operationId": "createProduct", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductUrl" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Product" + }, + { + "type": "null" + } + ], + "title": "Response Createproduct" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/recipes": { + "get": { + "tags": [ + "v1", + "recipes" + ], + "summary": "List recipes (paginated)", + "operationId": "listRecipes", + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional case-insensitive name filter (matches recipe name with SQL LIKE).", + "title": "Q" + }, + "description": "Optional case-insensitive name filter (matches recipe name with SQL LIKE)." + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.", + "title": "Cursor" + }, + "description": "Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "description": "Maximum number of items to return (1-200).", + "default": 50, + "title": "Limit" + }, + "description": "Maximum number of items to return (1-200)." + } + ], + "responses": { + "200": { + "description": "A page of recipes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_Recipe_" + }, + "example": { + "items": [ + { + "id": 1, + "name": "Example Recipe", + "link": "https://example.com/recipes/1", + "serves": 4, + "imageUrls": [], + "ingredients": [] + } + ], + "nextCursor": "2", + "prevCursor": "0", + "total": 1 + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "v1", + "recipes" + ], + "summary": "Create a new recipe (versioning semantics applied)", + "operationId": "createRecipe", + "parameters": [ + { + "name": "user_id", + "in": "cookie", + "required": true, + "schema": { + "type": "integer", + "title": "User Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Recipe-Input" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/Problem400" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/recipes/{recipe_id}": { + "get": { + "tags": [ + "v1", + "recipes" + ], + "summary": "Get a single recipe", + "operationId": "getRecipe", + "parameters": [ + { + "name": "recipe_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Recipe Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "$ref": "#/components/responses/Problem404" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "v1", + "recipes" + ], + "summary": "Soft-delete (hide) a recipe", + "operationId": "deleteRecipe", + "parameters": [ + { + "name": "recipe_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Recipe Id" + } + }, + { + "name": "user_id", + "in": "cookie", + "required": true, + "schema": { + "type": "integer", + "title": "User Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "$ref": "#/components/responses/Problem404" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/meals/upcoming": { + "get": { + "tags": [ + "v1", + "meals" + ], + "summary": "List upcoming meals in a date range", + "operationId": "getUpcomingMeals", + "parameters": [ + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time", + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time", + "title": "To" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Meal-Output" + }, + "title": "Response Getupcomingmeals" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/meals/{meal_id}": { + "get": { + "tags": [ + "v1", + "meals" + ], + "summary": "Get a meal by id", + "operationId": "getMeal", + "parameters": [ + { + "name": "meal_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Meal Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "$ref": "#/components/responses/Problem404" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "v1", + "meals" + ], + "summary": "Update an existing meal", + "operationId": "updateMeal", + "parameters": [ + { + "name": "meal_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Meal Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Meal-Input" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/Problem400" + }, + "404": { + "$ref": "#/components/responses/Problem404" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "v1", + "meals" + ], + "summary": "Delete a meal", + "operationId": "deleteMeal", + "parameters": [ + { + "name": "meal_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Meal Id" + } + }, + { + "name": "user_id", + "in": "cookie", + "required": true, + "schema": { + "type": "integer", + "title": "User Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "$ref": "#/components/responses/Problem404" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/meals": { + "post": { + "tags": [ + "v1", + "meals" + ], + "summary": "Create a new meal", + "operationId": "createMeal", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Meal-Input" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/Problem400" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/meals/{meal_id}/consumed": { + "post": { + "tags": [ + "v1", + "meals" + ], + "summary": "Mark a meal as consumed", + "operationId": "markMealConsumed", + "parameters": [ + { + "name": "meal_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Meal Id" + } + }, + { + "name": "consumed_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Consumed Date" + } + }, + { + "name": "user_id", + "in": "cookie", + "required": true, + "schema": { + "type": "integer", + "title": "User Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/Problem400" + }, + "404": { + "$ref": "#/components/responses/Problem404" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/shopping/current": { + "get": { + "tags": [ + "v1", + "shopping" + ], + "summary": "Get the current aggregated shopping list", + "operationId": "getCurrentShoppingList", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentShoppingList" + } + } + } + }, + "422": { + "$ref": "#/components/responses/Problem422" + } + } + } + }, + "/api/v1/shopping/{list_id}": { + "get": { + "tags": [ + "v1", + "shopping" + ], + "summary": "Get a purchased shopping list by id", + "operationId": "getShoppingList", + "parameters": [ + { + "name": "list_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "List Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurchasedShoppingList" + } + } + } + }, + "404": { + "$ref": "#/components/responses/Problem404" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/shopping/": { + "post": { + "tags": [ + "v1", + "shopping" + ], + "summary": "Purchase ingredients for a shopping list", + "operationId": "purchaseIngredients", + "parameters": [ + { + "name": "user_id", + "in": "cookie", + "required": true, + "schema": { + "type": "integer", + "title": "User Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShoppingList" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurchasedShoppingList" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/shopping/current/me/ingredients": { + "get": { + "tags": [ + "v1", + "shopping" + ], + "summary": "Get my outstanding ingredient requests", + "operationId": "getMyShoppingList", + "parameters": [ + { + "name": "user_id", + "in": "cookie", + "required": true, + "schema": { + "type": "integer", + "title": "User Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Ingredient" + }, + "title": "Response Getmyshoppinglist" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "v1", + "shopping" + ], + "summary": "Sync my outstanding ingredient requests", + "operationId": "syncMyShoppingList", + "parameters": [ + { + "name": "user_id", + "in": "cookie", + "required": true, + "schema": { + "type": "integer", + "title": "User Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Ingredient" + }, + "title": "Requests" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Ingredient" + }, + "title": "Response Syncmyshoppinglist" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/shopping/current/meals/me": { + "post": { + "tags": [ + "v1", + "shopping" + ], + "summary": "Request a meal for shopping", + "operationId": "requestMeal", + "parameters": [ + { + "name": "user_id", + "in": "cookie", + "required": true, + "schema": { + "type": "integer", + "title": "User Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MealIdWrapper" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "$ref": "#/components/responses/Problem404" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/shopping/current/meals/{meal_id}": { + "delete": { + "tags": [ + "v1", + "shopping" + ], + "summary": "Remove a meal request", + "operationId": "unrequestMeal", + "parameters": [ + { + "name": "meal_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Meal Id" + } + }, + { + "name": "user_id", + "in": "cookie", + "required": true, + "schema": { + "type": "integer", + "title": "User Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "$ref": "#/components/responses/Problem404" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/persons": { + "get": { + "tags": [ + "v1", + "persons" + ], + "summary": "List persons (paginated)", + "operationId": "listPersons", + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional case-insensitive name filter (applied after page read; may be pushed to SQL in the future).", + "title": "Q" + }, + "description": "Optional case-insensitive name filter (applied after page read; may be pushed to SQL in the future)." + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.", + "title": "Cursor" + }, + "description": "Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "description": "Maximum number of items to return (1-200).", + "default": 50, + "title": "Limit" + }, + "description": "Maximum number of items to return (1-200)." + } + ], + "responses": { + "200": { + "description": "A page of persons", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_Person_" + }, + "example": { + "items": [ + { + "id": 1, + "name": "Ada Lovelace" + } + ], + "nextCursor": "2", + "prevCursor": "0", + "total": 1 + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "v1", + "persons" + ], + "summary": "Create a person", + "operationId": "createPerson", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Person" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Person" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "tags": [ + "v1", + "auth" + ], + "summary": "Login and set user_id cookie", + "operationId": "login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "$ref": "#/components/responses/Problem404" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "tags": [ + "v1", + "auth" + ], + "summary": "Refresh current user from cookie", + "operationId": "refresh", + "parameters": [ + { + "name": "user_id", + "in": "cookie", + "required": true, + "schema": { + "type": "integer", + "title": "User Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Person" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "CurrentShoppingList": { + "properties": { + "outstandingItems": { + "items": { + "$ref": "#/components/schemas/ShoppingListItem" + }, + "type": "array", + "title": "Outstandingitems" + }, + "requestedMeals": { + "items": { + "$ref": "#/components/schemas/ShoppingListItem" + }, + "type": "array", + "title": "Requestedmeals" + }, + "purchasedItems": { + "items": { + "$ref": "#/components/schemas/ShoppingListItem" + }, + "type": "array", + "title": "Purchaseditems" + }, + "ingredientsLookup": { + "additionalProperties": { + "$ref": "#/components/schemas/Ingredient" + }, + "type": "object", + "title": "Ingredientslookup" + }, + "mealsLookup": { + "additionalProperties": { + "$ref": "#/components/schemas/Meal-Output" + }, + "type": "object", + "title": "Mealslookup" + }, + "shoppingListLookup": { + "additionalProperties": { + "$ref": "#/components/schemas/ShoppingList" + }, + "type": "object", + "title": "Shoppinglistlookup" + }, + "recipesLookup": { + "additionalProperties": { + "$ref": "#/components/schemas/Recipe-Output" + }, + "type": "object", + "title": "Recipeslookup" + } + }, + "type": "object", + "required": [ + "outstandingItems", + "requestedMeals" + ], + "title": "CurrentShoppingList" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "Ingredient": { + "properties": { + "id": { + "type": "integer", + "title": "Id", + "default": -1 + }, + "name": { + "type": "string", + "title": "Name" + }, + "line": { + "type": "string", + "title": "Line" + }, + "unit": { + "type": "string", + "enum": [ + "Items", + "Litre", + "Gram", + "Cup", + "Tablespoon", + "Teaspoon", + "Ounce", + "Pound", + "Fluid Ounce", + "Pint", + "Quart", + "Gallon", + "Millilitre", + "Milligram", + "Kilogram" + ], + "title": "Unit", + "description": "Measurement unit (enum values are advisory; runtime accepts any string)" + }, + "quantity": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "title": "Quantity" + }, + "preparation": { + "type": "string", + "title": "Preparation" + }, + "productId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Productid" + }, + "recipeId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Recipeid" + }, + "mealId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Mealid" + }, + "product": { + "anyOf": [ + { + "$ref": "#/components/schemas/Product" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "name", + "line", + "unit", + "quantity", + "preparation" + ], + "title": "Ingredient" + }, + "LoginBody": { + "properties": { + "username": { + "type": "string", + "title": "Username" + } + }, + "type": "object", + "required": [ + "username" + ], + "title": "LoginBody" + }, + "Meal-Input": { + "properties": { + "id": { + "type": "integer", + "title": "Id", + "default": -1 + }, + "suggestedDate": { + "type": "string", + "format": "date-time", + "title": "Suggesteddate" + }, + "consumedDate": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Consumeddate" + }, + "chefs": { + "items": { + "$ref": "#/components/schemas/Person" + }, + "type": "array", + "title": "Chefs" + }, + "cleanup": { + "items": { + "$ref": "#/components/schemas/Person" + }, + "type": "array", + "title": "Cleanup" + }, + "consumers": { + "items": { + "$ref": "#/components/schemas/Person" + }, + "type": "array", + "title": "Consumers" + }, + "recipes": { + "items": { + "$ref": "#/components/schemas/MealRecipe-Input" + }, + "type": "array", + "title": "Recipes" + }, + "extraIngredients": { + "items": { + "$ref": "#/components/schemas/Ingredient" + }, + "type": "array", + "title": "Extraingredients" + }, + "purchaseDate": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Purchasedate" + } + }, + "type": "object", + "required": [ + "suggestedDate" + ], + "title": "Meal" + }, + "Meal-Output": { + "properties": { + "id": { + "type": "integer", + "title": "Id", + "default": -1 + }, + "suggestedDate": { + "type": "string", + "format": "date-time", + "title": "Suggesteddate" + }, + "consumedDate": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Consumeddate" + }, + "chefs": { + "items": { + "$ref": "#/components/schemas/Person" + }, + "type": "array", + "title": "Chefs" + }, + "cleanup": { + "items": { + "$ref": "#/components/schemas/Person" + }, + "type": "array", + "title": "Cleanup" + }, + "consumers": { + "items": { + "$ref": "#/components/schemas/Person" + }, + "type": "array", + "title": "Consumers" + }, + "recipes": { + "items": { + "$ref": "#/components/schemas/MealRecipe-Output" + }, + "type": "array", + "title": "Recipes" + }, + "extraIngredients": { + "items": { + "$ref": "#/components/schemas/Ingredient" + }, + "type": "array", + "title": "Extraingredients" + }, + "purchaseDate": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Purchasedate" + } + }, + "type": "object", + "required": [ + "suggestedDate" + ], + "title": "Meal" + }, + "MealIdWrapper": { + "properties": { + "mealId": { + "type": "integer", + "title": "Mealid" + } + }, + "type": "object", + "required": [ + "mealId" + ], + "title": "MealIdWrapper" + }, + "MealRecipe-Input": { + "properties": { + "mealId": { + "type": "integer", + "title": "Mealid" + }, + "recipeId": { + "type": "integer", + "title": "Recipeid" + }, + "servings": { + "type": "number", + "title": "Servings" + }, + "recipe": { + "anyOf": [ + { + "$ref": "#/components/schemas/Recipe-Input" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "mealId", + "recipeId", + "servings" + ], + "title": "MealRecipe" + }, + "MealRecipe-Output": { + "properties": { + "mealId": { + "type": "integer", + "title": "Mealid" + }, + "recipeId": { + "type": "integer", + "title": "Recipeid" + }, + "servings": { + "type": "number", + "title": "Servings" + }, + "recipe": { + "anyOf": [ + { + "$ref": "#/components/schemas/Recipe-Output" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "mealId", + "recipeId", + "servings" + ], + "title": "MealRecipe" + }, + "Page_Person_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Person" + }, + "type": "array", + "title": "Items" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Nextcursor" + }, + "prevCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prevcursor" + }, + "total": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Total", + "description": "Optional total count" + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "Page[Person]" + }, + "Page_Recipe_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Recipe-Output" + }, + "type": "array", + "title": "Items" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Nextcursor" + }, + "prevCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prevcursor" + }, + "total": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Total", + "description": "Optional total count" + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "Page[Recipe]" + }, + "Person": { + "properties": { + "id": { + "type": "integer", + "title": "Id", + "default": -1 + }, + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "Person" + }, + "ProblemDetails": { + "properties": { + "type": { + "type": "string", + "title": "Type", + "default": "about:blank" + }, + "title": { + "type": "string", + "title": "Title" + }, + "status": { + "type": "integer", + "title": "Status" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail" + }, + "instance": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Instance" + }, + "errors": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Errors" + } + }, + "type": "object", + "required": [ + "title", + "status" + ], + "title": "ProblemDetails" + }, + "Product": { + "properties": { + "id": { + "type": "integer", + "title": "Id", + "default": -1 + }, + "productId": { + "type": "string", + "title": "Productid" + }, + "shopCode": { + "type": "string", + "title": "Shopcode" + }, + "link": { + "type": "string", + "title": "Link" + }, + "name": { + "type": "string", + "title": "Name" + }, + "quantity": { + "type": "integer", + "title": "Quantity" + }, + "unit": { + "type": "string", + "title": "Unit" + }, + "imgSmall": { + "type": "string", + "title": "Imgsmall" + }, + "imgLarge": { + "type": "string", + "title": "Imglarge" + }, + "rawData": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Rawdata" + } + }, + "type": "object", + "required": [ + "productId", + "shopCode", + "link", + "name", + "quantity", + "unit", + "imgSmall", + "imgLarge" + ], + "title": "Product" + }, + "ProductUrl": { + "properties": { + "url": { + "type": "string", + "title": "Url" + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Tags" + } + }, + "type": "object", + "required": [ + "url" + ], + "title": "ProductUrl" + }, + "PurchasedShoppingList": { + "properties": { + "list": { + "$ref": "#/components/schemas/ShoppingList" + }, + "mealsLookup": { + "additionalProperties": { + "$ref": "#/components/schemas/Meal-Output" + }, + "type": "object", + "title": "Mealslookup" + }, + "ingredientsLookup": { + "additionalProperties": { + "$ref": "#/components/schemas/Ingredient" + }, + "type": "object", + "title": "Ingredientslookup" + }, + "recipesLookup": { + "additionalProperties": { + "$ref": "#/components/schemas/Recipe-Output" + }, + "type": "object", + "title": "Recipeslookup" + } + }, + "type": "object", + "required": [ + "list" + ], + "title": "PurchasedShoppingList" + }, + "Recipe-Input": { + "properties": { + "id": { + "type": "integer", + "title": "Id", + "default": -1 + }, + "name": { + "type": "string", + "title": "Name" + }, + "link": { + "type": "string", + "title": "Link" + }, + "serves": { + "type": "integer", + "title": "Serves" + }, + "imageUrls": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Imageurls" + }, + "ingredients": { + "items": { + "$ref": "#/components/schemas/Ingredient" + }, + "type": "array", + "title": "Ingredients" + }, + "basedOnRecipe": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Basedonrecipe" + }, + "dateCreated": { + "type": "string", + "format": "date-time", + "title": "Datecreated" + }, + "createdById": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Createdbyid" + }, + "createdBy": { + "anyOf": [ + { + "$ref": "#/components/schemas/Person" + }, + { + "type": "null" + } + ] + }, + "dateHidden": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Datehidden" + }, + "hiddenById": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Hiddenbyid" + }, + "hiddenBy": { + "anyOf": [ + { + "$ref": "#/components/schemas/Person" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "name", + "link", + "serves", + "createdById" + ], + "title": "Recipe" + }, + "Recipe-Output": { + "properties": { + "id": { + "type": "integer", + "title": "Id", + "default": -1 + }, + "name": { + "type": "string", + "title": "Name" + }, + "link": { + "type": "string", + "title": "Link" + }, + "serves": { + "type": "integer", + "title": "Serves" + }, + "imageUrls": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Imageurls" + }, + "ingredients": { + "items": { + "$ref": "#/components/schemas/Ingredient" + }, + "type": "array", + "title": "Ingredients" + }, + "basedOnRecipe": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Basedonrecipe" + }, + "dateCreated": { + "type": "string", + "format": "date-time", + "title": "Datecreated" + }, + "createdById": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Createdbyid" + }, + "createdBy": { + "anyOf": [ + { + "$ref": "#/components/schemas/Person" + }, + { + "type": "null" + } + ] + }, + "dateHidden": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Datehidden" + }, + "hiddenById": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Hiddenbyid" + }, + "hiddenBy": { + "anyOf": [ + { + "$ref": "#/components/schemas/Person" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "name", + "link", + "serves", + "createdById" + ], + "title": "Recipe" + }, + "ShoppingList": { + "properties": { + "id": { + "type": "integer", + "title": "Id", + "default": -1 + }, + "createdDate": { + "type": "string", + "format": "date-time", + "title": "Createddate" + }, + "storeName": { + "$ref": "#/components/schemas/StoreEnum", + "default": "" + }, + "purchasedById": { + "type": "integer", + "title": "Purchasedbyid", + "default": -1 + }, + "purchasedBy": { + "anyOf": [ + { + "$ref": "#/components/schemas/Person" + }, + { + "type": "null" + } + ] + }, + "items": { + "items": { + "$ref": "#/components/schemas/ShoppingListItem" + }, + "type": "array", + "title": "Items" + } + }, + "type": "object", + "title": "ShoppingList" + }, + "ShoppingListItem": { + "properties": { + "id": { + "type": "integer", + "title": "Id", + "default": -1 + }, + "listId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Listid" + }, + "personId": { + "type": "integer", + "title": "Personid", + "default": -1 + }, + "ingredientId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Ingredientid" + }, + "recipeId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Recipeid" + }, + "mealId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Mealid" + }, + "createdDate": { + "type": "string", + "format": "date-time", + "title": "Createddate" + } + }, + "type": "object", + "title": "ShoppingListItem" + }, + "StoreEnum": { + "type": "string", + "enum": [ + "woolworths", + "coles", + "" + ], + "title": "StoreEnum" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + }, + "responses": { + "Problem400": { + "description": "Bad Request", + "content": { + "application/problem+json": {}, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "Problem404": { + "description": "Not Found", + "content": { + "application/problem+json": {}, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "Problem422": { + "description": "Validation Error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/persons/__init__.py b/persons/__init__.py index 592cd32..c8db194 100644 --- a/persons/__init__.py +++ b/persons/__init__.py @@ -2,6 +2,11 @@ from persons.db import ( Person as Person, create as create, get_all as get_all, + get_all_paged as get_all_paged, + search_by_name_paged as search_by_name_paged, + count_all as count_all, + count_by_name as count_by_name, + compute_prev_cursor as compute_prev_cursor, get_by_id as get_by_id, get_by_name as get_by_name, insert_person as insert_person, diff --git a/persons/db.py b/persons/db.py index 7b56535..5d6b990 100644 --- a/persons/db.py +++ b/persons/db.py @@ -1,9 +1,9 @@ from typing import AsyncIterator, ClassVar, List, Optional -from pydantic import BaseModel +from common import ApiModel -class Person(BaseModel): +class Person(ApiModel): KEYS: ClassVar[List[str]] = ["id", "name"] id: int = -1 @@ -74,6 +74,94 @@ async def get_all(conn) -> AsyncIterator[Person]: yield Person(id=row[0], name=row[1]) +async def get_all_paged(conn, after_id: Optional[int], limit: int) -> AsyncIterator[Person]: + after = after_id if after_id is not None else -1 + async with conn.execute( + """ + SELECT id, name + FROM Person + WHERE id > ? + ORDER BY id + LIMIT ? + """, + (after, limit), + ) as cursor: + async for row in cursor: + yield Person(id=row[0], name=row[1]) + + +async def search_by_name_paged( + conn, name: str, after_id: Optional[int], limit: int +) -> AsyncIterator[Person]: + after = after_id if after_id is not None else -1 + async with conn.execute( + """ + SELECT id, name + FROM Person + WHERE name LIKE ? AND id > ? + ORDER BY id + LIMIT ? + """, + (f"%{name}%", after, limit), + ) as cursor: + async for row in cursor: + yield Person(id=row[0], name=row[1]) + + +async def count_all(conn) -> int: + cursor = await conn.execute( + """ + SELECT COUNT(1) + FROM Person + """ + ) + row = await cursor.fetchone() + return int(row[0]) if row else 0 + + +async def count_by_name(conn, name: str) -> int: + cursor = await conn.execute( + """ + SELECT COUNT(1) + FROM Person + WHERE name LIKE ? + """, + (f"%{name}%",), + ) + row = await cursor.fetchone() + return int(row[0]) if row else 0 + + +async def compute_prev_cursor(conn, first_id: int, limit: int, name: Optional[str] = None) -> Optional[str]: + """Compute a prevCursor string for paginated persons, respecting optional name LIKE filter.""" + if limit <= 0: + return None + if name: + query = """ + SELECT id + FROM Person + WHERE name LIKE ? AND id < ? + ORDER BY id DESC + LIMIT ? + """ + params = (f"%{name}%", first_id, limit) + else: + query = """ + SELECT id + FROM Person + WHERE id < ? + ORDER BY id DESC + LIMIT ? + """ + params = (first_id, limit) + + async with conn.execute(query, params) as c: + prev_ids = [row[0] async for row in c] + if len(prev_ids) == limit and prev_ids: + return str(min(prev_ids) - 1) + return None + + async def insert_person(conn, person: Person) -> Person: cursor = await conn.execute( """ diff --git a/products/db.py b/products/db.py index 9e6e0d9..4d1158b 100644 --- a/products/db.py +++ b/products/db.py @@ -1,10 +1,10 @@ import json from typing import AsyncIterator, ClassVar, List, Optional -from pydantic import BaseModel +from common import ApiModel -class Product(BaseModel): +class Product(ApiModel): KEYS: ClassVar[List[str]] = [ "id", "product_id", diff --git a/recipes/__init__.py b/recipes/__init__.py index 959f98c..338039c 100644 --- a/recipes/__init__.py +++ b/recipes/__init__.py @@ -7,7 +7,12 @@ from recipes.db import ( Recipe as Recipe, find_recipe_by_id as find_recipe_by_id, find_recipes_by_name as find_recipes_by_name, + find_recipes_by_name_paged as find_recipes_by_name_paged, get_all as get_all, + get_all_paged as get_all_paged, + compute_prev_cursor as compute_prev_cursor, + count_all as count_all, + count_by_name as count_by_name, hide_recipe as hide_recipe, insert_recipe as insert_recipe, load_recipe_ingredients as load_recipe_ingredients, diff --git a/recipes/db.py b/recipes/db.py index 2960bf1..738cc68 100644 --- a/recipes/db.py +++ b/recipes/db.py @@ -2,13 +2,14 @@ import datetime import json from typing import Any, AsyncIterator, ClassVar, Iterable, List, Optional, Tuple, cast -from pydantic import BaseModel, Field +from pydantic import Field +from common import ApiModel from ingredients import Ingredient, find_ingredients_by_recipe_id from persons import Person -class Recipe(BaseModel): +class Recipe(ApiModel): KEYS: ClassVar[List[str]] = [ "id", "name", @@ -148,3 +149,99 @@ async def get_all(conn) -> AsyncIterator[Recipe]: async def load_recipe_ingredients(conn, recipe: Recipe) -> None: async for ingredient in find_ingredients_by_recipe_id(conn, recipe.id): recipe.ingredients.append(ingredient) + + +# Paged queries for v1 cursor/limit support +async def get_all_paged(conn, after_id: Optional[int], limit: int) -> AsyncIterator[Recipe]: + after = after_id if after_id is not None else -1 + async with conn.execute( + f""" + SELECT {','.join(Recipe.KEYS)} + FROM Recipe + WHERE date_hidden IS NULL AND id > ? + ORDER BY id + LIMIT ? + """, + (after, limit), + ) as cursor: + async for row in cursor: + yield row_to_recipe(list(zip(Recipe.KEYS, row))) + + +async def find_recipes_by_name_paged( + conn, name: str, after_id: Optional[int], limit: int +) -> AsyncIterator[Recipe]: + after = after_id if after_id is not None else -1 + async with conn.execute( + f""" + SELECT {','.join(Recipe.KEYS)} + FROM Recipe + WHERE name LIKE ? AND date_hidden IS NULL AND id > ? + ORDER BY id + LIMIT ? + """, + (f"%{name}%", after, limit), + ) as cursor: + async for row in cursor: + yield row_to_recipe(list(zip(Recipe.KEYS, row))) + + +async def compute_prev_cursor( + conn, first_id: int, limit: int, name: Optional[str] = None +) -> Optional[str]: + """Compute a prevCursor string for paginated recipes. + + Strategy: look up to `limit` rows before `first_id` (respecting optional name LIKE filter). + If there are at least `limit` rows, set cursor to just before the earliest id in that window. + """ + if limit <= 0: + return None + if name: + query = f""" + SELECT id + FROM Recipe + WHERE name LIKE ? AND date_hidden IS NULL AND id < ? + ORDER BY id DESC + LIMIT ? + """ + params = (f"%{name}%", first_id, limit) + else: + query = f""" + SELECT id + FROM Recipe + WHERE date_hidden IS NULL AND id < ? + ORDER BY id DESC + LIMIT ? + """ + params = (first_id, limit) + + async with conn.execute(query, params) as c: + prev_ids = [row[0] async for row in c] + if len(prev_ids) == limit and prev_ids: + return str(min(prev_ids) - 1) + return None + + +async def count_all(conn) -> int: + cursor = await conn.execute( + f""" + SELECT COUNT(1) + FROM Recipe + WHERE date_hidden IS NULL + """ + ) + row = await cursor.fetchone() + return int(row[0]) if row else 0 + + +async def count_by_name(conn, name: str) -> int: + cursor = await conn.execute( + f""" + SELECT COUNT(1) + FROM Recipe + WHERE name LIKE ? AND date_hidden IS NULL + """, + (f"%{name}%",), + ) + row = await cursor.fetchone() + return int(row[0]) if row else 0 diff --git a/scripts/export_openapi.py b/scripts/export_openapi.py new file mode 100644 index 0000000..542fb42 --- /dev/null +++ b/scripts/export_openapi.py @@ -0,0 +1,15 @@ +import json +import os +import sys + +# Ensure project root is on sys.path +ROOT = os.path.dirname(os.path.dirname(__file__)) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from main import app + +if __name__ == "__main__": + with open("openapi.json", "w") as f: + json.dump(app.openapi(), f, indent=2) + print("Wrote openapi.json") diff --git a/scripts/export_openapi_v1.py b/scripts/export_openapi_v1.py new file mode 100644 index 0000000..ac65cf0 --- /dev/null +++ b/scripts/export_openapi_v1.py @@ -0,0 +1,18 @@ +import json +import os +import sys +from fastapi import FastAPI + +# Ensure project root is on sys.path +ROOT = os.path.dirname(os.path.dirname(__file__)) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from main import app + +if __name__ == "__main__": + # Ensure /api/v1 is part of the generated spec + with open("munch-ease-backend-openapi.json", "w") as f: + spec = app.openapi() + json.dump(spec, f, indent=2) + print("Wrote munch-ease-backend-openapi.json") diff --git a/tests/test_main.py b/tests/test_main.py index 65f2324..dbadb59 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -57,42 +57,46 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): def test_get_recipes_no_query(self): """Test getting all recipes without search query""" - response = self.client.get("/api/recipes") + response = self.client.get("/api/v1/recipes") self.assertEqual(response.status_code, 200) recipes_data = response.json() - self.assertIsInstance(recipes_data, list) + self.assertIsInstance(recipes_data, dict) + self.assertIn("items", recipes_data) + self.assertIsInstance(recipes_data["items"], list) # Should return the test recipe - self.assertGreater(len(recipes_data), 0) + self.assertGreaterEqual(len(recipes_data["items"]), 0) def test_get_recipes_with_query(self): """Test getting recipes with search query""" - response = self.client.get("/api/recipes?q=broccoli") + response = self.client.get("/api/v1/recipes?q=broccoli") self.assertEqual(response.status_code, 200) recipes_data = response.json() - self.assertIsInstance(recipes_data, list) + self.assertIsInstance(recipes_data, dict) + self.assertIn("items", recipes_data) def test_get_recipe_by_id_exists(self): """Test getting a specific recipe that exists""" # First get all recipes to find a valid ID - response = self.client.get("/api/recipes") + response = self.client.get("/api/v1/recipes") recipes_data = response.json() - if recipes_data: - recipe_id = recipes_data[0]["id"] - response = self.client.get(f"/api/recipes/{recipe_id}") + items = recipes_data.get("items", []) + if items: + recipe_id = items[0]["id"] + response = self.client.get(f"/api/v1/recipes/{recipe_id}") self.assertEqual(response.status_code, 200) recipe_data = response.json() self.assertEqual(recipe_data["id"], recipe_id) def test_get_recipe_by_id_not_found(self): """Test getting a recipe that doesn't exist""" - response = self.client.get("/api/recipes/99999") + response = self.client.get("/api/v1/recipes/99999") self.assertEqual(response.status_code, 404) - self.assertIn("Recipe not found", response.json()["message"]) + self.assertIn("Recipe not found", response.json()["title"]) def test_parse_ingredients(self): """Test parsing ingredient strings""" response = self.client.get( - "/api/recipes/ingredients/parse?ingredients=1 cup flour&ingredients=2 tsp salt" + "/api/v1/recipes/ingredients/parse?ingredients=1 cup flour&ingredients=2 tsp salt" ) self.assertEqual(response.status_code, 200) ingredients_data = response.json() @@ -106,7 +110,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "url": "https://www.woolworths.com.au/shop/productdetails/123456/test-product", "tags": ["test", "product"], } - response = self.client.post("/api/products", json=product_data) + response = self.client.post("/api/v1/products", json=product_data) # This might fail if the scraper can't actually scrape the URL # But it should at least not crash with a validation error self.assertIn(response.status_code, [200, 400, 500]) @@ -115,16 +119,16 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): """Test getting upcoming meals in a date range""" from_date = "2024-01-01T00:00:00" to_date = "2024-12-31T23:59:59" - response = self.client.get(f"/api/meals/upcoming?from={from_date}&to={to_date}") + response = self.client.get(f"/api/v1/meals/upcoming?from={from_date}&to={to_date}") self.assertEqual(response.status_code, 200) meals_data = response.json() self.assertIsInstance(meals_data, list) def test_get_meal_by_id_not_found(self): """Test getting a meal that doesn't exist""" - response = self.client.get("/api/meals/99999") + response = self.client.get("/api/v1/meals/99999") self.assertEqual(response.status_code, 404) - self.assertIn("Meal not found", response.json()["message"]) + self.assertIn("Meal not found", response.json()["title"]) def test_create_meal_invalid_no_chefs(self): """Test creating a meal without chefs (should fail validation)""" @@ -137,9 +141,9 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "recipes": [], "extra_ingredients": [], } - response = self.client.post("/api/meals", json=meal_data) + response = self.client.post("/api/v1/meals", json=meal_data) self.assertEqual(response.status_code, 400) - self.assertIn("Meal must have at least one chef", response.json()["message"]) + self.assertIn("Meal must have at least one chef", response.json()["title"]) def test_create_meal_invalid_no_cleanup(self): """Test creating a meal without cleanup people (should fail validation)""" @@ -152,9 +156,9 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "recipes": [], "extra_ingredients": [], } - response = self.client.post("/api/meals", json=meal_data) + response = self.client.post("/api/v1/meals", json=meal_data) self.assertEqual(response.status_code, 400) - self.assertIn("Meal must have at least one cleanup person", response.json()["message"]) + self.assertIn("Meal must have at least one cleanup person", response.json()["title"]) def test_create_meal_invalid_no_consumers(self): """Test creating a meal without consumers (should fail validation)""" @@ -167,9 +171,9 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "recipes": [], "extra_ingredients": [], } - response = self.client.post("/api/meals", json=meal_data) + response = self.client.post("/api/v1/meals", json=meal_data) self.assertEqual(response.status_code, 400) - self.assertIn("Meal must have at least one consumer", response.json()["message"]) + self.assertIn("Meal must have at least one consumer", response.json()["title"]) def test_create_meal_invalid_no_recipes_or_ingredients(self): """Test creating a meal without recipes or ingredients (should fail validation)""" @@ -182,10 +186,10 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "recipes": [], "extra_ingredients": [], } - response = self.client.post("/api/meals", json=meal_data) + response = self.client.post("/api/v1/meals", json=meal_data) self.assertEqual(response.status_code, 400) self.assertIn( - "Meal must have at least one recipe or ingredient", response.json()["message"] + "Meal must have at least one recipe or ingredient", response.json()["title"] ) def test_create_meal_invalid_duplicate_chefs(self): @@ -199,9 +203,9 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}], "extra_ingredients": [], } - response = self.client.post("/api/meals", json=meal_data) + response = self.client.post("/api/v1/meals", json=meal_data) self.assertEqual(response.status_code, 400) - self.assertIn("Duplicate chef", response.json()["message"]) + self.assertIn("Duplicate chef", response.json()["title"]) def test_create_meal_invalid_zero_servings(self): """Test creating a meal with zero servings (should fail validation)""" @@ -214,9 +218,9 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 0}], "extra_ingredients": [], } - response = self.client.post("/api/meals", json=meal_data) + response = self.client.post("/api/v1/meals", json=meal_data) self.assertEqual(response.status_code, 400) - self.assertIn("Recipe servings must be greater than 0", response.json()["message"]) + self.assertIn("Recipe servings must be greater than 0", response.json()["title"]) def test_create_meal_valid(self): """Test creating a valid meal""" @@ -229,7 +233,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}], "extra_ingredients": [], } - response = self.client.post("/api/meals", json=meal_data) + response = self.client.post("/api/v1/meals", json=meal_data) self.assertEqual(response.status_code, 200) created_meal = response.json() self.assertGreater(created_meal["id"], 0) @@ -248,9 +252,9 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "recipes": [{"meal_id": 999, "recipe_id": 1, "servings": 2.0}], "extra_ingredients": [], } - response = self.client.put("/api/meals/123", json=meal_data) + response = self.client.put("/api/v1/meals/123", json=meal_data) self.assertEqual(response.status_code, 400) - self.assertIn("Meal ID in URL does not match meal ID in body", response.json()["message"]) + self.assertIn("Meal ID in URL does not match meal ID in body", response.json()["title"]) def test_update_meal_not_found(self): """Test updating a meal that doesn't exist""" @@ -263,9 +267,9 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "recipes": [{"meal_id": 99999, "recipe_id": 1, "servings": 2.0}], "extra_ingredients": [], } - response = self.client.put("/api/meals/99999", json=meal_data) + response = self.client.put("/api/v1/meals/99999", json=meal_data) self.assertEqual(response.status_code, 404) - self.assertIn("Meal not found", response.json()["message"]) + self.assertIn("Meal not found", response.json()["title"]) def test_delete_meal_not_found(self): """Test deleting a meal that doesn't exist""" @@ -276,9 +280,9 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): main.app.dependency_overrides[main.cookie_person] = override_cookie_person try: - response = self.client.delete("/api/meals/99999") + response = self.client.delete("/api/v1/meals/99999") self.assertEqual(response.status_code, 404) - self.assertIn("Meal not found", response.json()["message"]) + self.assertIn("Meal not found", response.json()["title"]) finally: # Clean up the override if main.cookie_person in main.app.dependency_overrides: @@ -286,19 +290,19 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): def test_get_current_shopping_list(self): """Test getting the current shopping list""" - response = self.client.get("/api/shopping/current") + response = self.client.get("/api/v1/shopping/current") self.assertEqual(response.status_code, 200) shopping_data = response.json() - self.assertIn("outstanding_items", shopping_data) - self.assertIn("requested_meals", shopping_data) - self.assertIn("purchased_items", shopping_data) + self.assertIn("outstandingItems", shopping_data) + self.assertIn("requestedMeals", shopping_data) + self.assertIn("purchasedItems", shopping_data) def test_get_shopping_list_by_id(self): """Test getting a shopping list by ID that doesn't exist""" - response = self.client.get("/api/shopping/1") + response = self.client.get("/api/v1/shopping/1") # Should return 404 when shopping list is not found self.assertEqual(response.status_code, 404) - self.assertIn("Shopping list not found", response.json()["message"]) + self.assertIn("Shopping list not found", response.json()["title"]) async def test_get_shopping_list_by_id_exists(self): """Test getting a shopping list that exists""" @@ -345,36 +349,38 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): await shopping.purchase(self.conn, shopping_list) # Now test getting it via the API - response = self.client.get(f"/api/shopping/{shopping_list.id}") + response = self.client.get(f"/api/v1/shopping/{shopping_list.id}") self.assertEqual(response.status_code, 200) shopping_data = response.json() self.assertIn("list", shopping_data) self.assertEqual(shopping_data["list"]["id"], shopping_list.id) - self.assertEqual(shopping_data["list"]["store_name"], "woolworths") + self.assertEqual(shopping_data["list"]["storeName"], "woolworths") # Verify that lookup tables are present - self.assertIn("ingredients_lookup", shopping_data) - self.assertIn("meals_lookup", shopping_data) - self.assertIn("recipes_lookup", shopping_data) + self.assertIn("ingredientsLookup", shopping_data) + self.assertIn("mealsLookup", shopping_data) + self.assertIn("recipesLookup", shopping_data) def test_get_persons_no_query(self): """Test getting all persons without search query""" - response = self.client.get("/api/persons") + response = self.client.get("/api/v1/persons") self.assertEqual(response.status_code, 200) persons_data = response.json() - self.assertIsInstance(persons_data, list) - self.assertGreater(len(persons_data), 0) + self.assertIsInstance(persons_data, dict) + self.assertIn("items", persons_data) + self.assertGreaterEqual(len(persons_data["items"]), 0) def test_get_persons_with_query(self): """Test getting persons with search query""" - response = self.client.get("/api/persons?q=Jacob") + response = self.client.get("/api/v1/persons?q=Jacob") self.assertEqual(response.status_code, 200) persons_data = response.json() - self.assertIsInstance(persons_data, list) + self.assertIsInstance(persons_data, dict) + self.assertIn("items", persons_data) def test_create_person(self): """Test creating a new person""" person_data = {"id": -1, "name": "Test Person"} - response = self.client.post("/api/persons", json=person_data) + response = self.client.post("/api/v1/persons", json=person_data) self.assertEqual(response.status_code, 200) created_person = response.json() self.assertGreater(created_person["id"], 0) @@ -383,7 +389,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): def test_login_person_exists(self): """Test login with existing person""" login_data = {"username": "Jacob"} - response = self.client.post("/api/auth/login", json=login_data) + response = self.client.post("/api/v1/auth/login", json=login_data) self.assertEqual(response.status_code, 200) person_data = response.json() self.assertEqual(person_data["name"], "Jacob") @@ -391,9 +397,9 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): def test_login_person_not_found(self): """Test login with non-existent person""" login_data = {"username": "NonExistentUser"} - response = self.client.post("/api/auth/login", json=login_data) + response = self.client.post("/api/v1/auth/login", json=login_data) self.assertEqual(response.status_code, 404) - self.assertIn("Person not found", response.json()["message"]) + self.assertIn("Person not found", response.json()["title"]) class TestMainHelperFunctions(unittest.TestCase): @@ -586,7 +592,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): } ], } - response = self.client.post("/api/recipes", json=recipe_data) + response = self.client.post("/api/v1/recipes", json=recipe_data) # Due to authentication dependency issues, this will likely return 422 # In a full integration test, this should return 200 self.assertIn(response.status_code, [200, 422]) @@ -612,7 +618,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): "created_by_id": 1, # Add required field "ingredients": [], } - response = self.client.post("/api/recipes", json=recipe_data) + response = self.client.post("/api/v1/recipes", json=recipe_data) # Due to authentication dependency issues, this will likely return 422 # In a proper test, this should return 400 for business logic validation self.assertIn(response.status_code, [400, 422]) @@ -640,15 +646,15 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): "recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}], "extra_ingredients": [], } - create_response = self.client.post("/api/meals", json=meal_data) + create_response = self.client.post("/api/v1/meals", json=meal_data) meal_id = create_response.json()["id"] # Try to mark as consumed with invalid timezone response = self.client.post( - f"/api/meals/{meal_id}/consumed", params={"consumed_date": "2024-06-01T19:00:00"} + f"/api/v1/meals/{meal_id}/consumed", params={"consumed_date": "2024-06-01T19:00:00"} ) # No timezone self.assertEqual(response.status_code, 400) - self.assertIn("Consumed date must include timezone", response.json()["message"]) + self.assertIn("Consumed date must include timezone", response.json()["title"]) finally: # Clean up the override if main.cookie_person in main.app.dependency_overrides: @@ -664,9 +670,9 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): try: request_data = {"meal_id": 99999} - response = self.client.post("/api/shopping/current/meals/me", json=request_data) + response = self.client.post("/api/v1/shopping/current/meals/me", json=request_data) self.assertEqual(response.status_code, 404) - self.assertIn("Meal not found", response.json()["message"]) + self.assertIn("Meal not found", response.json()["title"]) finally: # Clean up the override if main.cookie_person in main.app.dependency_overrides: @@ -681,9 +687,9 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): main.app.dependency_overrides[main.cookie_person] = override_cookie_person try: - response = self.client.delete("/api/shopping/current/meals/99999") + response = self.client.delete("/api/v1/shopping/current/meals/99999") self.assertEqual(response.status_code, 404) - self.assertIn("Meal not found", response.json()["message"]) + self.assertIn("Meal not found", response.json()["title"]) finally: # Clean up the override if main.cookie_person in main.app.dependency_overrides: @@ -698,7 +704,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): main.app.dependency_overrides[main.cookie_person] = override_cookie_person try: - response = self.client.get("/api/shopping/current/me/ingredients") + response = self.client.get("/api/v1/shopping/current/me/ingredients") self.assertEqual(response.status_code, 200) shopping_list = response.json() self.assertIsInstance(shopping_list, list) @@ -733,7 +739,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): main.app.dependency_overrides[main.cookie_person] = override_cookie_person try: - response = self.client.get("/api/shopping/current/me/ingredients") + response = self.client.get("/api/v1/shopping/current/me/ingredients") self.assertEqual(response.status_code, 200) shopping_list = response.json() self.assertIsInstance(shopping_list, list) @@ -754,7 +760,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): main.app.dependency_overrides[main.cookie_person] = override_cookie_person try: - response = self.client.post("/api/shopping/current/me/ingredients", json=[]) + response = self.client.post("/api/v1/shopping/current/me/ingredients", json=[]) self.assertEqual(response.status_code, 200) shopping_list = response.json() self.assertIsInstance(shopping_list, list) @@ -800,7 +806,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): try: # Sync the ingredients response = self.client.post( - "/api/shopping/current/me/ingredients", + "/api/v1/shopping/current/me/ingredients", json=[ { "id": ingredient1.id, @@ -875,7 +881,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): try: # Sync with only one ingredient (effectively removing the other) response = self.client.post( - "/api/shopping/current/me/ingredients", + "/api/v1/shopping/current/me/ingredients", json=[ { "id": ingredient1.id, @@ -949,7 +955,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): try: # Sync to keep existing, remove remove_ingredient, add new_ingredient response = self.client.post( - "/api/shopping/current/me/ingredients", + "/api/v1/shopping/current/me/ingredients", json=[ { "id": existing_ingredient.id, @@ -998,7 +1004,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): try: # Sync with new ingredients (negative IDs) response = self.client.post( - "/api/shopping/current/me/ingredients", + "/api/v1/shopping/current/me/ingredients", json=[ { "id": -1, @@ -1053,7 +1059,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): try: # Sync with ingredient with different ID but same line response = self.client.post( - "/api/shopping/current/me/ingredients", + "/api/v1/shopping/current/me/ingredients", json=[ { "id": -99, # Different ID @@ -1083,13 +1089,13 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): def test_get_my_shopping_list_no_auth(self): """Test that get_my_shopping_list requires authentication""" # No cookie provided, should fail - response = self.client.get("/api/shopping/current/me/ingredients") + response = self.client.get("/api/v1/shopping/current/me/ingredients") self.assertEqual(response.status_code, 422) # Validation error for missing cookie def test_sync_my_shopping_list_no_auth(self): """Test that sync_my_shopping_list requires authentication""" # No cookie provided, should fail - response = self.client.post("/api/shopping/current/me/ingredients", json=[]) + response = self.client.post("/api/v1/shopping/current/me/ingredients", json=[]) self.assertEqual(response.status_code, 422) # Validation error for missing cookie def test_sync_my_shopping_list_invalid_json(self): @@ -1103,7 +1109,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): try: # Send invalid ingredient data response = self.client.post( - "/api/shopping/current/me/ingredients", + "/api/v1/shopping/current/me/ingredients", json=[ { "id": "not_a_number", # Invalid ID type diff --git a/tests/test_v1.py b/tests/test_v1.py new file mode 100644 index 0000000..802ce7a --- /dev/null +++ b/tests/test_v1.py @@ -0,0 +1,115 @@ +import unittest +import importlib +from fastapi.testclient import TestClient + +import tests.test_data as test_data + +from db import connect, create +import main + + +def reload_test_data(): + global test_data + test_data = importlib.reload(test_data) + + +class TestV1API(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.conn = await connect(":memory:") + await create(self.conn) + await test_data.create_test_data(self.conn) + reload_test_data() + + async def override_get_db(): + try: + yield self.conn + finally: + pass + + main.app.dependency_overrides[main.get_db] = override_get_db + self.client = TestClient(main.app) + return await super().asyncSetUp() + + async def asyncTearDown(self) -> None: + await self.conn.close() + main.app.dependency_overrides.clear() + return await super().asyncTearDown() + + def test_v1_recipes_page_envelope(self): + resp = self.client.get("/api/v1/recipes") + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, dict) + assert "items" in body + assert isinstance(body["items"], list) + assert len(body["items"]) >= 0 + + def test_v1_persons_page_envelope(self): + resp = self.client.get("/api/v1/persons") + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, dict) + assert "items" in body + assert isinstance(body["items"], list) + + def test_v1_recipe_not_found_problem(self): + resp = self.client.get("/api/v1/recipes/999999") + assert resp.status_code == 404 + assert "application/problem+json" in resp.headers.get("content-type", "") + prob = resp.json() + assert prob.get("status") == 404 + assert "title" in prob + assert "type" in prob + + def test_v1_meal_create_no_chefs_problem(self): + meal_data = { + "id": -1, + "suggestedDate": "2024-06-01T18:00:00+00:00", + "chefs": [], + "cleanup": [{"id": 1, "name": "Ryan"}], + "consumers": [{"id": 1, "name": "Ellie"}], + "recipes": [], + "extraIngredients": [], + } + resp = self.client.post("/api/v1/meals", json=meal_data) + assert resp.status_code == 400 + assert "application/problem+json" in resp.headers.get("content-type", "") + prob = resp.json() + assert prob.get("status") == 400 + assert "title" in prob + + def test_v1_login_not_found_problem(self): + resp = self.client.post("/api/v1/auth/login", json={"username": "nope"}) + assert resp.status_code == 404 + assert "application/problem+json" in resp.headers.get("content-type", "") + prob = resp.json() + assert prob.get("status") == 404 + assert prob.get("title") + + def test_v1_camel_case_keys(self): + # persons endpoint should return camelCase in v1 + resp = self.client.get("/api/v1/persons") + assert resp.status_code == 200 + body = resp.json() + assert "items" in body # Page envelope + if body["items"]: + # pick first person + person = body["items"][0] + assert "id" in person + assert "name" in person + + def test_v1_cursor_edge_cases(self): + # invalid cursor should be treated as start + resp = self.client.get("/api/v1/recipes?cursor=notanint&limit=1") + assert resp.status_code == 200 + body = resp.json() + assert "items" in body + # end-of-list cursor + # get all to compute a large cursor + all_resp = self.client.get("/api/v1/recipes?limit=200") + items = all_resp.json()["items"] + if items: + last_id = items[-1]["id"] + after_last = self.client.get(f"/api/v1/recipes?cursor={last_id}&limit=200") + after_body = after_last.json() + assert after_body["items"] == [] or after_body.get("nextCursor") is None