Remove v1 API surface, skip legacy tests; finalize v2-only routing and docs

This commit is contained in:
jableader 2025-11-01 16:18:46 +11:00
parent 1d60a3d925
commit aa18a1502d
8 changed files with 202 additions and 1908 deletions

View file

@ -63,16 +63,7 @@ def extend_with_problem_and_cookie_auth(app: FastAPI) -> None:
}, },
) )
# Cookie-based auth for v1 documentation (does not enforce at runtime) # Remove legacy cookieAuth; JWT bearer is the only auth now
security_schemes.setdefault(
"cookieAuth",
{
"type": "apiKey",
"in": "cookie",
"name": "user_id",
"description": "Authentication via user_id cookie (session-style).",
},
)
# Bearer (JWT) auth for v2 # Bearer (JWT) auth for v2
security_schemes.setdefault( security_schemes.setdefault(
@ -85,22 +76,8 @@ def extend_with_problem_and_cookie_auth(app: FastAPI) -> None:
}, },
) )
# Normalize v1 responses and mark cookie security for known endpoints # Normalize responses (RFC7807) but do not add cookie auth
paths = spec.get("paths", {}) paths = spec.get("paths", {})
protected_ops: set[str] = {
"parseRecipe",
"createRecipe",
"deleteRecipe",
"markMealConsumed",
"deleteMeal",
"purchaseIngredients",
"getMyShoppingList",
"syncMyShoppingList",
"requestMeal",
"unrequestMeal",
"refresh",
"refreshV2",
}
for path, ops in paths.items(): for path, ops in paths.items():
if not isinstance(path, str) or not path.startswith("/api/v1/"): if not isinstance(path, str) or not path.startswith("/api/v1/"):
continue continue
@ -119,25 +96,6 @@ def extend_with_problem_and_cookie_auth(app: FastAPI) -> None:
if "422" not in resp: if "422" not in resp:
resp["422"] = {"$ref": "#/components/responses/Problem422"} resp["422"] = {"$ref": "#/components/responses/Problem422"}
op_id = op.get("operationId")
if isinstance(op_id, str) and op_id in protected_ops:
security = op.setdefault("security", [])
if not any(isinstance(s, dict) and "cookieAuth" in s for s in security):
security.append({"cookieAuth": []})
# Ensure the cookie parameter is documented as required integer (non-null)
params = op.get("parameters")
if isinstance(params, list):
for p in params:
if not isinstance(p, dict):
continue
if p.get("in") == "cookie" and p.get("name") == "user_id":
p["required"] = True
schema = p.setdefault("schema", {})
if isinstance(schema, dict):
schema.clear()
schema.update({"type": "integer", "title": "User Id"})
# Keep endpoint-specific schemas driven by route declarations only (no forced overrides) # Keep endpoint-specific schemas driven by route declarations only (no forced overrides)
# Normalize outward-facing shopping list storeName enum to avoid empty-string value # Normalize outward-facing shopping list storeName enum to avoid empty-string value
@ -162,8 +120,7 @@ def extend_with_problem_and_cookie_auth(app: FastAPI) -> None:
if isinstance(store, dict) and store.get("$ref") == "#/components/schemas/StoreEnum": if isinstance(store, dict) and store.get("$ref") == "#/components/schemas/StoreEnum":
props["storeName"] = {"$ref": "#/components/schemas/StoreNameOut"} props["storeName"] = {"$ref": "#/components/schemas/StoreNameOut"}
# Mark bearer security for v2 routes we know require auth # Mark bearer security for protected routes: /api/v1/users/me/* and /api/v1/households/*
# Simple heuristic: underline select paths under /api/v1/users/me and /api/v1/households/* that are protected
for path, ops in paths.items(): for path, ops in paths.items():
if not isinstance(path, str) or not path.startswith("/api/v1/"): if not isinstance(path, str) or not path.startswith("/api/v1/"):
continue continue

View file

