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/:
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:
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:
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:
- Baris 3-8 (
struct Tugas): representasi satu tugas yang dikirim ke client. DeriveSerializemembuat serde mampu mengubah struct ini menjadi body JSON, deriveClonememudahkan penyalinan dari penyimpanan, danDebugmembantu kita mencetak nilai saat menguji. - Baris 10-13 (
struct BuatTugas): bentuk body requestPOST— hanya fieldjudul. DeriveDeserializemembuat serde mengubah JSON masuk menjadi struct ini secara otomatis. - Baris 15-19 (
struct PerbaruiTugas): bentuk body requestPUT—juduldanselesaisekaligus, karenaPUTkita 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:
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:
- Baris 9 (
Arc<RwLock<HashMap<u64, Tugas>>>):HashMapmenyimpan tugas dengan kunci id.RwLockdi sekelilingnya mengizinkan banyak pembaca masuk bersamaan (read), tetapi menulis (write) bersifat eksklusif — cocok untuk API yang jauh lebih sering dibaca daripada ditulis.Arcpaling luar membuat salinan struct tetap menunjuk ke data yang sama, sehingga setiap salinan state di setiap task berbagi satu penyimpanan. - Baris 10 (
Arc<AtomicU64>): penghitung id berikutnya. Operasifetch_addbertambah satu secara atomik tanpa mengunci apa pun, sehingga dua requestPOSTyang datang bersamaan pasti mendapat id berbeda.Ordering::Relaxedcukup karena kita hanya butuh keunikan angka, bukan urutan operasi lain. - Baris 7 (
#[derive(Clone)]):ClonemilikAppStatehanya menyalin dua pointerArc— operasi murah. Inilah yang membuat.with_state()bisa menyebar state ke seluruh handler tanpa menyalin seluruh isi HashMap. - Baris 22 (
self.daftar.read().expect(...)): memegang kunci baca selama fungsi berjalan. Guard kunci dilepas otomatis saat variabeldaftardrop di akhir fungsi.expectdengan pesan “teracuni” berjaga bila thread lain panik sambil memegang kunci — kasus yang nyaris mustahil pada kode kita karena handler tidak memegang kunci sambil panik. - Baris 23-24 (
collect()lalusort_by_key):HashMaptidak menjamin urutan, jadi kita salin nilainya keVeclalu mengurutkan berdasarkanidagar respons selalu stabil dan mudah dibaca. - Baris 42-43 (
cari): pembacaan tunggal..get(&id).cloned()mengembalikanOption<Tugas>—Noneberarti tugas tidak ada, dan kita teruskan informasi itu ke handler. - Baris 46-60 (
perbaruidanhapus): dua operasi penulisan. Keduanya memegang kunci tulis lewatwrite(), mengubah isi, lalu melepas kunci saat guard drop di akhir fungsi.Optionsebagai 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:
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:
- Baris 14 (
State(state): State<AppState>): pola destrukturisasi extractor — parameter bertipeState<AppState>langsung dibongkar menjadi variabelstate. Axum mengambil salinan state dari router sebelum memanggil handler. - Baris 20 (
Json(input): Json<BuatTugas>): extractor kedua. Axum membaca seluruh body request, memeriksa headerContent-Type: application/json, lalu serde mengubah JSON menjadiBuatTugas. Bila JSON tidak sesuai, request ditolak sebelum handler menyala — kita lihat detail penolakannya di Bagian 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 mengirim201 Createdsekaligus headerLocationyang menunjuk tugas baru — praktik standar REST. - Baris 34 (
Path(id): Path<u64>): mengambil segmen{id}dari path dan langsung mengubahnya menjadiu64. Bila nilainya bukan angka, extractor menolaknya sebelum handler berjalan. - Baris 35 (
Result<Json<Tugas>, StatusCode>): handler mengembalikanResult. CabangErr(StatusCode::NOT_FOUND)cukup untuk sekarang —StatusCodemengimplementasikanIntoResponse, sehingga axum mengirim status telanjang tanpa body. - Baris 58 (
Ok(StatusCode::NO_CONTENT)):204berarti sukses tanpa isi. Kita sengaja tidak mengirim body JSON apa pun padaDELETEyang berhasil.
Menguji Endpoint CRUD #
Jalankan cargo run lalu kerjakan satu putaran penuh kontrak CRUD di terminal kedua:
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/tugasOutput yang Dihasilkan:
{"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 200Server kini benar-benar mengingat datanya. Lanjutkan dengan mengambil satu tugas, memperbarui, lalu menghapus:
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/2Output yang Dihasilkan:
{"id":1,"judul":"Belajar Rust","selesai":false}
HTTP 200
{"id":1,"judul":"Belajar Rust sampai finish","selesai":true}
HTTP 200
HTTP 204
HTTP 404Perhatikan 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:
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:
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[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 berkasuse 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 berkasuse 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 berkaspub 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 berkasuse 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 berkasuse 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 berkasuse 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 berkasuse 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" })
);
}