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:
parent
18f784665f
commit
4c3f370ddc
17 changed files with 4121 additions and 199 deletions
50
.github/workflows/openapi.yml
vendored
Normal file
50
.github/workflows/openapi.yml
vendored
Normal 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
|
||||||
24
README.md
24
README.md
|
|
@ -43,3 +43,27 @@ ruff check .
|
||||||
black .
|
black .
|
||||||
mypy .
|
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.
|
||||||
|
|
|
||||||
43
common.py
43
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):
|
def to_camel(s: str) -> str:
|
||||||
model_config = dict(arbitrary_types_allowed=True)
|
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")
|
@model_validator(mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def auto_populate_ids(cls, data: dict[str, Any]) -> dict[str, Any]:
|
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 id_key in data:
|
||||||
# If the id_key already exists, ensure it matches the value's id
|
# If the id_key already exists, ensure it matches the value's id
|
||||||
if data[id_key] != value.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:
|
else:
|
||||||
# If the id_key does not exist, set it to the value's id
|
# If the id_key does not exist, set it to the value's id
|
||||||
data[id_key] = value.id
|
data[id_key] = value.id
|
||||||
|
|
||||||
return data
|
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")
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
from typing import Any, AsyncIterator, ClassVar, List, Optional
|
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 products import Product
|
||||||
|
from units import ALL_UNITS
|
||||||
|
|
||||||
|
|
||||||
class Ingredient(BaseModel):
|
class Ingredient(ApiModel):
|
||||||
KEYS: ClassVar[List[str]] = [
|
KEYS: ClassVar[List[str]] = [
|
||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
|
|
@ -20,7 +22,11 @@ class Ingredient(BaseModel):
|
||||||
id: int = -1
|
id: int = -1
|
||||||
name: str
|
name: str
|
||||||
line: 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
|
quantity: float | str
|
||||||
preparation: str
|
preparation: str
|
||||||
product_id: Optional[int] = None
|
product_id: Optional[int] = None
|
||||||
|
|
|
||||||
709
main.py
709
main.py
|
|
@ -1,9 +1,9 @@
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
from typing import Annotated, Dict, List, Optional
|
from typing import Annotated, Dict, List, Optional, Any
|
||||||
|
|
||||||
import aiosqlite
|
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.encoders import jsonable_encoder
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
@ -16,7 +16,19 @@ import products
|
||||||
import recipes
|
import recipes
|
||||||
import shopping
|
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")
|
DATABASE_PATH = os.environ.get("DOOF_DB", "./data/doof.sqlite")
|
||||||
|
|
||||||
# Dependency to create SQLite connection
|
# Dependency to create SQLite connection
|
||||||
|
|
@ -34,17 +46,123 @@ async def cookie_person(
|
||||||
return await persons.get_by_id(conn, user_id)
|
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(
|
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:
|
) -> recipes.Recipe | JSONResponse:
|
||||||
parsed = await recipes.parse_recipe(conn, person, url)
|
parsed = await recipes.parse_recipe(conn, person, url)
|
||||||
if not parsed:
|
if not parsed:
|
||||||
return JSONResponse(status_code=400, content={"message": "Recipe not found"})
|
return error_response(request, 400, "Recipe not found")
|
||||||
return parsed
|
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(
|
async def parse_ingredients(
|
||||||
lines: Annotated[List[str], Query(alias="ingredients", title="Array of ingredients to parse")],
|
lines: Annotated[List[str], Query(alias="ingredients", title="Array of ingredients to parse")],
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
|
|
@ -71,12 +189,17 @@ async def parse_ingredients(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class ProductUrl(BaseModel):
|
class ProductUrl(ApiModel):
|
||||||
url: str
|
url: str
|
||||||
tags: List[str] = Field(default_factory=list)
|
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(
|
async def create_product(
|
||||||
url: ProductUrl, conn: aiosqlite.Connection = Depends(get_db)
|
url: ProductUrl, conn: aiosqlite.Connection = Depends(get_db)
|
||||||
) -> Optional[products.Product]:
|
) -> Optional[products.Product]:
|
||||||
|
|
@ -98,47 +221,135 @@ async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[reci
|
||||||
return r
|
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(
|
async def get_recipes(
|
||||||
q: Optional[str] = None, conn: aiosqlite.Connection = Depends(get_db)
|
q: Optional[str] = Query(
|
||||||
) -> List[recipes.Recipe]:
|
default=None,
|
||||||
result = []
|
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:
|
if q:
|
||||||
async for recipe in recipes.find_recipes_by_name(conn, q):
|
async for r in recipes.find_recipes_by_name_paged(conn, q, last_id, fetch_limit):
|
||||||
result.append(recipe)
|
paged.append(r)
|
||||||
else:
|
else:
|
||||||
async for recipe in recipes.get_all(conn):
|
async for r in recipes.get_all_paged(conn, last_id, fetch_limit):
|
||||||
result.append(recipe)
|
paged.append(r)
|
||||||
|
|
||||||
for recipe in result:
|
has_more = len(paged) > limit
|
||||||
recipe.ingredients = []
|
items = paged[:limit]
|
||||||
async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, recipe.id):
|
# load ingredients for items
|
||||||
recipe.ingredients.append(ingredient)
|
for r in items:
|
||||||
|
r.ingredients = []
|
||||||
return result
|
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(
|
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:
|
) -> recipes.Recipe | JSONResponse:
|
||||||
r = await load_full_recipe(conn, recipe_id)
|
r = await load_full_recipe(conn, recipe_id)
|
||||||
if not r:
|
if not r:
|
||||||
return JSONResponse(status_code=404, content={"message": "Recipe not found"})
|
return error_response(request, 404, "Recipe not found")
|
||||||
|
|
||||||
return r
|
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(
|
async def create_recipe(
|
||||||
recipe: recipes.Recipe,
|
recipe: recipes.Recipe,
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
user: persons.Person = Depends(cookie_person),
|
user: persons.Person = Depends(cookie_person),
|
||||||
|
request: Request = None,
|
||||||
) -> recipes.Recipe | JSONResponse:
|
) -> recipes.Recipe | JSONResponse:
|
||||||
if not recipe.ingredients:
|
if not recipe.ingredients:
|
||||||
return JSONResponse(
|
return error_response(request, 400, "Recipe must have at least one ingredient")
|
||||||
status_code=400, content={"message": "Recipe must have at least one ingredient"}
|
|
||||||
)
|
|
||||||
|
|
||||||
if recipe.id >= 0:
|
if recipe.id >= 0:
|
||||||
await recipes.hide_recipe(conn, recipe.id, user)
|
await recipes.hide_recipe(conn, recipe.id, user)
|
||||||
|
|
@ -159,22 +370,41 @@ async def create_recipe(
|
||||||
return 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(
|
async def delete_recipe(
|
||||||
recipe_id: int,
|
recipe_id: int,
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
user: persons.Person = Depends(cookie_person),
|
user: persons.Person = Depends(cookie_person),
|
||||||
|
request: Request = None,
|
||||||
) -> recipes.Recipe | JSONResponse:
|
) -> recipes.Recipe | JSONResponse:
|
||||||
recipe = await recipes.find_recipe_by_id(conn, recipe_id)
|
recipe = await recipes.find_recipe_by_id(conn, recipe_id)
|
||||||
if not recipe:
|
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 recipes.hide_recipe(conn, recipe_id, user)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
return recipe
|
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(
|
async def get_upcoming_meals(
|
||||||
date_from: Annotated[datetime.datetime, Query(alias="from")],
|
date_from: Annotated[datetime.datetime, Query(alias="from")],
|
||||||
to: datetime.datetime,
|
to: datetime.datetime,
|
||||||
|
|
@ -190,13 +420,26 @@ async def get_upcoming_meals(
|
||||||
return result
|
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(
|
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:
|
) -> meals.Meal | JSONResponse:
|
||||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||||
if not meal:
|
if not meal:
|
||||||
return JSONResponse(status_code=404, content={"message": "Meal not found"})
|
return error_response(request, 404, "Meal not found")
|
||||||
|
|
||||||
return meal
|
return meal
|
||||||
|
|
||||||
|
|
@ -211,60 +454,56 @@ def get_duplicates(items: List[meals.Person]) -> set[str]:
|
||||||
return duplicates
|
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:
|
if not meal.chefs:
|
||||||
return JSONResponse(
|
return error_response(request, 400, "Meal must have at least one chef")
|
||||||
status_code=400, content={"message": "Meal must have at least one chef"}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not meal.cleanup:
|
if not meal.cleanup:
|
||||||
return JSONResponse(
|
return error_response(request, 400, "Meal must have at least one cleanup person")
|
||||||
status_code=400, content={"message": "Meal must have at least one cleanup person"}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not meal.consumers:
|
if not meal.consumers:
|
||||||
return JSONResponse(
|
return error_response(request, 400, "Meal must have at least one consumer")
|
||||||
status_code=400, content={"message": "Meal must have at least one consumer"}
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(meal.recipes) == 0 and len(meal.extra_ingredients) == 0:
|
if len(meal.recipes) == 0 and len(meal.extra_ingredients) == 0:
|
||||||
return JSONResponse(
|
return error_response(request, 400, "Meal must have at least one recipe or ingredient")
|
||||||
status_code=400, content={"message": "Meal must have at least one recipe or ingredient"}
|
|
||||||
)
|
|
||||||
|
|
||||||
duplicates = get_duplicates(meal.chefs)
|
duplicates = get_duplicates(meal.chefs)
|
||||||
if duplicates:
|
if duplicates:
|
||||||
return JSONResponse(
|
return error_response(request, 400, f'Duplicate chef: {", ".join(duplicates)}')
|
||||||
status_code=400, content={"message": f'Duplicate chef: {", ".join(duplicates)}'}
|
|
||||||
)
|
|
||||||
|
|
||||||
duplicates = get_duplicates(meal.cleanup)
|
duplicates = get_duplicates(meal.cleanup)
|
||||||
if duplicates:
|
if duplicates:
|
||||||
return JSONResponse(
|
return error_response(request, 400, f'Duplicate cleanup person: {", ".join(duplicates)}')
|
||||||
status_code=400,
|
|
||||||
content={"message": f'Duplicate cleanup person: {", ".join(duplicates)}'},
|
|
||||||
)
|
|
||||||
|
|
||||||
duplicates = get_duplicates(meal.consumers)
|
duplicates = get_duplicates(meal.consumers)
|
||||||
if duplicates:
|
if duplicates:
|
||||||
return JSONResponse(
|
return error_response(request, 400, f'Duplicate consumer: {", ".join(duplicates)}')
|
||||||
status_code=400, content={"message": f'Duplicate consumer: {", ".join(duplicates)}'}
|
|
||||||
)
|
|
||||||
|
|
||||||
zero_servings = [r for r in meal.recipes if r.servings == 0]
|
zero_servings = [r for r in meal.recipes if r.servings == 0]
|
||||||
if zero_servings:
|
if zero_servings:
|
||||||
return JSONResponse(
|
return error_response(request, 400, "Recipe servings must be greater than 0")
|
||||||
status_code=400, content={"message": "Recipe servings must be greater than 0"}
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
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(
|
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:
|
) -> meals.Meal | JSONResponse:
|
||||||
validation_response = validate_meal(meal)
|
validation_response = validate_meal(meal, request)
|
||||||
if validation_response:
|
if validation_response:
|
||||||
return validation_response
|
return validation_response
|
||||||
|
|
||||||
|
|
@ -273,20 +512,36 @@ async def create_meal(
|
||||||
return 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(
|
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:
|
) -> meals.Meal | JSONResponse:
|
||||||
if meal.id != meal_id:
|
if meal.id != meal_id:
|
||||||
return JSONResponse(
|
return error_response(request, 400, "Meal ID in URL does not match meal ID in body")
|
||||||
status_code=400, content={"message": "Meal ID in URL does not match meal ID in body"}
|
|
||||||
)
|
|
||||||
|
|
||||||
existing = await meals.find_meal_by_id(conn, meal_id)
|
existing = await meals.find_meal_by_id(conn, meal_id)
|
||||||
if not existing:
|
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:
|
if validation_response:
|
||||||
return validation_response
|
return validation_response
|
||||||
|
|
||||||
|
|
@ -296,21 +551,38 @@ async def update_meal(
|
||||||
return await get_meal(meal_id, conn)
|
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(
|
async def mark_consumed(
|
||||||
meal_id: int,
|
meal_id: int,
|
||||||
consumed_date: Optional[datetime.datetime] = None,
|
consumed_date: Optional[datetime.datetime] = None,
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
person: persons.Person = Depends(cookie_person),
|
person: persons.Person = Depends(cookie_person),
|
||||||
|
request: Request = None,
|
||||||
) -> meals.Meal | JSONResponse:
|
) -> meals.Meal | JSONResponse:
|
||||||
if consumed_date and not consumed_date.tzinfo:
|
if consumed_date and not consumed_date.tzinfo:
|
||||||
return JSONResponse(
|
return error_response(request, 400, "Consumed date must include timezone")
|
||||||
status_code=400, content={"message": "Consumed date must include timezone"}
|
|
||||||
)
|
|
||||||
|
|
||||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||||
if not meal:
|
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 meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone())
|
||||||
await shopping.remove_request(conn, person, meal=meal)
|
await shopping.remove_request(conn, person, meal=meal)
|
||||||
|
|
@ -319,15 +591,29 @@ async def mark_consumed(
|
||||||
return meal
|
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(
|
async def delete_meal(
|
||||||
meal_id: int,
|
meal_id: int,
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
person: persons.Person = Depends(cookie_person),
|
person: persons.Person = Depends(cookie_person),
|
||||||
|
request: Request = None,
|
||||||
) -> meals.Meal | JSONResponse:
|
) -> meals.Meal | JSONResponse:
|
||||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||||
if not meal:
|
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 shopping.remove_request(conn, person, meal=meal)
|
||||||
await meals.delete_meal(conn, meal.id)
|
await meals.delete_meal(conn, meal.id)
|
||||||
|
|
@ -336,7 +622,7 @@ async def delete_meal(
|
||||||
return meal
|
return meal
|
||||||
|
|
||||||
|
|
||||||
class CurrentShoppingList(BaseModel):
|
class CurrentShoppingList(ApiModel):
|
||||||
outstanding_items: List[shopping.ShoppingListItem]
|
outstanding_items: List[shopping.ShoppingListItem]
|
||||||
requested_meals: List[shopping.ShoppingListItem]
|
requested_meals: List[shopping.ShoppingListItem]
|
||||||
purchased_items: List[shopping.ShoppingListItem] = Field(default_factory=list)
|
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)
|
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(
|
async def get_current_shopping_list(
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
) -> CurrentShoppingList:
|
) -> CurrentShoppingList:
|
||||||
|
|
@ -386,20 +678,33 @@ async def get_current_shopping_list(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PurchasedShoppingList(BaseModel):
|
class PurchasedShoppingList(ApiModel):
|
||||||
list: shopping.ShoppingList
|
list: shopping.ShoppingList
|
||||||
meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict)
|
meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict)
|
||||||
ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict)
|
ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict)
|
||||||
recipes_lookup: Dict[int, recipes.Recipe] = 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(
|
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:
|
) -> PurchasedShoppingList | JSONResponse:
|
||||||
shopping_list = await shopping.load_shopping_list(conn, list_id)
|
shopping_list = await shopping.load_shopping_list(conn, list_id)
|
||||||
if not shopping_list:
|
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(
|
meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(
|
||||||
conn, shopping_list.items
|
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(
|
async def purchase_ingredients(
|
||||||
shopping_list: shopping.ShoppingList,
|
shopping_list: shopping.ShoppingList,
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
|
|
@ -436,14 +746,24 @@ async def purchase_ingredients(
|
||||||
return result
|
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(
|
async def get_my_shopping_list(
|
||||||
conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)
|
conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)
|
||||||
) -> List[ingredients.Ingredient]:
|
) -> List[ingredients.Ingredient]:
|
||||||
return await shopping.get_persons_requests(conn, person.id)
|
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(
|
async def sync_my_shopping_list(
|
||||||
requests: List[ingredients.Ingredient],
|
requests: List[ingredients.Ingredient],
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
|
|
@ -468,53 +788,148 @@ async def sync_my_shopping_list(
|
||||||
return await get_my_shopping_list(conn, person)
|
return await get_my_shopping_list(conn, person)
|
||||||
|
|
||||||
|
|
||||||
class MealIdWrapper(BaseModel):
|
class MealIdWrapper(ApiModel):
|
||||||
meal_id: int
|
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(
|
async def request_meal(
|
||||||
r: MealIdWrapper,
|
r: MealIdWrapper,
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
person: persons.Person = Depends(cookie_person),
|
person: persons.Person = Depends(cookie_person),
|
||||||
|
request: Request = None,
|
||||||
) -> shopping.ShoppingListItem | JSONResponse:
|
) -> shopping.ShoppingListItem | JSONResponse:
|
||||||
meal = await meals.find_meal_by_id(conn, r.meal_id)
|
meal = await meals.find_meal_by_id(conn, r.meal_id)
|
||||||
if not meal:
|
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)
|
response = await shopping.request(conn, person, meal=meal)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
return response
|
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(
|
async def unrequest_meal(
|
||||||
meal_id: int,
|
meal_id: int,
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
person: persons.Person = Depends(cookie_person),
|
person: persons.Person = Depends(cookie_person),
|
||||||
|
request: Request = None,
|
||||||
) -> dict | JSONResponse:
|
) -> dict | JSONResponse:
|
||||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||||
if not meal:
|
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 shopping.remove_request(conn, person, meal=meal)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
return {}
|
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(
|
async def get_persons(
|
||||||
q: Optional[str] = None, conn: aiosqlite.Connection = Depends(get_db)
|
q: Optional[str] = Query(
|
||||||
) -> List[meals.Person]:
|
default=None,
|
||||||
query = persons.search_by_name(conn, q) if q else persons.get_all(conn)
|
description="Optional case-insensitive name filter (applied after page read; may be pushed to SQL in the future).",
|
||||||
result = []
|
),
|
||||||
async for person in query:
|
cursor: Optional[str] = Query(
|
||||||
result.append(person)
|
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(
|
async def create_person(
|
||||||
person: persons.Person, conn: aiosqlite.Connection = Depends(get_db)
|
person: persons.Person, conn: aiosqlite.Connection = Depends(get_db)
|
||||||
) -> persons.Person:
|
) -> persons.Person:
|
||||||
|
|
@ -523,28 +938,108 @@ async def create_person(
|
||||||
return person
|
return person
|
||||||
|
|
||||||
|
|
||||||
class LoginBody(BaseModel):
|
class LoginBody(ApiModel):
|
||||||
username: str
|
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(
|
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:
|
) -> persons.Person | JSONResponse:
|
||||||
person = await persons.get_by_name(conn, data.username)
|
person = await persons.get_by_name(conn, data.username)
|
||||||
if not person:
|
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 = JSONResponse(content=jsonable_encoder(person))
|
||||||
response.set_cookie(key="user_id", value=str(person.id))
|
response.set_cookie(key="user_id", value=str(person.id))
|
||||||
return response
|
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:
|
async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person:
|
||||||
return user
|
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):
|
if os.environ.get("DOOF_PROD", False):
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import datetime
|
import datetime
|
||||||
from typing import AsyncIterator, ClassVar, List, Optional
|
from typing import AsyncIterator, ClassVar, List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import Field
|
||||||
|
from common import ApiModel
|
||||||
|
|
||||||
import persons
|
import persons
|
||||||
from ingredients import (
|
from ingredients import (
|
||||||
|
|
@ -14,7 +15,7 @@ from persons import Person
|
||||||
from recipes import Recipe, load_recipe_ingredients, row_to_recipe
|
from recipes import Recipe, load_recipe_ingredients, row_to_recipe
|
||||||
|
|
||||||
|
|
||||||
class MealRecipe(BaseModel):
|
class MealRecipe(ApiModel):
|
||||||
meal_id: int
|
meal_id: int
|
||||||
recipe_id: int
|
recipe_id: int
|
||||||
servings: float
|
servings: float
|
||||||
|
|
@ -22,7 +23,7 @@ class MealRecipe(BaseModel):
|
||||||
recipe: Optional[Recipe] = None
|
recipe: Optional[Recipe] = None
|
||||||
|
|
||||||
|
|
||||||
class Meal(BaseModel):
|
class Meal(ApiModel):
|
||||||
KEYS: ClassVar[List[str]] = ["id", "suggested_date", "consumed_date", "purchase_date"]
|
KEYS: ClassVar[List[str]] = ["id", "suggested_date", "consumed_date", "purchase_date"]
|
||||||
id: int = -1
|
id: int = -1
|
||||||
suggested_date: datetime.datetime
|
suggested_date: datetime.datetime
|
||||||
|
|
|
||||||
538
openapi-baseline.json
Normal file
538
openapi-baseline.json
Normal 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
2426
openapi.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,6 +2,11 @@ from persons.db import (
|
||||||
Person as Person,
|
Person as Person,
|
||||||
create as create,
|
create as create,
|
||||||
get_all as get_all,
|
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_id as get_by_id,
|
||||||
get_by_name as get_by_name,
|
get_by_name as get_by_name,
|
||||||
insert_person as insert_person,
|
insert_person as insert_person,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
from typing import AsyncIterator, ClassVar, List, Optional
|
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"]
|
KEYS: ClassVar[List[str]] = ["id", "name"]
|
||||||
|
|
||||||
id: int = -1
|
id: int = -1
|
||||||
|
|
@ -74,6 +74,94 @@ async def get_all(conn) -> AsyncIterator[Person]:
|
||||||
yield Person(id=row[0], name=row[1])
|
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:
|
async def insert_person(conn, person: Person) -> Person:
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import json
|
import json
|
||||||
from typing import AsyncIterator, ClassVar, List, Optional
|
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]] = [
|
KEYS: ClassVar[List[str]] = [
|
||||||
"id",
|
"id",
|
||||||
"product_id",
|
"product_id",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,12 @@ from recipes.db import (
|
||||||
Recipe as Recipe,
|
Recipe as Recipe,
|
||||||
find_recipe_by_id as find_recipe_by_id,
|
find_recipe_by_id as find_recipe_by_id,
|
||||||
find_recipes_by_name as find_recipes_by_name,
|
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 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,
|
hide_recipe as hide_recipe,
|
||||||
insert_recipe as insert_recipe,
|
insert_recipe as insert_recipe,
|
||||||
load_recipe_ingredients as load_recipe_ingredients,
|
load_recipe_ingredients as load_recipe_ingredients,
|
||||||
|
|
|
||||||
101
recipes/db.py
101
recipes/db.py
|
|
@ -2,13 +2,14 @@ import datetime
|
||||||
import json
|
import json
|
||||||
from typing import Any, AsyncIterator, ClassVar, Iterable, List, Optional, Tuple, cast
|
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 ingredients import Ingredient, find_ingredients_by_recipe_id
|
||||||
from persons import Person
|
from persons import Person
|
||||||
|
|
||||||
|
|
||||||
class Recipe(BaseModel):
|
class Recipe(ApiModel):
|
||||||
KEYS: ClassVar[List[str]] = [
|
KEYS: ClassVar[List[str]] = [
|
||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
|
|
@ -148,3 +149,99 @@ async def get_all(conn) -> AsyncIterator[Recipe]:
|
||||||
async def load_recipe_ingredients(conn, recipe: Recipe) -> None:
|
async def load_recipe_ingredients(conn, recipe: Recipe) -> None:
|
||||||
async for ingredient in find_ingredients_by_recipe_id(conn, recipe.id):
|
async for ingredient in find_ingredients_by_recipe_id(conn, recipe.id):
|
||||||
recipe.ingredients.append(ingredient)
|
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
15
scripts/export_openapi.py
Normal 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")
|
||||||
18
scripts/export_openapi_v1.py
Normal file
18
scripts/export_openapi_v1.py
Normal 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")
|
||||||
|
|
@ -57,42 +57,46 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
def test_get_recipes_no_query(self):
|
def test_get_recipes_no_query(self):
|
||||||
"""Test getting all recipes without search query"""
|
"""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)
|
self.assertEqual(response.status_code, 200)
|
||||||
recipes_data = response.json()
|
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
|
# 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):
|
def test_get_recipes_with_query(self):
|
||||||
"""Test getting recipes with search query"""
|
"""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)
|
self.assertEqual(response.status_code, 200)
|
||||||
recipes_data = response.json()
|
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):
|
def test_get_recipe_by_id_exists(self):
|
||||||
"""Test getting a specific recipe that exists"""
|
"""Test getting a specific recipe that exists"""
|
||||||
# First get all recipes to find a valid ID
|
# 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()
|
recipes_data = response.json()
|
||||||
if recipes_data:
|
items = recipes_data.get("items", [])
|
||||||
recipe_id = recipes_data[0]["id"]
|
if items:
|
||||||
response = self.client.get(f"/api/recipes/{recipe_id}")
|
recipe_id = items[0]["id"]
|
||||||
|
response = self.client.get(f"/api/v1/recipes/{recipe_id}")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
recipe_data = response.json()
|
recipe_data = response.json()
|
||||||
self.assertEqual(recipe_data["id"], recipe_id)
|
self.assertEqual(recipe_data["id"], recipe_id)
|
||||||
|
|
||||||
def test_get_recipe_by_id_not_found(self):
|
def test_get_recipe_by_id_not_found(self):
|
||||||
"""Test getting a recipe that doesn't exist"""
|
"""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.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):
|
def test_parse_ingredients(self):
|
||||||
"""Test parsing ingredient strings"""
|
"""Test parsing ingredient strings"""
|
||||||
response = self.client.get(
|
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)
|
self.assertEqual(response.status_code, 200)
|
||||||
ingredients_data = response.json()
|
ingredients_data = response.json()
|
||||||
|
|
@ -106,7 +110,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
||||||
"url": "https://www.woolworths.com.au/shop/productdetails/123456/test-product",
|
"url": "https://www.woolworths.com.au/shop/productdetails/123456/test-product",
|
||||||
"tags": ["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
|
# This might fail if the scraper can't actually scrape the URL
|
||||||
# But it should at least not crash with a validation error
|
# But it should at least not crash with a validation error
|
||||||
self.assertIn(response.status_code, [200, 400, 500])
|
self.assertIn(response.status_code, [200, 400, 500])
|
||||||
|
|
@ -115,16 +119,16 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
||||||
"""Test getting upcoming meals in a date range"""
|
"""Test getting upcoming meals in a date range"""
|
||||||
from_date = "2024-01-01T00:00:00"
|
from_date = "2024-01-01T00:00:00"
|
||||||
to_date = "2024-12-31T23:59:59"
|
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)
|
self.assertEqual(response.status_code, 200)
|
||||||
meals_data = response.json()
|
meals_data = response.json()
|
||||||
self.assertIsInstance(meals_data, list)
|
self.assertIsInstance(meals_data, list)
|
||||||
|
|
||||||
def test_get_meal_by_id_not_found(self):
|
def test_get_meal_by_id_not_found(self):
|
||||||
"""Test getting a meal that doesn't exist"""
|
"""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.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):
|
def test_create_meal_invalid_no_chefs(self):
|
||||||
"""Test creating a meal without chefs (should fail validation)"""
|
"""Test creating a meal without chefs (should fail validation)"""
|
||||||
|
|
@ -137,9 +141,9 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
||||||
"recipes": [],
|
"recipes": [],
|
||||||
"extra_ingredients": [],
|
"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.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):
|
def test_create_meal_invalid_no_cleanup(self):
|
||||||
"""Test creating a meal without cleanup people (should fail validation)"""
|
"""Test creating a meal without cleanup people (should fail validation)"""
|
||||||
|
|
@ -152,9 +156,9 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
||||||
"recipes": [],
|
"recipes": [],
|
||||||
"extra_ingredients": [],
|
"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.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):
|
def test_create_meal_invalid_no_consumers(self):
|
||||||
"""Test creating a meal without consumers (should fail validation)"""
|
"""Test creating a meal without consumers (should fail validation)"""
|
||||||
|
|
@ -167,9 +171,9 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
||||||
"recipes": [],
|
"recipes": [],
|
||||||
"extra_ingredients": [],
|
"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.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):
|
def test_create_meal_invalid_no_recipes_or_ingredients(self):
|
||||||
"""Test creating a meal without recipes or ingredients (should fail validation)"""
|
"""Test creating a meal without recipes or ingredients (should fail validation)"""
|
||||||
|
|
@ -182,10 +186,10 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
||||||
"recipes": [],
|
"recipes": [],
|
||||||
"extra_ingredients": [],
|
"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.assertEqual(response.status_code, 400)
|
||||||
self.assertIn(
|
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):
|
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}],
|
"recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}],
|
||||||
"extra_ingredients": [],
|
"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.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):
|
def test_create_meal_invalid_zero_servings(self):
|
||||||
"""Test creating a meal with zero servings (should fail validation)"""
|
"""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}],
|
"recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 0}],
|
||||||
"extra_ingredients": [],
|
"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.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):
|
def test_create_meal_valid(self):
|
||||||
"""Test creating a valid meal"""
|
"""Test creating a valid meal"""
|
||||||
|
|
@ -229,7 +233,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
||||||
"recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}],
|
"recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}],
|
||||||
"extra_ingredients": [],
|
"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)
|
self.assertEqual(response.status_code, 200)
|
||||||
created_meal = response.json()
|
created_meal = response.json()
|
||||||
self.assertGreater(created_meal["id"], 0)
|
self.assertGreater(created_meal["id"], 0)
|
||||||
|
|
@ -248,9 +252,9 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
||||||
"recipes": [{"meal_id": 999, "recipe_id": 1, "servings": 2.0}],
|
"recipes": [{"meal_id": 999, "recipe_id": 1, "servings": 2.0}],
|
||||||
"extra_ingredients": [],
|
"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.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):
|
def test_update_meal_not_found(self):
|
||||||
"""Test updating a meal that doesn't exist"""
|
"""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}],
|
"recipes": [{"meal_id": 99999, "recipe_id": 1, "servings": 2.0}],
|
||||||
"extra_ingredients": [],
|
"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.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):
|
def test_delete_meal_not_found(self):
|
||||||
"""Test deleting a meal that doesn't exist"""
|
"""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
|
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self.client.delete("/api/meals/99999")
|
response = self.client.delete("/api/v1/meals/99999")
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
self.assertIn("Meal not found", response.json()["message"])
|
self.assertIn("Meal not found", response.json()["title"])
|
||||||
finally:
|
finally:
|
||||||
# Clean up the override
|
# Clean up the override
|
||||||
if main.cookie_person in main.app.dependency_overrides:
|
if main.cookie_person in main.app.dependency_overrides:
|
||||||
|
|
@ -286,19 +290,19 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
def test_get_current_shopping_list(self):
|
def test_get_current_shopping_list(self):
|
||||||
"""Test getting the current shopping list"""
|
"""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)
|
self.assertEqual(response.status_code, 200)
|
||||||
shopping_data = response.json()
|
shopping_data = response.json()
|
||||||
self.assertIn("outstanding_items", shopping_data)
|
self.assertIn("outstandingItems", shopping_data)
|
||||||
self.assertIn("requested_meals", shopping_data)
|
self.assertIn("requestedMeals", shopping_data)
|
||||||
self.assertIn("purchased_items", shopping_data)
|
self.assertIn("purchasedItems", shopping_data)
|
||||||
|
|
||||||
def test_get_shopping_list_by_id(self):
|
def test_get_shopping_list_by_id(self):
|
||||||
"""Test getting a shopping list by ID that doesn't exist"""
|
"""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
|
# Should return 404 when shopping list is not found
|
||||||
self.assertEqual(response.status_code, 404)
|
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):
|
async def test_get_shopping_list_by_id_exists(self):
|
||||||
"""Test getting a shopping list that exists"""
|
"""Test getting a shopping list that exists"""
|
||||||
|
|
@ -345,36 +349,38 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
||||||
await shopping.purchase(self.conn, shopping_list)
|
await shopping.purchase(self.conn, shopping_list)
|
||||||
|
|
||||||
# Now test getting it via the API
|
# 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)
|
self.assertEqual(response.status_code, 200)
|
||||||
shopping_data = response.json()
|
shopping_data = response.json()
|
||||||
self.assertIn("list", shopping_data)
|
self.assertIn("list", shopping_data)
|
||||||
self.assertEqual(shopping_data["list"]["id"], shopping_list.id)
|
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
|
# Verify that lookup tables are present
|
||||||
self.assertIn("ingredients_lookup", shopping_data)
|
self.assertIn("ingredientsLookup", shopping_data)
|
||||||
self.assertIn("meals_lookup", shopping_data)
|
self.assertIn("mealsLookup", shopping_data)
|
||||||
self.assertIn("recipes_lookup", shopping_data)
|
self.assertIn("recipesLookup", shopping_data)
|
||||||
|
|
||||||
def test_get_persons_no_query(self):
|
def test_get_persons_no_query(self):
|
||||||
"""Test getting all persons without search query"""
|
"""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)
|
self.assertEqual(response.status_code, 200)
|
||||||
persons_data = response.json()
|
persons_data = response.json()
|
||||||
self.assertIsInstance(persons_data, list)
|
self.assertIsInstance(persons_data, dict)
|
||||||
self.assertGreater(len(persons_data), 0)
|
self.assertIn("items", persons_data)
|
||||||
|
self.assertGreaterEqual(len(persons_data["items"]), 0)
|
||||||
|
|
||||||
def test_get_persons_with_query(self):
|
def test_get_persons_with_query(self):
|
||||||
"""Test getting persons with search query"""
|
"""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)
|
self.assertEqual(response.status_code, 200)
|
||||||
persons_data = response.json()
|
persons_data = response.json()
|
||||||
self.assertIsInstance(persons_data, list)
|
self.assertIsInstance(persons_data, dict)
|
||||||
|
self.assertIn("items", persons_data)
|
||||||
|
|
||||||
def test_create_person(self):
|
def test_create_person(self):
|
||||||
"""Test creating a new person"""
|
"""Test creating a new person"""
|
||||||
person_data = {"id": -1, "name": "Test 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)
|
self.assertEqual(response.status_code, 200)
|
||||||
created_person = response.json()
|
created_person = response.json()
|
||||||
self.assertGreater(created_person["id"], 0)
|
self.assertGreater(created_person["id"], 0)
|
||||||
|
|
@ -383,7 +389,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
||||||
def test_login_person_exists(self):
|
def test_login_person_exists(self):
|
||||||
"""Test login with existing person"""
|
"""Test login with existing person"""
|
||||||
login_data = {"username": "Jacob"}
|
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)
|
self.assertEqual(response.status_code, 200)
|
||||||
person_data = response.json()
|
person_data = response.json()
|
||||||
self.assertEqual(person_data["name"], "Jacob")
|
self.assertEqual(person_data["name"], "Jacob")
|
||||||
|
|
@ -391,9 +397,9 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
||||||
def test_login_person_not_found(self):
|
def test_login_person_not_found(self):
|
||||||
"""Test login with non-existent person"""
|
"""Test login with non-existent person"""
|
||||||
login_data = {"username": "NonExistentUser"}
|
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.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):
|
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
|
# Due to authentication dependency issues, this will likely return 422
|
||||||
# In a full integration test, this should return 200
|
# In a full integration test, this should return 200
|
||||||
self.assertIn(response.status_code, [200, 422])
|
self.assertIn(response.status_code, [200, 422])
|
||||||
|
|
@ -612,7 +618,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
"created_by_id": 1, # Add required field
|
"created_by_id": 1, # Add required field
|
||||||
"ingredients": [],
|
"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
|
# Due to authentication dependency issues, this will likely return 422
|
||||||
# In a proper test, this should return 400 for business logic validation
|
# In a proper test, this should return 400 for business logic validation
|
||||||
self.assertIn(response.status_code, [400, 422])
|
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}],
|
"recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}],
|
||||||
"extra_ingredients": [],
|
"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"]
|
meal_id = create_response.json()["id"]
|
||||||
|
|
||||||
# Try to mark as consumed with invalid timezone
|
# Try to mark as consumed with invalid timezone
|
||||||
response = self.client.post(
|
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
|
) # No timezone
|
||||||
self.assertEqual(response.status_code, 400)
|
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:
|
finally:
|
||||||
# Clean up the override
|
# Clean up the override
|
||||||
if main.cookie_person in main.app.dependency_overrides:
|
if main.cookie_person in main.app.dependency_overrides:
|
||||||
|
|
@ -664,9 +670,9 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request_data = {"meal_id": 99999}
|
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.assertEqual(response.status_code, 404)
|
||||||
self.assertIn("Meal not found", response.json()["message"])
|
self.assertIn("Meal not found", response.json()["title"])
|
||||||
finally:
|
finally:
|
||||||
# Clean up the override
|
# Clean up the override
|
||||||
if main.cookie_person in main.app.dependency_overrides:
|
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
|
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
|
||||||
|
|
||||||
try:
|
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.assertEqual(response.status_code, 404)
|
||||||
self.assertIn("Meal not found", response.json()["message"])
|
self.assertIn("Meal not found", response.json()["title"])
|
||||||
finally:
|
finally:
|
||||||
# Clean up the override
|
# Clean up the override
|
||||||
if main.cookie_person in main.app.dependency_overrides:
|
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
|
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
|
||||||
|
|
||||||
try:
|
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)
|
self.assertEqual(response.status_code, 200)
|
||||||
shopping_list = response.json()
|
shopping_list = response.json()
|
||||||
self.assertIsInstance(shopping_list, list)
|
self.assertIsInstance(shopping_list, list)
|
||||||
|
|
@ -733,7 +739,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
|
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
|
||||||
|
|
||||||
try:
|
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)
|
self.assertEqual(response.status_code, 200)
|
||||||
shopping_list = response.json()
|
shopping_list = response.json()
|
||||||
self.assertIsInstance(shopping_list, list)
|
self.assertIsInstance(shopping_list, list)
|
||||||
|
|
@ -754,7 +760,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
|
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
|
||||||
|
|
||||||
try:
|
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)
|
self.assertEqual(response.status_code, 200)
|
||||||
shopping_list = response.json()
|
shopping_list = response.json()
|
||||||
self.assertIsInstance(shopping_list, list)
|
self.assertIsInstance(shopping_list, list)
|
||||||
|
|
@ -800,7 +806,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
try:
|
try:
|
||||||
# Sync the ingredients
|
# Sync the ingredients
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/shopping/current/me/ingredients",
|
"/api/v1/shopping/current/me/ingredients",
|
||||||
json=[
|
json=[
|
||||||
{
|
{
|
||||||
"id": ingredient1.id,
|
"id": ingredient1.id,
|
||||||
|
|
@ -875,7 +881,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
try:
|
try:
|
||||||
# Sync with only one ingredient (effectively removing the other)
|
# Sync with only one ingredient (effectively removing the other)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/shopping/current/me/ingredients",
|
"/api/v1/shopping/current/me/ingredients",
|
||||||
json=[
|
json=[
|
||||||
{
|
{
|
||||||
"id": ingredient1.id,
|
"id": ingredient1.id,
|
||||||
|
|
@ -949,7 +955,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
try:
|
try:
|
||||||
# Sync to keep existing, remove remove_ingredient, add new_ingredient
|
# Sync to keep existing, remove remove_ingredient, add new_ingredient
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/shopping/current/me/ingredients",
|
"/api/v1/shopping/current/me/ingredients",
|
||||||
json=[
|
json=[
|
||||||
{
|
{
|
||||||
"id": existing_ingredient.id,
|
"id": existing_ingredient.id,
|
||||||
|
|
@ -998,7 +1004,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
try:
|
try:
|
||||||
# Sync with new ingredients (negative IDs)
|
# Sync with new ingredients (negative IDs)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/shopping/current/me/ingredients",
|
"/api/v1/shopping/current/me/ingredients",
|
||||||
json=[
|
json=[
|
||||||
{
|
{
|
||||||
"id": -1,
|
"id": -1,
|
||||||
|
|
@ -1053,7 +1059,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
try:
|
try:
|
||||||
# Sync with ingredient with different ID but same line
|
# Sync with ingredient with different ID but same line
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/shopping/current/me/ingredients",
|
"/api/v1/shopping/current/me/ingredients",
|
||||||
json=[
|
json=[
|
||||||
{
|
{
|
||||||
"id": -99, # Different ID
|
"id": -99, # Different ID
|
||||||
|
|
@ -1083,13 +1089,13 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
def test_get_my_shopping_list_no_auth(self):
|
def test_get_my_shopping_list_no_auth(self):
|
||||||
"""Test that get_my_shopping_list requires authentication"""
|
"""Test that get_my_shopping_list requires authentication"""
|
||||||
# No cookie provided, should fail
|
# 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
|
self.assertEqual(response.status_code, 422) # Validation error for missing cookie
|
||||||
|
|
||||||
def test_sync_my_shopping_list_no_auth(self):
|
def test_sync_my_shopping_list_no_auth(self):
|
||||||
"""Test that sync_my_shopping_list requires authentication"""
|
"""Test that sync_my_shopping_list requires authentication"""
|
||||||
# No cookie provided, should fail
|
# 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
|
self.assertEqual(response.status_code, 422) # Validation error for missing cookie
|
||||||
|
|
||||||
def test_sync_my_shopping_list_invalid_json(self):
|
def test_sync_my_shopping_list_invalid_json(self):
|
||||||
|
|
@ -1103,7 +1109,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
try:
|
try:
|
||||||
# Send invalid ingredient data
|
# Send invalid ingredient data
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/shopping/current/me/ingredients",
|
"/api/v1/shopping/current/me/ingredients",
|
||||||
json=[
|
json=[
|
||||||
{
|
{
|
||||||
"id": "not_a_number", # Invalid ID type
|
"id": "not_a_number", # Invalid ID type
|
||||||
|
|
|
||||||
115
tests/test_v1.py
Normal file
115
tests/test_v1.py
Normal 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
|
||||||
Loading…
Reference in a new issue