Skip to Content
Course content

48: Building Web Servers with Actix or Axum

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Think of a web server like a busy restaurant. When a customer walks through the door, they don't just wander into the kitchen and start cooking their own meal. That would be chaos. Instead, they meet a host—the router. The host looks at what the customer wants ("I'd like a table for two" or "I'm just picking up a takeout order") and directs them to the right place. If it's takeout, they go to the counter; if it's a full meal, they go to the dining room. The people actually preparing the food—the chefs—are your handler functions. They take a specific request, do the work, and send back a finished plate (the response).

In Rust, we have two heavy hitters for this: Actix-web and Axum. Actix is a powerhouse and has been around longer, but Axum is currently the darling of the community because it's built on top of tokio and tower, making it feel more like a natural extension of the async ecosystem. I'll focus on Axum here because its use of "extractors" is a pattern you'll see everywhere.

Routing Requests to Your Logic

In a basic server, the router is where you define your API's "menu." You tell the server: "If someone hits this specific URL path with this specific HTTP method, run this function."

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

#[tokio::main]
async fn main() {
    // We build our "menu" here
    let app = Router::new()
        .route("/health", get(health_check))
        .route("/books/:id", get(get_book));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn health_check() -> &'static str {
    "Server is humming along nicely!"
}

async fn get_book(axum::extract::Path(id): axum::extract::Path<u32>) -> String {
    format!("Fetching details for book ID: {}", id)
}

Notice that get_book doesn't just take a generic request object. It uses a Path extractor. This is a brilliant bit of Rust magic: Axum looks at the function arguments and says, "Oh, you need a u32 from the URL path? I'll parse that for you before I even call the function." If the user sends /books/abc instead of a number, Axum handles the error automatically and returns a 400 Bad Request. You don't have to write the boilerplate parsing code yourself.

Managing the Pantry with Shared State

A chef can't cook if the pantry is locked. In a web server, your "pantry" is your database connection or a shared cache. Since Rust is obsessed with memory safety, you can't just have a global variable that every request touches—that would be a data race nightmare.

The solution is to use Arc (Atomic Reference Counting) and often a Mutex or RwLock if the data needs to change. We wrap this in an AppState struct and "inject" it into the router.

use axum::{extract::State, routing::get, Router};
use std::sync::{Arc, RwLock};
use std::collections::HashMap;

struct AppState {
    inventory: RwLock<HashMap<u32, String>>,
}

async fn get_book(
    State(state): State<Arc<AppState>>, 
    axum::extract::Path(id): axum::extract::Path<u32>
) -> String {
    let inventory = state.inventory.read().unwrap();
    match inventory.get(&id) {
        Some(book) => format!("Found: {}", book),
        None => "Book not found in catalog".to_string(),
    }
}

#[tokio::main]
async fn main() {
    let shared_state = Arc::new(AppState {
        inventory: RwLock::new(HashMap::from([
            (1, "The Rust Programming Language".to_string()),
            (2, "Programming Rust".to_string()),
        ])),
    });

    let app = Router::new()
        .route("/books/:id", get(get_book))
        .with_state(shared_state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

I prefer this pattern because it's explicit. You can see exactly what dependencies your handler needs just by looking at the function signature. If a handler needs the database, it asks for State. If it needs a user ID, it asks for Path. It makes testing these functions in isolation much easier because you can just pass in a mock state.

Choosing Between Actix and Axum

You might be wondering why you'd ever pick Actix over Axum. Actix-web is incredibly fast—often topping the Techempower benchmarks. It has its own actor system under the hood, which is powerful but adds a layer of conceptual complexity. Axum, on the other hand, is built on the tower ecosystem, meaning any middleware written for tower (like timeout, tracing, or rate-limiting) works out of the box. For 95% of projects, Axum's ergonomics and integration with the broader Tokio ecosystem make it the smoother choice.




📋 Practical Task

Build a Real-time Hardware Inventory Tracker

Your task is to build a small web server using Axum that tracks the stock levels of computer components. Your server needs to handle the following requirements:

  • Shared State: Create an AppState struct that holds a HashMap<String, u32> (where the key is the component name, like "GPU", and the value is the quantity in stock). This must be wrapped in an Arc and a RwLock.
  • The GET Endpoint: Create a route /stock/:item. If the item exists in the map, return the quantity (e.g., "GPU: 15 units"). If it doesn't exist, return a 404 Not Found response.
  • The POST Endpoint: Create a route /restock. This endpoint should accept a JSON body (you'll need to use axum::Json) containing the item name and the amount to add. It should update the shared state and return a success message.

Hint: You will need to add serde = { version = "1.0", features = ["derive"] } to your Cargo.toml to handle the JSON payload for the restock endpoint.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.