How do I build web APIs and services in Rust?

Walkthrough

Axum is an ergonomic web framework built on Tokio and Hyper, focusing on modularity and type-safe routing. It leverages Rust's type system to extract request data, handle errors, and compose handlers. Axum integrates seamlessly with the Tower ecosystem for middleware and service composition.

Core concepts:

  1. Handlers — async functions that process requests and return responses
  2. Extractors — types that extract data from requests (path, query, JSON, etc.)
  3. Routes — map HTTP methods and paths to handlers
  4. State — share application state across handlers using State extractor
  5. Middleware — layers that wrap requests/responses via Tower

Axum's compile-time guarantees catch routing and extraction errors before runtime.

Code Example

# Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use axum::{
    extract::{Path, Query},
    response::{IntoResponse, Json},
    routing::{get, post},
    Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
 
// ===== Response Types =====
 
#[derive(Debug, Serialize, Deserialize)]
struct User {
    id: u32,
    name: String,
    email: String,
}
 
#[derive(Debug, Deserialize)]
struct CreateUser {
    name: String,
    email: String,
}
 
#[derive(Debug, Deserialize)]
struct Pagination {
    #[serde(default = "default_page")]
    page: u32,
    #[serde(default = "default_limit")]
    limit: u32,
}
 
fn default_page() -> u32 { 1 }
fn default_limit() -> u32 { 10 }
 
// ===== Handlers =====
 
async fn hello() -> &'static str {
    "Hello, World!"
}
 
async fn json_response() -> Json<User> {
    Json(User {
        id: 1,
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
    })
}
 
async fn path_param(Path(id): Path<u32>) -> impl IntoResponse {
    format!("User ID: {}", id)
}
 
async fn query_params(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
    format!("Query params: {:?}", params)
}
 
async fn pagination(Query(pagination): Query<Pagination>) -> impl IntoResponse {
    format!("Page: {}, Limit: {}", pagination.page, pagination.limit)
}
 
async fn create_user(Json(payload): Json<CreateUser>) -> impl IntoResponse {
    let user = User {
        id: 42,
        name: payload.name,
        email: payload.email,
    };
    
    (axum::http::StatusCode::CREATED, Json(user))
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(hello))
        .route("/user", get(json_response))
        .route("/user/{id}", get(path_param))
        .route("/search", get(query_params))
        .route("/items", get(pagination))
        .route("/user", post(create_user));
    
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Server running on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

Shared State and CRUD Operations

use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::{IntoResponse, Json},
    routing::{delete, get, post, put},
    Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
 
// ===== Domain Types =====
 
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Todo {
    id: u64,
    title: String,
    completed: bool,
}
 
#[derive(Debug, Deserialize)]
struct CreateTodo {
    title: String,
}
 
#[derive(Debug, Deserialize)]
struct UpdateTodo {
    title: Option<String>,
    completed: Option<bool>,
}
 
// ===== Application State =====
 
type Db = Arc<RwLock<Vec<Todo>>>;
 
#[derive(Clone)]
struct AppState {
    db: Db,
}
 
// ===== Handlers =====
 
async fn list_todos(State(state): State<AppState>) -> impl IntoResponse {
    let todos = state.db.read().await;
    Json(todos.clone())
}
 
async fn get_todo(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<impl IntoResponse, StatusCode> {
    let todos = state.db.read().await;
    let todo = todos.iter().find(|t| t.id == id).ok_or(StatusCode::NOT_FOUND)?;
    Ok(Json(todo.clone()))
}
 
async fn create_todo(
    State(state): State<AppState>,
    Json(payload): Json<CreateTodo>,
) -> impl IntoResponse {
    let mut todos = state.db.write().await;
    
    let id = todos.len() as u64 + 1;
    let todo = Todo {
        id,
        title: payload.title,
        completed: false,
    };
    
    todos.push(todo.clone());
    
    (StatusCode::CREATED, Json(todo))
}
 
async fn update_todo(
    State(state): State<AppState>,
    Path(id): Path<u64>,
    Json(payload): Json<UpdateTodo>,
) -> Result<impl IntoResponse, StatusCode> {
    let mut todos = state.db.write().await;
    
    let todo = todos.iter_mut().find(|t| t.id == id).ok_or(StatusCode::NOT_FOUND)?;
    
    if let Some(title) = payload.title {
        todo.title = title;
    }
    if let Some(completed) = payload.completed {
        todo.completed = completed;
    }
    
    Ok(Json(todo.clone()))
}
 
async fn delete_todo(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> impl IntoResponse {
    let mut todos = state.db.write().await;
    let len_before = todos.len();
    todos.retain(|t| t.id != id);
    
    if todos.len() < len_before {
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}
 
#[tokio::main]
async fn main() {
    let state = AppState {
        db: Arc::new(RwLock::new(Vec::new())),
    };
    
    let app = Router::new()
        .route("/todos", get(list_todos).post(create_todo))
        .route("/todos/{id}", get(get_todo).put(update_todo).delete(delete_todo))
        .with_state(state);
    
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Server running on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

Error Handling

use axum::{
    extract::State,
    http::StatusCode,
    response::{IntoResponse, Json, Response},
    routing::get,
    Router,
};
use serde_json::json;
use std::sync::Arc;
 
// ===== Error Types =====
 
#[derive(Debug)]
enum ApiError {
    NotFound(String),
    ValidationError(String),
    InternalError(String),
}
 
impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
            ApiError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg),
            ApiError::InternalError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
        };
        
        let body = Json(json!({
            "error": message,
        }));
        
        (status, body).into_response()
    }
}
 
