| name | heater-appb-endpoint-slice |
| description | Use when adding or modifying an /api/* endpoint in HEATER App B — a new route, a new page contract, extending a response, wiring a service — or when tests/api/test_no_logic_in_routers.py or the OpenAPI snapshot test fail after touching api/ code. |
App B Endpoint Slice — the proven per-endpoint pattern
Every one of the 25+ live endpoints was built this way. Deviating breaks an AST or snapshot guard.
The 7 steps
- Contract — Pydantic models in
api/contracts/<page>.py. Shared shapes (PlayerRef, StatItem) live in api/contracts/common.py — reuse, don't redeclare. New fields on live responses must be Optional/defaulted (additive; the frontend consumes generated types).
- Service —
api/services/<page>_service.py. This is the ONE layer allowed to import src/ (lazy imports inside methods, matching streaming_service.py). All mapping, NaN-safety (_f-style coercers), and composition here. Never-raise for read paths: degrade to empty + logger.warning.
- Router —
api/routers/<page>.py, logic-free: parse params → call service → return contract. tests/api/test_no_logic_in_routers.py AST-fails any import src.* (lines 10-16) or any assignment with arithmetic BinOp (lines 24-32) in a router.
- Gate — pick the auth dependency (table below).
- DI provider —
get_<page>_service() in api/deps.py; router uses Depends(get_<page>_service) so tests can override.
- Test —
tests/api/test_api_<page>.py (the test_api_* basename marks dependency-override endpoint tests; plain test_*.py in tests/api/ = unit tests). Pattern: build a bare FastAPI(), include_router, override the service provider with a fake + override identity deps; assert status + shape. DB-free — see heater-appb-testing-ci.
- Mount + regen —
app.include_router(...) in api/main.py::create_app (routers mounted ~lines 59-107), then python scripts/export_openapi.py (writes api/openapi.json from create_app().openapi()) and, if the schema changed, cd web && pnpm gen:api (regenerates web/src/lib/api/generated.ts). CI enforces both: test_openapi_contract.py byte-compares the JSON; the openapi-ts-sync job runs pnpm gen:api + git diff --exit-code.
Gate choice
| Dependency | Use for | Behavior |
|---|
require_login (api/auth.py:197) | league-wide reads (standings, streaming, players, leaders, closers, research…) | dormant until clerk_configured(); then 401 without valid Clerk JWT |
require_viewer_context (api/tenancy.py:90) | personalized reads (team, matchup, lineup, free-agents, trade, chat, schedule…) | resolves viewer's team; call ctx.effective_team(team_name) or resolve_required_team(ctx, …) (409 TEAM_NOT_LINKED when authed+unassigned) |
require_principal (api/auth.py:187) | writes (roster_write) | deny-by-default token/Clerk verify |
require_pro (api/gating.py:34) | compute-heavy (lineup/optimize, trade/, playoff-odds, draft/) | no-op until Stripe env; then 402 for non-Pro |
New personalized endpoints MUST take the viewer context — never trust a client-supplied team_name alone (see heater-appb-two-dbs-tenancy).
Version pins (do not "upgrade in passing")
fastapi==0.137.1 + httpx==0.28.1 (requirements.txt:62-67): FastAPI auto-generates OpenAPI error schemas (ValidationError gained ctx/input in 0.137.x), so an unpinned bump fails the snapshot test with zero code change. ruff==0.14.10 must match .github/workflows/ci.yml:41.
Failure stories
- Endpoint that never worked:
POST /api/lineup/optimize shipped calling pipeline.run() — no such method → result=None → empty lineup for every user, on Railway too, while all fake-service tests stayed green (plan 2026-06-20 optimizer-enrich-slice1). Rule: after wiring a service to a real engine, run one live-ish smoke against the actual engine object, not just fakes.
- Silent id bug:
fa_service read add_player_id/drop_player_id from engine move dicts — keys the engine never emits — so every FA rec's PlayerRef.id was 0 (M0 slice 2). Rule: when mapping engine dicts, open the engine source and copy the real key names; don't guess.
When NOT to apply
Service-internal changes with no schema delta (skip step 7's regen — but run test_openapi_contract.py to prove it). Frontend-only work (heater-appb-web-frontend). Never hand-edit api/openapi.json or generated.ts — always regenerate.