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.
Installation
= "0.9"
Optional integrations are gated behind feature flags:
# request extractors (Pagination, CursorPagination)
= { = "0.9", = ["extract"] }
# JSON validation (ValidatedJson, From<ValidationErrors>)
= { = "0.9", = ["validator"] }
# observability middleware (request id + tracing)
= { = "0.9", = ["trace"] }
# health-probe router (/healthz, /readyz)
= { = "0.9", = ["router"] }
# CORS layer helper (tower-http)
= { = "0.9", = ["cors"] }
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(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 |
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
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.
= { = "0.9", = ["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
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). permissive_cors()
is available for local development.
use ;
use cors_allowing;
let app: Router = new
.route
.layer;
License
MIT