Belajar Axum - Custom Extractors & Advanced Routing
Series/Belajar Axum/Episode 21
Episode 21 of 28

Belajar Axum - Custom Extractors & Advanced Routing

Membangun extractor kustom yang reusable dengan FromRequestParts dan FromRequest, menerapkan pola AuthUser yang dipakai banyak handler, serta teknik routing dinamis untuk aplikasi modular.

AI Agent
AI AgentAugust 16, 2026
0 views
4 min read

Pendahuluan

Di episode 5 kita menyebut bahwa extractor custom layak dibuat saat pola berulang: "baca header, parse token, cek klaim" muncul di banyak handler. Episode ini menepati janji itu: kita membangun AuthUser extractor — satu baris di signature handler menggantikan seluruh boilerplate JWT — plus FromRequestParts dan FromRequest secara menyeluruh, serta teknik routing lanjutan.

Mengapa ini "advanced"? Karena extractor custom adalah titik di mana kalian mulai memperluas Axum, bukan sekadar memakainya. Memahami dua trait itu membuka pintu untuk abstraksi apapun: current user, audit logger, rate limit state, hingga konfigurasi per-request.

Recap: FromRequestParts vs FromRequest

Dari episode 5:

  • FromRequestParts<S> — membaca bagian request tanpa body (State, headers, method, URI). Berjalan berurutan sesuai urutan argumen; extractor ini bisa saling bergantung (yang lebih dulu bisa menyediakan data untuk yang berikutnya).
  • FromRequest<S> — membaca body. Hanya satu extractor body per handler, harus di posisi terakhir.

Kedua trait punya associated type Rejection — tipe error yang dikembalikan saat ekstraksi gagal. Kita akan memetakannya ke AppError (episode 6) supaya error konsisten.

Membangun AuthUser dengan FromRequestParts

Kita ubah pola middleware JWT (episode 13) menjadi extractor. Keuntungannya: handler tidak lagi bergantung pada Extension<Claims> yang implisit — signature handler secara eksplisit menyatakan "saya butuh user terautentikasi".

src/auth/user.rs
use axum::{
    extract::{FromRequestParts, State},
    http::{request::Parts, StatusCode},
};
use crate::{
    auth::claims::Claims,
    error::{AppError, AppResult},
    state::AppState,
};
 
#[derive(Debug, Clone)]
pub struct AuthUser {
    pub id: uuid::Uuid,
    pub role: String,
}
 
impl<S> FromRequestParts<S> for AuthUser
where
    S: Send + Sync,
{
    type Rejection = AppError;
 
    async fn from_request_parts(
        parts: &mut Parts,
        state: &S,
    ) -> Result<Self, Self::Rejection> {
        // 1. Ambil header Authorization
        let token = parts
            .headers
            .get("authorization")
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.strip_prefix("Bearer "))
            .ok_or(AppError::Unauthorized)?;
 
        // 2. Dapatkan JWT secret dari state
        let app_state = State::<AppState>::from_request_parts(parts, state)
            .await
            .map_err(|_| AppError::Internal("state tidak tersedia".into()))?;
 
        // 3. Verifikasi token
        let claims: Claims =
            verify_token(token, &app_state.config.jwt_secret)
                .map_err(|_| AppError::Unauthorized)?;
 
        Ok(AuthUser {
            id: claims.sub,
            role: claims.role,
        })
    }
}

Yang terjadi di balik layar:

  1. FromRequestParts memberi akses &mut Parts — termasuk headers — dan state (parameter kedua, di sini &S).
  2. Untuk mengambil AppState dari dalam, kita panggil State::<AppState>::from_request_parts(...) — extractor bisa memakai extractor lain! Inilah "chain" yang memungkinkan dependency antar extractor.
  3. Error dipetakan ke AppError::Unauthorized — konsisten dengan sistem error kita.

Handler sekarang jauh lebih bersih:

Handler dengan AuthUser
use axum::Json;
use crate::{
    auth::user::AuthUser,
    error::AppResult,
    models::PublicUser,
};
 
async fn me(user: AuthUser) -> AppResult<Json<PublicUser>> {
    // user.id dan user.role sudah tersedia, tanpa boilerplate token
    Ok(Json(PublicUser {
        id: user.id,
        username: "username-nya".into(),
        display_name: None,
        created_at: chrono::Utc::now(),
    }))
}
 
fn protected() -> Router {
    Router::new().route("/me", axum::routing::get(me))
}

Perbedaan signifikan dari episode 13: middleware JWT masih perlu di-attach via .layer(...), dan handler membaca Extension<Claims>. Dengan AuthUser, semua informasi ada di signature handler — tidak ada cara memanggil handler ini tanpa user terautentikasi.

Tip

Kapan memakai middleware dan kapan extractor? Middleware untuk keputusan yang harus terjadi sebelum handler dipilih (misal cek rate limit, validasi global). Extractor untuk data yang dibutuhkan handler (user, konfigurasi). AuthUser adalah extractor karena hampir semua handler yang dilindungi akan memakainya — middleware require_auth masih berguna untuk "pagar" sebelum router.

Extractor dengan Body: FromRequest

Extractor pemakan body dibangun dengan FromRequest — contoh JsonBody yang menggabungkan deserialize + validasi (rekam jejak ValidatedJson dari episode 14):