@ -10,9 +10,9 @@
This document has been validated against the current codebase (v1) to ensure the plan captures all required changes. It starts with a concise baseline of what exists today, then details the v2 multi-tenancy/auth refactor with concrete, file-scoped steps and acceptance criteria. This document has been validated against the current codebase (v1) to ensure the plan captures all required changes. It starts with a concise baseline of what exists today, then details the v2 multi-tenancy/auth refactor with concrete, file-scoped steps and acceptance criteria.
Date reviewed: 2025-11-01 (updated after completing meals write flows and scoped endpoints) Date reviewed: 2025-11-01 (updated after completing meals write flows, scoped endpoints, and v1 removal)
Repo modules checked: `main.py`, `api/*`, `persons/*`, `meals/*`, `ingredients/*`, `recipes/*`, `products/*`, `shopping/*`, `common.py`, `db.py`, `settings.py`, tests in `tests/*`. Repo modules checked: `main.py`, `api/*` (v2 routers only), `persons/*` (legacy, pending removal), `meals/*`, `ingredients/*`, `recipes/*`, `products/*`, `shopping/*`, `common.py`, `db.py`, `settings.py`, tests in `tests/*`.
Key conventions in v1: Key conventions in v1:
- Response shape uses camelCase aliases (via `ApiModel` in `common.py`). - Response shape uses camelCase aliases (via `ApiModel` in `common.py`).
@ -22,7 +22,7 @@ Key conventions in v1:
--- ---
## 0. Current v1 Baseline (validated) ## 0. Current v1 Baseline (retired)
### 0.1 Auth (prototype) ### 0.1 Auth (prototype)
- Mechanism: `user_id` cookie containing a Person ID. - Mechanism: `user_id` cookie containing a Person ID.
@ -33,9 +33,9 @@ Key conventions in v1:
- `cookie_person`: requires cookie, loads Person by id; 401 if unknown id, 422 if cookie missing (FastAPI validation). - `cookie_person`: requires cookie, loads Person by id; 401 if unknown id, 422 if cookie missing (FastAPI validation).
- `error_response`: builds RFC7807 responses. - `error_response`: builds RFC7807 responses.
Special-case 401: In `main.py`, missing `user_id` cookie on POST `/api/v1/shopping` is mapped from 422 → 401 for “Unauthorized”. All other protected endpoints surface 422 when the cookie is missing. Special-case 401: Removed. v1 cookie-based auth and routes have been retired in favor of JWT-only v2.
### 0.2 API surface ### 0.2 API surface (historical)
- Persons (`api/persons.py`) - Persons (`api/persons.py`)
- GET `/api/v1/persons``Page<Person>` with optional name filter `q`, cursor pagination. - GET `/api/v1/persons``Page<Person>` with optional name filter `q`, cursor pagination.
- POST `/api/v1/persons` → create Person; sets `Location` header. - POST `/api/v1/persons` → create Person; sets `Location` header.
@ -76,7 +76,7 @@ Special-case 401: In `main.py`, missing `user_id` cookie on POST `/api/v1/shoppi
- ShoppingList(id PK, created_date, store_name, purchased_by_id FK) - ShoppingList(id PK, created_date, store_name, purchased_by_id FK)
- ShoppingListItem(id PK, ingredient_id FK, list_id FK NULL for requests, person_id FK, meal_id FK, recipe_id FK, created_date) - ShoppingListItem(id PK, ingredient_id FK, list_id FK NULL for requests, person_id FK, meal_id FK, recipe_id FK, created_date)
### 0.4 Validated behaviors and invariants ### 0.4 Validated behaviors and invariants (carried forward into v2 where applicable)
- ProblemDetails content-type returned for 400/404/422. - ProblemDetails content-type returned for 400/404/422.
- Pagination: `cursor` is treated leniently (invalid → start). `prevCursor` is computed by a DB helper; `total` is non-null integer. - Pagination: `cursor` is treated leniently (invalid → start). `prevCursor` is computed by a DB helper; `total` is non-null integer.
- Recipe creation requires ≥1 ingredient; soft-deletes older version when updating. - Recipe creation requires ≥1 ingredient; soft-deletes older version when updating.
@ -96,7 +96,8 @@ Special-case 401: In `main.py`, missing `user_id` cookie on POST `/api/v1/shoppi
--- ---
## 1. Objective ## 1. Objective (status)
v2 household-scoped API is complete and v1 has been removed from the app. Tests have been migrated to v2 equivalents or disabled when purely legacy. OpenAPI reflects JWT bearer and household scoping.
Refactor the backend from a single-tenant architecture to a robust, multi-tenant system based on "Households". This requires evolving the data model to enforce data isolation, overhauling the authentication system to support standard credential types, and introducing an invitation mechanism for household management. This plan is adapted to the existing codebase. Refactor the backend from a single-tenant architecture to a robust, multi-tenant system based on "Households". This requires evolving the data model to enforce data isolation, overhauling the authentication system to support standard credential types, and introducing an invitation mechanism for household management. This plan is adapted to the existing codebase.
@ -260,7 +261,7 @@ Impact on existing routes (exact files to refactor):
- ⏳ Email Delivery: Stub only. Pending adding an email sender utility/service and persistence of delivery state. - ⏳ Email Delivery: Stub only. Pending adding an email sender utility/service and persistence of delivery state.
- **Acceptance**: Admins can invite by email; invite accept adds user to household; listing households shows membership with roles. (Met for API behavior; email sending pending.) - **Acceptance**: Admins can invite by email; invite accept adds user to household; listing households shows membership with roles. (Met for API behavior; email sending pending.)
5. **[~] Update OpenAPI Specification**: 5. **[] Update OpenAPI Specification**:
- ✅ Augmentation updated in `api/openapi.py`: - ✅ Augmentation updated in `api/openapi.py`:
- Adds `bearerAuth` security scheme while keeping `cookieAuth` for v1. - Adds `bearerAuth` security scheme while keeping `cookieAuth` for v1.
- Marks household routes and `/users/me/*` with bearer security and adds `403` Problem response. - Marks household routes and `/users/me/*` with bearer security and adds `403` Problem response.
@ -268,11 +269,10 @@ Impact on existing routes (exact files to refactor):
- ✅ New tests `tests/test_openapi_security.py` verify presence of bearerAuth and 403s. - ✅ New tests `tests/test_openapi_security.py` verify presence of bearerAuth and 403s.
- ✅ Export script writes updated `openapi.json`; re-run after adding meals v2 write endpoints to include them in the schema. - ✅ Export script writes updated `openapi.json`; re-run after adding meals v2 write endpoints to include them in the schema.
6. **[ ] Refactor and Test**: 6. **[✅] Refactor and Test**:
- Update `tests/` to reflect the new API structure and authentication. Tests will need to be updated to handle the `{householdSlug}` path parameter and provide a valid JWT. - Tests updated to v2 household-scoped endpoints with JWT setup. Legacy v1 API tests are marked skipped (kept only as historical reference).
- Manually test all API flows to ensure data is strictly isolated between households. - Full suite green under `make all-checks`. OpenAPI export updated successfully.
- **Cleanup**: Once all tests pass, remove the `persons/` package and any lingering references to it. - Next cleanup: remove `persons/` package and remaining references in domain internals once users fully replace persons in models.
- Keep existing v1 behavior parity (ProblemDetails, Location headers on create, pagination envelopes, ingredient parsing behavior). Update/extend tests in: `tests/test_main.py`, `tests/test_v1.py`, `tests/test_shopping*.py`.
--- ---

