Step 15 of 17

Web services with Axum

Build a JSON API with Axum, Tokio and Serde: routing, extractors, shared state, typed errors, middleware, tests and graceful shutdown.

20 min 10 sections

This lesson puts the roadmap together: a small but production-shaped JSON API. You'll use Axum, the web framework maintained by the Tokio team, with Serde for JSON. Axum is built on tower, so middleware is shared across a large ecosystem, and handlers are ordinary async functions whose arguments say what they need from the request. Along the way you'll see ownership, traits, error handling and async working together: routing and extractors, shared application state, mapping domain errors to HTTP responses, middleware, testing without a network, and graceful shutdown.

Setup

bash
cargo new tasks-api && cd tasks-api
cargo add axum
cargo add tokio --features full
cargo add serde --features derive
cargo add serde_json thiserror tracing
cargo add tracing-subscriber --features env-filter
cargo add tower-http --features trace,timeout
cargo add --dev tower --features util
cargo add --dev http-body-util

The examples target Axum 0.8. A minimal server:

rust
use axum::{Router, routing::get};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/health", get(|| async { "ok" }));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
text
$ curl localhost:3000/health
ok

A Router maps paths and methods to handlers. A handler is any async function whose arguments are extractors and whose return type implements IntoResponse. Strings, StatusCode, Json<T>, tuples like (StatusCode, Json<T>) and Result<T, E> (when both sides implement it) all do.

Serde: JSON in and out

Serde's derive macros generate serialization code at compile time, with no reflection:

rust
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize)]
pub struct Task {
    pub id: u64,
    pub title: String,
    pub done: bool,
}

#[derive(Debug, Deserialize)]
pub struct CreateTask {
    pub title: String,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpdateTask {
    pub title: Option<String>, // missing field -> None
    pub done: Option<bool>,
}

Deserialize checks types as it parses: a request with "done": "yes" is rejected before your handler runs. Attributes adjust the mapping: #[serde(rename_all = "camelCase")], #[serde(default)], #[serde(skip_serializing_if = "Option::is_none")]. deny_unknown_fields rejects typos like "tittle" instead of silently ignoring them.

Shared state

Handlers need access to the store, config and clients. Put them in a state struct, cheap to clone, and attach it with with_state. Here the store is an in-memory map behind a lock; in a real service it would be a database pool such as sqlx::PgPool, which is already an Arc internally.

rust
use std::collections::HashMap;
use std::sync::{Arc, RwLock};

#[derive(Clone, Default)]
pub struct AppState {
    db: Arc<RwLock<Db>>,
}

#[derive(Default)]
struct Db {
    tasks: HashMap<u64, Task>,
    next_id: u64,
}

Axum clones the state for every request, which is why it's an Arc inside: cloning just bumps a counter. The lock is a std::sync::RwLock because handlers hold it only for a few lines of synchronous code, never across an .await (see the async lesson).

Handlers and extractors

Extractors pull typed data out of the request. If extraction fails, Axum responds with a 4xx before your code runs.

ExtractorGives you
State<S>The shared application state
Path<T>Path parameters: /tasks/{id} → Path<u64>
Query<T>Query string, deserialized: ?done=true&limit=10
Json<T>The request body, parsed as JSON. Must be the last argument.
HeaderMap, TypedHeaderRequest headers
rust
use axum::{
    Json, Router,
    extract::{Path, Query, State},
    http::StatusCode,
    routing::get,
};

#[derive(Debug, Deserialize)]
pub struct ListParams {
    done: Option<bool>,
}

async fn list_tasks(State(state): State<AppState>, Query(p): Query<ListParams>) -> Json<Vec<Task>> {
    let db = state.db.read().unwrap();
    let mut tasks: Vec<Task> = db
        .tasks
        .values()
        .filter(|t| p.done.is_none_or(|d| t.done == d))
        .cloned()
        .collect();
    tasks.sort_by_key(|t| t.id);
    Json(tasks)
}

async fn create_task(
    State(state): State<AppState>,
    Json(input): Json<CreateTask>,
) -> Result<(StatusCode, Json<Task>), ApiError> {
    let title = input.title.trim();
    if title.is_empty() {
        return Err(ApiError::Validation("title must not be empty".into()));
    }
    let mut db = state.db.write().unwrap();
    db.next_id += 1;
    let task = Task { id: db.next_id, title: title.to_string(), done: false };
    db.tasks.insert(task.id, task.clone());
    Ok((StatusCode::CREATED, Json(task)))
}

async fn get_task(State(state): State<AppState>, Path(id): Path<u64>) -> Result<Json<Task>, ApiError> {
    let db = state.db.read().unwrap();
    db.tasks.get(&id).cloned().map(Json).ok_or(ApiError::NotFound(id))
}

async fn update_task(
    State(state): State<AppState>,
    Path(id): Path<u64>,
    Json(input): Json<UpdateTask>,
) -> Result<Json<Task>, ApiError> {
    let mut db = state.db.write().unwrap();
    let task = db.tasks.get_mut(&id).ok_or(ApiError::NotFound(id))?;
    if let Some(title) = input.title {
        task.title = title;
    }
    if let Some(done) = input.done {
        task.done = done;
    }
    Ok(Json(task.clone()))
}

async fn delete_task(State(state): State<AppState>, Path(id): Path<u64>) -> Result<StatusCode, ApiError> {
    let mut db = state.db.write().unwrap();
    db.tasks.remove(&id).map(|_| StatusCode::NO_CONTENT).ok_or(ApiError::NotFound(id))
}

pub fn router(state: AppState) -> Router {
    Router::new()
        .route("/tasks", get(list_tasks).post(create_task))
        .route("/tasks/{id}", get(get_task).patch(update_task).delete(delete_task))
        .with_state(state)
}

Path parameters use {name} syntax since Axum 0.8 (older versions used :name). Option::is_none_or is stable since Rust 1.82. Each handler declares exactly what it needs, and the compiler checks every route: a handler whose arguments can't be extracted, or whose return type can't become a response, is a compile error at .route(...).

Errors as responses

The handlers return Result<_, ApiError>. Implementing IntoResponse for the error type maps each domain error to a status code and a consistent JSON body, in one place:

rust
use axum::response::{IntoResponse, Response};
use serde_json::json;

#[derive(Debug, thiserror::Error)]
pub enum ApiError {
    #[error("task {0} not found")]
    NotFound(u64),
    #[error("{0}")]
    Validation(String),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let status = match &self {
            ApiError::NotFound(_) => StatusCode::NOT_FOUND,
            ApiError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
        };
        (status, Json(json!({ "error": self.to_string() }))).into_response()
    }
}

