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
Create a new binary project:
cargo new rust_axum_server cd rust_axum_serverAdd dependencies: We’ll need
tokio(for the async runtime),axum(the web framework),serdeandserde_json(for JSON handling), andtower-http(for useful middleware likeTrace).# 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 configurationAlternatively, 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 ourasync mainfunction.tracingandtracing-subscriber: Used for robust logging, essential for web servers. We configure it to print debug logs for our app andtower-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 thehandlerfunction.axum::Server::bind(&addr).serve(...): Binds the server to an address and starts serving the application.handler(): Anasyncfunction that returns aString. Axum automatically converts thisStringinto an HTTP response body with aContent-Type: text/plainheader.
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:- Parse the
Content-Typeheader to ensure it’sapplication/json. - Read the request body.
- Deserialize the JSON body into an
EchoPayloadstruct. - If any of these fail, Axum automatically returns an appropriate HTTP error (e.g., 400 Bad Request).
- Parse the
- Returning
Json<EchoPayload>: Axum automatically serializes the struct back into JSON and sets theContent-Typeheader toapplication/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 deriveClonebecause 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 initializedAppStateto the router.State(state): State<AppState>: This is theStateextractor. In our handler functions, we can use this to get a clone of ourAppState. Axum efficiently manages this.- Inside
get_count_handlerandincrement_count_handler: We acquire theMutexlock to safely access and modify the counter.*state.counter.lock().unwrap()dereferences theArc, acquires theMutexlock, and then dereferences theu32value.
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.