Bagian 2 meninggalkan dua lubang yang nyata terasa bagi client. Pertama, POST /api/tugas menerima judul kosong sekalipun — tugas tanpa judul masuk penyimpanan dan menumpuk sebagai sampah data. Kedua, seluruh kegagalan membalas 404 atau 400 berupa status code telanjang: client tahu request gagal, tetapi tidak tahu mengapa. Pada bagian ini kita menutup kedua lubang itu dengan dua alat Rust yang sudah kita kenal — fungsi validasi yang mengembalikan Result, dan enum galat yang mengimplementasikan IntoResponse sehingga setiap kegagalan domain membalas body JSON berbentuk {"error": "..."}.

Langkah 1: Validasi Judul #

Aturan validasi judul kita dua kalimat: judul wajib berisi teks setelah spasi di awal dan akhir dibuang, dan panjangnya tidak boleh melebihi 140 karakter. Tambahkan konstanta dan fungsi berikut ke src/models.rs:

rust
pub const JUDUL_MAX: usize = 140;

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

Mari kita bedah perannya baris per baris:

  1. Baris 1 (pub const JUDUL_MAX: usize = 140;): batas panjang dinyatakan sebagai konstanta bernama. Handler tidak menulis angka ajaib 140, dan mengubah batas cukup satu tempat.
  2. Baris 4 (let bersih = judul.trim();): trim() membuang spasi, tab, dan baris baru di kedua ujung string. Teks " " hasil trim menjadi string kosong — inilah yang menggagalkan judul berisi spasi saja.
  3. Baris 5-7 (if bersih.is_empty()): penolakan pertama. Nilai balik Err(String) membawa pesan yang langsung siap dikirim ke client, bukan kode galat yang harus diterjemahkan lagi.
  4. Baris 8-10 (bersih.chars().count() > JUDUL_MAX): penghitungan memakai chars().count(), bukan len(). Tipe String milik Rust adalah byte UTF-8, sehingga len() menghitung byte — satu huruf seperti "é" bernilai 2 byte. chars().count() menghitung karakter seperti yang dilihat manusia, dan itulah makna “140 karakter” bagi pengguna kita.
  5. Baris 11 (Ok(String::from(bersih))): judul yang lolos dikembalikan dalam bentuk yang sudah bersih. Handler tinggal menyimpan nilai ini tanpa memanggil trim() lagi.

Pola Result<String, String> terlihat sederhana, tetapi pas untuk kebutuhan kita: kesalahan validasi memang hanya berupa pesan teks, dan pesan tersebut mengalir utuh sampai ke client.

Langkah 2: Enum ApiError dan IntoResponse #

Sekarang kita pusatkan seluruh galat domain ke satu enum. Buat berkas baru src/error.rs:

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

Enum galat dengan implementasi IntoResponse adalah idiom yang sering dipakai proyek axum skala besar. Mari kita bedah mekanismenya:

  1. Baris 6-10 (enum ApiError): tiga varian galat, masing-masing membawa data yang dibutuhkan pesannya — TidakDitemukan membawa id yang dicari, Validasi membawa pesan dari validasi_judul, dan IdTidakValid membawa segmen path mentah. Menambah jenis galat baru tinggal menambah varian; kompilator kemudian memaksa setiap match kita menanganinya.
  2. Baris 12 (impl IntoResponse for ApiError): dengan mengimplementasikan trait ini, ApiError resmi menjadi tipe response yang sah. Handler boleh membalas Result<Json<Tugas>, ApiError> dan axum mengubah cabang Err menjadi response utuh.
  3. Baris 14-24 (let (status, pesan) = match self): satu-satunya tempat di seluruh program yang memetakan galat ke status code. TidakDitemukan menjadi 404, dua varian lainnya menjadi 400. Karena pemetaan terpusat di sini, kontrak endpoint pada halaman overview terjaga tanpa perlu mengingat-ingat angka di banyak berkas.
  4. Baris 26 ((status, Json(json!({ "error": pesan }))).into_response()): status dan body JSON digabungkan lewat tuple, pola yang sama dengan handler buat_tugas pada Bagian 2. Hasil akhirnya selalu berbentuk {"error": "..."} dengan header Content-Type: application/json.

Jangan lupa mendaftarkan modulnya: tambahkan mod error; pada src/main.rs, lalu lengkapi impor di src/handlers.rs dengan use crate::error::ApiError; dan use crate::models::validasi_judul;.

Langkah 3: Handler Memakai ApiError #

Handler sekarang memvalidasi lebih dulu, baru menyentuh state. Potongan src/handlers.rs berikut memperlihatkan handler yang berubah — versi lengkapnya ada pada Berkas Sumber di bawah halaman proyek:

rust
use crate::error::ApiError;
use crate::models::validasi_judul;

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