// ===== Result Wrapper =====
 
type ApiResult<T> = Result<T, ApiError>;
 
// ===== Handlers with Error Handling =====
 
async fn get_user(Path(id): Path<u32>) -> ApiResult<impl IntoResponse> {
    if id == 0 {
        return Err(ApiError::ValidationError("Invalid user ID".to_string()));
    }
    
    if id > 100 {
        return Err(ApiError::NotFound(format!("User {} not found", id)));
    }
    
    Ok(Json(json!({
        "id": id,
        "name": format!("User {}", id),
    })))
}
 
use axum::extract::Path;
 
// ===== Fallback Handler =====
 
async fn fallback() -> impl IntoResponse {
    (
        StatusCode::NOT_FOUND,
        Json(json!({ "error": "Route not found" })),
    )
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/user/{id}", get(get_user))
        .fallback(fallback);
    
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Server running on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

Middleware and Request Extensions

use axum::{
    body::Body,
    extract::Request,
    http::{header, StatusCode},
    middleware::{self, Next},
    response::{IntoResponse, Response},
    routing::get,
    Router,
};
use serde_json::json;
 
// ===== Timing Middleware =====
 
async fn timing_middleware(
    request: Request,
    next: Next,
) -> Response {
    let start = std::time::Instant::now();
    let response = next.run(request).await;
    let elapsed = start.elapsed();
    
    println!("Request took {:?}", elapsed);
    
    response
}
 
// ===== Auth Middleware =====
 
async fn auth_middleware(
    request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let auth_header = request
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|h| h.to_str().ok());
    
    match auth_header {
        Some(token) if token == "Bearer secret-token" => {
            Ok(next.run(request).await)
        }
        _ => Err(StatusCode::UNAUTHORIZED),
    }
}
 
// ===== Handlers =====
 
async fn public() -> impl IntoResponse {
    "Public endpoint"
}
 
async fn protected() -> impl IntoResponse {
    "Protected endpoint - you're authenticated!"
}
 
#[tokio::main]
async fn main() {
    let protected_routes = Router::new()
        .route("/dashboard", get(protected))
        .route_layer(middleware::from_fn(auth_middleware));
    
    let app = Router::new()
        .route("/", get(public))
        .merge(protected_routes)
        .layer(middleware::from_fn(timing_middleware));
    
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Server running on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

Multiple Extractors and Nested Routers

use axum::{
    extract::{ConnectInfo, OriginalUri, State},
    http::{Method, Uri},
    response::IntoResponse,
    routing::get,
    Router,
};
use std::net::SocketAddr;
 
#[derive(Clone)]
struct ApiContext {
    version: String,
}
 
async fn debug_info(
    method: Method,
    uri: Uri,
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
    State(ctx): State<ApiContext>,
) -> impl IntoResponse {
    format!(
        "Method: {}\nURI: {}\nClient: {}\nAPI Version: {}",
        method, uri, addr, ctx.version
    )
}
 
// Nested router for API v1
fn api_v1() -> Router<ApiContext> {
    Router::new()
        .route("/users", get(|| async { "v1 users" }))
        .route("/items", get(|| async { "v1 items" }))
}
 
// Nested router for API v2
fn api_v2() -> Router<ApiContext> {
    Router::new()
        .route("/users", get(|| async { "v2 users" }))
        .route("/items", get(|| async { "v2 items" }))
}
 
#[tokio::main]
async fn main() {
    let ctx = ApiContext {
        version: "1.0.0".to_string(),
    };
    
    let app = Router::new()
        .route("/debug", get(debug_info))
        .nest("/api/v1", api_v1())
        .nest("/api/v2", api_v2())
        .with_state(ctx);
    
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    println!("Server running on http://{}", addr);
    axum::serve(listener, app).await.unwrap();
}

Static Files and CORS

use axum::{
    http::{header, Method},
    routing::get,
    Router,
};
use tower_http::{
    cors::{Any, CorsLayer},
    services::ServeDir,
};
 
#[tokio::main]
async fn main() {
    let cors = CorsLayer::new()
        .allow_origin(Any)
        .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
        .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]);
    
    let app = Router::new()
        .route("/api/hello", get(|| async { "Hello from API" }))
        .nest_service("/static", ServeDir::new("./static"))
        .layer(cors);
    
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Server running on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

Summary

  • Define handlers as async functions; Axum calls them with extracted parameters
  • Extractors like Path, Query, Json, and State declaratively parse request data
  • Return (StatusCode, impl IntoResponse) tuples for full control over status and headers
  • Use Router::new().route(path, method(handler)) to define routes
  • Share state with .with_state(state) and extract it with State(state): State<AppState>
  • Group routes with .nest("/prefix", router) for modular API design
  • Add middleware with .layer() or .route_layer() for per-route middleware
  • Implement IntoResponse for custom error types to unify error handling
  • Use axum::extract::Request for raw access to the entire HTTP request
  • fallback() handler catches unmatched routes
  • Tower middleware (like tower-http::cors) integrates seamlessly via .layer()
  • Serve static files with ServeDir from tower-http
  • Run with axum::serve(listener, app).await for a production-ready server