background-shape
Building a JSON API in Rust with Axum 0.4
March 16, 2022 · 5 min read · by Muhammad Amal programming

TL;DR — Axum is the Tokio team’s HTTP framework. Routes are typed functions. Inputs (path, query, JSON body) come in via extractors. State is shared via Extension<T>. Error handling via IntoResponse. Middleware via tower. Minimal, fast, idiomatic.

After async + Tokio, the next thing for a backend service is the HTTP layer. In 2022 there are three serious Rust web frameworks: Axum (Tokio team), Actix-web 4 (just released Feb 25, 2022), and Warp (older, type-heavy).

I’m picking Axum. Reasons: it’s part of the Tokio ecosystem (no version-skew with everything else I use), the API is small enough to learn in an afternoon, and middleware via tower means I get a whole ecosystem of pre-built behaviour (rate limiting, timeouts, tracing) for free.

The smallest working server

use axum::{
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;

#[derive(Deserialize)]
struct CreateUser {
    email: String,
}

#[derive(Serialize)]
struct User {
    id: u64,
    email: String,
}

async fn create_user(Json(payload): Json<CreateUser>) -> Json<User> {
    let user = User { id: 1, email: payload.email };
    Json(user)
}

async fn root() -> &'static str {
    "ok"
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(root))
        .route("/users", post(create_user));

    let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

That’s a working HTTP server. Bind, route, serve. POST /users accepts JSON, returns JSON. The shapes are typed.

Extractors: the input contract

Axum handlers are async functions whose parameters are extractors — types that know how to pull themselves out of the request. Common ones:

use axum::{
    extract::{Path, Query, State, Json},
    http::HeaderMap,
};

async fn get_user(
    Path(user_id): Path<u64>,                       // /users/:user_id
    Query(params): Query<ListParams>,               // ?limit=10
    headers: HeaderMap,                             // request headers
    State(db): State<DbPool>,                       // shared state
    Json(body): Json<UpdateUser>,                   // request body
) -> Result<Json<User>, AppError> {
    // handler logic
}

The compiler picks the right extractor based on the parameter type. Order doesn’t strictly matter, but consume-body extractors (Json, Form, Bytes) must be last — they take ownership of the request body.

If extraction fails (bad path param, malformed JSON), Axum returns a 400 Bad Request automatically. You can customize the response by implementing IntoResponse for the error.

Shared state

For things like database connection pools, HTTP clients, config — anything you want available to handlers without re-creating per request — use Extension or (newer in Axum 0.5+) State. Axum 0.4 uses Extension:

use axum::Extension;
use sqlx::PgPool;

#[derive(Clone)]
struct AppState {
    db: PgPool,
    config: Arc<Config>,
}

async fn list_users(
    Extension(state): Extension<AppState>,
) -> Result<Json<Vec<User>>, AppError> {
    let users = sqlx::query_as!(User, "SELECT id, email FROM users")
        .fetch_all(&state.db).await?;
    Ok(Json(users))
}

