Guide contents

Lesson 4 of 6

Web Services with axum

axum is the most widely used Rust web framework. It's built by the Tokio team on top of hyper (the HTTP implementation) and tower (a library of reusable middleware). Its programming model is deliberately plain: there are no annotations, no runtime scanning, and no reflection. A handler is an ordinary async fn; its parameters say what it needs from the request, and its return type says what it sends back. A router maps paths to handlers in plain code.

Terminal
cargo add axum
cargo add tokio --features full
cargo add serde --features derive
cargo add tower --features util          # for testing handlers in-process
cargo add tower-http --features trace,cors
Concept axum
Request handlers async fn handlers + a Router
Mapping a route .route("/users/{id}", get(handler))
Path parameters Path<T>
Query parameters Query<T>
JSON request body Json<T>
Response with a status code (StatusCode, Json<T>), or any impl IntoResponse
Central error handling an error type that implements IntoResponse
Shared dependencies State<T>
Request filters and interceptors middleware (middleware::from_fn, tower layers)
In-process tests tower::ServiceExt::oneshot
Running the server axum::serve on a Tokio TcpListener

It helps to have read Async Fundamentals and Serialization with serde.

Part 1: Routes and handlers

1. Hello, axum

A complete server: a router with one route, a Tokio TcpListener, and axum::serve. The handler returns a &'static str, which axum turns into a 200 OK response with a text/plain body.

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

async fn hello() -> &'static str {
    "Hello from axum!"
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(hello));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    println!("listening on http://127.0.0.1:3000");
    axum::serve(listener, app).await.unwrap();
}
Text
$ cargo run
listening on http://127.0.0.1:3000

$ curl -i http://127.0.0.1:3000/
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 16

Hello from axum!

2. Calling the app without a network

A Router is a tower Service: something that takes a request and asynchronously returns a response. oneshot from tower::ServiceExt sends it a single request directly, with no port and no server. Every following example uses this small send helper to show requests and responses.

Rust
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::{Router, routing::get};
use tower::ServiceExt;

async fn hello() -> &'static str {
    "Hello from axum!"
}

async fn send(app: &Router, method: &str, uri: &str, body: &str) -> (StatusCode, String) {
    let request = Request::builder().method(method).uri(uri).header("content-type", "application/json").body(Body::from(body.to_string())).unwrap();
    let response = app.clone().oneshot(request).await.unwrap();
    let status = response.status();
    let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
    (status, String::from_utf8_lossy(&bytes).into_owned())
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(hello));
    println!("{:?}", send(&app, "GET", "/", "").await);
    println!("{:?}", send(&app, "POST", "/", "").await); // route exists, method doesn't
    println!("{:?}", send(&app, "GET", "/missing", "").await);
}
Output
(200, "Hello from axum!")
(405, "")
(404, "")

3. Path and query parameters

Handler parameters are extractors: each one pulls something out of the request. Path<T> parses the {...} segments of the route, and Query<T> deserializes the query string into a struct with serde. If extraction fails, axum answers with a 400 and an explanation, and the handler never runs.

Rust
use axum::body::Body;
use axum::extract::{Path, Query};
use axum::http::{Request, StatusCode};
use axum::{Router, routing::get};
use serde::Deserialize;
use tower::ServiceExt;

#[derive(Deserialize)]
struct Paging {
    #[serde(default)]
    page: u32,
    #[serde(default = "default_size")]
    size: u32,
}

fn default_size() -> u32 {
    20
}

async fn user_orders(Path((user_id, status)): Path<(u64, String)>, Query(paging): Query<Paging>) -> String {
    format!("orders of user {user_id} with status {status}, page {} of size {}", paging.page, paging.size)
}

