Coding by Hand
Rust home

Build an API with axum

A restaurant has a menu board on the wall and a line of cooks behind the pass, and every dish a customer can order has to appear on both. The menu lists what is possible. The cooks know how to make each one. An order ticket flies in, the right cook grabs it, and a plate comes back out. An HTTP server runs the same way. The menu is the route table — the list of URLs the server is willing to talk about. Each cook is a handler — an async function that knows how to answer one kind of request. axum is the kitchen layout that wires the menu to the cooks so the right one fires for every order.

A Router maps method-and-path pairs to handler functions, like a restaurant menu mapping dishes to cooks.
A Router maps method-and-path pairs to handler functions, like a restaurant menu mapping dishes to cooks.

The Rust web story started rough. Iron came first in 2014 and felt like a bolted-together pipeline. Rocket arrived in 2016 with nicer ergonomics but leaned on the nightly compiler for years, which scared off teams that needed stable builds. Hyper, the low-level HTTP library built by Sean McArthur at Mozilla, kept getting faster but it asked the user to manage too much by hand. The bottleneck was a framework that was idiomatic, async-first on stable Rust, and built on the type system instead of around it. Tokio shipped axum in 2021 as the answer. It sits directly on top of hyper and tower, so the request pipeline is the same one every other async Rust service already trusts, and the routing layer is small enough to read in an afternoon.

Start with the cooks. A handler in axum is just an async function. The arguments tell axum what parts of the request the cook needs, and the return value is what gets plated.

async fn hello_handler() -> &'static str {
    "hello, world"
}

async fn get_user_handler(Path(id): Path<u64>) -> Json<User> {
    Json(User {
        id,
        name: "ada".to_string(),
    })
}

async fn create_user_handler(Json(input): Json<NewUser>) -> (StatusCode, Json<User>) {
    let created = User {
        id: 7,
        name: input.name,
    };
    (StatusCode::CREATED, Json(created))
}

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

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

hello_handler is the smallest possible cook. It takes nothing, it returns a &'static str, and axum knows how to turn that into a 200 response with text/plain. The body is whatever the function returns. No framing code. No header juggling. Whatever the function gives back, axum wraps it.

get_user_handler is the next step up. The argument Path(id): Path<u64> is axum's extractor pattern. Path is a type that says "pull a piece out of the URL and parse it as this type." The compiler checks that u64 actually fits the path segment, and if a caller hits /users/abc the request gets rejected before the handler ever runs. The return type is Json<User>, which signals two things at once — the body is JSON, and the body's shape is the User struct. axum reads the Serialize impl on User to write the bytes. Nothing in the handler ever touches the network directly.

create_user_handler is the same trick applied to the request body. Json(input): Json<NewUser> says "read the body, parse it as JSON, deserialize it into a NewUser." If the body is missing, malformed, or missing the name field, the request is rejected with a 400 before the handler runs. The handler returns a tuple (StatusCode, Json<User>). axum has an IntoResponse impl for tuples that takes the first element as the status code and the rest as the body, so (StatusCode::CREATED, Json(created)) becomes a real 201 with a JSON body. The cook never writes a Content-Length header. The cook never picks a status code by string. The types do that work.

An axum handler's argument types tell the framework what parts of the request to extract, and the return type defines the response.
An axum handler's argument types tell the framework what parts of the request to extract, and the return type defines the response.

The menu is the Router. It maps a method-and-path pair to one of the cooks.

fn build_router() -> Router {
    Router::new()
        .route("/hello", get(hello_handler))
        .route("/users/:id", get(get_user_handler))
        .route("/users", post(create_user_handler))
}

Router::new() builds an empty menu. .route("/hello", get(hello_handler)) adds one entry — when a GET comes in for /hello, run hello_handler. The get and post helpers exist because the same path can host different handlers for different methods, and the router needs to tell them apart. The path /users/:id has a colon segment, which is the syntax axum uses to mark a piece the path extractor will pull out. The compiler does not yet know that :id lines up with Path<u64> on the handler — that match is enforced at the type-extraction step when a request actually arrives, and the price for a mismatch is a 400 response instead of a panic.

