Server pada Bagian 1 bisa menerima request, tetapi setiap handler bekerja sendiri-sendiri tanpa ingatan bersama: tugas yang dikirim lewat POST tidak akan pernah terlihat oleh GET. Masalah berikutnya lebih halus lagi — axum menjalankan banyak request secara bersamaan pada banyak task, sehingga penyimpanan bersama harus aman diakses dari beberapa task sekaligus. Pada bagian ini kita membangun AppState: satu struct penyimpanan in-memory berisi Arc<RwLock<HashMap>> dan AtomicU64, lalu melengkapi kelima endpoint CRUD di atasnya.

Langkah 1: Memecah Program Menjadi Modul #

Satu berkas main.rs tidak akan cukup untuk lima handler, model data, dan state. Kita pecah program menjadi empat modul di dalam src/:

text
rest-api/
├── Cargo.toml
├── src/
│   ├── main.rs      (titik masuk + router)
│   ├── models.rs    (bentuk data tugas)
│   ├── state.rs     (penyimpanan bersama)
│   └── handlers.rs  (lima handler)

Setelah modul tumbuh, main.rs cukup menjadi perekat yang menyambungkan semuanya. Ganti isinya dengan versi berikut:

rust
mod handlers;
mod models;
mod state;

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

#[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");
}

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())
}

Dua bagian layak digarisbawahi. Baris 1-3 (mod handlers; dan kawan-kawan) mendaftarkan tiga modul baru sehingga kompilator membaca berkas src/handlers.rs, src/models.rs, dan src/state.rs. Baris 34 (.with_state(AppState::baru())) menempelkan state ke router; axum kemudian menyalin state tersebut ke setiap handler yang memintanya lewat extractor State. Syaratnya satu: tipe state harus mengimplementasikan Clone.

Router pada baris 26 dan 30 juga memperkenalkan pola baru: get(handlers::daftar_tugas).post(handlers::buat_tugas) menggabungkan beberapa method HTTP pada satu path yang sama, dan path /api/tugas/{id} memuat parameter segmen bernama id.

Langkah 2: Model Data Tugas #

Buat berkas src/models.rs dengan tiga struct:

rust
use serde::{Deserialize, Serialize};

#[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,
}

Mari kita bedah rancangan ketiganya:

  1. Baris 3-8 (struct Tugas): representasi satu tugas yang dikirim ke client. Derive Serialize membuat serde mampu mengubah struct ini menjadi body JSON, derive Clone memudahkan penyalinan dari penyimpanan, dan Debug membantu kita mencetak nilai saat menguji.
  2. Baris 10-13 (struct BuatTugas): bentuk body request POST — hanya field judul. Derive Deserialize membuat serde mengubah JSON masuk menjadi struct ini secara otomatis.
  3. Baris 15-19 (struct PerbaruiTugas): bentuk body request PUT — judul dan selesai sekaligus, karena PUT kita definisikan sebagai penggantian menyeluruh, bukan tambalan sebagian.

Pemisahan Tugas (keluar) dari BuatTugas/PerbaruiTugas (masuk) bukan kebetulan. Client tidak boleh mengirim id atau selesai saat membuat tugas — id adalah urusan server. Dengan DTO terpisah, request yang mencoba menyelundupkan field tambahan akan ditolak serde, dan kontrak API kita terjaga oleh tipe.

Langkah 3: AppState dengan Arc RwLock #

Inilah jantung penyimpanan kita. Buat berkas src/state.rs:

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)
    }
}

Desain struct ini menjawab dua masalah concurrency sekaligus. Mari kita bedah:

  1. Baris 9 (Arc<RwLock<HashMap<u64, Tugas>>>): HashMap menyimpan tugas dengan kunci id. RwLock di sekelilingnya mengizinkan banyak pembaca masuk bersamaan (read), tetapi menulis (write) bersifat eksklusif — cocok untuk API yang jauh lebih sering dibaca daripada ditulis. Arc paling luar membuat salinan struct tetap menunjuk ke data yang sama, sehingga setiap salinan state di setiap task berbagi satu penyimpanan.
  2. Baris 10 (Arc<AtomicU64>): penghitung id berikutnya. Operasi fetch_add bertambah satu secara atomik tanpa mengunci apa pun, sehingga dua request POST yang datang bersamaan pasti mendapat id berbeda. Ordering::Relaxed cukup karena kita hanya butuh keunikan angka, bukan urutan operasi lain.
  3. Baris 7 (#[derive(Clone)]): Clone milik AppState hanya menyalin dua pointer Arc — operasi murah. Inilah yang membuat .with_state() bisa menyebar state ke seluruh handler tanpa menyalin seluruh isi HashMap.
  4. Baris 22 (self.daftar.read().expect(...)): memegang kunci baca selama fungsi berjalan. Guard kunci dilepas otomatis saat variabel daftar drop di akhir fungsi. expect dengan pesan “teracuni” berjaga bila thread lain panik sambil memegang kunci — kasus yang nyaris mustahil pada kode kita karena handler tidak memegang kunci sambil panik.
  5. Baris 23-24 (collect() lalu sort_by_key): HashMap tidak menjamin urutan, jadi kita salin nilainya ke Vec lalu mengurutkan berdasarkan id agar respons selalu stabil dan mudah dibaca.
  6. Baris 42-43 (cari): pembacaan tunggal. .get(&id).cloned() mengembalikan Option<Tugas> — None berarti tugas tidak ada, dan kita teruskan informasi itu ke handler.
  7. Baris 46-60 (perbarui dan hapus): dua operasi penulisan. Keduanya memegang kunci tulis lewat write(), mengubah isi, lalu melepas kunci saat guard drop di akhir fungsi. Option sebagai nilai balik memberi tahu handler apakah id ditemukan.

Seluruh metode AppState berupa fungsi sinkron — bukan async fn. Handler async memanggilnya secara langsung, dan karena operasinya cepat (murni manipulasi memori), kita tidak perlu spawn_blocking.

Langkah 4: Handler CRUD dan Extractor #

Terakhir, buat src/handlers.rs. Handler adalah tempat extractor axum bekerja: State menyuntikkan state, Json membongkar body request, dan Path mengambil segmen URL:

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::models::{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>,
) -> Response {
    let tugas = state.simpan(input.judul);
    let lokasi = format!("/api/tugas/{}", tugas.id);
    (
        StatusCode::CREATED,
        [(header::LOCATION, lokasi)],
        Json(tugas),
    )
        .into_response()
}

pub async fn ambil_tugas(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<Json<Tugas>, StatusCode> {
    match state.cari(id) {
        Some(tugas) => Ok(Json(tugas)),
        None => Err(StatusCode::NOT_FOUND),
    }
}

pub async fn perbarui_tugas(
    State(state): State<AppState>,
    Path(id): Path<u64>,
    Json(input): Json<PerbaruiTugas>,
) -> Result<Json<Tugas>, StatusCode> {
    match state.perbarui(id, input) {
        Some(tugas) => Ok(Json(tugas)),
        None => Err(StatusCode::NOT_FOUND),
    }
}

pub async fn hapus_tugas(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<StatusCode, StatusCode> {
    match state.hapus(id) {
        Some(_) => Ok(StatusCode::NO_CONTENT),
        None => Err(StatusCode::NOT_FOUND),
    }
}

Bagian-bagian pentingnya adalah sebagai berikut:

  1. Baris 14 (State(state): State<AppState>): pola destrukturisasi extractor — parameter bertipe State<AppState> langsung dibongkar menjadi variabel state. Axum mengambil salinan state dari router sebelum memanggil handler.
  2. Baris 20 (Json(input): Json<BuatTugas>): extractor kedua. Axum membaca seluruh body request, memeriksa header Content-Type: application/json, lalu serde mengubah JSON menjadi BuatTugas. Bila JSON tidak sesuai, request ditolak sebelum handler menyala — kita lihat detail penolakannya di Bagian 3.
  3. Baris 24-29 (tuple (StatusCode::CREATED, [(header::LOCATION, lokasi)], Json(tugas))): axum mengizinkan response dirakit dari kombinasi status, header, dan body dalam satu tuple. .into_response() menyatukan ketiganya. Inilah cara kita mengirim 201 Created sekaligus header Location yang menunjuk tugas baru — praktik standar REST.
  4. Baris 34 (Path(id): Path<u64>): mengambil segmen {id} dari path dan langsung mengubahnya menjadi u64. Bila nilainya bukan angka, extractor menolaknya sebelum handler berjalan.
  5. Baris 35 (Result<Json<Tugas>, StatusCode>): handler mengembalikan Result. Cabang Err(StatusCode::NOT_FOUND) cukup untuk sekarang — StatusCode mengimplementasikan IntoResponse, sehingga axum mengirim status telanjang tanpa body.
  6. Baris 58 (Ok(StatusCode::NO_CONTENT)): 204 berarti sukses tanpa isi. Kita sengaja tidak mengirim body JSON apa pun pada DELETE yang berhasil.

Menguji Endpoint CRUD #

Jalankan cargo run lalu kerjakan satu putaran penuh kontrak CRUD di terminal kedua:

bash
curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:8080/api/tugas \
    -H "Content-Type: application/json" -d '{"judul": "Belajar Rust"}'
curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:8080/api/tugas \
    -H "Content-Type: application/json" -d '{"judul": "Kirim laporan bulanan"}'
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:8080/api/tugas

Output yang Dihasilkan:

text
{"id":1,"judul":"Belajar Rust","selesai":false}
HTTP 201
{"id":2,"judul":"Kirim laporan bulanan","selesai":false}
HTTP 201
[{"id":1,"judul":"Belajar Rust","selesai":false},{"id":2,"judul":"Kirim laporan bulanan","selesai":false}]
HTTP 200

Server kini benar-benar mengingat datanya. Lanjutkan dengan mengambil satu tugas, memperbarui, lalu menghapus:

bash
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:8080/api/tugas/1
curl -s -w "\nHTTP %{http_code}\n" -X PUT http://127.0.0.1:8080/api/tugas/1 \
    -H "Content-Type: application/json" \
    -d '{"judul": "Belajar Rust sampai finish", "selesai": true}'
curl -s -w "\nHTTP %{http_code}\n" -X DELETE http://127.0.0.1:8080/api/tugas/2
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:8080/api/tugas/2

Output yang Dihasilkan:

text
{"id":1,"judul":"Belajar Rust","selesai":false}
HTTP 200
{"id":1,"judul":"Belajar Rust sampai finish","selesai":true}
HTTP 200

HTTP 204

HTTP 404

Perhatikan dua baris kosong terakhir: respons 204 memang tanpa body, dan 404 kita saat ini juga telanjang — curl -s tidak menemukan apa pun untuk dicetak. Client hanya tahu bahwa request gagal, tetapi tidak tahu alasannya. Header Location pada 201 layak dibuktikan sekali dengan curl -i:

bash
curl -s -i -X POST http://127.0.0.1:8080/api/tugas \
    -H "Content-Type: application/json" -d '{"judul": "Cek header"}'

Output yang Dihasilkan:

text
HTTP/1.1 201 Created
content-type: application/json
location: /api/tugas/3
content-length: 45
date: Tue, 13 Oct 2026 23:31:28 GMT

{"id":3,"judul":"Cek header","selesai":false}

Lima endpoint CRUD kini hidup di atas penyimpanan bersama. Dua kelemahan masih terbuka: request dengan judul kosong diterima begitu saja, dan seluruh galat membalas status telanjang tanpa pesan. Pada bagian berikutnya kita mendisiplinkan masukan dengan validasi dan merapikan seluruh galat ke satu format JSON lewat enum ApiError.

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" })
    );
}