axum-api-kit
Shared response types for Axum JSON APIs.
Every Axum CRUD service defines the same ApiError, HealthResponse, and paginated list types. This crate provides one canonical implementation.
Stability
As of 1.0.0 the public API is stable and follows semantic versioning:
breaking changes will only ship in a new major version. Optional features (validator,
sqlx, extract, trace, router, cors, openapi, problem) track their upstream
crates and may update those bounds in a minor release.
See ROADMAP.md for the full post-1.0 maintenance policy (MSRV, dependency tracking, 2.0 triggers).
Installation
= "1"
Optional integrations are gated behind feature flags:
# request extractors (Pagination, CursorPagination, ApiJson)
= { = "1", = ["extract"] }
# JSON validation (ValidatedJson, From<ValidationErrors>)
= { = "1", = ["validator"] }
# observability middleware (request id + tracing)
= { = "1", = ["trace"] }
# health-probe router (/healthz, /readyz)
= { = "1", = ["router"] }
# CORS layer helper (tower-http)
= { = "1", = ["cors"] }
# OpenAPI schemas (utoipa ToSchema on the response types)
= { = "1", = ["openapi"] }
# RFC 9457 problem+json responses (Problem)
= { = "1", = ["problem"] }
Types
ApiError
A machine-readable JSON error body with code, message, and optional details.
use IntoResponse;
use ApiError;
// Factory helpers return (StatusCode, Json<ApiError>) which implement IntoResponse
async
// Use too_many_requests directly
async
// Or build manually for fully custom status codes
async
// ApiError implements Display and std::error::Error
async
// Chain error sources with with_source()
async
// Use ? operator in handlers with From<std::io::Error> and From<serde_json::Error>
async
Available factory methods:
| Method | Status |
|---|---|
ApiError::bad_request(code, msg) |
400 |
ApiError::unauthorized(msg) |
401 |
ApiError::forbidden(msg) |
403 |
ApiError::not_found(msg) |
404 |
ApiError::conflict(msg) |
409 |
ApiError::unprocessable_entity(msg) |
422 |
ApiError::too_many_requests(msg) |
429 |
ApiError::internal(msg) |
500 |
ApiError::not_implemented(msg) |
501 |
ApiError::db_error() |
500 |
ApiError::service_unavailable(msg) |
503 |
ApiError::too_many_requests_with_retry_after(msg, duration) |
429 + Retry-After |
ApiError::service_unavailable_with_retry_after(msg, duration) |
503 + Retry-After |
The _with_retry_after variants of too_many_requests and service_unavailable also emit
a delay-seconds Retry-After header (the duration is rounded up to whole seconds, so
1500ms becomes "2") while keeping the same { "code", "message" } JSON body:
use IntoResponse;
use ApiError;
use Duration;
async
ListResponse<T>
Generic paginated collection response.
use IntoResponse;
use ListResponse;
use Serialize;
async
HealthResponse
| Constructor | status |
HTTP |
|---|---|---|
HealthResponse::ok() |
"ok" |
200 |
HealthResponse::degraded() |
"degraded" |
200 |
HealthResponse::unhealthy() |
"unhealthy" |
503 |
use IntoResponse;
use HealthResponse;
async
CursorResponse<T>
Cursor-based paginated response for large datasets or feeds. Use instead of ListResponse when:
- Total count is expensive to compute
- Data is streamed or unbounded
- You're building a feed (social media, notifications, etc.)
- You need bidirectional navigation via opaque tokens
| Field | Type | Meaning |
|---|---|---|
data |
Vec<T> |
Items in this page |
next_cursor |
Option<String> |
Token for next page; None = last page |
has_more |
bool |
Convenience flag: true if more data exists |
use IntoResponse;
use CursorResponse;
use Serialize;
async
Success responses: Created<T>, Accepted<T>, NoContent
Helpers for the rest of the CRUD lifecycle, complementing ListResponse and ApiError.
| Type | Status | Body |
|---|---|---|
Created::new(resource) |
201 | the resource as JSON |
Created::new(resource).with_location("/users/42") |
201 | resource as JSON + Location header |
Accepted::new(job) |
202 | the body as JSON |
NoContent |
204 | empty |
use IntoResponse;
use ;
use Serialize;
async
async
async
Problem (RFC 9457)
With the problem feature (no new dependencies), Problem is an
RFC 9457 problem details response that emits
Content-Type: application/problem+json. Use it when the error format needs to
interoperate with gateways, OpenAPI tooling, or polyglot clients; ApiError's flat shape
remains the default.
use ;
use Problem;
async
Serves HTTP 403 with Content-Type: application/problem+json and this body:
An existing ApiError (or a factory tuple) bridges over losslessly in one line:
use StatusCode;
use ;
// {"title":"Not Found","status":404,"detail":"account not found","code":"NOT_FOUND"}
let problem = from;
// or via the method form:
let problem = new.into_problem;
with_retry_after(duration) emits a delay-seconds Retry-After header; the delay is
header-only and never appears in the JSON body.
Accept-header content negotiation (opt-in)
Problem's plain IntoResponse always serves application/problem+json; that never
changes. Handlers can opt in to serving the same body as plain application/json for
clients that explicitly prefer it: extract ProblemFormat (it reads the request's
Accept headers) and finish with into_response_with, or call
into_response_for(&headers) directly with a HeaderMap.
use ;
use ;
// Accept: application/json -> Content-Type: application/json
// Accept: */* (or no Accept header) -> Content-Type: application/problem+json
async
Plain JSON is served only when the Accept header ranks application/json strictly
higher than application/problem+json (q-values considered; exact matches beat
application/*, which beats */*). Every ambiguous case (no Accept header, */*,
equal preference, unparseable values) keeps application/problem+json. The matcher is
deliberately minimal, not a full RFC 9110 implementation; the exact rules are documented
on ProblemFormat::negotiate.
problem+json extractor rejections (opt-in)
ApiJson and ValidatedJson rejection bodies never change. To get RFC 9457 rejections
instead, swap in the problem-flavored siblings: ProblemJson<T> (features problem +
extract) and ProblemValidatedJson<T> (features problem + validator). Same
deserialization, validation, and status codes; only the failure body format differs.
The format is chosen by naming the extractor in the handler signature, so enabling the
problem feature alone (for example through an unrelated dependency) changes nothing.
use ProblemValidatedJson;
use Deserialize;
use Validate;
async
A validation failure rejects with HTTP 422 and this problem+json body, carrying the same
code and field-level details the flat ApiError rejection exposes (message maps to
detail, code and details become extension members):
Rejections negotiate their Content-Type from the request's Accept headers exactly
like ProblemFormat above: application/problem+json in every ambiguous case, plain
application/json (byte-identical body) only when the client strictly prefers it. Each
rejection is a public ProblemRejection { problem, format }, inspectable in tests and
reusable as the rejection type of custom extractors.
Error Propagation with From Implementations
Convert common Rust errors directly to ApiError (HTTP 500 Internal Error):
use io;
use ApiError;
async
Supported conversions:
std::io::Error- file I/O failuresserde_json::Error- JSON parsing errors
Optional Integrations
Validator Integration (validator feature)
Enable the feature to convert validator::ValidationErrors directly into ApiError.
use IntoResponse;
use ApiError;
async
The resulting ApiError uses this shape:
sqlx Integration (sqlx feature)
Enable the feature to convert sqlx::Error into semantically correct ApiError responses.
= { = "1", = ["sqlx"] }
use IntoResponse;
use ApiError;
async
sqlx::Error variant |
code |
HTTP |
|---|---|---|
RowNotFound |
NOT_FOUND |
404 |
Database (unique or FK violation) |
CONFLICT |
409 |
Database (check violation) |
VALIDATION_ERROR |
422 |
Database (other) |
DB_ERROR |
500 |
PoolTimedOut / PoolClosed / WorkerCrashed |
SERVICE_UNAVAILABLE |
503 |
| everything else | DB_ERROR |
500 |
Validated JSON extractor (validator feature)
ValidatedJson<T> deserializes a JSON body and runs validator validation before your
handler runs, rejecting with an ApiError body when either step fails.
use ValidatedJson;
use Deserialize;
use Validate;
async
| Failure | HTTP | code |
|---|---|---|
| malformed JSON | 400 | INVALID_JSON |
| well-formed JSON of the wrong shape | 422 | INVALID_BODY |
missing or incorrect Content-Type |
415 | UNSUPPORTED_MEDIA_TYPE |
| validation failure | 422 | VALIDATION_ERROR (with field-level details) |
Pagination extractors (extract feature)
Pagination and CursorPagination parse query parameters into typed values and provide
helpers that build the matching response type. limit defaults to 50 and is clamped to
1..=100 (Pagination::DEFAULT_LIMIT / Pagination::MAX_LIMIT); a non-numeric value
rejects with 400 Bad Request (INVALID_QUERY).
use ;
use Serialize;
// GET /items?limit=25&offset=50
async
// GET /feed?cursor=abc123&limit=25
async
JSON extractor (extract feature)
ApiJson<T> is a drop-in replacement for axum::Json that rejects with an ApiError body
instead of Axum's default plain-text response. Use it when you want consistent error bodies
but don't need validator validation (otherwise reach for ValidatedJson).
| Failure | Status | code |
|---|---|---|
| malformed JSON | 400 | INVALID_JSON |
| well-formed JSON, wrong shape | 422 | INVALID_BODY |
missing/incorrect Content-Type |
415 | UNSUPPORTED_MEDIA_TYPE |
use ApiJson;
use Deserialize;
// Bad input becomes a JSON ApiError before the handler runs.
async
It also implements IntoResponse, so it can be returned from handlers like axum::Json.
Observability middleware (trace feature)
Two axum::middleware::from_fn middlewares for request correlation and structured logging:
propagate_request_idreuses an incomingx-request-idheader (or mints a UUID v4), stores it in request extensions (extractable viaRequestId), and echoes it on the response.trace_requestsemits aninfo-leveltracingevent per request withmethod,path,status,latency_ms, andrequest_id. It is a no-op without atracingsubscriber.
use ;
use ;
async
// The last `.layer` is the outermost, so request ids are assigned before
// trace_requests records its event.
let app: Router = new
.route
.layer
.layer;
Health-probe router (router feature)
health_routes returns a Router with /healthz (liveness, always ok) and /readyz
(readiness, runs your async check). It is generic over router state, so it merges into a
stateful app. Capture whatever the readiness check needs in the closure.
use Router;
use ;
let app: Router = new.merge;
/readyz returns the status code of the HealthResponse it produces, so unhealthy()
yields 503.
CORS helper (cors feature)
cors_allowing builds a tower_http CorsLayer for a known origin allow-list (common REST
methods, content-type/authorization headers, credentials enabled). cors_permissive()
is available for local development.
use ;
use cors_allowing;
let app: Router = new
.route
.layer;
OpenAPI schemas (openapi feature)
With the openapi feature, ApiError, ListResponse<T>, CursorResponse<T>, and
HealthResponse derive utoipa::ToSchema, so you can reference
them from your OpenApi document and they show up in the generated spec.
use ;
use OpenApi;
;
let spec = openapi.to_json.unwrap;
License
MIT