← HTTP API

Contract checks

http api · conformance
Decisions taken
Cross-cutting invariants are owned by the layer, not the module Error shape, UTC serialization, pagination, rate limit — identical across all routers. One parameterized suite runs against the ASGI app and sweeps the registered routes automatically: a new endpoint inherits the checks without carrying its own copy. Modules test the semantics of their own resources (who owns it, which code for which violation) — these shared facets aren't duplicated there.
The rules live on the HTTP API map Here — their enforcement by test. A rule states "how it should be," the case below verifies it holds, on every router of both tiers (/api/v1, /public/v1).
Stack and coverage
Tooling httpx · ASGI transport — the real app without a network
Route sweep introspection of app.routes — every registered route in the parameters
Schema Schemathesis over OpenAPI — catches 500s and schema mismatches; the envelope and semantics are covered by the cases below
Markers @pytest.mark.api — the contract layer over the app
API · P1 Cross-cutting invariants — one contract for all routers httpx · ASGI · parameterized over app.routes
test_api_conformance.py
Error · codes · time · version · identity · rate limit
Cases
single error shapeany failure → RFC 9457 application/problem+json: {type, title, status, detail, code, request_id} (+ errors[] on 422); the client reads the machine code, doesn't parse text; one shape across all routers
business violation → 4xx, not 500a rule violation returns its own code (401 · 403 · 404 · 409 · 422 · 429), doesn't fall through to 500; 500 is only an unplanned failure
code vocabulary mapped401 no identity · 403 no permission · 404 no resource (private masked as 404, not 403) · 409 conflict/lock · 422 body validation · 429 rate limit
time — UTC ISO-8601every datetime in a response serializes as …Z (UTC); neither naive nor local zone; the timezone is applied by the frontend
version in the path/api/v1 · /public/v1; no version → 404; a breaking change → a new version, the old one still lives
identity before the handlera JWT or an ach_… key resolves to user_id → role → ACL before entering the handler; no identity → 401 uniformly on protected routes
rate limit → 429 + Retry-Afterexceeding the window → 429 with a Retry-After header; the body is the same envelope (rate-limit)
no bare routesparameterized over app.routes: every protected route answers 401 without identity — a new endpoint won't slip past the check
test_api_pagination.py
One list contract for list GETs
Cases
single page envelopea list GET returns the same shape (items + cursor/total), one across all routers, not a per-endpoint schema
page-size capa request over the server cap is trimmed to the limit, not an unbounded result; a default size when the parameter is absent
stable orderingsorting is deterministic on equal keys (tie-break by id) — pages don't overlap or drop rows
cursor survives insertioncursor pagination doesn't shift the window when new rows are inserted between page requests
bad parameter → 422a negative/non-numeric limit, a corrupt cursor → 422 (envelope), not 500 and not a silent default