async fn send(app: &Router, method: &str, uri: &str, body: &str) -> (StatusCode, String) {
    let request = Request::builder().method(method).uri(uri).header("content-type", "application/json").body(Body::from(body.to_string())).unwrap();
    let response = app.clone().oneshot(request).await.unwrap();
    let status = response.status();
    let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
    (status, String::from_utf8_lossy(&bytes).into_owned())
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/users/{user_id}/orders/{status}", get(user_orders));
    for uri in ["/users/42/orders/shipped", "/users/42/orders/open?page=3&size=50", "/users/ana/orders/open", "/users/42/orders/open?page=-1"] {
        println!("{uri}\n  {:?}", send(&app, "GET", uri, "").await);
    }
}
Output
/users/42/orders/shipped
  (200, "orders of user 42 with status shipped, page 0 of size 20")
/users/42/orders/open?page=3&size=50
  (200, "orders of user 42 with status open, page 3 of size 50")
/users/ana/orders/open
  (400, "Invalid URL: Cannot parse value at index 0 with value `ana` to a `u64`")
/users/42/orders/open?page=-1
  (400, "Failed to deserialize query string: page: invalid digit found in string")

4. JSON in, JSON out

Json<T> as a parameter deserializes the request body; as a return value, it serializes the response and sets the content-type. Returning a tuple (StatusCode, Json<T>) sets the status too, like ResponseEntity.status(201).body(user). Malformed JSON gets a 400 and JSON that doesn't match the struct a 422, before your code runs.

Rust
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::{Json, Router, routing::post};
use serde::{Deserialize, Serialize};
use tower::ServiceExt;

#[derive(Deserialize)]
struct NewUser {
    name: String,
    email: String,
}

#[derive(Serialize)]
struct User {
    id: u64,
    name: String,
    email: String,
}

async fn create_user(Json(input): Json<NewUser>) -> (StatusCode, Json<User>) {
    let user = User { id: 1, name: input.name, email: input.email.to_lowercase() };
    (StatusCode::CREATED, Json(user))
}

async fn send(app: &Router, method: &str, uri: &str, body: &str) -> (StatusCode, String) {
    let request = Request::builder().method(method).uri(uri).header("content-type", "application/json").body(Body::from(body.to_string())).unwrap();
    let response = app.clone().oneshot(request).await.unwrap();
    let status = response.status();
    let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
    (status, String::from_utf8_lossy(&bytes).into_owned())
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/users", post(create_user));
    for body in [r#"{"name": "Ana", "email": "Ana@Example.com"}"#, r#"{"name": "Ben"}"#, r#"{"name": "Cy","#] {
        println!("{:?}", send(&app, "POST", "/users", body).await);
    }
}
Output
(201, "{\"id\":1,\"name\":\"Ana\",\"email\":\"ana@example.com\"}")
(422, "Failed to deserialize the JSON body into the target type: missing field `email` at line 1 column 15")
(400, "Failed to parse the request body as JSON: EOF while parsing a value at line 1 column 14")

Part 2: State and errors

5. Shared state

Handlers are plain functions, so anything they share, such as a database pool, configuration, or an in-memory store, is passed in with .with_state(...) and extracted with State<T>. The state is cloned for each request, so it's usually an Arc around a struct, with a Mutex or RwLock for data that changes (Shared State).

Rust
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::{Request, StatusCode};
use axum::{Json, Router, routing::get};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use tower::ServiceExt;

#[derive(Clone, Serialize)]
struct Product {
    id: u32,
    name: String,
    stock: u32,
}

#[derive(Deserialize)]
struct NewProduct {
    name: String,
    stock: u32,
}

#[derive(Default)]
struct AppState {
    products: Mutex<BTreeMap<u32, Product>>,
}

type Shared = Arc<AppState>;

async fn list(State(state): State<Shared>) -> Json<Vec<Product>> {
    Json(state.products.lock().unwrap().values().cloned().collect())
}

