3.4 KiB
http-axum is the driving adapter, and every JSON refusal has one shape
The old presentation crate was renamed http-axum and now reads as what it is: the HTTP driving
adapter. It is split into three modules by audience: api (JSON under /api/v1), html (the
classic server-rendered UI, with its askama templates absorbed into html/templates) and
federation (ActivityPub-facing and federation-gated routes, compiled only with the federation
feature, ADR-0009). router.rs assembles them; handlers reach use cases only through
app_ctx.deps (ADR-0006).
A JSON client previously met several failure bodies: {"error": "..."} from ApiError, bare
status codes with empty bodies from handlers that returned StatusCode::NOT_FOUND, plain text from
domain_error_response, axum's plain-text rejections for malformed Json/Query/Path, and the
rate limiter's own 429 text. None of the refusals appeared in the OpenAPI document.
Decision
One body. api_types::ErrorResponse { error, code }. error keeps its name and meaning (the
SPA and existing clients read it); code is an additive, stable machine-readable field:
NOT_FOUND, INVALID_INPUT, CONFLICT, UNAUTHORIZED, FORBIDDEN, RATE_LIMITED, INTERNAL.
One constructor. errors::refuse(status, code, message) -> Response is the only place a JSON
error body is built. ApiError maps every DomainError variant to it with the status mapping that
existed before (InvalidRating/ValidationError 400, NotFound 404, Unauthorized 401,
Forbidden 403, InfrastructureError 500 with the generic message internal server error and the
cause logged, never sent).
Extractors. extractors::Body<T>, Params<T> and PathId<T> wrap axum's Json, Query and
Path and turn their rejections into refuse(.., INVALID_INPUT, ..). The status is whatever axum
chose for the rejection (400 for syntax errors, 415 wrong content type, 422 well-formed JSON of the
wrong shape, 413 oversize), so clients that already handled those statuses are unaffected; only the
body changed. JSON API handlers use them instead of the axum originals.
Rate limiting. The 429 of the /api/v1 limiters goes through refuse (RATE_LIMITED). The
HTML and ActivityPub limiters keep axum-governor's default response.
HTML is unchanged. Classic handlers keep domain_error_response (plain status text / HTML
pages), redirects and statuses. ActivityPub-facing routes keep their status-only errors. Only JSON
responses were unified.
The spec says so. Every operation documents at least one 4xx, every authenticated operation
documents 401, and every 4xx/5xx references ErrorResponse. tests/openapi_contract_test.rs
builds the spec from http_axum::openapi::spec() (no bootstrap) and fails when a new endpoint
skips this, together with a test that every 2xx JSON response declares a schema.
Consequences
- Statuses of existing JSON endpoints are unchanged; error bodies that were empty or plain text on
/api/v1are now{"error", "code"}JSON. Bodies that were already{"error"}gainedcode. cargo test -p http-axumalone does not build with default features because the dev-dependency onbootstrapneeds itsfederationfeature to match;cargo test -p http-axum --no-default-features --test openapi_contract_testruns the contract test standalone, andcargo test --workspaceruns it with federation on.