axum / actix-web
axum / actix-web
Level 16 — Ecosystem & Tooling The premier asynchronous HTTP web frameworks in Rust —
axum(built on Tokio, Hyper, and Tower) andactix-web(built on Actix actor runtime) — for building high-throughput, type-safe REST APIs and microservices.
1. Prerequisites
tokio— The underlying asynchronous runtime.serde— Handles JSON request body deserialization and response serialization.async/.await— Asynchronous handler functions.
2. Term Category
Rust Ecosystem Framework (asynchronous web application frameworks): axum and actix-web are the two dominant web microservice frameworks in the Rust ecosystem. Both leverage Rust's strong type system and async/await syntax to map HTTP routes to strongly-typed async handler functions with zero runtime reflection overhead.
3. Explanation
(1) Design Motivation — "Why did we design this?"
In dynamically typed languages (like Node.js Express or Python Flask):
- Route parameters, query strings, and JSON body payloads are parsed dynamically at runtime.
- Missing a JSON field causes runtime
TypeErrorcrashes in production. - Middleware chaining can alter request context unexpectedly.
Rust web frameworks (axum and actix-web) use Declarative Type-Safe Extractors:
- Compile-Time Type Safety: Route handlers declare parameters using extractors (e.g.
Path(id): Path<u64>,Json(payload): Json<CreateUser>). If a client sends invalid JSON, the framework rejects the request with HTTP 400 before your handler code ever runs! - Zero-Reflection Parsing: Serde deserializes HTTP bodies directly into typed Rust structs at native hardware speeds.
- Composable Middleware: Built on standard
towermiddleware layers (axum) or Actix services (actix-web).
(2) Code Examples
1. REST API Server with axum
use axum::{
routing::{get, post},
extract::{Path, Json},
Router,
};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct CreateUser {
username: String,
email: String,
}
#[derive(Serialize)]
struct User {
id: u64,
username: String,
email: String,
}
async fn get_user(Path(id): Path<u64>) -> Json<User> {
Json(User {
id,
username: String::from("ferris"),
email: String::from("ferris@rust-lang.org"),
})
}
async fn create_user(Json(payload): Json<CreateUser>) -> (axum::http::StatusCode, Json<User>) {
let user = User {
id: 42,
username: payload.username,
email: payload.email,
};
(axum::http::StatusCode::CREATED, Json(user))
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users/:id", get(get_user))
.route("/users", post(create_user));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Axum web server running on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
2. REST API Server with actix-web
use actix_web::{get, post, web, App, HttpServer, Responder, HttpResponse};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct CreateUser {
username: String,
}
#[derive(Serialize)]
struct User {
id: u64,
username: String,
}
#[get("/users/{id}")]
async fn get_user(path: web::Path<u64>) -> impl Responder {
let user_id = path.into_inner();
HttpResponse::Ok().json(User { id: user_id, username: "ferris".into() })
}
#[post("/users")]
async fn create_user(payload: web::Json<CreateUser>) -> impl Responder {
HttpResponse::Created().json(User { id: 101, username: payload.username.clone() })
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.service(get_user)
.service(create_user)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
4. Common Mistakes & Pitfalls
Mistake 2: Missing Error Extractor or Swallowing Errors in Handlers
The mistake: Returning generic StatusCode::INTERNAL_SERVER_ERROR from handlers without structured error types.
Why it's wrong: Swallowing underlying errors prevents client diagnostics and breaks API error contracts. Handlers should implement IntoResponse for custom error enums.
Fix: Implement IntoResponse for domain error enums to yield clean JSON error payloads.
Mistake 3: Shared Mutable State Bottlenecks Without Async Mutex / Atomic Primitives
The mistake: Locking std::sync::Mutex across .await points in Axum/Actix handlers.
Why it's wrong: Holding a synchronous mutex across async yield points blocks the web server worker thread, creating severe request latency spikes.
Fix: Use tokio::sync::Mutex or atomic types (AtomicUsize) for shared web state.
Mistake 1: Placing Axum State Extractors Out of Order
The mistake: Placing axum::extract::State after Json or String body extractors in handler parameter lists.
Why it's wrong: In axum, body extractors consume the HTTP request payload stream. Extractors that consume the request body must always come last in the parameter list.
5. Practice Exercises
Exercise 1: Shared Application State & REST Endpoints in axum
Scenario: Build an in-memory user registry service using axum.
- Define an
AppStatestruct holding thread-safe shared state:users: Arc<RwLock<HashMap<u64, User>>>. - Implement route handlers:
POST /users: AcceptsJson<CreateUserPayload>, generates an incremental ID, inserts the user into state, and returns(StatusCode::CREATED, Json<User>).GET /users/:id: ExtractsPath(id). If found, returnsOk(Json<User>); if not found, returnsErr(StatusCode::NOT_FOUND).
- Write socket-less integration unit tests using
tower::ServiceExt::oneshotto execute requests againstappand verify response status codes and body contents usingassert_eq!.
Answer
Implementation
use axum::{
extract::{Path, State},
http::{Request, StatusCode},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tower::ServiceExt; // for oneshot
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct User {
pub id: u64,
pub username: String,
pub email: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateUserPayload {
pub username: String,
pub email: String,
}
#[derive(Clone)]
pub struct AppState {
pub users: Arc<RwLock<HashMap<u64, User>>>,
pub next_id: Arc<RwLock<u64>>,
}
impl AppState {
pub fn new() -> Self {
Self {
users: Arc::new(RwLock::new(HashMap::new())),
next_id: Arc::new(RwLock::new(1)),
}
}
}
pub async fn create_user(
State(state): State<AppState>,
Json(payload): Json<CreateUserPayload>,
) -> (StatusCode, Json<User>) {
let mut next_id_guard = state.next_id.write().unwrap();
let user_id = *next_id_guard;
*next_id_guard += 1;
let user = User {
id: user_id,
username: payload.username,
email: payload.email,
};
let mut users_guard = state.users.write().unwrap();
users_guard.insert(user_id, user.clone());
(StatusCode::CREATED, Json(user))
}
pub async fn get_user(
State(state): State<AppState>,
Path(id): Path<u64>,
) -> Result<Json<User>, StatusCode> {
let users_guard = state.users.read().unwrap();
match users_guard.get(&id) {
Some(user) => Ok(Json(user.clone())),
None => Err(StatusCode::NOT_FOUND),
}
}
pub fn create_router(state: AppState) -> Router {
Router::new()
.route("/users", post(create_user))
.route("/users/:id", get(get_user))
.with_state(state)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use http_body_util::BodyExt; // for collect()
#[tokio::test]
async fn test_create_and_get_user() {
let state = AppState::new();
let app = create_router(state);
// 1. Test POST /users
let create_payload = r#"{"username":"ferris","email":"ferris@rust-lang.org"}"#;
let request = Request::builder()
.method("POST")
.uri("/users")
.header("content-type", "application/json")
.body(Body::from(create_payload))
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body_bytes = response.into_body().collect().await.unwrap().to_bytes();
let created_user: User = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(created_user.id, 1);
assert_eq!(created_user.username, "ferris");
// 2. Test GET /users/1
let get_request = Request::builder()
.method("GET")
.uri("/users/1")
.body(Body::empty())
.unwrap();
let get_response = app.clone().oneshot(get_request).await.unwrap();
assert_eq!(get_response.status(), StatusCode::OK);
let get_bytes = get_response.into_body().collect().await.unwrap().to_bytes();
let fetched_user: User = serde_json::from_slice(&get_bytes).unwrap();
assert_eq!(fetched_user, created_user);
// 3. Test GET /users/999 (Not Found)
let missing_request = Request::builder()
.method("GET")
.uri("/users/999")
.body(Body::empty())
.unwrap();
let missing_response = app.oneshot(missing_request).await.unwrap();
assert_eq!(missing_response.status(), StatusCode::NOT_FOUND);
}
}
Technical Explanation
- Thread-Safe State (
Arc<RwLock<...>>): Wraps application state so it can be safely shared across Tokio threads handling concurrent HTTP requests. - State Extractor Order: The
State(state)extractor is listed beforeJson(payload)becauseJsonconsumes the HTTP request body stream. - Socket-less Testing:
app.oneshot(request)tests web services directly in memory without binding to actual network sockets or ports.
Exercise 2: actix-web Query Parameters, App State, and Integration Testing
Scenario: Build a product search endpoint using actix-web.
- Define a
CatalogStatestruct holding a static list ofProductitems (id: u64,name: String,price_cents: u64). - Define a
SearchQuerystruct for query string parameters:max_price: Option<u64>. - Implement a handler
search_products(data: web::Data<CatalogState>, query: web::Query<SearchQuery>) -> impl Responder. Ifmax_priceis provided, filter products whose price is<= max_price. - Write integration unit tests using
actix_web::test(init_service,TestRequest,call_service,read_body_json) verifying query string parsing and filtering logic.
Answer
Implementation
use actix_web::{get, web, App, HttpResponse, Responder};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Product {
pub id: u64,
pub name: String,
pub price_cents: u64,
}
pub struct CatalogState {
pub products: Vec<Product>,
}
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
pub max_price: Option<u64>,
}
#[get("/products")]
pub async fn search_products(
data: web::Data<CatalogState>,
query: web::Query<SearchQuery>,
) -> impl Responder {
let filtered: Vec<Product> = data
.products
.iter()
.filter(|p| match query.max_price {
Some(max) => p.price_cents <= max,
None => true,
})
.cloned()
.collect();
HttpResponse::Ok().json(filtered)
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::test;
#[actix_web::test]
async fn test_product_search_with_query_params() {
let state = web::Data::new(CatalogState {
products: vec![
Product { id: 1, name: "Keyboard".into(), price_cents: 5000 },
Product { id: 2, name: "Mouse".into(), price_cents: 2500 },
Product { id: 3, name: "Monitor".into(), price_cents: 20000 },
],
});
let app = test::init_service(
App::new()
.app_data(state.clone())
.service(search_products),
)
.await;
// 1. Test search with max_price query filter
let req = test::TestRequest::get()
.uri("/products?max_price=3000")
.to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
let products: Vec<Product> = test::read_body_json(resp).await;
assert_eq!(products.len(), 1);
assert_eq!(products[0].name, "Mouse");
// 2. Test search without query parameters (returns all)
let req_all = test::TestRequest::get().uri("/products").to_request();
let resp_all = test::call_service(&app, req_all).await;
let all_products: Vec<Product> = test::read_body_json(resp_all).await;
assert_eq!(all_products.len(), 3);
}
}
Technical Explanation
web::Query<T>Extractor: Automatically parses URL query parameters (?max_price=3000) into typed Rust structs via Serde.web::Data<T>State Injection: Actix-web manages shared thread state usingweb::Datawrappers around application data structures.- Actix Test Suite:
actix_web::test::init_serviceconstructs a test instance of the application pipeline for in-memory integration testing.
Exercise 3: Custom Header Extractor and Custom Response Error Mapping in axum
Scenario: Implement a custom header extractor for Bearer token authentication in axum.
- Define an
AuthClaimsstruct:pub user_id: u64. - Implement
axum::extract::FromRequestParts<S>forAuthClaims. Look up the"Authorization"header. If missing or invalid format (e.g. not starting with"Bearer secret-token-"), return an error tuple(StatusCode::UNAUTHORIZED, "Invalid Auth Header"). - Create a protected route handler
dashboard(claims: AuthClaims) -> Stringthat returns"Welcome user <user_id>". - Write unit tests with
tower::ServiceExt::oneshotverifying both authorized (200 OK) and unauthorized (401 Unauthorized) requests usingassert_eq!.
Answer
Implementation
use axum::{
async_trait,
extract::FromRequestParts,
http::{request::Parts, Request, StatusCode},
routing::get,
Router,
};
use tower::ServiceExt;
#[derive(Debug, PartialEq, Eq)]
pub struct AuthClaims {
pub user_id: u64,
}
#[async_trait]
impl<S> FromRequestParts<S> for AuthClaims
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization Header"))?;
if let Some(token) = auth_header.strip_prefix("Bearer secret-token-") {
if let Ok(user_id) = token.parse::<u64>() {
return Ok(AuthClaims { user_id });
}
}
Err((StatusCode::UNAUTHORIZED, "Invalid Auth Token Format"))
}
}
pub async fn dashboard(claims: AuthClaims) -> String {
format!("Welcome user {}", claims.user_id)
}
pub fn create_auth_router() -> Router {
Router::new().route("/dashboard", get(dashboard))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use http_body_util::BodyExt;
#[tokio::test]
async fn test_auth_extractor() {
let app = create_auth_router();
// 1. Authorized request with valid Bearer token
let valid_req = Request::builder()
.method("GET")
.uri("/dashboard")
.header("authorization", "Bearer secret-token-42")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(valid_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&body[..], b"Welcome user 42");
// 2. Unauthorized request without header
let missing_req = Request::builder()
.method("GET")
.uri("/dashboard")
.body(Body::empty())
.unwrap();
let resp_missing = app.oneshot(missing_req).await.unwrap();
assert_eq!(resp_missing.status(), StatusCode::UNAUTHORIZED);
}
}
Technical Explanation
FromRequestPartsTrait: Allows implementing custom header/metadata extractors without consuming the request body stream.- Declarative Route Security: Simply adding
claims: AuthClaimsto a route handler's parameters enforces authentication automatically. If validation fails,axumreturns the rejection response before executing the handler. - Error Mapping: Standard HTTP status code tuples
(StatusCode, &'static str)implementIntoResponse, converting extractor failures cleanly into HTTP responses.
6. Related Terms
- None!
7. Key Takeaways
axumandactix-webare the leading web microservice frameworks in Rust.- They use compile-time type-safe extractors to validate HTTP parameters, headers, and JSON bodies.
axumintegrates natively withtokio,hyper, andtower.- Place
StateandPathextractors before body extractors (Json,String) inaxumhandler parameters.