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:
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:
- Baris 1 (
pub const JUDUL_MAX: usize = 140;): batas panjang dinyatakan sebagai konstanta bernama. Handler tidak menulis angka ajaib140, dan mengubah batas cukup satu tempat. - 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. - Baris 5-7 (
if bersih.is_empty()): penolakan pertama. Nilai balikErr(String)membawa pesan yang langsung siap dikirim ke client, bukan kode galat yang harus diterjemahkan lagi. - Baris 8-10 (
bersih.chars().count() > JUDUL_MAX): penghitungan memakaichars().count(), bukanlen(). TipeStringmilik Rust adalah byte UTF-8, sehinggalen()menghitung byte — satu huruf seperti"é"bernilai 2 byte.chars().count()menghitung karakter seperti yang dilihat manusia, dan itulah makna “140 karakter” bagi pengguna kita. - Baris 11 (
Ok(String::from(bersih))): judul yang lolos dikembalikan dalam bentuk yang sudah bersih. Handler tinggal menyimpan nilai ini tanpa memanggiltrim()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:
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:
- Baris 6-10 (
enum ApiError): tiga varian galat, masing-masing membawa data yang dibutuhkan pesannya —TidakDitemukanmembawaidyang dicari,Validasimembawa pesan darivalidasi_judul, danIdTidakValidmembawa segmen path mentah. Menambah jenis galat baru tinggal menambah varian; kompilator kemudian memaksa setiapmatchkita menanganinya. - Baris 12 (
impl IntoResponse for ApiError): dengan mengimplementasikan trait ini,ApiErrorresmi menjadi tipe response yang sah. Handler boleh membalasResult<Json<Tugas>, ApiError>dan axum mengubah cabangErrmenjadi response utuh. - Baris 14-24 (
let (status, pesan) = match self): satu-satunya tempat di seluruh program yang memetakan galat ke status code.TidakDitemukanmenjadi404, dua varian lainnya menjadi400. Karena pemetaan terpusat di sini, kontrak endpoint pada halaman overview terjaga tanpa perlu mengingat-ingat angka di banyak berkas. - Baris 26 (
(status, Json(json!({ "error": pesan }))).into_response()): status dan body JSON digabungkan lewat tuple, pola yang sama dengan handlerbuat_tugaspada Bagian 2. Hasil akhirnya selalu berbentuk{"error": "..."}dengan headerContent-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:
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:
- Baris 8 (
validasi_judul(&input.judul).map_err(ApiError::Validasi)?): tiga teknik bekerja dalam satu baris.validasi_judulmenghasilkanResult<String, String>;map_errmembungkus pesan string menjadi varianApiError::Validasi; operator?meneruskan galat ke keluaran handler begitu validasi gagal, sehingga sisa fungsi hanya berurusan dengan judul yang pasti bersih. - Baris 21 (
Path(nilai): Path<String>): extractorPathkini memintaString, bukanu64. Perubahan ini disengaja: bila memakaiPath<u64>, segmen bukan angka ditolak extractor dengan response bawaan yang berformat teks. DenganPath<String>, kita yang memegang kendali penuh atas bentuk galatnya. - Baris 23 (
let id = parse_id(nilai)?;): konversi ke angka dipindahkan ke dalam handler lewat helperparse_id. Galat parsing menjadiApiError::IdTidakValidyang membawa nilai aslinya, sehingga client menerima pesan sepertiId "abc" bukan angka yang valid. - Baris 41-44 (
fn parse_id):str::parse::<u64>()sudah mengembalikanResult, jadimap_errtinggal menukar tipe galatnya. Closure|_|mengabaikan galat bawaan karena pesan kita sendiri lebih jelas bagi pengguna API. - Handler
perbarui_tugasmenggabungkan kedua pola: memanggilparse_iduntuk path,validasi_juduluntuk body, lalustate.perbaruiuntuk 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:
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:
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 415Ketiga 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:
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/99Output yang Dihasilkan:
{"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 404Judul 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:
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/99Output yang Dihasilkan:
{"error":"judul tidak boleh kosong"}
HTTP 400
{"error":"Tugas dengan id 99 tidak ditemukan"}
HTTP 404Bandingkan 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[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" })
);
}