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 intoT(au64, a tuple, or a struct). A non-numericidis rejected with400before your code runs.Query<T>— the query string, deserialised with serde.Json<T>— the request body as JSON. Wrong or missing content type gives415; malformed JSON gives400or422.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::MutexGuardorRwLockguard across an.awaitin a handler. The future is no longerSend, which again shows up as aHandlertrait error. - Calling
.unwrap()on user input in a handler. Return a4xxresponse 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.