#[tokio::main]
async fn main() {
    let state = AppState {
        db: PgPool::connect(&std::env::var("DATABASE_URL").unwrap()).await.unwrap(),
        config: Arc::new(load_config()),
    };

    let app = Router::new()
        .route("/users", get(list_users))
        .layer(Extension(state));

    axum::Server::bind(&"0.0.0.0:8080".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

.layer(Extension(state)) injects state into the request extensions. Handlers extract it. Cheap (Extension<T> is just an Arc<T> lookup internally for clones).

Error handling

The error-handling post outlined the AppError pattern. Here’s the Axum-specific glue:

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;

#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("not found")]
    NotFound,

    #[error("unauthorized")]
    Unauthorized,

    #[error(transparent)]
    Database(#[from] sqlx::Error),

    #[error(transparent)]
    Other(#[from] anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            AppError::NotFound        => (StatusCode::NOT_FOUND, "not found".to_string()),
            AppError::Unauthorized    => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()),
            AppError::Database(_)     => {
                tracing::error!(error = ?self, "db error");
                (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
            }
            AppError::Other(_)        => {
                tracing::error!(error = ?self, "internal");
                (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
            }
        };
        let body = Json(json!({ "error": message }));
        (status, body).into_response()
    }
}

Now any ? in a handler that returns Result<_, AppError> produces a sensible HTTP response. Database errors are logged but never leaked to clients. Domain errors (NotFound, Unauthorized) map to correct status codes.

Middleware via tower

Axum builds on tower::Service, which means the entire tower and tower-http middleware ecosystem applies:

use std::time::Duration;
use tower_http::{
    trace::TraceLayer,
    timeout::TimeoutLayer,
    cors::CorsLayer,
};

let app = Router::new()
    .route("/users", get(list_users))
    .layer(TraceLayer::new_for_http())
    .layer(TimeoutLayer::new(Duration::from_secs(10)))
    .layer(CorsLayer::permissive())
    .layer(Extension(state));

Each .layer(...) wraps the router with middleware. Order matters: outermost layer runs first on the request, last on the response.

Layers I add to every service:

  • TraceLayer — emits structured tracing spans per request
  • TimeoutLayer — request-level timeout
  • CorsLayer — only if needed
  • A custom request-id middleware that pulls/generates X-Request-Id and adds it to extensions

Custom middleware is just a tower::Service. For most cases, axum::middleware::from_fn is enough:

use axum::{
    middleware::Next,
    response::Response,
    http::Request,
};

async fn request_id<B>(mut req: Request<B>, next: Next<B>) -> Response {
    let id = req.headers()
        .get("x-request-id")
        .and_then(|v| v.to_str().ok())
        .map(String::from)
        .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
    req.extensions_mut().insert(RequestId(id.clone()));
    let mut response = next.run(req).await;
    response.headers_mut().insert("x-request-id", id.parse().unwrap());
    response
}

let app = Router::new()
    .route(...)
    .layer(axum::middleware::from_fn(request_id));

Path and query patterns

A handful of routing patterns:

let app = Router::new()
    // Path params
    .route("/users/:id", get(get_user))
    .route("/users/:id/orders/:order_id", get(get_order))

    // Nested routers (sub-routing per resource)
    .nest("/billing", billing_routes())
    .nest("/notifications", notifications_routes())

    // Fallback for 404
    .fallback(handler_404);

async fn handler_404() -> impl IntoResponse {
    (StatusCode::NOT_FOUND, Json(json!({"error": "not found"})))
}

.nest("/billing", router) mounts a sub-router at a prefix. Useful for splitting handlers across files by domain.

Testing handlers

Axum routers are tower::Services, which means you can call them directly in tests without an actual TCP listener:

use axum::{body::Body, http::{Request, StatusCode}};
use tower::ServiceExt;

#[tokio::test]
async fn create_user_returns_201() {
    let app = build_app(test_state()).await;

    let request = Request::builder()
        .method("POST")
        .uri("/users")
        .header("content-type", "application/json")
        .body(Body::from(r#"{"email":"test@example.com"}"#))
        .unwrap();

    let response = app.oneshot(request).await.unwrap();

    assert_eq!(response.status(), StatusCode::CREATED);
}

Fast, hermetic, no port allocation, no TCP. Run thousands per second.

Common Pitfalls

Forgetting tower::ServiceExt in tests. Without the trait imported, .oneshot(...) doesn’t exist as a method. Easy to miss; the error message is unhelpful.

Heavy work in a Json extractor body. The extractor reads + deserializes the body before your handler runs. If the body is large, this blocks the handler. Use axum::extract::RawBody for streaming.

Returning String instead of impl IntoResponse. Works, but you lose the ability to set status codes. Return tuples or implement IntoResponse.

State Mutex held across .await. As covered in the Tokio post — use tokio::sync::Mutex, not std::sync::Mutex.

Building the router inside a handler. It should be built once at startup. Reconstructing per request defeats the framework.

Misusing Extension vs State. In Axum 0.4, Extension is the way; State arrives in 0.5. If you’re starting fresh later in 2022, prefer State — it’s more type-safe.

Wrapping Up

Axum is roughly 200 lines of boilerplate away from a real HTTP service. Router + handlers + extractors + state + error mapping + tower middleware = enough to ship. Friday: Postgres with sqlx — the database layer that plugs into this service.