Perubahan pentingnya adalah sebagai berikut:

  1. Baris 8 (validasi_judul(&input.judul).map_err(ApiError::Validasi)?): tiga teknik bekerja dalam satu baris. validasi_judul menghasilkan Result<String, String>; map_err membungkus pesan string menjadi varian ApiError::Validasi; operator ? meneruskan galat ke keluaran handler begitu validasi gagal, sehingga sisa fungsi hanya berurusan dengan judul yang pasti bersih.
  2. Baris 21 (Path(nilai): Path<String>): extractor Path kini meminta String, bukan u64. Perubahan ini disengaja: bila memakai Path<u64>, segmen bukan angka ditolak extractor dengan response bawaan yang berformat teks. Dengan Path<String>, kita yang memegang kendali penuh atas bentuk galatnya.
  3. Baris 23 (let id = parse_id(nilai)?;): konversi ke angka dipindahkan ke dalam handler lewat helper parse_id. Galat parsing menjadi ApiError::IdTidakValid yang membawa nilai aslinya, sehingga client menerima pesan seperti Id "abc" bukan angka yang valid.
  4. Baris 41-44 (fn parse_id): str::parse::<u64>() sudah mengembalikan Result, jadi map_err tinggal menukar tipe galatnya. Closure |_| mengabaikan galat bawaan karena pesan kita sendiri lebih jelas bagi pengguna API.
  5. Handler perbarui_tugas menggabungkan kedua pola: memanggil parse_id untuk path, validasi_judul untuk body, lalu state.perbarui untuk data. Urutannya sengaja — masukan divalidasi dulu, penyimpanan disentuh terakhir.

Setelah ketiga berkas berubah, jalankan cargo build — kompilator akan memaksa setiap match pada ApiError lengkap tiga variannya, dan itu justru jaminan bahwa tidak ada jenis galat yang lupa ditangani.

Langkah 4: Perilaku Bawaan Extractor Json #

Validasi kita menutup galat domain, tetapi ada satu keluarga galat yang ditangani axum sebelum handler sempat berjalan: extractor Json menolak request yang bukan JSON sah. Jalankan server lalu kirim tiga request bermasalah:

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": }'
curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:8080/api/tugas \
    -H "Content-Type: application/json" -d '{"selesai": false}'
curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:8080/api/tugas \
    -d '{"judul": "Tanpa header"}'

Output yang Dihasilkan:

text
Failed to parse the request body as JSON: judul: expected value at line 1 column 11
HTTP 400
Failed to deserialize the JSON body into the target type: missing field `judul` at line 1 column 18
HTTP 422
Expected request with `Content-Type: application/json`
HTTP 415

Ketiga respons berasal dari tiga jenis penolakan berbeda: JSON yang tidak dapat diparse membalas 400, JSON sah yang tidak cocok dengan struct (field hilang atau tipe salah) membalas 422, dan request tanpa header Content-Type: application/json membalas 415. Pesan-pesan ini akurat dan menyebut posisi galat sampai kolomnya, tetapi perhatikan formatnya: teks polos, bukan JSON seperti respons ApiError kita.

Menguji Respons Error #

Sekarang buktikan seluruh baris gagal pada tabel kontrak. Jalankan server, lalu uji validasi dan id:

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": ""}'
curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:8080/api/tugas \
    -H "Content-Type: application/json" -d '{"judul": "   "}'
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:8080/api/tugas/abc
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:8080/api/tugas/-1
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:8080/api/tugas/99

Output yang Dihasilkan:

text
{"error":"judul tidak boleh kosong"}
HTTP 400
{"error":"judul tidak boleh kosong"}
HTTP 400
{"error":"Id \"abc\" bukan angka yang valid"}
HTTP 400
{"error":"Id \"-1\" bukan angka yang valid"}
HTTP 400
{"error":"Tugas dengan id 99 tidak ditemukan"}
HTTP 404

Judul kosong dan judul spasi sama-sama ditolak — keduanya menghasilkan string kosong setelah trim(). Segmen abc dan -1 ditolak sebagai id karena parse::<u64>() menolak keduanya, sedangkan 99 lolos parsing lalu ditolak penyimpanan dengan 404. Uji pula dua jalur galat terakhir pada PUT dan DELETE:

bash
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": "", "selesai": true}'
curl -s -w "\nHTTP %{http_code}\n" -X DELETE http://127.0.0.1:8080/api/tugas/99

Output yang Dihasilkan:

text
{"error":"judul tidak boleh kosong"}
HTTP 400
{"error":"Tugas dengan id 99 tidak ditemukan"}
HTTP 404

Bandingkan dengan sesi pengujian Bagian 2: galat yang tadinya telanjang kini selalu membawa penjelasan JSON. Kontrak endpoint pada halaman overview berlaku penuh — tinggal satu kelemahan tersisa, dan bukan pada perilaku API-nya: kita masih menguji semuanya dengan tangan. Pada bagian terakhir kita mengotomatiskan seluruh kontrak ini menjadi test integration dan membangun biner release yang siap ditempatkan di server.

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