EthicsComputerCourses
Join training +91 90532 10052

Web APIs with Axum

Rust · Lesson 40 of 43 Advanced 10 min read Updated

Most backend work today means writing web APIs: programs that receive HTTP requests and answer with JSON. A mobile app asks "is train 12001 late?", and your server replies with a small JSON object. Rust is a strong choice for such services because they run fast with little memory, and the compiler catches many bugs before deployment.

The most widely used Rust web framework is Axum, built by the Tokio team. In this lesson you will build a small train status API step by step with Axum 0.8 (0.8.9 in November 2026): routes, handlers, extractors that read the request for you, shared state, proper error responses, middleware and graceful shutdown. You need Async in Depth: Channels, JoinSet & Cancellation and Serde & JSON in Depth first.

How Axum is organised

Axum is a thin layer over three other crates: Tokio runs the async code, hyper speaks HTTP, and tower provides middleware. Your code has three parts:

  • A handler is an ordinary async fn that returns something Axum can turn into a response, such as a String, a StatusCode or Json<T>.
  • An extractor is a handler parameter that pulls data out of the request: Path for parts of the URL, Query for ?key=value pairs, Json for the body and State for data you share between requests.
  • A router maps a method and a path, such as GET /trains/{number}, to a handler.

There are no macros on handlers and no special base types. If your function's parameters are extractors and its return type implements IntoResponse, it is a handler.

Building a train status API

You will build the API in seven steps:

  1. Create the project and add the crates.
  2. Start a server with one route.
  3. Store the data in shared state.
  4. Read the URL with Path and Query.
  5. Turn errors into JSON responses.
  6. Accept JSON in a POST request.
  7. Add middleware and a graceful shutdown.

Step 1: create the project

Terminal
cargo new train-status
cd train-status
cargo add axum
cargo add tokio --features full
cargo add serde --features derive
cargo add serde_json
cargo add tower-http --features trace

Step 2: a server with one route

Replace src/main.rs with:

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

#[tokio::main]
async fn main() {
    let app = Router::new().route("/health", get(health));

    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();
}

async fn health() -> &'static str {
    "ok"
}

Start it with cargo run, and in a second terminal ask for the page with curl (on Windows PowerShell type curl.exe, because plain curl may be an alias for a different command):

Terminal
curl http://127.0.0.1:3000/health
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/nothing-here
Output
ok
404

get(health) says "for GET requests, call health". A path with no route gets 404 Not Found automatically. 127.0.0.1 accepts connections only from your own computer; a server inside a container binds to 0.0.0.0 instead.

Step 3: data in shared state

Every request runs in its own task, possibly on a different thread, so shared data must be safe to use from many threads at once. Wrap it in Arc (shared ownership) and a lock, as you learned in Fearless Concurrency. Add these types and a function with some made-up trains:

Rust
use std::collections::HashMap;
use std::sync::{Arc, RwLock};

use serde::{Deserialize, Serialize};

#[derive(Clone, Serialize)]
struct Train {
    number: u32,
    name: String,
    from: String,
    to: String,
    delay_min: u32,
}

type Db = Arc<RwLock<HashMap<u32, Train>>>;

fn sample_data() -> Db {
    let list = [
        (12001, "Pink City Express", "Jaipur", "Delhi"),
        (12002, "Desert Queen", "Jaipur", "Jodhpur"),
        (16301, "Backwater Mail", "Kochi", "Chennai"),
    ];
    let mut map = HashMap::new();
    for (number, name, from, to) in list {
        let train = Train {
            number,
            name: name.to_string(),
            from: from.to_string(),
            to: to.to_string(),
            delay_min: 0,
        };
        map.insert(number, train);
    }
    Arc::new(RwLock::new(map))
}

RwLock lets many readers in at once but only one writer, which suits an API that reads far more often than it writes. Cloning a Db clones only the Arc pointer, so it is cheap. The lock here is the standard library one; that is fine as long as you never hold it across an .await.

Step 4: reading the URL with Path and Query

Rust
use axum::extract::{Path, Query, State};
use axum::Json;

#[derive(Deserialize)]
struct Filter {
    from: Option<String>,
}