69
main.py
View file

@ -9,16 +9,10 @@ from pydantic import ValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from api import ( from api import (
auth as auth_router,
auth_v2 as auth_v2_router, auth_v2 as auth_v2_router,
meals as meals_router,
persons as persons_router,
products as products_router,
recipes as recipes_router,
recipes_v2 as recipes_v2_router, recipes_v2 as recipes_v2_router,
meals_v2 as meals_v2_router, meals_v2 as meals_v2_router,
shopping_v2 as shopping_v2_router, shopping_v2 as shopping_v2_router,
shopping as shopping_router,
households as households_router, households as households_router,
) )
from api.deps import ( from api.deps import (
@ -96,33 +90,7 @@ async def request_validation_exc_handler(request: Request, exc: Exception):
for e in exc.errors(): for e in exc.errors():
loc = ".".join([str(p) for p in e.get("loc", [])]) loc = ".".join([str(p) for p in e.get("loc", [])])
errors.setdefault(loc, []).append(e.get("msg")) errors.setdefault(loc, []).append(e.get("msg"))
# Special-case: Missing user_id cookie on POST /api/v1/shopping should be 401 # Legacy cookie-based auth behavior removed; standard 422 for validation errors
try:
is_shopping_post = (
request.method.upper() == "POST" and request.url.path == "/api/v1/shopping"
)
except Exception:
is_shopping_post = False
if is_shopping_post:
if any(
isinstance(e.get("loc"), (list, tuple))
and len(e.get("loc")) >= 2
and e.get("loc")[0] == "cookie"
and e.get("loc")[1] == "user_id"
for e in exc.errors()
):
body = ProblemDetails(
title="Unauthorized",
status=401,
type="https://httpstatuses.com/401",
instance=str(request.url),
errors=errors,
)
return JSONResponse(
content=body.model_dump(by_alias=True),
status_code=401,
media_type="application/problem+json",
)
body = ProblemDetails( body = ProblemDetails(
title="Validation Error", title="Validation Error",
@ -160,23 +128,20 @@ def create_app() -> FastAPI:
app.add_exception_handler(RequestValidationError, request_validation_exc_handler) app.add_exception_handler(RequestValidationError, request_validation_exc_handler)
# Routers # Routers
app.include_router(products_router.router, prefix="/api/v1", tags=["v1"]) # extracted # v1 routers removed; v2 household-scoped and JWT-only API below
app.include_router(recipes_router.router, prefix="/api/v1", tags=["v1"]) # extracted # v2 JWT auth and households
app.include_router(meals_router.router, prefix="/api/v1", tags=["v1"]) # extracted app.include_router(auth_v2_router.router, prefix="/api/v1", tags=["auth"]) # canonical
app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted app.include_router(households_router.router, prefix="/api/v1", tags=["households"]) # canonical
app.include_router(persons_router.router, prefix="/api/v1", tags=["v1"]) # extracted
app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted
# Experimental v2 auth endpoints (JWT to be implemented). Kept alongside v1 during transition.
app.include_router(auth_v2_router.router, prefix="/api/v1", tags=["v2"])
app.include_router(households_router.router, prefix="/api/v1", tags=["v2"]) # new
# Mount household-scoped endpoints # Mount household-scoped endpoints
try: try:
app.include_router(households_router.scoped, prefix="/api/v1", tags=["v2"]) app.include_router(
households_router.scoped, prefix="/api/v1", tags=["households"]
) # scoped
except Exception: except Exception:
pass pass
app.include_router(recipes_v2_router.router, prefix="/api/v1", tags=["v2"]) # new app.include_router(recipes_v2_router.router, prefix="/api/v1", tags=["recipes"]) # canonical
app.include_router(meals_v2_router.router, prefix="/api/v1", tags=["v2"]) # new app.include_router(meals_v2_router.router, prefix="/api/v1", tags=["meals"]) # canonical
app.include_router(shopping_v2_router.router, prefix="/api/v1", tags=["v2"]) # new app.include_router(shopping_v2_router.router, prefix="/api/v1", tags=["shopping"]) # canonical
# Routes # Routes
app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus) app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus)
@ -193,10 +158,20 @@ def create_app() -> FastAPI:
from starlette.responses import StreamingResponse from starlette.responses import StreamingResponse
async def _reverse_proxy(request: StarletteRequest): async def _reverse_proxy(request: StarletteRequest):
# Do not proxy API routes; return 404 to let API clients fail fast in dev
if str(request.url.path).startswith("/api/"):
from starlette.exceptions import HTTPException as StarletteHTTPException
raise StarletteHTTPException(status_code=404)
import httpx import httpx
url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8")) url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8"))
client = app.state.proxy_client client = getattr(app.state, "proxy_client", None)
if client is None:
from starlette.exceptions import HTTPException as StarletteHTTPException
raise StarletteHTTPException(status_code=503, detail="Proxy not configured")
rp_req = client.build_request( rp_req = client.build_request(
request.method, url, headers=request.headers.raw, content=request.stream() request.method, url, headers=request.headers.raw, content=request.stream()
) )

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,10 @@ class TestHealthAndLocationHeaders(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self): async def asyncSetUp(self):
self.conn = await connect(":memory:") self.conn = await connect(":memory:")
await create(self.conn) await create(self.conn)
# Ensure v2 household schema is present
from scripts.migration_to_households import run_migration
await run_migration(self.conn)
await test_data.create_test_data(self.conn) await test_data.create_test_data(self.conn)
reload_test_data() reload_test_data()
@ -28,12 +32,17 @@ class TestHealthAndLocationHeaders(unittest.IsolatedAsyncioTestCase):
main.app.dependency_overrides[main.get_db] = override_get_db main.app.dependency_overrides[main.get_db] = override_get_db
# Always act as an authenticated user for tests that require auth
async def override_cookie_person():
return test_data.Persons.jacob
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
self.client = TestClient(main.app) self.client = TestClient(main.app)
# Register user and create a household
r = self.client.post(
"/api/v1/auth/register",
json={"email": "loc@test.com", "password": "pw", "displayName": "Loc"},
)
assert r.status_code == 200, r.text
self.headers = {"Authorization": f"Bearer {r.json()['accessToken']}"}
r2 = self.client.post("/api/v1/households", headers=self.headers, json={"name": "Locals"})
assert r2.status_code == 200, r2.text
self.slug = r2.json()["slug"]
return await super().asyncSetUp() return await super().asyncSetUp()
async def asyncTearDown(self) -> None: async def asyncTearDown(self) -> None:
@ -47,8 +56,8 @@ class TestHealthAndLocationHeaders(unittest.IsolatedAsyncioTestCase):
assert resp.json() == {"status": "ok"} assert resp.json() == {"status": "ok"}
def test_location_headers_on_create(self): def test_location_headers_on_create(self):
# Use an existing seeded person from test data (avoids cross-request transaction issues) # Use the registered user id placeholder for v2 meal participants
person_id = test_data.Persons.jacob.id person = {"id": 1, "name": "Loc"}
# Skip recipe endpoint complexity here; covered by other tests # Skip recipe endpoint complexity here; covered by other tests
@ -56,9 +65,9 @@ class TestHealthAndLocationHeaders(unittest.IsolatedAsyncioTestCase):
meal_body = { meal_body = {
"id": -1, "id": -1,
"suggestedDate": "2024-06-01T18:00:00+00:00", "suggestedDate": "2024-06-01T18:00:00+00:00",
"chefs": [{"id": person_id, "name": "Jacob"}], "chefs": [person],
"cleanup": [{"id": person_id, "name": "Jacob"}], "cleanup": [person],
"consumers": [{"id": person_id, "name": "Jacob"}], "consumers": [person],
"recipes": [], "recipes": [],
"extraIngredients": [ "extraIngredients": [
{ {
@ -71,6 +80,8 @@ class TestHealthAndLocationHeaders(unittest.IsolatedAsyncioTestCase):
} }
], ],
} }
resp_meal = self.client.post("/api/v1/meals", json=meal_body) resp_meal = self.client.post(
f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=meal_body
)
assert resp_meal.status_code == 200 assert resp_meal.status_code == 200
assert "Location" in resp_meal.headers assert "Location" in resp_meal.headers

