Membangun Web API dengan Rust dan Axum
Enam artikel sebelumnya kita sudah belajar semua konsep Rust dari dasar sampai advanced. Sekarang saatnya membangun sesuatu yang nyata: REST API dengan framework Axum.
Axum adalah web framework dari Tokio team yang ringan, cepat, dan sangat ergonomik. Berbeda dari Actix-web yang lebih tua, Axum dibangun di atas ekosistem Tokio yang sudah kita pelajari di artikel async programming.
Kenapa Axum?
| Framework | Kelebihan | Kekurangan |
|---|---|---|
| Axum | Ergonomik, Tokio ecosystem, type-safe | Relatif baru |
| Actix-web | Sangat cepat, mature | Macro-heavy |
| Rocket | Mudah dipakai | Lebih lambat |
| Warp | Functional style | Kurang populer |
Axum adalah pilihan terbaik untuk tahun 2026 karena integrasi sempurna dengan Tokio, Tower, dan Hyper.
Setup Project
Buat project baru:
bash
bashcargo new rust-api
cd rust-api
Tambahkan dependencies di Cargo.toml:
toml
toml[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["cors"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
Hello World API
Buat file src/main.rs:
rust
rustuse axum::{routing::get, Router};
async fn hello() -> &'static str {
"Hello, World!"
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(hello));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
println!("Server running on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
Jalankan:
bash
bashcargo run
Buka http://localhost:3000 di browser. API pertama kamu sudah jalan!
Routing dan Handler
Axum mendukung semua HTTP method:
rust
rustuse axum::{
routing::{get, post, put, delete},
Router,
};
async fn get_users() -> &'static str { "GET users" }
async fn create_user() -> &'static str { "POST user" }
async fn update_user() -> &'static str { "PUT user" }
async fn delete_user() -> &'static str { "DELETE user" }
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users", get(get_users).post(create_user))
.route("/users/{id}", put(update_user).delete(delete_user));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
JSON Response
Untuk mengembalikan JSON, gunakan Json extractor:
rust
rustuse axum::{routing::get, Json, Router};
use serde::Serialize;
#[derive(Serialize)]
struct User {
id: String,
name: String,
email: String,
}
async fn get_users() -> Json<Vec<User>> {
let users = vec![
User {
id: "1".to_string(),
name: "ERLKIM".to_string(),
email: "erlkim@mail.com".to_string(),
},
User {
id: "2".to_string(),
name: "Guest".to_string(),
email: "guest@mail.com".to_string(),
},
];
Json(users)
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/users", get(get_users));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
Request Body
Untuk menerima JSON dari request body:
rust
rustuse axum::{routing::post, Json, Router};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
#[derive(Serialize)]
struct UserResponse {
id: String,
name: String,
email: String,
message: String,
}
async fn create_user(Json(payload): Json<CreateUser>) -> Json<UserResponse> {
let user = UserResponse {
id: uuid::Uuid::new_v4().to_string(),
name: payload.name,
email: payload.email,
message: "User created successfully".to_string(),
};
Json(user)
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/users", post(create_user));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
Test dengan curl:
bash
bashcurl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "ERLKIM", "email": "erlkim@mail.com"}'
Path Parameters dan Query Parameters
rust
rustuse axum::{
extract::{Path, Query},
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct User {
id: String,
name: String,
}
#[derive(Deserialize)]
struct Pagination {
page: Option<u32>,
limit: Option<u32>,
}
async fn get_user(Path(id): Path<String>) -> Json<User> {
Json(User {
id,
name: "ERLKIM".to_string(),
})
}
async fn list_users(Query(params): Query<Pagination>) -> String {
let page = params.page.unwrap_or(1);
let limit = params.limit.unwrap_or(10);
format!("Page: {}, Limit: {}", page, limit)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users", get(list_users))
.route("/users/{id}", get(get_user));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
Test:
bash
bashcurl http://localhost:3000/users/42
curl "http://localhost:3000/users?page=2&limit=20"
Error Handling
Axum menggunakan IntoResponse untuk error handling:
rust
rustuse axum::{
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use serde::Serialize;
#[derive(Serialize)]
struct ErrorResponse {
error: String,
message: String,
}
#[derive(Debug)]
enum AppError {
NotFound,
BadRequest(String),
InternalError,
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let (status, error, message) = match self {
AppError::NotFound => (
StatusCode::NOT_FOUND,
"Not Found".to_string(),
"Resource not found".to_string(),
),
AppError::BadRequest(msg) => (
StatusCode::BAD_REQUEST,
"Bad Request".to_string(),
msg,
),
AppError::InternalError => (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal Error".to_string(),
"Something went wrong".to_string(),
),
};
(status, Json(ErrorResponse { error, message })).into_response()
}
}
async fn get_user() -> Result<Json<serde_json::Value>, AppError> {
let user_exists = false;
if !user_exists {
return Err(AppError::NotFound);
}
Ok(Json(serde_json::json!({
"id": "1",
"name": "ERLKIM"
})))
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/users/{id}", get(get_user));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
Shared State
Untuk berbagi data antar handler (database connection, config, dll), gunakan Extension atau State:
rust
rustuse axum::{
extract::State,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct AppState {
users: Arc<Mutex<Vec<User>>>,
}
#[derive(Serialize, Deserialize, Clone)]
struct User {
id: String,
name: String,
email: String,
}
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
async fn list_users(State(state): State<AppState>) -> Json<Vec<User>> {
let users = state.users.lock().unwrap();
Json(users.clone())
}
async fn create_user(
State(state): State<AppState>,
Json(payload): Json<CreateUser>,
) -> Json<User> {
let user = User {
id: uuid::Uuid::new_v4().to_string(),
name: payload.name,
email: payload.email,
};
let mut users = state.users.lock().unwrap();
users.push(user.clone());
Json(user)
}
#[tokio::main]
async fn main() {
let state = AppState {
users: Arc::new(Mutex::new(vec![
User {
id: "1".to_string(),
name: "ERLKIM".to_string(),
email: "erlkim@mail.com".to_string(),
},
])),
};
let app = Router::new()
.route("/users", get(list_users).post(create_user))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
println!("Server running on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
Middleware dan CORS
Axum menggunakan Tower middleware:
rust
rustuse axum::{routing::get, Router};
use tower_http::cors::{Any, CorsLayer};
async fn hello() -> &'static str {
"Hello with CORS!"
}
#[tokio::main]
async fn main() {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
.route("/", get(hello))
.layer(cors);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
Full CRUD API
Berikut contoh lengkap CRUD API dengan semua konsep yang sudah dipelajari:
rust
rustuse axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{get, put, delete},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct AppState {
todos: Arc<Mutex<Vec<Todo>>>,
}
#[derive(Serialize, Deserialize, Clone)]
struct Todo {
id: String,
title: String,
completed: bool,
created_at: String,
}
#[derive(Deserialize)]
struct CreateTodo {
title: String,
}
#[derive(Deserialize)]
struct UpdateTodo {
title: Option<String>,
completed: Option<bool>,
}
#[derive(Serialize)]
struct ErrorResponse {
error: String,
}
enum AppError {
NotFound,
BadRequest(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let (status, message) = match self {
AppError::NotFound => (
StatusCode::NOT_FOUND,
"Todo not found".to_string(),
),
AppError::BadRequest(msg) => (
StatusCode::BAD_REQUEST,
msg,
),
};
(status, Json(ErrorResponse { error: message })).into_response()
}
}
async fn list_todos(State(state): State<AppState>) -> Json<Vec<Todo>> {
let todos = state.todos.lock().unwrap();
Json(todos.clone())
}
async fn get_todo(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Todo>, AppError> {
let todos = state.todos.lock().unwrap();
todos
.iter()
.find(|t| t.id == id)
.cloned()
.map(Json)
.ok_or(AppError::NotFound)
}
async fn create_todo(
State(state): State<AppState>,
Json(payload): Json<CreateTodo>,
) -> (StatusCode, Json<Todo>) {
if payload.title.is_empty() {
// Akan di-handle oleh validasi
}
let todo = Todo {
id: uuid::Uuid::new_v4().to_string(),
title: payload.title,
completed: false,
created_at: chrono::Utc::now().to_rfc3339(),
};
let mut todos = state.todos.lock().unwrap();
todos.push(todo.clone());
(StatusCode::CREATED, Json(todo))
}
async fn update_todo(
State(state): State<AppState>,
Path(id): Path<String>,
Json(payload): Json<UpdateTodo>,
) -> Result<Json<Todo>, AppError> {
let mut todos = state.todos.lock().unwrap();
let todo = todos
.iter_mut()
.find(|t| t.id == id)
.ok_or(AppError::NotFound)?;
if let Some(title) = payload.title {
todo.title = title;
}
if let Some(completed) = payload.completed {
todo.completed = completed;
}
Ok(Json(todo.clone()))
}
async fn delete_todo(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, AppError> {
let mut todos = state.todos.lock().unwrap();
let len_before = todos.len();
todos.retain(|t| t.id != id);
if todos.len() == len_before {
return Err(AppError::NotFound);
}
Ok(StatusCode::NO_CONTENT)
}
async fn stats(State(state): State<AppState>) -> Json<serde_json::Value> {
let todos = state.todos.lock().unwrap();
let total = todos.len();
let completed = todos.iter().filter(|t| t.completed).count();
let pending = total - completed;
Json(serde_json::json!({
"total": total,
"completed": completed,
"pending": pending,
}))
}
#[tokio::main]
async fn main() {
let state = AppState {
todos: Arc::new(Mutex::new(vec![])),
};
let app = Router::new()
.route("/todos", get(list_todos).post(create_todo))
.route("/todos/{id}", get(get_todo).put(update_todo).delete(delete_todo))
.route("/todos/stats", get(stats))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
println!("Todo API running on http://localhost:3000");
println!("Endpoints:");
println!(" GET /todos - List all todos");
println!(" POST /todos - Create todo");
println!(" GET /todos/:id - Get todo by ID");
println!(" PUT /todos/:id - Update todo");
println!(" DELETE /todos/:id - Delete todo");
println!(" GET /todos/stats - Statistics");
axum::serve(listener, app).await.unwrap();
}
Test API dengan curl:
bash
bash# Create todo
curl -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title": "Belajar Rust"}'
# List all todos
curl http://localhost:3000/todos
# Get by ID
curl http://localhost:3000/todos/{id}
# Update
curl -X PUT http://localhost:3000/todos/{id} \
-H "Content-Type: application/json" \
-d '{"completed": true}'
# Delete
curl -X DELETE http://localhost:3000/todos/{id}
# Stats
curl http://localhost:3000/todos/stats
Project Structure
Untuk project yang lebih besar, pisahkan kode ke beberapa file:
text
textsrc/
├── main.rs # Entry point, server setup
├── routes/
│ ├── mod.rs # Route definitions
│ ├── todos.rs # Todo handlers
│ └── health.rs # Health check
├── models/
│ ├── mod.rs
│ └── todo.rs # Todo struct
├── errors/
│ ├── mod.rs
│ └── app_error.rs # Error types
└── state.rs # AppState
rust
rust// src/state.rs
use std::sync::{Arc, Mutex};
use crate::models::todo::Todo;
#[derive(Clone)]
pub struct AppState {
pub todos: Arc<Mutex<Vec<Todo>>>,
}
rust
rust// src/models/todo.rs
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone)]
pub struct Todo {
pub id: String,
pub title: String,
pub completed: bool,
pub created_at: String,
}
#[derive(Deserialize)]
pub struct CreateTodo {
pub title: String,
}
#[derive(Deserialize)]
pub struct UpdateTodo {
pub title: Option<String>,
pub completed: Option<bool>,
}
rust
rust// src/main.rs
mod routes;
mod models;
mod errors;
mod state;
use state::AppState;
use std::sync::{Arc, Mutex};
#[tokio::main]
async fn main() {
let state = AppState {
todos: Arc::new(Mutex::new(vec![])),
};
let app = routes::create_routes(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
println!("Server running on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
Testing
Rust punya built-in testing framework:
rust
rust#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
#[tokio::test]
async fn test_create_todo() {
let state = AppState {
todos: Arc::new(Mutex::new(vec![])),
};
let app = Router::new()
.route("/todos", post(create_todo))
.with_state(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/todos")
.header("Content-Type", "application/json")
.body(Body::from(r#"{"title": "Test Todo"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn test_list_todos() {
let state = AppState {
todos: Arc::new(Mutex::new(vec![
Todo {
id: "1".to_string(),
title: "Existing Todo".to_string(),
completed: false,
created_at: "2026-01-01".to_string(),
},
])),
};
let app = Router::new()
.route("/todos", get(list_todos))
.with_state(state);
let response = app
.oneshot(
Request::builder()
.uri("/todos")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
}
Jalankan test:
bash
bashcargo test
Deploy
Deploy ke Shuttle
Shuttle adalah platform deploy untuk Rust yang sangat mudah:
bash
bashcargo install cargo-shuttle
cargo shuttle init
cargo shuttle deploy
Deploy sebagai Docker
dockerfile
dockerfileFROM rust:1.82 as builder
WORKDIR /app
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim
COPY --from=builder /app/target/release/rust-api /usr/local/bin/
EXPOSE 3000
CMD ["rust-api"]
bash
bashdocker build -t rust-api .
docker run -p 3000:3000 rust-api
Deploy ke Cloudflare Workers
Dengan worker-rs, kamu bisa deploy Rust ke Cloudflare Workers:
bash
bashcargo install worker-cli
wrangler init
wrangler deploy
Perbandingan: Rust API vs Node.js API
| Aspek | Rust + Axum | Node.js + Express |
|---|---|---|
| Performa | Sangat cepat | Cukup cepat |
| Memory usage | Sangat rendah | Lebih tinggi |
| Startup time | ~1ms | ~100ms |
| Type safety | Compile time | Runtime (tanpa TS) |
| Learning curve | Sulit | Mudah |
| Ecosystem | Berkembang | Sangat besar |
| Cocok untuk | High-performance API | Rapid prototyping |
Tips
1. Gunakan Tower Middleware
rust
rustuse tower_http::trace::TraceLayer;
let app = Router::new()
.route("/", get(hello))
.layer(TraceLayer::new_for_http());
2. Gunakan Extractor untuk Validasi
Axum extractor otomatis memvalidasi input. Jika JSON tidak valid, Axum mengembalikan 400 Bad Request.
3. Struktur Error yang Konsisten
Buat satu enum AppError dan implementasikan IntoResponse untuk semua variant.
4. Gunakan State untuk Database
Di production, ganti Arc<Mutex<Vec>> dengan database connection pool:
rust
rustuse sqlx::PgPool;
#[derive(Clone)]
struct AppState {
db: PgPool,
}
Kesimpulan
Membangun web API dengan Rust dan Axum sangat powerful:
- Performa tinggi dengan memory usage rendah
- Type safety yang mencegah bug di compile time
- Async/await yang efisien untuk I/O-bound work
- Tower ecosystem untuk middleware yang modular
- Testing yang terintegrasi langsung di Rust
Rust API cocok untuk service yang membutuhkan performa tinggi, latency rendah, dan reliability tinggi. Dengan fondasi yang sudah kamu pelajari di enam artikel sebelumnya, kamu sudah siap membangun production-grade API dengan Rust.
~Erlkim
Komentar