Squashed commit of the following:

commit 4189d9f824f681b480f797b109e963762eb22e9c
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:41:57 2025 +1100

    Openapi complete

commit bebf8c30cba0b85a889198fe44879614065a0c34
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:35:39 2025 +1100

    Removed unversioned api

commit dd9cc2eae75d66fceebe14c918c3ed8498376ee6
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:07:03 2025 +1100

    Spec updates

commit b993c4530688f79ea983278283f984e9d8e83860
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:01:50 2025 +1100

    docs(spec): update doof-back-spec with v1 RFC7807 422, reusable Problem* responses, and shopping/current aliasing; tests passing; openapi.json refreshed

commit 30bac7e57367b14ce924667a7955845de949d779
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:00:13 2025 +1100

    openapi polish

commit eb7f7f224f7085fa5b3fadc7716db0ebb7f47eb0
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:54:52 2025 +1100

    OpenAPI reusable responses: Added components.responses for `Problem400`, `Problem404`, and `Problem422`; v1 routes reference these consistently.
     - Units enum: Exposed advisory enum in schema for `Ingredient.unit` using existing units list (no runtime enforcement).

commit 037037e17d684a89b264f2406377970a0de7ec99
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:43:01 2025 +1100

    Add `total` counts to v1 page responses for recipes/persons; push persons name filter into SQL for v1 when `q` is provided.

commit 07e7735076aae8cbd04bb10f9aa324c1a3d80ae4
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:40:17 2025 +1100

    Add parameter descriptions for `cursor`, `limit`, and `q` on v1 list endpoints; include example `Page` envelopes in 200 responses.

commit e5bf9396b0fe870501d1b4712cba2555dd2ef9b1
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:36:27 2025 +1100

    DB pagination

commit 782315cd2a0c18cc50e4deaf28e7ec6e4b58c6e2
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:29:38 2025 +1100

    camelcase tests

commit b207c33e2844c00fc9e531fb9cf8c07a3f5cd543
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:24:57 2025 +1100

    OpenAPI enrichment, Error responses

commit dc84681ab743008e5cd8bec7f3ccb0d05b557518
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:16:39 2025 +1100

    v1 tests: Added basic tests to assert `Page` envelopes and RFC7807 responses for v1 endpoints without affecting legacy tests.

commit 1524b7a98ffe04af11c2731c8125ac9348751cff
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:14:51 2025 +1100

    Pagination

commit 92e91d7acf15c09b14bc76f7b16a7a47e65129ec
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:03:50 2025 +1100

    Use middleware for naming case changes

commit c68f964f9b8e3d8f8ef020661e747e0990459c81
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:00:09 2025 +1100

    Openapi gen
This commit is contained in:
jableader 2025-10-18 16:44:36 +11:00
parent 18f784665f
commit 4c3f370ddc
17 changed files with 4121 additions and 199 deletions

50
.github/workflows/openapi.yml vendored Normal file
View file

@ -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

View file

@ -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.

View file

@ -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")

View file

@ -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

709
main.py
View file

@ -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

View file

@ -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

538
openapi-baseline.json Normal file
View file

@ -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"
}
}
}
}

2426
openapi.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -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,

View file

@ -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(
"""

View file

@ -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",

View file

@ -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,

View file

@ -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

15
scripts/export_openapi.py Normal file
View file

@ -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")

View file

@ -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")

View file

@ -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

115
tests/test_v1.py Normal file
View file

@ -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