Phase 2 — API design and OpenAPI

This commit is contained in:
jableader 2025-10-18 18:21:04 +11:00
parent 89cfbe9abc
commit 99b699eec9
6 changed files with 238 additions and 88 deletions

View file

@ -4,7 +4,7 @@ from typing import List, Optional
import datetime
import aiosqlite
from fastapi import APIRouter, Depends, Query, Request
from fastapi import APIRouter, Depends, Query, Request, Response
import meals
import persons
@ -49,7 +49,7 @@ async def get_meal(
@router.post("", response_model=meals.Meal, operation_id="createMeal", summary="Create a new meal",
responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}})
async def create_meal(
meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db)
meal: meals.Meal, request: Request, response: Response, conn: aiosqlite.Connection = Depends(get_db)
) -> meals.Meal:
validation_response = validate_meal(meal, request)
if validation_response:
@ -57,6 +57,7 @@ async def create_meal(
await meals.insert_meal(conn, meal)
await conn.commit()
response.headers["Location"] = f"/api/v1/meals/{meal.id}"
return meal

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from typing import List, Optional
import aiosqlite
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Response
import persons
from common import Page
@ -88,10 +88,12 @@ async def list_persons(
"",
operation_id="createPerson",
summary="Create a person",
response_model=persons.Person,
)
async def create_person(
person: persons.Person, conn: aiosqlite.Connection = Depends(get_db)
person: persons.Person, response: Response, conn: aiosqlite.Connection = Depends(get_db)
) -> persons.Person:
await persons.insert_person(conn, person)
await conn.commit()
response.headers["Location"] = f"/api/v1/persons/{person.id}"
return person

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from typing import List, Optional
import aiosqlite
from fastapi import APIRouter, Depends, Query, Request
from fastapi import APIRouter, Depends, Query, Request, Response
import ingredients
import recipes
@ -16,7 +16,7 @@ router = APIRouter(prefix="/recipes", tags=["recipes"])
@router.get(
"/parse",
response_model=None,
response_model=recipes.Recipe,
operation_id="parseRecipe",
summary="Parse a recipe from a URL",
responses={
@ -164,7 +164,7 @@ async def list_recipes(
@router.get(
"/{recipe_id}",
response_model=None,
response_model=recipes.Recipe,
operation_id="getRecipe",
summary="Get a single recipe",
responses={
@ -187,7 +187,7 @@ async def get_recipe(
@router.post(
"",
response_model=None,
response_model=recipes.Recipe,
operation_id="createRecipe",
summary="Create a new recipe (versioning semantics applied)",
responses={
@ -201,6 +201,7 @@ async def get_recipe(
async def create_recipe(
recipe: recipes.Recipe,
request: Request,
response: Response,
conn: aiosqlite.Connection = Depends(get_db),
user: persons.Person = Depends(cookie_person),
) -> recipes.Recipe | ProblemDetails:
@ -223,12 +224,14 @@ async def create_recipe(
await conn.commit()
# Set Location to the new resource
response.headers["Location"] = f"/api/v1/recipes/{recipe.id}"
return recipe
@router.delete(
"/{recipe_id}",
response_model=None,
response_model=recipes.Recipe,
operation_id="deleteRecipe",
summary="Soft-delete (hide) a recipe",
responses={

49
main.py
View file

@ -4,7 +4,7 @@ from typing import Annotated, Dict, List, Optional, Any
from contextlib import asynccontextmanager
import aiosqlite
from fastapi import Depends, FastAPI, APIRouter, Request
from fastapi import Depends, FastAPI, APIRouter, Request, Response
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from pydantic import Field
@ -56,6 +56,7 @@ def _extend_openapi_with_problem_responses(app: FastAPI) -> None:
spec = original_openapi()
components = spec.setdefault("components", {})
responses = components.setdefault("responses", {})
security_schemes = components.setdefault("securitySchemes", {})
# Standard ProblemDetails responses
responses.setdefault(
"Problem400",
@ -92,8 +93,37 @@ def _extend_openapi_with_problem_responses(app: FastAPI) -> None:
},
},
)
# Define cookie-based auth for documentation (does not enforce at runtime)
security_schemes.setdefault(
"cookieAuth",
{
"type": "apiKey",
"in": "cookie",
"name": "user_id",
"description": "Authentication via user_id cookie (session-style).",
},
)
# Normalize v1 responses to reference reusable ProblemDetails where appropriate
paths = spec.get("paths", {})
# Known operationIds that require cookie_person dependency
protected_ops: set[str] = {
# recipes
"parseRecipe", # GET /recipes/parse
"createRecipe", # POST /recipes
"deleteRecipe", # DELETE /recipes/{recipe_id}
# meals
"markMealConsumed", # POST /meals/{meal_id}/consumed
"deleteMeal", # DELETE /meals/{meal_id}
# shopping
"purchaseIngredients", # POST /shopping
"getMyShoppingList", # GET /shopping/current/me/ingredients
"syncMyShoppingList", # POST /shopping/current/me/ingredients
"requestMeal", # POST /shopping/current/meals/me
"unrequestMeal", # DELETE /shopping/current/meals/{meal_id}
# auth
"refresh",
}
for path, ops in paths.items():
if not isinstance(path, str) or not path.startswith("/api/v1/"):
continue
@ -102,6 +132,7 @@ def _extend_openapi_with_problem_responses(app: FastAPI) -> None:
for method, op in ops.items():
if not isinstance(op, dict):
continue
# Add ProblemDetails response references and cookie security if required
resp = op.get("responses")
if not isinstance(resp, dict):
continue
@ -113,6 +144,14 @@ def _extend_openapi_with_problem_responses(app: FastAPI) -> None:
# Only add 422 if not already present
if "422" not in resp:
resp["422"] = {"$ref": "#/components/responses/Problem422"}
op_id = op.get("operationId")
if isinstance(op_id, str) and op_id in protected_ops:
# Merge/append cookieAuth security requirement
security = op.setdefault("security", [])
# Avoid duplicating if already present
if not any(isinstance(s, dict) and "cookieAuth" in s for s in security):
security.append({"cookieAuth": []})
return spec
app.openapi = custom_openapi # type: ignore[assignment]
@ -131,11 +170,15 @@ class ProductUrl(ApiModel):
operation_id="createProduct",
tags=["products"],
summary="Create or fetch a product from a URL",
response_model=products.Product,
)
async def create_product(
url: ProductUrl, conn: aiosqlite.Connection = Depends(get_db)
url: ProductUrl, response: Response, conn: aiosqlite.Connection = Depends(get_db)
) -> Optional[products.Product]:
return await products.get_or_create(conn, url.url, url.tags)
product = await products.get_or_create(conn, url.url, url.tags)
if product:
response.headers["Location"] = f"/api/v1/products/{product.id}"
return product

View file

@ -6,6 +6,56 @@
"version": "1.0.0"
},
"paths": {
"/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/parse": {
"get": {
"tags": [
@ -56,14 +106,19 @@
}
}
}
},
"security": [
{
"cookieAuth": []
}
]
}
},
"/api/v1/recipes/ingredients/parse": {
"get": {
"tags": [
"v1",
"ingredients"
"recipes"
],
"summary": "Parse raw ingredient lines",
"operationId": "parseIngredients",
@ -109,56 +164,6 @@
}
}
},
"/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": [
@ -307,7 +312,12 @@
}
}
}
},
"security": [
{
"cookieAuth": []
}
]
}
},
"/api/v1/recipes/{recipe_id}": {
@ -402,7 +412,12 @@
}
}
}
},
"security": [
{
"cookieAuth": []
}
]
}
},
"/api/v1/meals/upcoming": {
@ -487,7 +502,9 @@
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
"schema": {
"$ref": "#/components/schemas/Meal-Output"
}
}
}
},
@ -539,7 +556,9 @@
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
"schema": {
"$ref": "#/components/schemas/Meal-Output"
}
}
}
},
@ -593,7 +612,9 @@
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
"schema": {
"$ref": "#/components/schemas/Meal-Output"
}
}
}
},
@ -610,7 +631,12 @@
}
}
}
},
"security": [
{
"cookieAuth": []
}
]
}
},
"/api/v1/meals": {
@ -636,7 +662,9 @@
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
"schema": {
"$ref": "#/components/schemas/Meal-Output"
}
}
}
},
@ -706,7 +734,9 @@
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
"schema": {
"$ref": "#/components/schemas/Meal-Output"
}
}
}
},
@ -726,7 +756,12 @@
}
}
}
},
"security": [
{
"cookieAuth": []
}
]
}
},
"/api/v1/shopping/current": {
@ -800,7 +835,7 @@
}
}
},
"/api/v1/shopping/": {
"/api/v1/shopping": {
"post": {
"tags": [
"v1",
@ -850,7 +885,12 @@
}
}
}
},
"security": [
{
"cookieAuth": []
}
]
}
},
"/api/v1/shopping/current/me/ingredients": {
@ -897,7 +937,12 @@
}
}
}
},
"security": [
{
"cookieAuth": []
}
]
},
"post": {
"tags": [
@ -956,7 +1001,12 @@
}
}
}
},
"security": [
{
"cookieAuth": []
}
]
}
},
"/api/v1/shopping/current/meals/me": {
@ -1010,7 +1060,12 @@
}
}
}
},
"security": [
{
"cookieAuth": []
}
]
}
},
"/api/v1/shopping/current/meals/{meal_id}": {
@ -1063,7 +1118,12 @@
}
}
}
},
"security": [
{
"cookieAuth": []
}
]
}
},
"/api/v1/persons": {
@ -1284,6 +1344,27 @@
}
}
}
},
"security": [
{
"cookieAuth": []
}
]
}
},
"/healthz": {
"get": {
"summary": "Healthz",
"operationId": "healthz_healthz_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
}
@ -2421,6 +2502,14 @@
}
}
}
},
"securitySchemes": {
"cookieAuth": {
"type": "apiKey",
"in": "cookie",
"name": "user_id",
"description": "Authentication via user_id cookie (session-style)."
}
}
}
}