Add a variant like Internal(#[from] anyhow::Error) that maps to 500 and logs the details while returning a generic message, and ? will work on any fallible call in a handler. Because match is exhaustive, a new error variant can't ship without a status code.

Middleware and the main function

Middleware in Axum is a tower::Layer. tower-http provides the common ones: request tracing, timeouts, CORS, compression, request IDs, body size limits.

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

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt().with_env_filter("info,tower_http=debug").init();

    let app = router(AppState::default())
        .layer(TraceLayer::new_for_http())                    // logs every request and response
        .layer(TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, Duration::from_secs(10)));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    tracing::info!("listening on {}", listener.local_addr().unwrap());
    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await
        .unwrap();
}

async fn shutdown_signal() {
    let ctrl_c = async { tokio::signal::ctrl_c().await.expect("install Ctrl+C handler") };
    #[cfg(unix)]
    let terminate = async {
        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
            .expect("install SIGTERM handler")
            .recv()
            .await;
    };
    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }
    tracing::info!("shutting down");
}

with_graceful_shutdown stops accepting connections when the signal future completes, then waits for in-flight requests to finish, which is what Kubernetes and most orchestrators expect on SIGTERM. See graceful shutdown. Layers wrap the router, so each request passes through them in order, like the middleware chain in any other framework. For your own middleware, axum::middleware::from_fn turns an async function into a layer.

Testing without a network

A Router is a tower::Service: it takes a request and returns a future of a response. Tests can call it directly, with no port and no HTTP client:

rust
#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::Body;
    use axum::http::Request;
    use http_body_util::BodyExt;
    use tower::ServiceExt; // for oneshot

    #[tokio::test]
    async fn create_then_get() {
        let app = router(AppState::default());

        let res = app
            .clone()
            .oneshot(
                Request::post("/tasks")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"title":"write tests"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(res.status(), StatusCode::CREATED);

        let res = app.oneshot(Request::get("/tasks/1").body(Body::empty()).unwrap()).await.unwrap();
        assert_eq!(res.status(), StatusCode::OK);
        let body = res.into_body().collect().await.unwrap().to_bytes();
        let task: serde_json::Value = serde_json::from_slice(&body).unwrap();
        assert_eq!(task["title"], "write tests");
    }

    #[tokio::test]
    async fn missing_task_is_404() {
        let app = router(AppState::default());
        let res = app.oneshot(Request::get("/tasks/99").body(Body::empty()).unwrap()).await.unwrap();
        assert_eq!(res.status(), StatusCode::NOT_FOUND);
    }
}

#[tokio::test] gives each test its own runtime. Because the state is created per test, tests are isolated and run in parallel. Cloning the router is cheap, and oneshot consumes a clone per request.

From memory to PostgreSQL

To persist tasks, swap the RwLock<Db> for a connection pool. With sqlx, the state holds a PgPool, handlers become genuinely async, and errors map through IntoResponse like before:

rust
// cargo add sqlx --features runtime-tokio,postgres
#[derive(Clone)]
pub struct AppState {
    pool: sqlx::PgPool,
}

