# Web services with Axum

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

Rust roadmap · Systems & production · 20 min read · https://levelcode.org/roadmaps/rust/web-services-axum

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](https://levelcode.org/roadmaps/rust/async-rust)).

## 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 |

```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(...)`.

> [!TIP]
> Axum's compile errors for a bad handler can be long and vague ("the trait `Handler<_, _>` is not implemented"). Adding `#[axum::debug_handler]` above the handler makes the compiler point at the exact argument or return type that's wrong.

## 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](https://levelcode.org/concepts/graceful-shutdown). Layers wrap the router, so each request passes through them in order, like the [middleware](https://levelcode.org/concepts/middleware) chain in any other framework. For your own middleware, `axum::middleware::from_fn` turns an async function into a layer.

> [!NOTE]
> Bind to `0.0.0.0` inside containers so traffic from outside the container can reach the server; `127.0.0.1` only accepts connections from the same network namespace. Read the address from configuration (an environment variable such as `PORT`) rather than hard-coding it.

## 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.)

**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 `Arc`s 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.

## Quiz

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

   A. Json is the slowest extractor
   B. Axum sorts extractors alphabetically
   C. It consumes the request body, which can only be read once
   D. Rust requires generic arguments to come last

   **Answer: C.** Body extractors take ownership of the request body. Other extractors only read parts of the request (path, headers, state), so they come first and the body extractor comes last.

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

   A. Axum rejects the request with a 4xx before the handler runs
   B. title becomes "42"
   C. The handler panics
   D. title becomes an empty string

   **Answer: A.** Serde checks types while deserializing. The Json extractor fails, and Axum returns a client error response without calling your handler.

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

   A. Axum requires every type to be Copy
   B. Arc makes the data immutable
   C. Clone makes handlers run in parallel
   D. Axum clones the state for each request, so cloning must be cheap and share the same data

   **Answer: D.** Each request gets its own clone of the state. With Arc (or a pool, which is an Arc inside) a clone just bumps a reference count and all handlers see the same underlying data.

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

   A. Kills all in-flight requests immediately
   B. Stops accepting new connections and waits for in-flight requests to finish
   C. Restarts the server with the new configuration
   D. Ignores it until the next request arrives

   **Answer: B.** Graceful shutdown drains: the listener closes, existing requests complete, then serve returns. That's what orchestrators expect after sending SIGTERM.
