Membangun Web API dengan Rust dan Axum • Erlkim

Membangun Web API dengan Rust dan Axum

Membangun Web API dengan Rust dan Axum
Jun 13, 2026 ... views
16 min

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?

FrameworkKelebihanKekurangan
AxumErgonomik, Tokio ecosystem, type-safeRelatif baru
Actix-webSangat cepat, matureMacro-heavy
RocketMudah dipakaiLebih lambat
WarpFunctional styleKurang 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

AspekRust + AxumNode.js + Express
PerformaSangat cepatCukup cepat
Memory usageSangat rendahLebih tinggi
Startup time~1ms~100ms
Type safetyCompile timeRuntime (tanpa TS)
Learning curveSulitMudah
EcosystemBerkembangSangat besar
Cocok untukHigh-performance APIRapid 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

0/2000