async fn create(State(state): State<Shared>, Json(input): Json<NewProduct>) -> (StatusCode, Json<Product>) {
    let mut products = state.products.lock().unwrap();
    let id = products.keys().last().map_or(1, |last| last + 1);
    let product = Product { id, name: input.name, stock: input.stock };
    products.insert(id, product.clone());
    (StatusCode::CREATED, Json(product))
}

async fn get_one(State(state): State<Shared>, Path(id): Path<u32>) -> Result<Json<Product>, StatusCode> {
    state.products.lock().unwrap().get(&id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}

async fn send(app: &Router, method: &str, uri: &str, body: &str) -> (StatusCode, String) {
    let request = Request::builder().method(method).uri(uri).header("content-type", "application/json").body(Body::from(body.to_string())).unwrap();
    let response = app.clone().oneshot(request).await.unwrap();
    let status = response.status();
    let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
    (status, String::from_utf8_lossy(&bytes).into_owned())
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/products", get(list).post(create))
        .route("/products/{id}", get(get_one))
        .with_state(Arc::new(AppState::default()));

    println!("{:?}", send(&app, "POST", "/products", r#"{"name": "lamp", "stock": 12}"#).await);
    println!("{:?}", send(&app, "POST", "/products", r#"{"name": "desk", "stock": 3}"#).await);
    println!("{:?}", send(&app, "GET", "/products", "").await);
    println!("{:?}", send(&app, "GET", "/products/2", "").await);
    println!("{:?}", send(&app, "GET", "/products/9", "").await);
}
Output
(201, "{\"id\":1,\"name\":\"lamp\",\"stock\":12}")
(201, "{\"id\":2,\"name\":\"desk\",\"stock\":3}")
(200, "[{\"id\":1,\"name\":\"lamp\",\"stock\":12},{\"id\":2,\"name\":\"desk\",\"stock\":3}]")
(200, "{\"id\":2,\"name\":\"desk\",\"stock\":3}")
(404, "")

The std::sync::Mutex is fine here because it's never held across an .await. If a handler needs to keep a lock while awaiting, use tokio::sync::Mutex (Async Channels & Locks).

6. Errors that become responses

A handler can return Result<T, E> for any error type E that implements IntoResponse. Define one error enum for the application, decide in one place how each variant maps to a status code and a JSON body, and use ? in the handlers. Error handling lives in one place, checked by the compiler.

Rust
use axum::body::Body;
use axum::extract::Path;
use axum::http::{Request, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::{Json, Router, routing::get};
use serde_json::json;
use tower::ServiceExt;

enum AppError {
    NotFound(String),
    Invalid(String),
    Internal(String),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound(what) => (StatusCode::NOT_FOUND, format!("{what} not found")),
            AppError::Invalid(why) => (StatusCode::UNPROCESSABLE_ENTITY, why),
            AppError::Internal(detail) => {
                eprintln!("internal error: {detail}"); // log the details, don't send them
                (StatusCode::INTERNAL_SERVER_ERROR, String::from("something went wrong"))
            }
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

impl From<std::num::ParseIntError> for AppError {
    fn from(e: std::num::ParseIntError) -> Self {
        AppError::Invalid(format!("not a number: {e}"))
    }
}

async fn invoice(Path(raw_id): Path<String>) -> Result<Json<serde_json::Value>, AppError> {
    let id: u32 = raw_id.parse()?; // ParseIntError -> AppError::Invalid via From
    match id {
        1 => Ok(Json(json!({ "id": 1, "total": 4990 }))),
        500 => Err(AppError::Internal(String::from("database connection reset"))),
        _ => Err(AppError::NotFound(format!("invoice {id}"))),
    }
}

async fn send(app: &Router, method: &str, uri: &str, body: &str) -> (StatusCode, String) {
    let request = Request::builder().method(method).uri(uri).header("content-type", "application/json").body(Body::from(body.to_string())).unwrap();
    let response = app.clone().oneshot(request).await.unwrap();
    let status = response.status();
    let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
    (status, String::from_utf8_lossy(&bytes).into_owned())
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/invoices/{id}", get(invoice));
    for uri in ["/invoices/1", "/invoices/7", "/invoices/abc", "/invoices/500"] {
        println!("{uri:<14} {:?}", send(&app, "GET", uri, "").await);
    }
}
Output
/invoices/1    (200, "{\"id\":1,\"total\":4990}")
/invoices/7    (404, "{\"error\":\"invoice 7 not found\"}")
/invoices/abc  (422, "{\"error\":\"not a number: invalid digit found in string\"}")
/invoices/500  (500, "{\"error\":\"something went wrong\"}")

In a real application, AppError often derives thiserror::Error and has #[from] variants for sqlx::Error and other library errors, so ? works on all of them.

7. Status codes, headers, and other responses

Anything that implements IntoResponse can be returned, and tuples combine parts: a status, headers as [(name, value)], and a body. Html sets an HTML content type and Redirect builds redirects. When one handler returns different types on different paths, convert each with .into_response().

Rust
use axum::body::Body;
use axum::extract::Path;
use axum::http::{HeaderMap, Request, StatusCode, header};
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::{Router, routing::get};
use tower::ServiceExt;

async fn created() -> impl IntoResponse {
    (StatusCode::CREATED, [(header::LOCATION, "/items/17"), (header::CACHE_CONTROL, "no-store")], "created item 17")
}

async fn page(Path(name): Path<String>) -> Response {
    match name.as_str() {
        "old-home" => Redirect::permanent("/home").into_response(),
        "home" => Html("<h1>Welcome</h1>").into_response(),
        _ => StatusCode::NOT_FOUND.into_response(),
    }
}

async fn whoami(headers: HeaderMap) -> String {
    let agent = headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or("unknown");
    format!("your user agent is {agent}")
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/items", get(created)).route("/pages/{name}", get(page)).route("/whoami", get(whoami));

    for uri in ["/items", "/pages/home", "/pages/old-home", "/pages/other", "/whoami"] {
        let request = Request::builder().uri(uri).header(header::USER_AGENT, "curl/8.5").body(Body::empty()).unwrap();
        let response = app.clone().oneshot(request).await.unwrap();
        let interesting: Vec<String> = [header::LOCATION, header::CONTENT_TYPE, header::CACHE_CONTROL]
            .iter()
            .filter_map(|h| response.headers().get(h).map(|v| format!("{h}: {}", v.to_str().unwrap())))
            .collect();
        let status = response.status();
        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
        println!("{uri:<16} {status} {interesting:?} {:?}", String::from_utf8_lossy(&body));
    }
}
Output
/items           201 Created ["location: /items/17", "content-type: text/plain; charset=utf-8", "cache-control: no-store"] "created item 17"
/pages/home      200 OK ["content-type: text/html; charset=utf-8"] "<h1>Welcome</h1>"
/pages/old-home  308 Permanent Redirect ["location: /home"] ""
/pages/other     404 Not Found [] ""
/whoami          200 OK ["content-type: text/plain; charset=utf-8"] "your user agent is curl/8.5"

Part 3: Structure and middleware

8. Organizing routes

Routers compose. nest mounts a router under a path prefix, merge combines routers, one route can have several methods (get(...).post(...)), and fallback handles every request that matched nothing, here with a JSON 404 instead of an empty one.

Rust
use axum::body::Body;
use axum::http::{Request, StatusCode, Uri};
use axum::{Json, Router, routing::get};
use serde_json::{Value, json};
use tower::ServiceExt;

fn users_routes() -> Router {
    Router::new().route("/", get(|| async { "list users" }).post(|| async { (StatusCode::CREATED, "create user") })).route("/{id}", get(|| async { "one user" }))
}

fn orders_routes() -> Router {
    Router::new().route("/", get(|| async { "list orders" }))
}

async fn not_found(uri: Uri) -> (StatusCode, Json<Value>) {
    (StatusCode::NOT_FOUND, Json(json!({ "error": format!("no route for {uri}") })))
}

async fn send(app: &Router, method: &str, uri: &str, body: &str) -> (StatusCode, String) {
    let request = Request::builder().method(method).uri(uri).header("content-type", "application/json").body(Body::from(body.to_string())).unwrap();
    let response = app.clone().oneshot(request).await.unwrap();
    let status = response.status();
    let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
    (status, String::from_utf8_lossy(&bytes).into_owned())
}

#[tokio::main]
async fn main() {
    let api = Router::new().nest("/users", users_routes()).nest("/orders", orders_routes());
    let app = Router::new().nest("/api/v1", api).route("/health", get(|| async { "ok" })).fallback(not_found);

    for (method, uri) in [("GET", "/api/v1/users"), ("POST", "/api/v1/users"), ("GET", "/api/v1/users/7"), ("GET", "/api/v1/orders"), ("GET", "/health"), ("GET", "/api/v2/users")] {
        println!("{method:<4} {uri:<16} {:?}", send(&app, method, uri, "").await);
    }
}
Output
GET  /api/v1/users    (200, "list users")
POST /api/v1/users    (201, "create user")
GET  /api/v1/users/7  (200, "one user")
GET  /api/v1/orders   (200, "list orders")
GET  /health          (200, "ok")
GET  /api/v2/users    (404, "{\"error\":\"no route for /api/v2/users\"}")

Closures such as || async { "ok" } work as handlers too, which is handy for trivial routes.

9. Middleware

middleware::from_fn turns an async fn into middleware, like a servlet Filter: it receives the request and next, the rest of the stack. It can inspect or change the request, reject it early, or look at the response on the way out. .layer applies middleware to every route added before it; .route_layer only to matched routes.

Rust
use axum::body::Body;
use axum::extract::Request;
use axum::http::{StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::Response;
use axum::{Router, routing::get};
use tower::ServiceExt;

// Logs every request and its outcome.
async fn log_requests(request: Request, next: Next) -> Response {
    let line = format!("{} {}", request.method(), request.uri().path());
    let response = next.run(request).await;
    println!("  [log] {line} -> {}", response.status());
    response
}

// Rejects requests without the right API key before they reach a handler.
async fn require_api_key(request: Request, next: Next) -> Result<Response, StatusCode> {
    match request.headers().get("x-api-key").and_then(|v| v.to_str().ok()) {
        Some("secret-key") => Ok(next.run(request).await),
        _ => Err(StatusCode::UNAUTHORIZED),
    }
}

#[tokio::main]
async fn main() {
    let admin = Router::new().route("/admin/stats", get(|| async { "42 users" })).route_layer(middleware::from_fn(require_api_key));
    let app = Router::new().route("/", get(|| async { "home" })).merge(admin).layer(middleware::from_fn(log_requests));

    for (uri, key) in [("/", None), ("/admin/stats", None), ("/admin/stats", Some("wrong")), ("/admin/stats", Some("secret-key"))] {
        let mut request = axum::http::Request::builder().uri(uri);
        if let Some(key) = key {
            request = request.header("x-api-key", key);
        }
        let response = app.clone().oneshot(request.header(header::ACCEPT, "*/*").body(Body::empty()).unwrap()).await.unwrap();
        println!("{uri} with key {key:?}: {}", response.status());
    }
}
Output
  [log] GET / -> 200 OK
/ with key None: 200 OK
  [log] GET /admin/stats -> 401 Unauthorized
/admin/stats with key None: 401 Unauthorized
  [log] GET /admin/stats -> 401 Unauthorized
/admin/stats with key Some("wrong"): 401 Unauthorized
  [log] GET /admin/stats -> 200 OK
/admin/stats with key Some("secret-key"): 200 OK

10. A custom extractor for the current user

Authentication often belongs in an extractor rather than middleware: implement FromRequestParts for a type, and any handler that lists it as a parameter gets an authenticated user, or never runs. The handler's signature then documents that it requires login.

Rust
use axum::body::Body;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::{Request, StatusCode, header};
use axum::{Router, routing::get};
use tower::ServiceExt;

struct CurrentUser {
    name: String,
    admin: bool,
}

impl<S: Send + Sync> FromRequestParts<S> for CurrentUser {
    type Rejection = (StatusCode, &'static str);

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let token = parts
            .headers
            .get(header::AUTHORIZATION)
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.strip_prefix("Bearer "))
            .ok_or((StatusCode::UNAUTHORIZED, "missing bearer token"))?;
        // A real app would verify a signed token or look up a session here.
        match token {
            "token-ana" => Ok(CurrentUser { name: String::from("ana"), admin: true }),
            "token-ben" => Ok(CurrentUser { name: String::from("ben"), admin: false }),
            _ => Err((StatusCode::UNAUTHORIZED, "invalid token")),
        }
    }
}

async fn profile(user: CurrentUser) -> String {
    format!("hello {}", user.name)
}

async fn admin_panel(user: CurrentUser) -> Result<&'static str, StatusCode> {
    if user.admin { Ok("admin panel") } else { Err(StatusCode::FORBIDDEN) }
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/me", get(profile)).route("/admin", get(admin_panel));
    for (uri, auth) in [("/me", None), ("/me", Some("Bearer nope")), ("/me", Some("Bearer token-ben")), ("/admin", Some("Bearer token-ben")), ("/admin", Some("Bearer token-ana"))] {
        let mut request = Request::builder().uri(uri);
        if let Some(auth) = auth {
            request = request.header(header::AUTHORIZATION, auth);
        }
        let response = app.clone().oneshot(request.body(Body::empty()).unwrap()).await.unwrap();
        let status = response.status();
        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
        println!("{uri:<7} {:<18} {status} {:?}", auth.unwrap_or("-"), String::from_utf8_lossy(&body));
    }
}
Output
/me     -                  401 Unauthorized "missing bearer token"
/me     Bearer nope        401 Unauthorized "invalid token"
/me     Bearer token-ben   200 OK "hello ben"
/admin  Bearer token-ben   403 Forbidden ""
/admin  Bearer token-ana   200 OK "admin panel"

11. Ready-made middleware from tower-http

tower-http provides production middleware that works with any tower-based server: request tracing, CORS, compression, timeouts, request IDs, and more. TraceLayer creates a tracing span for each request (Logging & Tracing); here it's configured to log one line per response.

Rust
use axum::body::Body;
use axum::http::{Method, Request, Response, header};
use axum::{Router, routing::get};
use std::time::Duration;
use tower::ServiceExt;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use tracing::{Span, info, info_span};

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt().without_time().with_ansi(false).with_target(false).init();

    let trace = TraceLayer::new_for_http()
        .make_span_with(|request: &Request<Body>| info_span!("http", method = %request.method(), path = %request.uri().path()))
        .on_request(())
        .on_response(|response: &Response<Body>, _latency: Duration, _span: &Span| info!(status = response.status().as_u16(), "response"));
    let cors = CorsLayer::new().allow_origin(["https://app.example.com".parse().unwrap()]).allow_methods([Method::GET, Method::POST]);

    let app = Router::new().route("/orders", get(|| async { "[]" })).layer(trace).layer(cors);

    let request = Request::builder().uri("/orders").header(header::ORIGIN, "https://app.example.com").body(Body::empty()).unwrap();
    let response = app.clone().oneshot(request).await.unwrap();
    println!("allow-origin: {:?}", response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN));

    let missing = Request::builder().uri("/nope").body(Body::empty()).unwrap();
    app.oneshot(missing).await.unwrap();
}
Output
 INFO http{method=GET path=/orders}: response status=200
allow-origin: Some("https://app.example.com")
 INFO http{method=GET path=/nope}: response status=404

Part 4: Running it for real

12. A real server, with graceful shutdown

Binding to port 0 lets the operating system pick a free port, which is how tests start real servers without clashing. with_graceful_shutdown stops accepting new connections when a future completes and lets requests in progress finish; in production that future waits for Ctrl-C or SIGTERM (tokio::signal::ctrl_c()). The requests here use reqwest, the HTTP client from the next lesson.

Rust
use axum::extract::Path;
use axum::{Router, routing::get};
use tokio::sync::oneshot;

async fn greet(Path(name): Path<String>) -> String {
    format!("hello, {name}")
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Router::new().route("/greet/{name}", get(greet));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
    let addr = listener.local_addr()?;
    println!("listening on a free port: {}", addr.port() > 0);

    let (stop_tx, stop_rx) = oneshot::channel::<()>();
    let server = tokio::spawn(async move {
        axum::serve(listener, app)
            .with_graceful_shutdown(async {
                stop_rx.await.ok(); // in production: tokio::signal::ctrl_c().await
            })
            .await
    });

    let client = reqwest::Client::new();
    for name in ["ana", "ben"] {
        let body = client.get(format!("http://{addr}/greet/{name}")).send().await?.text().await?;
        println!("{body}");
    }

    stop_tx.send(()).ok();
    server.await??;
    println!("server stopped cleanly");
    Ok(())
}
Output
listening on a free port: true
hello, ana
hello, ben
server stopped cleanly

13. Realistic example: a tested notes API

A small but complete REST API: create, list, read, update, and delete notes, with validation, an error type, shared state, and a test suite that exercises it in-process. The tests run with cargo test, and #[tokio::test] gives each test its own async runtime.

Rustcargo test
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};

#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub struct Note {
    id: u32,
    title: String,
    body: String,
}

#[derive(Deserialize)]
pub struct NoteInput {
    title: String,
    #[serde(default)]
    body: String,
}

#[derive(Default)]
struct Store {
    notes: BTreeMap<u32, Note>,
    next_id: u32,
}

type AppState = Arc<Mutex<Store>>;

pub enum ApiError {
    NotFound(u32),
    Invalid(&'static str),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            ApiError::NotFound(id) => (StatusCode::NOT_FOUND, format!("note {id} not found")),
            ApiError::Invalid(why) => (StatusCode::UNPROCESSABLE_ENTITY, why.to_string()),
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

fn validate(input: &NoteInput) -> Result<(), ApiError> {
    if input.title.trim().is_empty() {
        return Err(ApiError::Invalid("title must not be empty"));
    }
    if input.title.len() > 100 {
        return Err(ApiError::Invalid("title is too long"));
    }
    Ok(())
}

async fn list(State(state): State<AppState>) -> Json<Vec<Note>> {
    Json(state.lock().unwrap().notes.values().cloned().collect())
}

async fn create(State(state): State<AppState>, Json(input): Json<NoteInput>) -> Result<(StatusCode, Json<Note>), ApiError> {
    validate(&input)?;
    let mut store = state.lock().unwrap();
    store.next_id += 1;
    let note = Note { id: store.next_id, title: input.title, body: input.body };
    store.notes.insert(note.id, note.clone());
    Ok((StatusCode::CREATED, Json(note)))
}

async fn read(State(state): State<AppState>, Path(id): Path<u32>) -> Result<Json<Note>, ApiError> {
    state.lock().unwrap().notes.get(&id).cloned().map(Json).ok_or(ApiError::NotFound(id))
}

async fn update(State(state): State<AppState>, Path(id): Path<u32>, Json(input): Json<NoteInput>) -> Result<Json<Note>, ApiError> {
    validate(&input)?;
    let mut store = state.lock().unwrap();
    let note = store.notes.get_mut(&id).ok_or(ApiError::NotFound(id))?;
    note.title = input.title;
    note.body = input.body;
    Ok(Json(note.clone()))
}

async fn delete(State(state): State<AppState>, Path(id): Path<u32>) -> Result<StatusCode, ApiError> {
    state.lock().unwrap().notes.remove(&id).map(|_| StatusCode::NO_CONTENT).ok_or(ApiError::NotFound(id))
}

pub fn app() -> Router {
    Router::new()
        .route("/notes", get(list).post(create))
        .route("/notes/{id}", get(read).put(update).delete(delete))
        .with_state(AppState::default())
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::Body;
    use axum::http::Request;
    use serde_json::Value;
    use tower::ServiceExt;

    async fn call(app: &Router, method: &str, uri: &str, body: Value) -> (StatusCode, Value) {
        let request = Request::builder().method(method).uri(uri).header("content-type", "application/json").body(Body::from(body.to_string())).unwrap();
        let response = app.clone().oneshot(request).await.unwrap();
        let status = response.status();
        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
        (status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
    }

    #[tokio::test]
    async fn create_then_read() {
        let app = app();
        let (status, created) = call(&app, "POST", "/notes", json!({ "title": "groceries", "body": "milk" })).await;
        assert_eq!(status, StatusCode::CREATED);
        assert_eq!(created["id"], 1);

        let (status, read) = call(&app, "GET", "/notes/1", Value::Null).await;
        assert_eq!(status, StatusCode::OK);
        assert_eq!(read, created);
    }

    #[tokio::test]
    async fn validation_errors_are_422() {
        let (status, body) = call(&app(), "POST", "/notes", json!({ "title": "  " })).await;
        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
        assert_eq!(body["error"], "title must not be empty");
    }

    #[tokio::test]
    async fn update_and_delete() {
        let app = app();
        call(&app, "POST", "/notes", json!({ "title": "draft" })).await;
        let (status, updated) = call(&app, "PUT", "/notes/1", json!({ "title": "final", "body": "done" })).await;
        assert_eq!((status, updated["title"].as_str()), (StatusCode::OK, Some("final")));

        let (status, _) = call(&app, "DELETE", "/notes/1", Value::Null).await;
        assert_eq!(status, StatusCode::NO_CONTENT);
        let (status, body) = call(&app, "GET", "/notes/1", Value::Null).await;
        assert_eq!((status, body["error"].as_str()), (StatusCode::NOT_FOUND, Some("note 1 not found")));
    }

    #[tokio::test]
    async fn list_returns_notes_in_id_order() {
        let app = app();
        for title in ["b", "a", "c"] {
            call(&app, "POST", "/notes", json!({ "title": title })).await;
        }
        let (_, list) = call(&app, "GET", "/notes", Value::Null).await;
        let titles: Vec<&str> = list.as_array().unwrap().iter().map(|n| n["title"].as_str().unwrap()).collect();
        assert_eq!(titles, ["b", "a", "c"]);
    }
}
Output
running 4 tests
test tests::create_then_read ... ok
test tests::list_returns_notes_in_id_order ... ok
test tests::update_and_delete ... ok
test tests::validation_errors_are_422 ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

The main for this service would be the three lines from example 1 with app() as the router. The database lesson replaces the in-memory BTreeMap with SQLite.

Summary

  • A handler is an async fn: its parameters are extractors (Path, Query, Json, State, HeaderMap, your own FromRequestParts types) and its return type implements IntoResponse.
  • A Router maps paths (with {param} segments) and methods to handlers; routers nest and merge, and fallback catches the rest.
  • Bad input is rejected with 400 or 422 before your code runs.
  • Shared resources go in the state, typically an Arc holding a struct; .with_state passes it in.
  • One error enum implementing IntoResponse turns ? into proper HTTP errors.
  • Middleware is a function with middleware::from_fn, or a ready-made tower-http layer such as TraceLayer and CorsLayer.
  • Test with oneshot in-process; run for real with axum::serve, port 0 in tests, and with_graceful_shutdown.

Next: HTTP Clients with reqwest.