Skip to content
AgileLogics
9 min read

Building a Web API with Axum

Prerequisites: Benchmarking & profiling

Axum is a web framework from the Tokio project. It is built on hyper (HTTP) and tower (middleware), and its handlers are ordinary async functions whose arguments describe what they need from the request. This lesson builds a small JSON API for to-do items, kept in memory so it runs without a database.

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
tower-http = { version = "0.7", features = ["trace"] }
tracing = "0.1"
tracing-subscriber = "0.3"

The whole service

use std::collections::BTreeMap;
use std::sync::{Arc, RwLock};

use axum::{
    Json, Router,
    extract::{Path, State},
    http::StatusCode,
    routing::get,
};
use serde::{Deserialize, Serialize};
use tower_http::trace::TraceLayer;

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

#[derive(Deserialize)]
struct NewTodo {
    title: String,
}

#[derive(Default)]
struct Store {
    next_id: u64,
    todos: BTreeMap<u64, Todo>,
}

// Shared state: cheap to clone (it's an Arc), safe to share across tasks.
type AppState = Arc<RwLock<Store>>;

async fn list_todos(State(state): State<AppState>) -> Json<Vec<Todo>> {
    let store = state.read().unwrap();
    Json(store.todos.values().cloned().collect())
}

async fn create_todo(
    State(state): State<AppState>,
    Json(input): Json<NewTodo>,
) -> Result<(StatusCode, Json<Todo>), (StatusCode, String)> {
    let title = input.title.trim();
    if title.is_empty() {
        return Err((StatusCode::UNPROCESSABLE_ENTITY, "title must not be empty".into()));
    }
    let mut store = state.write().unwrap();
    store.next_id += 1;
    let todo = Todo { id: store.next_id, title: title.to_string(), done: false };
    store.todos.insert(todo.id, todo.clone());
    Ok((StatusCode::CREATED, Json(todo)))
}

async fn get_todo(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<Json<Todo>, StatusCode> {
    let store = state.read().unwrap();
    store.todos.get(&id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}

async fn delete_todo(State(state): State<AppState>, Path(id): Path<u64>) -> StatusCode {
    match state.write().unwrap().todos.remove(&id) {
        Some(_) => StatusCode::NO_CONTENT,
        None => StatusCode::NOT_FOUND,
    }
}

fn app(state: AppState) -> Router {
    Router::new()
        .route("/todos", get(list_todos).post(create_todo))
        .route("/todos/{id}", get(get_todo).delete(delete_todo))
        .layer(TraceLayer::new_for_http())
        .with_state(state)
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt().with_max_level(tracing::Level::DEBUG).init();

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    println!("listening on http://{}", listener.local_addr().unwrap());
    axum::serve(listener, app(AppState::default())).await.unwrap();
}

Trying it with curl from another terminal:

$ curl -s -X POST localhost:3000/todos -H 'content-type: application/json' -d '{"title":"write docs"}'
{"id":1,"title":"write docs","done":false}
$ curl -s localhost:3000/todos
[{"id":1,"title":"write docs","done":false}]
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/todos/99
404
$ curl -s -X POST localhost:3000/todos -H 'content-type: application/json' -d '{"title":"  "}'
title must not be empty

Routing

Router::route(path, method_router) maps a path to handlers per HTTP method; get(a).post(b) chains several methods on one path. Axum 0.8 writes path parameters as {name} (and a catch-all as {*rest}). Router::nest mounts a sub-router under a prefix, which is how larger apps split routes by module.

Handlers and extractors

A handler is an async function whose arguments implement FromRequestParts or FromRequest — these are extractors:

  • Path<T> — path parameters, parsed into T (a u64, a tuple, or a struct). A non-numeric id is rejected with 400 before your code runs.
  • Query<T> — the query string, deserialised with serde.
  • Json<T> — the request body as JSON. Wrong or missing content type gives 415; malformed JSON gives 400 or 422.
  • State<T> — shared application state.
  • HeaderMap, Method, Uri — raw request parts.

Anything that consumes the body (Json, String, Bytes, Form) must be the last argument, since the body can only be read once.

The return type must implement IntoResponse. Json<T>, StatusCode, String, tuples like (StatusCode, Json<T>), and Result<T, E> where both sides implement it, all do. For a real app you will usually define an error enum and implement IntoResponse for it, so handlers can use ?.

Shared state

.with_state(state) hands a value to every handler that asks for State<T>. It is cloned per request, so wrap it in Arc. A std::sync lock is fine here because no guard is held across an .await; with a database pool (next lesson) the state is simply the pool, which is already cheap to clone.

Middleware with tower

Axum doesn't have its own middleware system: it uses tower::Layers, so the whole tower-http ecosystem applies. TraceLayer logs each request and response through tracing. Others you will commonly add: CorsLayer, TimeoutLayer, CompressionLayer, RequestBodyLimitLayer. Layers wrap the routes added before them.

Common mistakes

  • Putting the body extractor first, e.g. async fn create_todo(Json(input): Json<NewTodo>, State(state): State<AppState>). The function then isn't a valid handler and the error is long and vague:
error[E0277]: the trait bound `fn(Json<NewTodo>, State<...>) -> ... {create_todo}: Handler<_, ...>` is not satisfied

Enable axum's macros feature and add #[axum::debug_handler] to get a precise message.

  • Using the axum 0.7 path syntax "/todos/:id". Axum 0.8 panics when the router is built, telling you to use {capture} instead.
  • Holding a std::sync::MutexGuard or RwLock guard across an .await in a handler. The future is no longer Send, which again shows up as a Handler trait error.
  • Calling .unwrap() on user input in a handler. Return a 4xx response instead (see the Security lesson).

Practice

Add PATCH /todos/{id} that accepts {"done": true} and returns the updated item, or 404 if it doesn't exist.

Show solution
#[derive(Deserialize)]
struct UpdateTodo {
    done: bool,
}

async fn update_todo(
    State(state): State<AppState>,
    Path(id): Path<u64>,
    Json(input): Json<UpdateTodo>,
) -> Result<Json<Todo>, StatusCode> {
    let mut store = state.write().unwrap();
    let todo = store.todos.get_mut(&id).ok_or(StatusCode::NOT_FOUND)?;
    todo.done = input.done;
    Ok(Json(todo.clone()))
}

// In app():
//     .route("/todos/{id}", get(get_todo).patch(update_todo).delete(delete_todo))

get_mut gives a mutable reference inside the write lock, and ? turns a missing item into the 404 error response.