Axum #
Axum adalah web framework Rust dari tim yang sama yang membangun tokio. Filosofinya berbeda dari Actix-web dan Rocket: Axum tidak mendefinisikan sistem middleware sendiri — ia menggunakan ekosistem tower yang sudah ada, sehingga middleware yang ditulis untuk tower bisa langsung digunakan di Axum. Ini memberikan composability yang luar biasa: timeout, rate limiting, CORS, request tracing, dan compression semuanya tersedia sebagai layer tower yang bisa dipasang dalam urutan apapun. Extractor pattern Axum juga lebih eksplisit dari Rocket — kamu tidak perlu macro di setiap fungsi, cukup tambahkan extractor sebagai parameter fungsi biasa. Artikel ini membahas Axum 0.7 secara menyeluruh termasuk custom extractor, middleware tower, SSE, dan arsitektur modular.
Instalasi #
[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 dan Nested Router #
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,
}
// Handler sederhana
async fn root() -> &'static str {
"Selamat datang di 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>) {
// Gunakan state.db untuk operasi database
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
}))
}
// Router modular
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 dengan 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 Extractor — Autentikasi sebagai 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 untuk 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, "Token tidak ada"),
AuthError::TokenTidakValid => (StatusCode::UNAUTHORIZED, "Token tidak valid"),
AuthError::AksesHanyaAdmin => (StatusCode::FORBIDDEN, "Hanya admin yang boleh akses"),
};
(status, pesan).into_response()
}
}
// Custom extractor: ambil token dari header dan validasi
#[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> {
// Ambil header Authorization
let TypedHeader(Authorization(bearer)) = parts
.extract::<TypedHeader<Authorization<Bearer>>>()
.await
.map_err(|_| AuthError::TokenTidakAda)?;
let token = bearer.token();
// Validasi token (dalam produksi: verifikasi JWT)
if !token.starts_with("valid-") {
return Err(AuthError::TokenTidakValid);
}
Ok(InfoPengguna {
id: 42,
nama: "Budi".to_string(),
peran: "user".to_string(),
})
}
}
// Admin guard sebagai extractor tersendiri
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))
}
}
// Handler yang menggunakan extractor
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!("Selamat datang di panel admin, {}!", pengguna.nama)
}
Error Handling dengan 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, "Terjadi kesalahan database".to_string())
}
AppError::Internal(msg) => {
eprintln!("Internal error: {}", msg);
(StatusCode::INTERNAL_SERVER_ERROR, "Terjadi kesalahan server".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 tidak ditemukan".into()),
_ => AppError::Database(e),
}
}
}
// Handler 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 tidak boleh 0".into()));
}
// Simulasi DB
if id > 1000 {
return Err(AppError::TidakDitemukan(format!("Produk {} tidak ada", id)));
}
Ok(Json(json!({
"id": id,
"nama": format!("Produk {}", id),
"harga": id * 1000
})))
}
Middleware dengan 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 — dieksekusi dari bawah ke atas untuk request,
// dan dari atas ke bawah untuk response
.layer(
ServiceBuilder::new()
// Request ID unik 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)))
// Batas ukuran body
.layer(RequestBodyLimitLayer::new(10 * 1024 * 1024)) // 10MB
// Kompresi response
.layer(CompressionLayer::new())
// CORS
.layer(cors),
)
.with_state(state)
}
async fn root() -> &'static str { "OK" }
Server-Sent Events (SSE) #
SSE untuk streaming data ke client tanpa 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 untuk mengirim event (simulasi)
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 dikirim"
}
WebSocket #
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>) {
// Kirim pesan sambutan
if socket.send(Message::Text("Terhubung!".to_string())).await.is_err() {
return;
}
let (mut sender, mut receiver) = socket.split();
// Task untuk menerima pesan
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(msg)) = receiver.next().await {
match msg {
Message::Text(teks) => {
println!("Diterima: {}", teks);
if sender.send(Message::Text(format!("Echo: {}", teks))).await.is_err() {
break;
}
}
Message::Close(_) => break,
_ => {}
}
}
});
// Tunggu task selesai
let _ = (&mut recv_task).await;
}
Testing dengan 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("Selamat datang di 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;
// Tanpa token
let resp = server.get("/profil").await;
resp.assert_status(StatusCode::UNAUTHORIZED);
// Dengan token valid
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());
}
}
Arsitektur Modular — Struktur Direktori yang Direkomendasikan #
src/
├── main.rs ← entry point, setup server
├── config.rs ← konfigurasi dari env
├── state.rs ← AppState definition
├── error.rs ← AppError + IntoResponse
├── middleware/
│ ├── mod.rs
│ ├── auth.rs ← custom extractor AuthToken, InfoPengguna
│ └── logging.rs ← custom tower layer
├── routes/
│ ├── mod.rs ← fungsi router() yang menyatukan semua
│ ├── pengguna.rs ← Router untuk /pengguna
│ ├── produk.rs ← Router untuk /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)
}
Ringkasan #
Router::new().nest()untuk modularitas — pisahkan route per domain (pengguna,produk,auth) dalam modul terpisah, gabungkan diroutes/mod.rs.- Custom extractor via
FromRequestParts— implementasikan trait ini untuk membuat extractor kustom sepertiInfoPenggunaatauHanyaAdmin. Jika ekstraksi gagal, handler tidak dipanggil.IntoResponseuntuk error handling — buat enumAppErroryang mengimplementasikanIntoResponse, lalu handler bisa returnResult<T, AppError>.- Tower middleware via
ServiceBuilder— pasang middleware dalam satu stack:TraceLayer,TimeoutLayer,CompressionLayer,CorsLayer. Urutan penting: middleware pertama dieksekusi pertama untuk request.with_state(state)untuk injeksi state —Arc<AppState>di-clone otomatis oleh Axum untuk setiap request. Akses di handler viaState(state): State<Arc<AppState>>.tower-httpuntuk middleware siap pakai —CompressionLayer,CorsLayer,TimeoutLayer,RequestBodyLimitLayer,TraceLayertersedia langsung tanpa implementasi manual.- SSE untuk real-time tanpa WebSocket —
Sse::new(stream)denganKeepAliveuntuk streaming data ke browser. Lebih sederhana dari WebSocket untuk use-case one-directional.axum-testuntuk testing ergonomis —TestServer::new(app)+.get()/.post()+.assert_status()/.assert_json()tanpa setup HTTP client manual.- Arsitektur modular dengan
routes/,handlers/,middleware/— pisahkan routing, logika handler, dan middleware di direktori berbeda untuk proyek yang lebih besar.