Guided Project 2: Simple HTTP Server with Axum

Guided Project 2: Simple HTTP Server with Axum

In this project, you’ll build a simple web server using Axum, a popular web framework built on top of Tokio and hyper. This project will demonstrate:

  • Asynchronous programming (async/await).
  • HTTP request and response handling.
  • Routing.
  • State management in web applications.
  • Working with JSON data in web APIs.

Our server will have two endpoints:

  • GET /: A simple “Hello, World!” greeting.
  • POST /echo: Echoes back the JSON body it receives.
  • GET /count: Returns the current value of a shared counter.
  • POST /increment: Increments the shared counter.

Project Setup

  1. Create a new binary project:

    cargo new rust_axum_server
    cd rust_axum_server
    
  2. Add dependencies: We’ll need tokio (for the async runtime), axum (the web framework), serde and serde_json (for JSON handling), and tower-http (for useful middleware like Trace).

    # Cargo.toml
    [package]
    name = "rust_axum_server"
    version = "0.1.0"
    edition = "2024"
    
    [dependencies]
    tokio = { version = "1", features = ["full"] } # Tokio runtime, "full" for convenience
    axum = "0.7" # Axum web framework
    serde = { version = "1.0", features = ["derive"] }
    serde_json = "1.0"
    tower-http = { version = "0.5", features = ["trace"] } # For request logging
    tracing = "0.1" # For logging infrastructure
    tracing-subscriber = { version = "0.3", features = ["env-filter"] } # For logging configuration
    

    Alternatively, use cargo add tokio --features full, cargo add axum, etc.

Step-by-Step Implementation

Step 1: Basic Server and Hello World

Let’s start by creating a simple server that responds to GET / with a “Hello, World!” message.

src/main.rs:

use axum::{routing::get, Router};
use std::net::SocketAddr;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