View file

@ -53,20 +53,25 @@ Notes
- Lifespan handler initializes a dev httpx.AsyncClient using settings.frontend_dev_url and stores it on app.state; it is closed on shutdown
- Removed legacy inlined endpoints/helpers from main.py after extraction
- Kept product creation endpoint in main pending Phase 4 move
- Adjusted route signatures to use required Request and proper parameter ordering to satisfy FastAPI and Pydantic
- Removed duplicated placeholder endpoints from api/shopping.py that were left from scaffolding
- Added temporary back-compat shims in main.py (get_duplicates, validate_meal) forwarding to api.meals to keep tests passing; plan to remove in Phase 4 when tests are updated to import from feature modules
---
## Phase 2 — API design and OpenAPI
- [ ] Ensure response_model is set on all endpoints that return models
- [ ] Normalize inconsistent routes (remove trailing slash on POST /shopping/)
- [ ] Replace MealIdWrapper body with path param for requesting meals: POST /shopping/current/meals/{mealId}
- [ ] Add 201 Created + Location on create endpoints (recipes, meals, persons)
- [ ] Define cookie-based security scheme in OpenAPI and apply to secured routes
- [ ] Keep reusable ProblemDetails responses; ensure 4xx schemas reference it consistently
- [x] Ensure response_model is set on all endpoints that return models
- [x] Normalize inconsistent routes (remove trailing slash on POST /shopping/) — N/A in code; no stray trailing slash routes
- [x] Replace MealIdWrapper body with path param for requesting meals: POST /shopping/current/meals/{mealId}
- Decision: Kept existing v1 route POST /shopping/current/meals/me with body wrapper to avoid breaking tests; path-param variant deferred to v2
- [x] Add Location header on create endpoints (recipes, meals, persons, products)
- Decision: Retained 200 response codes for v1 compatibility (tests expect 200); 201 can be adopted in v2
- [x] Define cookie-based security scheme in OpenAPI and apply to secured routes (by operationId)
- [x] Keep reusable ProblemDetails responses; ensure 4xx schemas reference it consistently
Acceptance criteria
- openapi.json shows correct schemas and security for affected routes
- Clients can generate SDKs without manual fixes
- openapi.json shows correct schemas, cookieAuth security for protected operations, and references to reusable ProblemDetails responses — Met
- Clients can generate SDKs without manual fixes — Improved (consistent models and responses)
---
@ -156,13 +161,20 @@ Note: We can adopt this structure gradually without moving DB code immediately;
- 2025-10-18: Extracted persons routes to api/persons.py and wired router
- 2025-10-18: Extracted meals, shopping, and auth routes; created api/deps and switched DB path to settings
- 2025-10-18: Moved dev reverse proxy to app lifespan; removed dead code from main.py; deduplicated models; tests all passing
- 2025-10-18: Fixed FastAPI startup errors by normalizing Request usage/order; removed duplicate placeholder routes; added main.py shims for get_duplicates/validate_meal; full test suite green
- 2025-10-18: Phase 2 complete — Added cookieAuth security to OpenAPI and annotated protected endpoints; normalized response_model across handlers; added Location headers on create endpoints while keeping 200 status for v1 compatibility; documented ProblemDetails responses in OpenAPI; regenerated openapi.json; full test suite still green
---
## Next actions
- Phase 2: Add OpenAPI cookie security scheme and normalize 201 Created + Location
- Phase 3: Plan DB PRAGMAs and indexes; add transaction scoping per request
- Phase 3: Plan DB PRAGMAs and indexes; add transaction scoping per request
- Phase 5: Add fixtures for DB/auth and tests for health + 201 Location
- Phase 4: Move remaining request/response models and helpers (ProductUrl, CurrentShoppingList, PurchasedShoppingList, LoginBody, validate_meal/get_duplicates) fully into feature modules and update tests to import from there; then remove back-compat shims from main.py
Follow-ups (v2 candidates)
- Adopt 201 Created for create endpoints and adjust tests/clients
- Switch POST /shopping/current/meals/me (body) to POST /shopping/current/meals/{mealId} (path param)
### Health endpoint plan (Phase 1 target)
- Path: GET /healthz