View file

@ -27,6 +27,7 @@ import products
import shopping import shopping
@unittest.skip("Legacy v1 API removed; covered by v2 tests")
class TestMainAPI(unittest.IsolatedAsyncioTestCase): class TestMainAPI(unittest.IsolatedAsyncioTestCase):
"""Test the main FastAPI application endpoints""" """Test the main FastAPI application endpoints"""
@ -403,6 +404,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
self.assertIn("Person not found", response.json()["title"]) self.assertIn("Person not found", response.json()["title"])
@unittest.skip("Legacy v1 API removed; covered by v2 tests")
class TestMainHelperFunctions(unittest.TestCase): class TestMainHelperFunctions(unittest.TestCase):
"""Test helper functions in main.py""" """Test helper functions in main.py"""
@ -536,6 +538,7 @@ class TestMainHelperFunctions(unittest.TestCase):
self.assertEqual(result.status_code, 400) self.assertEqual(result.status_code, 400)
@unittest.skip("Legacy v1 API removed; covered by v2 tests")
class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
"""Test endpoints that require authentication""" """Test endpoints that require authentication"""

View file

@ -12,6 +12,7 @@ def reload_test_data():
test_data = importlib.reload(test_data) test_data = importlib.reload(test_data)
@unittest.skip("Legacy v1 shopping API removed; see v2 household-scoped tests.")
class TestShoppingAPI(unittest.IsolatedAsyncioTestCase): class TestShoppingAPI(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self): async def asyncSetUp(self):
self.conn = await connect(":memory:") self.conn = await connect(":memory:")

View file

@ -13,6 +13,7 @@ def reload_test_data():
test_data = importlib.reload(test_data) test_data = importlib.reload(test_data)
@unittest.skip("Legacy v1 API removed; covered by v2 household-scoped tests")
class TestV1API(unittest.IsolatedAsyncioTestCase): class TestV1API(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self): async def asyncSetUp(self):
self.conn = await connect(":memory:") self.conn = await connect(":memory:")