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());
    }
}

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 in routes/mod.rs.
  • Custom extractors via FromRequestParts — implement this trait to create custom extractors like InfoPengguna or HanyaAdmin. If extraction fails, the handler is never called.
  • IntoResponse for error handling — create an AppError enum implementing IntoResponse, then handlers can return Result<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 injectionArc<AppState> is cloned automatically by Axum for each request. Access it in handlers via State(state): State<Arc<AppState>>.
  • tower-http for ready-made middlewareCompressionLayer, CorsLayer, TimeoutLayer, RequestBodyLimitLayer, TraceLayer are available directly without manual implementation.
  • SSE for real-time without WebSocketSse::new(stream) with KeepAlive for streaming data to the browser. Simpler than WebSocket for one-directional use cases.
  • axum-test for ergonomic testingTestServer::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.

← Previous: Rocket   Next: Warp →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact