Sepanjang Course Rust kita menulis program yang berjalan lalu selesai: menghitung, membaca berkas, lalu keluar. REST API bekerja dengan pola yang berbeda — program harus hidup terus, menunggu request dari jaringan, dan melayani banyak request secara bersamaan tanpa saling menghalangi. Pola seperti ini adalah ranah runtime async tokio yang kita pelajari pada Section Async, dan pada bagian ini kita memakainya untuk kebutuhan nyata: menyiapkan proyek, menghubungkan dependensi, lalu menyalakan server HTTP pertama dengan dua handler sederhana.

Server web adalah contoh terbaik dari program yang sibuk dengan I/O: sebagian besar waktu server tidak menghitung, melainkan menunggu — menunggu data sampai lewat socket, menunggu client berikutnya terhubung, menunggu body request terkirim lengkap. Jika setiap penantinan memblokir satu thread, satu proses dengan jumlah thread terbatas hanya bisa melayani segelintir client. Runtime async tokio menyelesaikannya dengan menidurkan task yang sedang menunggu dan membangunkannya begitu datanya siap, sehingga puluhan ribu koneksi hidup berbagi sejumlah kecil thread. Axum kemudian menambahkan lapisan web di atasnya: router untuk memilah path, extractor untuk membongkar request, dan tipe response yang serba otomatis.

Langkah 1: Menyiapkan Proyek Baru #

Inisialisasi proyek baru dengan cargo, lalu masuk ke direktorinya. Jalankan perintah berikut di terminal:

bash
cargo init rest-api
cd rest-api

Output yang Dihasilkan:

text
Creating binary (application) package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

Cargo membuat dua berkas: Cargo.toml sebagai manifest proyek dan src/main.rs sebagai titik masuk. Struktur proyek kita sekarang tampak seperti ini:

text
rest-api/
├── Cargo.toml
└── src/
    └── main.rs

Saat proyek tumbuh pada bagian-bagian berikutnya, kita akan menambahkan modul-modul baru di dalam src/ tanpa mengubah struktur dasarnya. Nama paket rest-api otomatis menjadi nama crate, dan cargo menamai biner hasil kompilasi dengan nama yang sama.

Langkah 2: Menambahkan Dependensi #

Kita membutuhkan empat crate. Buka Cargo.toml lalu lengkapi bagian [dependencies] menjadi seperti berikut:

toml
[package]
name = "rest-api"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Peran masing-masing crate adalah sebagai berikut:

CratePeran
tokioRuntime async: scheduler, timer, dan driver I/O
axumFramework web di atas tokio: router, handler, extractor
serdeDerive Serialize dan Deserialize untuk struct
serde_jsonMengubah struct menjadi JSON dan sebaliknya

Sekarang jalankan cargo build pertama untuk mengunduh dan mengkompilasi seluruh dependensi:

bash
cargo build

Output yang Dihasilkan:

text
   Compiling proc-macro2 v1.0.107
   Compiling libc v0.2.190
   Compiling quote v1.0.47
   ...
   Compiling axum v0.8.9
   Compiling rest-api v0.1.0 (/tmp/w8a-rest-api)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 20.07s

Axum membawa rantai dependensi cukup panjang — build pertama mengkompilasi 56 crate, dari serde sampai hyper yang menjadi mesin HTTP di balik layar. Build berikutnya jauh lebih cepat karena cargo menyimpan hasil kompilasi di direktori target/.

Langkah 3: Router dan Handler Pertama #

Ganti seluruh isi src/main.rs dengan program berikut. Server ini menawarkan GET /api/health untuk pemeriksaan kesehatan dan GET /api/tugas yang masih membalas daftar kosong:

rust
use axum::{routing::get, Json, Router};
use serde_json::json;

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/api/health", get(health))
        .route("/api/tugas", get(daftar_tugas));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:8080")
        .await
        .expect("gagal membuka alamat 127.0.0.1:8080");
    println!("Server berjalan di http://127.0.0.1:8080");

    axum::serve(listener, app).await.expect("server berhenti");
}

async fn health() -> Json<serde_json::Value> {
    Json(json!({ "status": "ok" }))
}

async fn daftar_tugas() -> Json<serde_json::Value> {
    Json(json!([]))
}

Mari kita bedah peran setiap baris kode di atas:

  1. Baris 1 (use axum::{routing::get, Json, Router};): mengimpor tiga alat utama axum — Router sebagai tabel pemetaan path ke handler, get sebagai pembangun route untuk method GET, dan Json sebagai wrapper response berformat JSON.
  2. Baris 2 (use serde_json::json;): mengimpor makro json! agar kita bisa menulis literal JSON langsung di dalam kode.
  3. Baris 4 (#[tokio::main]): makro tokio yang membungkus async fn main menjadi runtime multi-thread. Tanpa makro ini, async fn main tidak dapat dikompilasi karena titik masuk program harus fungsi sinkron.
  4. Baris 6-8 (Router::new().route(...).route(...)): membangun router dengan dua route. Setiap panggilan .route(path, method_router) mendaftarkan satu path; get(health) mengikat method GET pada path /api/health ke fungsi handler bernama health.
  5. Baris 10-12 (TcpListener::bind(...).await): membuka alamat 127.0.0.1:8080 secara async. .await menunggu sistem operasi mengizinkan alamat tersebut; expect menghentikan program dengan pesan yang jelas bila alamat sedang dipakai proses lain.
  6. Baris 15 (axum::serve(listener, app)): menjalankan server. Fungsi serve menggabungkan listener dan router, lalu menerima koneksi satu per satu; setiap koneksi ditangani sebagai task async sehingga banyak request dapat berjalan bersamaan. .await di baris ini hanya selesai ketika server berhenti.
  7. Baris 18-23 (async fn health dan async fn daftar_tugas): dua handler. Handler adalah fungsi async yang menerima request dan mengembalikan sesuatu yang mengimplementasikan IntoResponse. Tipe Json<serde_json::Value> otomatis mengisi header Content-Type: application/json dan menyerialisasi nilainya menjadi body JSON.

Perhatikan pola penting di sini: main tidak tahu apa-apa tentang endpoint, dan handler tidak tahu cara server menerima koneksi. Tanggung jawab terpisah rapi seperti ini akan memudahkan kita menambahkan modul state dan error pada bagian berikutnya.

Sebelum lanjut, mari kita lihat sebentar tumpukan di balik satu request. Ketika curl mengirim GET /api/health, data mentah tiba di TcpListener milik tokio. Runtime tokio membangunkan task yang menunggu koneksi tersebut, lalu axum::serve menyerahkan byte-byte HTTP ke mesin parsing milik hyper. Dari situ axum mengambil alih: path dicocokkan ke tabel router, handler dipanggil, dan hasil Json diserialisasi serde menjadi byte respons yang dikirim balik oleh tumpukan yang sama. Kode application kita hanya menyentuh lapisan paling atas — axum — sementara lapisan di bawahnya bekerja tanpa perlu kita tulis.

Langkah 4: Menjalankan dan Menguji Server #

Saatnya menyalakan server dan menguji kedua endpoint dengan curl:

bash
cargo run

Output yang Dihasilkan:

text
   Compiling rest-api v0.1.0 (/tmp/w8a-rest-api)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.28s
     Running `target/debug/rest-api`
Server berjalan di http://127.0.0.1:8080

Terminal sekarang menunggu request. Buka terminal kedua, lalu kirim beberapa permintaan:

bash
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:8080/api/health
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:8080/api/tugas
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:8080/

Output yang Dihasilkan:

text
{"status":"ok"}
HTTP 200
[]
HTTP 200

HTTP 404

Dua endpoint pertama membalas 200 sesuai rancangan. Permintaan ketiga menunjukkan perilaku bawaan axum yang juga penting: path / tidak terdaftar di router, sehingga router otomatis membalas 404 dengan body kosong — kita tidak perlu menulis handler untuk setiap path yang tidak dikenal. Matikan server dengan Ctrl+C setelah selesai menguji.

Flag -s membuat curl hanya mencetak body tanpa progress bar, sedangkan -w "\nHTTP %{http_code}\n" menambahkan status code setelah body — format yang kita pakai konsisten di seluruh proyek ini. Untuk melihat response header lengkap, ganti -s -w dengan -i. Mari kita buktikan sekali lagi bahwa tipe Json benar-benar mengatur header:

bash
curl -s -i http://127.0.0.1:8080/api/health

Output yang Dihasilkan:

text
HTTP/1.1 200 OK
content-type: application/json
content-length: 15
date: Tue, 13 Oct 2026 23:46:24 GMT

{"status":"ok"}

Baris content-type: application/json hadir tanpa kita menulis satu baris kode header pun — inilah yang dimaksud handler “mengembalikan sesuatu yang mengimplementasikan IntoResponse”: tipe Json tahu bagaimana cara mengurus dirinya sendiri.

Ringkasan Bagian 1 #

Fondasi server telah berdiri: proyek cargo siap, empat dependensi terpasang, router memetakan dua path ke handler, dan axum::serve menjaga server tetap hidup di atas runtime tokio. Namun GET /api/tugas masih membalas array kosong yang ditulis mati di dalam kode — server kita belum punya ingatan. Pada bagian berikutnya kita membangun penyimpanan in-memory dengan Arc RwLock dan melengkapi seluruh endpoint CRUD.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

src/Cargo.toml

Unduh berkas
toml
[package]
name = "rest-api"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

[dev-dependencies]
tower = { version = "0.5", features = ["util"] }

[profile.release]
strip = true
lto = true

src/error.rs

Unduh berkas
rust
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;

pub enum ApiError {
    TidakDitemukan { id: u64 },
    Validasi(String),
    IdTidakValid { nilai: String },
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, pesan) = match self {
            ApiError::TidakDitemukan { id } => (
                StatusCode::NOT_FOUND,
                format!("Tugas dengan id {} tidak ditemukan", id),
            ),
            ApiError::Validasi(pesan) => (StatusCode::BAD_REQUEST, pesan),
            ApiError::IdTidakValid { nilai } => (
                StatusCode::BAD_REQUEST,
                format!("Id \"{}\" bukan angka yang valid", nilai),
            ),
        };

        (status, Json(json!({ "error": pesan }))).into_response()
    }
}

src/handlers.rs

Unduh berkas
rust
use axum::extract::{Path, State};
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;

use crate::error::ApiError;
use crate::models::{validasi_judul, BuatTugas, PerbaruiTugas, Tugas};
use crate::state::AppState;

pub async fn health() -> Json<serde_json::Value> {
    Json(json!({ "status": "ok" }))
}

pub async fn daftar_tugas(State(state): State<AppState>) -> Json<Vec<Tugas>> {
    Json(state.ambil_semua())
}

pub async fn buat_tugas(
    State(state): State<AppState>,
    Json(input): Json<BuatTugas>,
) -> Result<Response, ApiError> {
    let judul = validasi_judul(&input.judul).map_err(ApiError::Validasi)?;
    let tugas = state.simpan(judul);
    let lokasi = format!("/api/tugas/{}", tugas.id);
    Ok((
        StatusCode::CREATED,
        [(header::LOCATION, lokasi)],
        Json(tugas),
    )
        .into_response())
}

pub async fn ambil_tugas(
    State(state): State<AppState>,
    Path(nilai): Path<String>,
) -> Result<Json<Tugas>, ApiError> {
    let id = parse_id(nilai)?;
    match state.cari(id) {
        Some(tugas) => Ok(Json(tugas)),
        None => Err(ApiError::TidakDitemukan { id }),
    }
}

pub async fn perbarui_tugas(
    State(state): State<AppState>,
    Path(nilai): Path<String>,
    Json(input): Json<PerbaruiTugas>,
) -> Result<Json<Tugas>, ApiError> {
    let id = parse_id(nilai)?;
    let judul = validasi_judul(&input.judul).map_err(ApiError::Validasi)?;
    let input = PerbaruiTugas {
        judul,
        selesai: input.selesai,
    };
    match state.perbarui(id, input) {
        Some(tugas) => Ok(Json(tugas)),
        None => Err(ApiError::TidakDitemukan { id }),
    }
}

pub async fn hapus_tugas(
    State(state): State<AppState>,
    Path(nilai): Path<String>,
) -> Result<StatusCode, ApiError> {
    let id = parse_id(nilai)?;
    match state.hapus(id) {
        Some(_) => Ok(StatusCode::NO_CONTENT),
        None => Err(ApiError::TidakDitemukan { id }),
    }
}

fn parse_id(nilai: String) -> Result<u64, ApiError> {
    nilai
        .parse::<u64>()
        .map_err(|_| ApiError::IdTidakValid { nilai })
}

src/lib.rs

Unduh berkas
rust
pub mod error;
pub mod handlers;
pub mod models;
pub mod state;

use axum::routing::get;
use axum::Router;
use state::AppState;

pub fn bangun_router() -> Router {
    Router::new()
        .route("/api/health", get(handlers::health))
        .route(
            "/api/tugas",
            get(handlers::daftar_tugas).post(handlers::buat_tugas),
        )
        .route(
            "/api/tugas/{id}",
            get(handlers::ambil_tugas)
                .put(handlers::perbarui_tugas)
                .delete(handlers::hapus_tugas),
        )
        .with_state(AppState::baru())
}

src/main.rs

Unduh berkas
rust
use rest_api::bangun_router;

#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:8080")
        .await
        .expect("gagal membuka alamat 127.0.0.1:8080");
    println!("Server berjalan di http://127.0.0.1:8080");

    axum::serve(listener, bangun_router())
        .await
        .expect("server berhenti");
}

src/models.rs

Unduh berkas
rust
use serde::{Deserialize, Serialize};

pub const JUDUL_MAX: usize = 140;

#[derive(Debug, Clone, Serialize)]
pub struct Tugas {
    pub id: u64,
    pub judul: String,
    pub selesai: bool,
}

#[derive(Debug, Deserialize)]
pub struct BuatTugas {
    pub judul: String,
}

#[derive(Debug, Deserialize)]
pub struct PerbaruiTugas {
    pub judul: String,
    pub selesai: bool,
}

pub fn validasi_judul(judul: &str) -> Result<String, String> {
    let bersih = judul.trim();
    if bersih.is_empty() {
        return Err(String::from("judul tidak boleh kosong"));
    }
    if bersih.chars().count() > JUDUL_MAX {
        return Err(format!("judul tidak boleh lebih dari {} karakter", JUDUL_MAX));
    }
    Ok(String::from(bersih))
}

src/state.rs

Unduh berkas
rust
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock};

use crate::models::{PerbaruiTugas, Tugas};

#[derive(Clone)]
pub struct AppState {
    daftar: Arc<RwLock<HashMap<u64, Tugas>>>,
    next_id: Arc<AtomicU64>,
}

impl AppState {
    pub fn baru() -> Self {
        Self {
            daftar: Arc::new(RwLock::new(HashMap::new())),
            next_id: Arc::new(AtomicU64::new(1)),
        }
    }

    pub fn ambil_semua(&self) -> Vec<Tugas> {
        let daftar = self.daftar.read().expect("RwLock daftar teracuni");
        let mut tugas: Vec<Tugas> = daftar.values().cloned().collect();
        tugas.sort_by_key(|t| t.id);
        tugas
    }

    pub fn simpan(&self, judul: String) -> Tugas {
        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
        let tugas = Tugas {
            id,
            judul,
            selesai: false,
        };
        self.daftar
            .write()
            .expect("RwLock daftar teracuni")
            .insert(id, tugas.clone());
        tugas
    }

    pub fn cari(&self, id: u64) -> Option<Tugas> {
        self.daftar.read().expect("RwLock daftar teracuni").get(&id).cloned()
    }

    pub fn perbarui(&self, id: u64, input: PerbaruiTugas) -> Option<Tugas> {
        let mut daftar = self.daftar.write().expect("RwLock daftar teracuni");
        match daftar.get_mut(&id) {
            None => None,
            Some(tugas) => {
                tugas.judul = input.judul;
                tugas.selesai = input.selesai;
                Some(tugas.clone())
            }
        }
    }

    pub fn hapus(&self, id: u64) -> Option<Tugas> {
        self.daftar.write().expect("RwLock daftar teracuni").remove(&id)
    }
}

tests/integration.rs

Unduh berkas
rust
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use axum::Router;
use rest_api::bangun_router;
use serde_json::{json, Value};
use tower::ServiceExt;

fn kirim(method: &str, uri: &str, body: Value) -> Request<Body> {
    Request::builder()
        .method(method)
        .uri(uri)
        .header("content-type", "application/json")
        .body(Body::from(body.to_string()))
        .expect("gagal membangun request")
}

async fn panggil(app: &Router, request: Request<Body>) -> (StatusCode, Value) {
    let response = app
        .clone()
        .oneshot(request)
        .await
        .expect("router gagal memproses request");
    let status = response.status();
    let bytes = to_bytes(response.into_body(), usize::MAX)
        .await
        .expect("gagal membaca body");
    if bytes.is_empty() {
        return (status, Value::Null);
    }
    let body: Value = serde_json::from_slice(&bytes).expect("body bukan JSON valid");
    (status, body)
}

#[tokio::test]
async fn daftar_kosong_mengembalikan_array_kosong() {
    let app = bangun_router();
    let (status, body) = panggil(&app, kirim("GET", "/api/tugas", json!(null))).await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(body, json!([]));
}

#[tokio::test]
async fn buat_tugas_mengembalikan_201_dan_lokasi() {
    let app = bangun_router();
    let response = app
        .oneshot(kirim(
            "POST",
            "/api/tugas",
            json!({ "judul": "Belajar Rust" }),
        ))
        .await
        .expect("router gagal memproses request");

    assert_eq!(response.status(), StatusCode::CREATED);
    assert_eq!(response.headers()["location"], "/api/tugas/1");

    let bytes = to_bytes(response.into_body(), usize::MAX)
        .await
        .expect("gagal membaca body");
    let body: Value = serde_json::from_slice(&bytes).expect("body bukan JSON valid");
    assert_eq!(
        body,
        json!({ "id": 1, "judul": "Belajar Rust", "selesai": false })
    );
}

#[tokio::test]
async fn buat_tugas_menolak_judul_kosong() {
    let app = bangun_router();
    let (status, body) =
        panggil(&app, kirim("POST", "/api/tugas", json!({ "judul": "   " }))).await;
    assert_eq!(status, StatusCode::BAD_REQUEST);
    assert_eq!(body, json!({ "error": "judul tidak boleh kosong" }));
}

#[tokio::test]
async fn id_tidak_valid_ditolak() {
    let app = bangun_router();
    let (status, body) = panggil(&app, kirim("GET", "/api/tugas/abc", json!(null))).await;
    assert_eq!(status, StatusCode::BAD_REQUEST);
    assert_eq!(
        body,
        json!({ "error": "Id \"abc\" bukan angka yang valid" })
    );
}

#[tokio::test]
async fn ambil_tugas_yang_tidak_ada_mengembalikan_404() {
    let app = bangun_router();
    let (status, body) = panggil(&app, kirim("GET", "/api/tugas/99", json!(null))).await;
    assert_eq!(status, StatusCode::NOT_FOUND);
    assert_eq!(
        body,
        json!({ "error": "Tugas dengan id 99 tidak ditemukan" })
    );
}

#[tokio::test]
async fn perbarui_tugas_mengubah_judul_dan_status() {
    let app = bangun_router();
    panggil(&app, kirim("POST", "/api/tugas", json!({ "judul": "Kirim laporan bulanan" }))).await;

    let (status, body) = panggil(
        &app,
        kirim(
            "PUT",
            "/api/tugas/1",
            json!({ "judul": "Kirim laporan mingguan", "selesai": true }),
        ),
    )
    .await;

    assert_eq!(status, StatusCode::OK);
    assert_eq!(
        body,
        json!({ "id": 1, "judul": "Kirim laporan mingguan", "selesai": true })
    );

    let (_, body) = panggil(&app, kirim("GET", "/api/tugas/1", json!(null))).await;
    assert_eq!(body["selesai"], json!(true));
}

#[tokio::test]
async fn hapus_tugas_mengembalikan_204_lalu_404() {
    let app = bangun_router();
    panggil(&app, kirim("POST", "/api/tugas", json!({ "judul": "Belajar axum" }))).await;

    let (status, body) = panggil(&app, kirim("DELETE", "/api/tugas/1", json!(null))).await;
    assert_eq!(status, StatusCode::NO_CONTENT);
    assert!(body.is_null());

    let (status, body) = panggil(&app, kirim("GET", "/api/tugas/1", json!(null))).await;
    assert_eq!(status, StatusCode::NOT_FOUND);
    assert_eq!(
        body,
        json!({ "error": "Tugas dengan id 1 tidak ditemukan" })
    );
}