#[tokio::main]
async fn main() {
    // Initialize logging
    tracing_subscriber::registry()
        .with(tracing_subscriber::EnvFilter::new(
            std::env::var("RUST_LOG")
                .unwrap_or_else(|_| "rust_axum_server=debug,tower_http=debug".into()),
        ))
        .with(tracing_subscriber::fmt::layer())
        .init();

    // Build our application with a single route
    let app = Router::new().route("/", get(handler));

    // Define the address to listen on
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    tracing::debug!("listening on {}", addr); // Log server start

    // Start the server
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn handler() -> String {
    "Hello, Axum World!".to_string()
}

Explanation:

  • #[tokio::main]: Sets up the Tokio runtime for our async main function.
  • tracing and tracing-subscriber: Used for robust logging, essential for web servers. We configure it to print debug logs for our app and tower-http.
  • Router::new().route("/", get(handler)): Creates a new router and adds a route. get(handler) means that when a GET request comes to /, it should be handled by the handler function.
  • axum::Server::bind(&addr).serve(...): Binds the server to an address and starts serving the application.
  • handler(): An async function that returns a String. Axum automatically converts this String into an HTTP response body with a Content-Type: text/plain header.

Run this step: cargo run. Open your browser to http://127.0.0.1:3000/. You should see “Hello, Axum World!”. Check your terminal for debug logs.

Step 2: Echoing JSON (POST Request)

Let’s add an endpoint that accepts a JSON body in a POST request and echoes it back. This demonstrates how to extract JSON from a request and return JSON in a response.

Modify src/main.rs:

use axum::{
    body::Body,
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

#[tokio::main]
async fn main() {
    // ... (logging setup - same as before) ...

    let app = Router::new()
        .route("/", get(root_handler))
        .route("/echo", post(echo_handler)); // New route for echoing JSON

    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    tracing::debug!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn root_handler() -> String {
    "Hello, Axum World!".to_string()
}

// Define a struct to represent the JSON data we expect
#[derive(Debug, Serialize, Deserialize)]
struct EchoPayload {
    message: String,
    data: u32,
}

// Handler for POST /echo
// `Json(payload)` extractor automatically deserializes the request body into EchoPayload
// Returning `Json(payload)` automatically serializes it back to JSON in the response
async fn echo_handler(Json(payload): Json<EchoPayload>) -> Json<EchoPayload> {
    tracing::debug!("Received echo payload: {:?}", payload);
    payload.into() // Convert payload back into a Json response (or directly return Json(payload))
}

Explanation:

  • #[derive(Debug, Serialize, Deserialize)] struct EchoPayload: Defines the structure of our JSON data.
  • Json(payload): Json<EchoPayload>: This is an extractor. Axum uses this to automatically:
    1. Parse the Content-Type header to ensure it’s application/json.
    2. Read the request body.
    3. Deserialize the JSON body into an EchoPayload struct.
    4. If any of these fail, Axum automatically returns an appropriate HTTP error (e.g., 400 Bad Request).
  • Returning Json<EchoPayload>: Axum automatically serializes the struct back into JSON and sets the Content-Type header to application/json.

Run this step: cargo run. Use curl or a tool like Postman/Insomnia to test:

curl -X POST -H "Content-Type: application/json" -d '{"message": "Hello from client", "data": 123}' http://127.0.0.1:3000/echo

You should get back the same JSON, and see logs in your terminal.

Step 3: Shared State with Arc<Mutex>

Many web applications need to maintain state across requests (e.g., a counter, a database connection pool). We’ll use Arc<Mutex> to safely share a counter between requests.

Modify src/main.rs:

use axum::{
    body::Body,
    extract::State, // Import State extractor
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::{net::SocketAddr, sync::{Arc, Mutex}}; // Import Arc and Mutex
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

// Define our shared application state
#[derive(Clone, Debug)] // Derive Clone for State to be passed to multiple handlers
struct AppState {
    counter: Arc<Mutex<u32>>, // Shared, mutable counter
}

#[tokio::main]
async fn main() {
    // ... (logging setup - same as before) ...

    // Initialize our application state
    let app_state = AppState {
        counter: Arc::new(Mutex::new(0)),
    };

    let app = Router::new()
        .route("/", get(root_handler))
        .route("/echo", post(echo_handler))
        .route("/count", get(get_count_handler)) // New route for getting count
        .route("/increment", post(increment_count_handler)) // New route for incrementing
        .with_state(app_state); // Pass the state to the router

    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    tracing::debug!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn root_handler() -> String {
    "Hello, Axum World!".to_string()
}

#[derive(Debug, Serialize, Deserialize)]
struct EchoPayload {
    message: String,
    data: u32,
}

async fn echo_handler(Json(payload): Json<EchoPayload>) -> Json<EchoPayload> {
    tracing::debug!("Received echo payload: {:?}", payload);
    payload.into()
}

// Handler to get the current count
// `State<AppState>` extractor automatically gives us access to our shared state
async fn get_count_handler(State(state): State<AppState>) -> String {
    let count = *state.counter.lock().unwrap(); // Acquire lock and read
    format!("Current count: {}", count)
}

// Handler to increment the count
async fn increment_count_handler(State(state): State<AppState>) -> String {
    let mut count = state.counter.lock().unwrap(); // Acquire mutable lock
    *count += 1; // Increment
    format!("Incremented count to: {}", *count)
}

Explanation:

  • AppState: A struct to hold all our shared state. We derive Clone because the router needs to clone the state to pass it to multiple handlers. Arc<Mutex<u32>> ensures thread-safe, shared, mutable access to the counter.
  • Router::new().with_state(app_state): Passes the initialized AppState to the router.
  • State(state): State<AppState>: This is the State extractor. In our handler functions, we can use this to get a clone of our AppState. Axum efficiently manages this.
  • Inside get_count_handler and increment_count_handler: We acquire the Mutex lock to safely access and modify the counter. *state.counter.lock().unwrap() dereferences the Arc, acquires the Mutex lock, and then dereferences the u32 value.

Run this step: cargo run. Test with curl:

  • curl http://127.0.0.1:3000/count (should return 0, then 1, 2, etc. after incrementing)
  • curl -X POST http://127.0.0.1:3000/increment (will increment the counter)
  • curl http://127.0.0.1:3000/count (to check the new count)

This project gives you a foundational understanding of building web services with Axum, covering essential concepts like routing, JSON handling, and shared state.