Files
movies-diary/docs/adr/0014-http-adapter-and-one-refusal-shape.md

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/v1 are now {"error", "code"} JSON. Bodies that were already {"error"} gained code.
  • cargo test -p http-axum alone does not build with default features because the dev-dependency on bootstrap needs its federation feature to match; cargo test -p http-axum --no-default-features --test openapi_contract_test runs the contract test standalone, and cargo test --workspace runs it with federation on.