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.
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
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-utilThe examples target Axum 0.8. A minimal server:
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();
}$ curl localhost:3000/health
okA 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:
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.
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.
| Extractor | Gives 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, TypedHeader | Request headers |
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:
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.
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:
#[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:
// 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.
- Add
GET /tasks?limit=&offset=pagination with a default limit of 20 and a maximum of 100. Rejectlimit=0with a 422 throughApiError::Validation. - Add a
created_at: u64(Unix seconds) field, set on create. - Reject titles longer than 200 characters.
- Write tests: creating with an empty title returns 422 with
{"error": "..."}; pagination returns at mostlimititems; deleting twice returns 204 then 404. - 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/slowroute that sleeps 5 seconds to try it.)
Show solution
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,Jsonlast), anythingIntoResponseas 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 astdlock across.await. - Implement
IntoResponsefor one error enum to map every domain error to a status and a consistent body. - Add
tower-httplayers for tracing and timeouts, test theRouterdirectly withoneshot, and shut down gracefully on SIGTERM.
Check yourself
Quiz
0/4 correct · 4 left
1.Why must Json<T> be the last argument of an Axum handler?
2.A client sends {"title": 42} to a handler taking Json<CreateTask> where title: String. What happens?
3.Why is AppState typically a struct of Arc fields (or pools) that derives Clone?
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.
- Result, Option and ?No null and no exceptions: absence is Option<T>, failure is Result<T, E>, and ? propagates both in one character.
- Lazy futures and async runtimesA Rust future does nothing until polled; a runtime such as Tokio polls it and wakes it when I/O is ready.
- MiddlewareWrap a handler in another handler to add logging, auth or recovery to every request.
- Graceful shutdownOn SIGTERM, stop taking new work, finish what’s in flight, then exit — so deploys drop no requests.
Finished reading?
Mark it complete to fill in your roadmap.