#[derive(serde::Serialize, sqlx::FromRow)]
pub struct Task {
    id: i64,
    title: String,
    done: bool,
}

async fn get_task(State(state): State<AppState>, Path(id): Path<i64>) -> Result<Json<Task>, ApiError> {
    sqlx::query_as::<_, Task>("SELECT id, title, done FROM tasks WHERE id = $1")
        .bind(id)
        .fetch_optional(&state.pool)
        .await?                                   // sqlx::Error -> ApiError via #[from]
        .map(Json)
        .ok_or(ApiError::NotFound(id))            // NotFound now holds an i64, matching BIGINT ids
}

The handler signature barely changes; that's the payoff of keeping storage behind state and errors behind one type. sqlx also offers query_as! macros that check your SQL against a real database at compile time.

Practice

Extend the tasks API.

  1. Add GET /tasks?limit=&offset= pagination with a default limit of 20 and a maximum of 100. Reject limit=0 with a 422 through ApiError::Validation.
  2. Add a created_at: u64 (Unix seconds) field, set on create.
  3. Reject titles longer than 200 characters.
  4. Write tests: creating with an empty title returns 422 with {"error": "..."}; pagination returns at most limit items; deleting twice returns 204 then 404.
  5. Run the server, create a few tasks with curl, then send SIGTERM (kill <pid>) during a slow request and observe that it completes before the process exits. (Add a /slow route that sleeps 5 seconds to try it.)
Show solution
rust
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Debug, Deserialize)]
pub struct ListParams {
    done: Option<bool>,
    limit: Option<usize>,
    offset: Option<usize>,
}

async fn list_tasks(
    State(state): State<AppState>,
    Query(p): Query<ListParams>,
) -> Result<Json<Vec<Task>>, ApiError> {
    let limit = p.limit.unwrap_or(20);
    if !(1..=100).contains(&limit) {
        return Err(ApiError::Validation("limit must be between 1 and 100".into()));
    }
    let db = state.db.read().unwrap();
    let mut tasks: Vec<Task> = db
        .tasks
        .values()
        .filter(|t| p.done.is_none_or(|d| t.done == d))
        .cloned()
        .collect();
    tasks.sort_by_key(|t| t.id);
    Ok(Json(tasks.into_iter().skip(p.offset.unwrap_or(0)).take(limit).collect()))
}

// In create_task, after trimming:
//     if title.chars().count() > 200 {
//         return Err(ApiError::Validation("title must be at most 200 characters".into()));
//     }
//     let created_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
//     let task = Task { id: db.next_id, title: title.to_string(), done: false, created_at };

// tests
#[tokio::test]
async fn empty_title_is_422() {
    let app = router(AppState::default());
    let res = app
        .oneshot(
            Request::post("/tasks")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"title":"   "}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
    let body = res.into_body().collect().await.unwrap().to_bytes();
    let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert!(json["error"].as_str().unwrap().contains("empty"));
}

#[tokio::test]
async fn delete_twice() {
    let app = router(AppState::default());
    let create = Request::post("/tasks")
        .header("content-type", "application/json")
        .body(Body::from(r#"{"title":"x"}"#))
        .unwrap();
    app.clone().oneshot(create).await.unwrap();
    let del = || Request::delete("/tasks/1").body(Body::empty()).unwrap();
    assert_eq!(app.clone().oneshot(del()).await.unwrap().status(), StatusCode::NO_CONTENT);
    assert_eq!(app.oneshot(del()).await.unwrap().status(), StatusCode::NOT_FOUND);
}

Use chars().count() rather than len() for the 200-character limit, since len() counts bytes. The pagination test creates, say, 30 tasks in a loop, requests ?limit=10, and asserts the decoded array's length is 10.

For step 5, add .route("/slow", get(|| async { tokio::time::sleep(Duration::from_secs(5)).await; "done" })). Start curl localhost:3000/slow, then kill the server: the log prints "shutting down", curl still receives done, and only then does the process exit. A second request sent after the signal is refused because the listener has closed.

Key takeaways

  • Axum handlers are async functions: extractors as arguments (State, Path, Query, Json last), anything IntoResponse as the result.
  • Serde derives typed JSON (de)serialization; malformed or mistyped bodies are rejected before your handler runs.
  • Share state through a cheap-to-clone struct of Arcs or pools; never hold a std lock across .await.
  • Implement IntoResponse for one error enum to map every domain error to a status and a consistent body.
  • Add tower-http layers for tracing and timeouts, test the Router directly with oneshot, and shut down gracefully on SIGTERM.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Why must Json<T> be the last argument of an Axum handler?

  2. 2.A client sends {"title": 42} to a handler taking Json<CreateTask> where title: String. What happens?

  3. 3.Why is AppState typically a struct of Arc fields (or pools) that derives Clone?

  4. 4.What does axum::serve(...).with_graceful_shutdown(signal) do when the signal future completes?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.