async fn list_trains(State(db): State<Db>, Query(filter): Query<Filter>) -> Json<Vec<Train>> {
    let db = db.read().unwrap();
    let mut list: Vec<Train> = db
        .values()
        .filter(|t| filter.from.as_deref().is_none_or(|city| t.from == city))
        .cloned()
        .collect();
    list.sort_by_key(|t| t.number); // HashMap order is random; sort for stable output
    Json(list)
}

Each extractor is written as a pattern: State(db) unpacks the State wrapper and gives you the Db, and Query(filter) deserialises the query string into Filter with serde. Because from is an Option, /trains and /trains?from=Jaipur both work. Returning Json(list) serialises the vector and sets the Content-Type: application/json header.

Path works the same way. In the route /trains/{number}, the {number} part is captured, and Path<u32> parses it as a number.

Step 5: errors as responses

A handler can return Result<T, E> where both T and E implement IntoResponse. Create an error enum and decide, in one place, which status code and JSON body each error produces:

Rust
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;

enum ApiError {
    NotFound(u32),
    BadDelay(u32),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            ApiError::NotFound(n) => (StatusCode::NOT_FOUND, format!("no train with number {n}")),
            ApiError::BadDelay(m) => (
                StatusCode::UNPROCESSABLE_ENTITY,
                format!("a delay of {m} minutes is not believable"),
            ),
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

async fn get_train(State(db): State<Db>, Path(number): Path<u32>) -> Result<Json<Train>, ApiError> {
    let db = db.read().unwrap();
    db.get(&number).cloned().map(Json).ok_or(ApiError::NotFound(number))
}

A tuple of (StatusCode, body) is itself a response, which is why the last line of into_response is so short. Clients now always receive JSON, even for errors, and the handler stays clean: it returns ApiError::NotFound and does not care how that is shown.

Step 6: accepting JSON

Station staff report delays with a POST request whose body is JSON such as {"minutes": 25}:

Rust
#[derive(Deserialize)]
struct DelayReport {
    minutes: u32,
}

async fn report_delay(
    State(db): State<Db>,
    Path(number): Path<u32>,
    Json(report): Json<DelayReport>,
) -> Result<Json<Train>, ApiError> {
    if report.minutes > 600 {
        return Err(ApiError::BadDelay(report.minutes));
    }
    let mut db = db.write().unwrap();
    let train = db.get_mut(&number).ok_or(ApiError::NotFound(number))?;
    train.delay_min = report.minutes;
    Ok(Json(train.clone()))
}

Extractors run from left to right, and the request body can be read only once, so the body extractor (Json, String or Bytes) must be the last parameter. Put it anywhere else and the handler does not compile. If the body is not valid JSON, or the Content-Type header is missing, Axum rejects the request before your code runs.

Step 7: routes, middleware and graceful shutdown

Now connect everything in main:

Rust
use axum::routing::post;
use tower_http::trace::TraceLayer;

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/health", get(health))
        .route("/trains", get(list_trains))
        .route("/trains/{number}", get(get_train))
        .route("/trains/{number}/delay", post(report_delay))
        .layer(TraceLayer::new_for_http())
        .with_state(sample_data());

    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)
        .with_graceful_shutdown(shutdown_signal())
        .await
        .unwrap();
}

async fn shutdown_signal() {
    tokio::signal::ctrl_c().await.expect("could not listen for Ctrl+C");
    println!("Shutting down, finishing open requests");
}
  • with_state hands the Db to every handler that asks for State<Db>. A router that still lacks its state cannot be served, so forgetting this is a compile error, not a crash.
  • layer wraps the routes in middleware, code that runs around every request. TraceLayer records each request and response as tracing events; install a subscriber as shown in Rust at Work: CLI Tools, Services & Embedded to see them. tower-http also has layers for CORS, timeouts and compression.
  • with_graceful_shutdown stops accepting new connections when Ctrl+C is pressed and lets requests already in progress finish.

Trying the API

With the server running, these bash commands exercise every route:

Terminal
curl http://127.0.0.1:3000/trains/12001
curl "http://127.0.0.1:3000/trains?from=Jaipur"
curl -X POST http://127.0.0.1:3000/trains/16301/delay -H "Content-Type: application/json" -d '{"minutes":25}'
curl http://127.0.0.1:3000/trains/99999
curl -s -w "\nstatus %{http_code}\n" -X POST http://127.0.0.1:3000/trains/16301/delay -d '{"minutes":25}'
Output
{"number":12001,"name":"Pink City Express","from":"Jaipur","to":"Delhi","delay_min":0}
[{"number":12001,"name":"Pink City Express","from":"Jaipur","to":"Delhi","delay_min":0},{"number":12002,"name":"Desert Queen","from":"Jaipur","to":"Jodhpur","delay_min":0}]
{"number":16301,"name":"Backwater Mail","from":"Kochi","to":"Chennai","delay_min":25}
{"error":"no train with number 99999"}
Expected request with `Content-Type: application/json`
status 415

curl does not add a newline after a response body, so in your terminal the prompt appears right after each answer; the output above puts each response on its own line for reading. The last command forgot the -H header, so Axum's Json extractor rejected it with 415 Unsupported Media Type and a plain-text message. Badly formed JSON gives 400 Bad Request, and well-formed JSON with the wrong shape (for example "minutes":"late") gives 422 Unprocessable Entity. You can write tests for all of these without starting a server; the Project: Orders API with Axum & Tests shows how.

Common mistakes

  • Putting Json before another extractor. The body extractor must be last; the compiler error mentions that the function is not a valid Handler.
  • Forgetting with_state. The router's type then says the state is missing, and axum::serve refuses it.
  • Holding a std::sync lock across .await. The handler's future stops being Send and Axum rejects it. Copy what you need out of the lock first.
  • Using unwrap on user input in handlers. One bad request would panic that task. Return Result with an error type that implements IntoResponse.
  • Testing with the wrong curl. In Windows PowerShell, use curl.exe and mind the different quoting rules, or use Git Bash.

Key points

  • Axum maps routes such as GET /trains/{number} to async handler functions; axum::serve runs the router on a Tokio TcpListener.
  • Extractors (Path, Query, Json, State) parse the request with serde before your code runs; the body extractor must come last.
  • Shared state goes through with_state, usually as an Arc holding a lock, a database pool or an actor's sender.
  • Anything that implements IntoResponse can be returned; a custom error enum gives consistent JSON errors.
  • tower-http layers add tracing, CORS, timeouts and compression around every route.
  • with_graceful_shutdown lets running requests finish when the server is asked to stop.

Exercise

Add a route GET /trains/late that returns only trains whose delay_min is more than 15, sorted by delay with the latest first. Then report a 40-minute delay for train 12002 and a 10-minute delay for train 12001 with curl, and call the new route. (Hint: register /trains/late as its own route; Axum prefers a fixed path over a {number} capture.)

Show solution
Rust
async fn late_trains(State(db): State<Db>) -> Json<Vec<Train>> {
    let db = db.read().unwrap();
    let mut late: Vec<Train> = db.values().filter(|t| t.delay_min > 15).cloned().collect();
    late.sort_by_key(|t| std::cmp::Reverse(t.delay_min));
    Json(late)
}

// in main, next to the other routes:
// .route("/trains/late", get(late_trains))
Terminal
curl -X POST http://127.0.0.1:3000/trains/12002/delay -H "Content-Type: application/json" -d '{"minutes":40}'
curl -X POST http://127.0.0.1:3000/trains/12001/delay -H "Content-Type: application/json" -d '{"minutes":10}'
curl http://127.0.0.1:3000/trains/late
Output
{"number":12002,"name":"Desert Queen","from":"Jaipur","to":"Jodhpur","delay_min":40}
{"number":12001,"name":"Pink City Express","from":"Jaipur","to":"Delhi","delay_min":10}
[{"number":12002,"name":"Desert Queen","from":"Jaipur","to":"Jodhpur","delay_min":40}]

std::cmp::Reverse flips the sort order, so the biggest delay comes first. Train 12001 is only 10 minutes late, so it is not in the list.

Quick Quiz

1. Which extractor reads the {number} part of the route /trains/{number}?

2. Why must Json<T> be the last parameter of a handler?

3. What does a handler that returns Err(ApiError::NotFound(7)) send to the client in this lesson?

4. What does with_graceful_shutdown(shutdown_signal()) do?

Learn Rust with a mentor

Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call