build_router returns a Router. The same value would normally get handed to axum::serve(...) along with a TcpListener, and that call would bind the socket and start the loop. This lesson stops one step earlier. Building the router proves every handler's signature is something axum can accept. Running the server would only show what the network does, which is the same on every framework. The interesting Rust is the part that fails to compile when the types do not line up.

The path an HTTP request takes through axum: socket bytes become a parsed Request, the Router picks a handler, extractors fill the arguments, and the return value becomes a Response.
The path an HTTP request takes through axum: socket bytes become a parsed Request, the Router picks a handler, extractors fill the arguments, and the return value becomes a Response.

To make the router visible, the binary keeps a parallel description of the same routes and walks that list at print time.

const ROUTES: &[(&str, &str, &str)] = &[
    ("GET", "/hello", "hello_handler"),
    ("GET", "/users/:id", "get_user_handler"),
    ("POST", "/users", "create_user_handler"),
];

fn print_route_table() {
    println!("--- route table ---");
    println!("{:<6} {:<14} -> {}", "METHOD", "PATH", "HANDLER");
    for (method, path, handler) in ROUTES {
        println!("{:<6} {:<14} -> {}", method, path, handler);
    }
}

ROUTES is a plain array of (method, path, handler) triples that mirror what build_router registers. Keeping it next to the router is a small price for being able to render the menu. A production server skips this because the runtime already knows the table — every request lookup walks it. A lesson binary cannot reach into axum's internal radix tree, so the table on the wall and the table in the cook's head get written once each, and the test snapshot pins the wall version.

Now drive a sample call by hand, in print, so the reader sees what a real request and response would look like.

fn print_sample_response() {
    println!("--- sample request ---");
    println!("GET /users/42");
    println!("status: 200 OK");
    println!("content-type: application/json");
    println!("body: {{\"id\":42,\"name\":\"ada\"}}");
    println!();
    println!("--- sample request ---");
    println!("POST /users");
    println!("body: {{\"name\":\"grace\"}}");
    println!("status: 201 Created");
    println!("content-type: application/json");
    println!("body: {{\"id\":7,\"name\":\"grace\"}}");
}

The sample is hardcoded for the same reason the route table is — the lesson cannot open a socket. The lines say what would come back if a real client hit /users/42 and posted to /users. The shape of the JSON is exactly what serde::Serialize on User would produce, and the status codes are the ones axum hands back for Json<User> (200) and (StatusCode::CREATED, Json<User>) (201). The bytes are not invented — they are dictated by the types.

--- route table ---
METHOD PATH           -> HANDLER
GET    /hello         -> hello_handler
GET    /users/:id     -> get_user_handler
POST   /users         -> create_user_handler

--- sample request ---
GET /users/42
status: 200 OK
content-type: application/json
body: {"id":42,"name":"ada"}

--- sample request ---
POST /users
body: {"name":"grace"}
status: 201 Created
content-type: application/json
body: {"id":7,"name":"grace"}

Read the output top to bottom. The menu prints first as a three-column table, one row per route, so the reader can see at a glance what the service answers. Below it, the two sample requests show the shape of the response that each handler produces. A GET to /users/42 returns a 200 with a JSON body holding the id and a hardcoded name. A POST to /users with a name in the body returns a 201 and a fresh user record that echoes the name back. One question worth asking — why does the POST return 201 instead of 200? The answer is in create_user_handler. The tuple (StatusCode::CREATED, Json(created)) says explicitly that something was created, which is what 201 means, and a generic 200 would tell the client less than the truth. The handler decided the status code with one extra value in the return type, no header API to learn.

The thing this service cannot do on its own is share data across requests. Each handler runs with whatever it can pull out of the request, and the moment two requests need to read the same user list, or write to the same database, or rate-limit by IP, the cook needs a pantry. axum solves that with State, and a real database needs a connection pool to live there — which is what the next lesson reaches for with sqlx.