Axum #
Axum is the Rust web framework from the same team that builds tokio. Its philosophy differs from Actix-web and Rocket: Axum doesn’t define its own middleware system — it uses the existing tower ecosystem, so middleware written for tower works in Axum directly. This provides incredible composability: timeouts, rate limiting, CORS, request tracing, and compression are all available as tower layers that can be attached in any order. Axum’s extractor pattern is also more explicit than Rocket’s — no macros needed on every function, just add extractors as ordinary function parameters. This article covers Axum 0.7 thoroughly, including custom extractors, tower middleware, SSE, and modular architecture.
Installation #
[dependencies]
axum = { version = "0.7", features = ["ws", "multipart"] }
tower = "0.4"
tower-http = { version = "0.5", features = [
"cors", "trace", "compression-gzip", "timeout", "limit",
"auth", "request-id", "set-header",
] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
Routing and Nested Routers #
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::Json,
routing::{delete, get, post, put},
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
db: sqlx::PgPool,
nama_aplikasi: String,
}
// A simple handler
async fn root() -> &'static str {
"Welcome to Axum!"
}
// Path parameter
async fn ambil_pengguna(Path(id): Path<u64>) -> Json<serde_json::Value> {
Json(serde_json::json!({
"id": id,
"nama": format!("Pengguna {}", id)
}))
}
// Query parameter
#[derive(Deserialize)]
struct ParamsCari {
q: Option<String>,
halaman: Option<u32>,
per_halaman: Option<u32>,
}
async fn cari(Query(params): Query<ParamsCari>) -> Json<serde_json::Value> {
Json(serde_json::json!({
"query": params.q,
"halaman": params.halaman.unwrap_or(1),
"per_halaman": params.per_halaman.unwrap_or(20),
"hasil": []
}))
}
// JSON body
#[derive(Deserialize, Serialize)]
struct InputPengguna {
nama: String,
email: String,
}
async fn buat_pengguna(
State(state): State<Arc<AppState>>,
Json(body): Json<InputPengguna>,
) -> (StatusCode, Json<serde_json::Value>) {
// Use state.db for database operations
let _ = &state.db;
(
StatusCode::CREATED,
Json(serde_json::json!({
"id": uuid::Uuid::new_v4(),
"nama": body.nama,
"email": body.email
})),
)
}
// Multiple path params
async fn artikel_pengguna(
Path((user_id, slug)): Path<(u64, String)>,
) -> Json<serde_json::Value> {
Json(serde_json::json!({
"pengguna_id": user_id,
"slug": slug
}))
}
// Modular router
fn rute_pengguna() -> Router<Arc<AppState>> {
Router::new()
.route("/", get(daftar_pengguna).post(buat_pengguna))
.route("/:id", get(ambil_pengguna).put(perbarui_pengguna).delete(hapus_pengguna))
.route("/:id/artikel/:slug", get(artikel_pengguna))
}
async fn daftar_pengguna() -> Json<serde_json::Value> {
Json(serde_json::json!({"data": []}))
}
async fn perbarui_pengguna(Path(id): Path<u64>, Json(body): Json<InputPengguna>)
-> Json<serde_json::Value>
{
Json(serde_json::json!({"id": id, "nama": body.nama}))
}
async fn hapus_pengguna(Path(id): Path<u64>) -> StatusCode {
StatusCode::NO_CONTENT
}
fn buat_router(state: Arc<AppState>) -> Router {
Router::new()
.route("/", get(root))
.route("/cari", get(cari))
// Nested router with a prefix
.nest("/api/v1/pengguna", rute_pengguna())
.nest("/api/v1/produk", rute_produk())
.with_state(state)
}
fn rute_produk() -> Router<Arc<AppState>> {
Router::new()
.route("/", get(|| async { Json(serde_json::json!({"produk": []})) }))
}
#[tokio::main]
async fn main() {
let pool = sqlx::PgPool::connect("postgres://localhost/db").await.unwrap();
let state = Arc::new(AppState {
db: pool,
nama_aplikasi: "Axum API".to_string(),
});
let app = buat_router(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Custom Extractors — Authentication as an Extractor #
use axum::{
async_trait,
extract::FromRequestParts,
http::{request::Parts, StatusCode},
response::{IntoResponse, Response},
RequestPartsExt,
};
use axum::extract::TypedHeader;
use headers::{Authorization, authorization::Bearer};
#[derive(Debug, Clone)]
struct InfoPengguna {
pub id: u64,
pub nama: String,
pub peran: String,
}
// Error for the extractor
#[derive(Debug)]
enum AuthError {
TokenTidakAda,
TokenTidakValid,
AksesHanyaAdmin,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let (status, pesan) = match self {
AuthError::TokenTidakAda => (StatusCode::UNAUTHORIZED, "Missing token"),
AuthError::TokenTidakValid => (StatusCode::UNAUTHORIZED, "Invalid token"),
AuthError::AksesHanyaAdmin => (StatusCode::FORBIDDEN, "Only admins can access this"),
};
(status, pesan).into_response()
}
}
// Custom extractor: get the token from the header and validate it
#[async_trait]
impl<S> FromRequestParts<S> for InfoPengguna
where
S: Send + Sync,
{
type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
// Get the Authorization header
let TypedHeader(Authorization(bearer)) = parts
.extract::<TypedHeader<Authorization<Bearer>>>()
.await
.map_err(|_| AuthError::TokenTidakAda)?;
let token = bearer.token();
// Validate the token (in production: verify a JWT)
if !token.starts_with("valid-") {
return Err(AuthError::TokenTidakValid);
}
Ok(InfoPengguna {
id: 42,
nama: "Budi".to_string(),
peran: "user".to_string(),
})
}
}
// An admin guard as its own extractor
struct HanyaAdmin(InfoPengguna);
#[async_trait]
impl<S> FromRequestParts<S> for HanyaAdmin
where
S: Send + Sync,
{
type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let pengguna = InfoPengguna::from_request_parts(parts, state).await?;
if pengguna.peran != "admin" {
return Err(AuthError::AksesHanyaAdmin);
}
Ok(HanyaAdmin(pengguna))
}
}
// Handlers using the extractors
async fn profil_saya(pengguna: InfoPengguna) -> Json<serde_json::Value> {
Json(serde_json::json!({
"id": pengguna.id,
"nama": pengguna.nama,
"peran": pengguna.peran
}))
}
async fn admin_panel(HanyaAdmin(pengguna): HanyaAdmin) -> String {
format!("Welcome to the admin panel, {}!", pengguna.nama)
}
Error Handling with IntoResponse
#
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
#[derive(Debug)]
enum AppError {
TidakDitemukan(String),
BadRequest(String),
Database(sqlx::Error),
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, pesan) = match &self {
AppError::TidakDitemukan(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Database(e) => {
eprintln!("Database error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "A database error occurred".to_string())
}
AppError::Internal(msg) => {
eprintln!("Internal error: {}", msg);
(StatusCode::INTERNAL_SERVER_ERROR, "A server error occurred".to_string())
}
};
(status, Json(json!({
"error": pesan,
"status": status.as_u16()
})))
.into_response()
}
}
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
match e {
sqlx::Error::RowNotFound => AppError::TidakDitemukan("Data not found".into()),
_ => AppError::Database(e),
}
}
}
// Handlers return Result<T, AppError>
async fn ambil_produk_db(
Path(id): Path<u64>,
State(state): State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, AppError> {
if id == 0 {
return Err(AppError::BadRequest("ID must not be 0".into()));
}
// Simulate the DB
if id > 1000 {
return Err(AppError::TidakDitemukan(format!("Product {} does not exist", id)));
}
Ok(Json(json!({
"id": id,
"nama": format!("Produk {}", id),
"harga": id * 1000
})))
}
Middleware with Tower #
use axum::Router;
use tower::ServiceBuilder;
use tower_http::{
compression::CompressionLayer,
cors::{Any, CorsLayer},
limit::RequestBodyLimitLayer,
request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer},
timeout::TimeoutLayer,
trace::TraceLayer,
};
use std::time::Duration;
use http::Method;
fn buat_app_dengan_middleware(state: Arc<AppState>) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers(Any);
Router::new()
.route("/", get(root))
.nest("/api/v1/pengguna", rute_pengguna())
// Middleware stack — executed bottom to top for requests,
// and top to bottom for responses
.layer(
ServiceBuilder::new()
// Unique request ID per request
.layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
.layer(PropagateRequestIdLayer::x_request_id())
// Tracing/logging
.layer(TraceLayer::new_for_http())
// Timeout per request
.layer(TimeoutLayer::new(Duration::from_secs(30)))
// Body size limit
.layer(RequestBodyLimitLayer::new(10 * 1024 * 1024)) // 10MB
// Response compression
.layer(CompressionLayer::new())
// CORS
.layer(cors),
)
.with_state(state)
}
async fn root() -> &'static str { "OK" }
Server-Sent Events (SSE) #
SSE for streaming data to clients without WebSocket:
use axum::{
extract::State,
response::sse::{Event, KeepAlive, Sse},
routing::get,
Router,
};
use futures::stream::Stream;
use std::convert::Infallible;
use tokio_stream::wrappers::BroadcastStream;
use tokio::sync::broadcast;
#[derive(Clone)]
struct SseState {
tx: broadcast::Sender<String>,
}
async fn sse_handler(
State(state): State<SseState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let rx = state.tx.subscribe();
let stream = BroadcastStream::new(rx)
.filter_map(|result| async move {
result.ok().map(|data| {
Ok(Event::default().data(data))
})
});
Sse::new(stream).keep_alive(KeepAlive::default())
}
// Endpoint for sending events (simulation)
async fn kirim_event(State(state): State<SseState>) -> &'static str {
let _ = state.tx.send(
serde_json::json!({"waktu": chrono::Utc::now().to_rfc3339(), "data": "update"})
.to_string()
);
"Event sent"
}
WebSockets #
use axum::{
extract::{ws::{Message, WebSocket, WebSocketUpgrade}, State},
response::IntoResponse,
};
use futures::{sink::SinkExt, stream::StreamExt};
async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(mut socket: WebSocket, _state: Arc<AppState>) {
// Send a welcome message
if socket.send(Message::Text("Connected!".to_string())).await.is_err() {
return;
}
let (mut sender, mut receiver) = socket.split();
// Task for receiving messages
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(msg)) = receiver.next().await {
match msg {
Message::Text(teks) => {
println!("Received: {}", teks);
if sender.send(Message::Text(format!("Echo: {}", teks))).await.is_err() {
break;
}
}
Message::Close(_) => break,
_ => {}
}
}
});
// Wait for the task to finish
let _ = (&mut recv_task).await;
}
Testing with axum-test
#
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
use axum_test::TestServer;
use serde_json::json;
async fn buat_test_server() -> TestServer {
let pool = sqlx::PgPool::connect("postgres://localhost/test_db")
.await
.unwrap();
let state = Arc::new(AppState {
db: pool,
nama_aplikasi: "Test App".to_string(),
});
let app = buat_router(state);
TestServer::new(app).unwrap()
}
#[tokio::test]
async fn test_root() {
let server = buat_test_server().await;
let resp = server.get("/").await;
resp.assert_status_ok();
resp.assert_text("Welcome to Axum!");
}
#[tokio::test]
async fn test_buat_pengguna() {
let server = buat_test_server().await;
let resp = server
.post("/api/v1/pengguna")
.json(&json!({"nama": "Budi", "email": "[email protected]"}))
.await;
resp.assert_status(StatusCode::CREATED);
let body = resp.json::<serde_json::Value>();
assert_eq!(body["nama"], "Budi");
assert!(body["id"].is_string()); // UUID
}
#[tokio::test]
async fn test_autentikasi_diperlukan() {
let server = buat_test_server().await;
// Without a token
let resp = server.get("/profil").await;
resp.assert_status(StatusCode::UNAUTHORIZED);
// With a valid token
let resp = server
.get("/profil")
.add_header("Authorization", "Bearer valid-token-123")
.await;
resp.assert_status_ok();
}
#[tokio::test]
async fn test_error_tidak_ditemukan() {
let server = buat_test_server().await;
let resp = server.get("/api/v1/produk/9999").await;
resp.assert_status(StatusCode::NOT_FOUND);
let body = resp.json::<serde_json::Value>();
assert!(body["error"].is_string());
}
}
Modular Architecture — Recommended Directory Structure #
src/
├── main.rs ← entry point, server setup
├── config.rs ← configuration from the env
├── state.rs ← AppState definition
├── error.rs ← AppError + IntoResponse
├── middleware/
│ ├── mod.rs
│ ├── auth.rs ← custom extractors AuthToken, InfoPengguna
│ └── logging.rs ← custom tower layer
├── routes/
│ ├── mod.rs ← the router() function that ties everything together
│ ├── pengguna.rs ← Router for /pengguna
│ ├── produk.rs ← Router for /produk
│ └── health.rs ← GET /health
└── handlers/
├── pengguna.rs ← handler functions
└── produk.rs ← handler functions
// src/routes/mod.rs
use axum::Router;
use std::sync::Arc;
use crate::state::AppState;
pub fn buat_router(state: Arc<AppState>) -> Router {
Router::new()
.nest("/api/v1/pengguna", pengguna::router())
.nest("/api/v1/produk", produk::router())
.with_state(state)
}
Summary #
Router::new().nest()for modularity — split routes per domain (pengguna,produk,auth) into separate modules, combine them inroutes/mod.rs.- Custom extractors via
FromRequestParts— implement this trait to create custom extractors likeInfoPenggunaorHanyaAdmin. If extraction fails, the handler is never called.IntoResponsefor error handling — create anAppErrorenum implementingIntoResponse, then handlers can returnResult<T, AppError>.- Tower middleware via
ServiceBuilder— attach middleware in one stack:TraceLayer,TimeoutLayer,CompressionLayer,CorsLayer. Order matters: the first middleware executes first for requests.with_state(state)for state injection —Arc<AppState>is cloned automatically by Axum for each request. Access it in handlers viaState(state): State<Arc<AppState>>.tower-httpfor ready-made middleware —CompressionLayer,CorsLayer,TimeoutLayer,RequestBodyLimitLayer,TraceLayerare available directly without manual implementation.- SSE for real-time without WebSocket —
Sse::new(stream)withKeepAlivefor streaming data to the browser. Simpler than WebSocket for one-directional use cases.axum-testfor ergonomic testing —TestServer::new(app)+.get()/.post()+.assert_status()/.assert_json()with no manual HTTP client setup.- Modular architecture with
routes/,handlers/,middleware/— separate routing, handler logic, and middleware into different directories for larger projects.