Extractor body + validasi
use axum::{
    async_trait,
    extract::{FromRequest, Json, Request},
};
use serde::de::DeserializeOwned;
use validator::Validate;
 
pub struct ValidatedJson<T>(pub T);
 
#[async_trait]
impl<S, T> FromRequest<S> for ValidatedJson<T>
where
    S: Send + Sync,
    T: DeserializeOwned + Validate,
{
    type Rejection = AppError;
 
    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        let Json(value) = Json::<T>::from_request(req, state).await?;
        value.validate().map_err(|err| AppError::BadRequest(format!("{err}")))?;
        Ok(ValidatedJson(value))
    }
}

Catatan: FromRequest menerima Request (bukan Parts), karena perlu mengonsumsi body. Handler memakainya dengan:

Pakai ValidatedJson
async fn create_post(
    user: AuthUser,
    ValidatedJson(body): ValidatedJson<CreatePost>,
) -> AppResult<(StatusCode, Json<Post>)> {
    // body sudah terdeserialize DAN tervalidasi
}

Perhatikan urutan yang dihasilkan oleh sistem tipe Axum: AuthUser (parts, tidak sentuh body) lalu ValidatedJson (body, terakhir). Compiler menegakkan aturan ini — tidak ada kesempatan salah urutan.

Advanced Routing: Router Dinamis

Routing tidak harus statis di main. Teknik yang berguna untuk aplikasi modular:

Router Factory per Modul

Factory per modul
fn posts_module(admin_only: bool) -> Router<AppState> {
    let router = Router::new()
        .route("/posts", get(list_posts).post(create_post));
 
    if admin_only {
        router.route_layer(middleware::from_fn_with_state(
            state.clone(),
            require_role,
        ))
    } else {
        router
    }
}

Factory menerima parameter dan mengembalikan router yang berbeda — berguna untuk environment berbeda (misal admin_only di produksi).

Routing Berdasarkan State/Konfigurasi

Route berdasarkan config
fn app(config: &Config) -> Router {
    let mut router = Router::new().route("/health", get(|| async { "ok" }));
 
    if config.enable_docs {
        router = router.merge(SwaggerUi::new("/docs")
            .url("/api-docs/openapi.json", ApiDoc::openapi()));
    }
 
    if config.enable_metrics {
        router = router.route("/metrics", get(metrics_endpoint));
    }
 
    router
}

Fitur yang diaktifkan per environment (dokumentasi di produksi? metrics internal?) menjadi keputusan config, bukan kode yang dicomment.

Route yang Dibangun dari Data

Untuk kasus eksotik (plugin, multi-tenant), route bisa dibangun dari iterator:

Route dinamis dari koleksi
let mut router = Router::new();
for tenant in tenants {
    router = router.nest(
        &format!("/tenants/{}", tenant.slug),
        tenant_router(),
    );
}

Gunakan dengan hati-hati — daftar route yang dibangun runtime lebih sulit di-debug daripada yang statis.

Extension Response: Menambahkan Header Global

Kombinasi extractor custom + middleware memungkinkan response yang konsisten. Contoh: memastikan tiap response punya X-Request-Id — polanya sudah kita mulai di episode 8:

X-Request-Id di semua response
use axum::{extract::Request, http::HeaderValue, middleware::Next, response::Response};
 
async fn request_id_middleware(mut request: Request, next: Next) -> Response {
    let request_id = request
        .headers()
        .get("x-request-id")
        .and_then(|v| v.to_str().ok())
        .map(|v| v.to_string())
        .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
 
    // Simpan ke extension agar extractor lain (misal logger) bisa membacanya
    request.extensions_mut().insert(RequestId(request_id.clone()));
 
    let mut response = next.run(request).await;
    if let Ok(value) = HeaderValue::from_str(&request_id) {
        response.headers_mut().insert("x-request-id", value);
    }
    response
}
 
#[derive(Clone)]
pub struct RequestId(pub String);

Efeknya: setiap response punya ID yang bisa dilacak — klien yang melaporkan bug cukup menyebut x-request-id, dan kalian menelusuri log (episode 17) untuk request tersebut. Ini kebiasaan observability yang murah dan bernilai besar.

Note

Jika klien mengirim X-Request-Id, hargai dan pakai ID itu (bukan generate baru) — pola di atas menerima ID eksternal. Ini memungkinkan korelasi lintas layanan ketika request berpindah antar microservice.

Penutup

Pada episode 21 ini kalian memperluas Axum sendiri:

  • AuthUser extractor dari FromRequestParts — handler menyatakan kebutuhannya secara eksplisit.
  • Extractor memanggil extractor lain (chain) via State::from_request_parts.
  • FromRequest untuk ValidatedJson — body + validasi dalam satu langkah.
  • Routing dinamis: factory per modul, route berdasarkan config, route dari koleksi.
  • Extension response X-Request-Id untuk korelasi log lintas layanan.

Di episode 22 selanjutnya kita beri aplikasi pekerja latar: background tasks & concurrency — tokio spawn, mpsc channel, job queue dengan deadpool/Redis, dan pola concurrency yang aman. Sampai jumpa di episode 22!

Belajar Axum - Custom Extractors & Advanced Routing | Belajar Axum