Mengendalikan kualitas input: validasi bertingkat dengan validator crate, custom deserializer serde, dan pemisahan deserialization dari validasi agar data yang masuk ke aplikasi selalu bersih dan teruji.

Selama ini extractor Json hanya memastikan body ter-parse — bukan valid. Json<NewPost> menerima judul kosong, email tanpa @, umur 1000 tahun. Episode ini menutup celah itu dengan validation: lapisan yang memeriksa makna data, bukan sekadar bentuknya.
Mengapa validasi perlu episode sendiri? Karena validasi yang buruk adalah sumber bug bisnis yang paling umum: data kotor masuk database, crash di tempat tak terduga, dan — yang lebih serius — celah keamanan seperti injeksi (episode 18). Kita akan membangun sistem validasi yang jelas bertingkat, memakai validator crate dan custom deserializer serde.
Dua konsep yang sering disalahartikan:
| Lapisan | Pertanyaan | Alat |
|---|---|---|
| Deserialization | Apakah data terbentuk benar? (JSON valid, tipe cocok) | serde |
| Validation | Apakah data bermakna benar? (email valid, umur wajar) | validator / logic manual |
Fungsi deserialize seperti parsing, validasi seperti pemeriksaan kualitas. Mencampurnya — misalnya validasi bisnis di dalam Deserialize — membuat kode sulit diuji dan error message tidak jelas. Pola yang sehat: pisahkan keduanya.
cargo add validator --features derivevalidator menyediakan attribute #[validate] pada struct dengan derive Validate. Aturan validasi dideklarasikan sebagai attribute di field:
use serde::Deserialize;
use validator::{Validate, ValidationError};
#[derive(Debug, Deserialize, Validate)]
pub struct CreatePost {
#[validate(length(min = 3, max = 120, message = "judul harus 3-120 karakter"))]
pub title: String,
#[validate(length(min = 1, max = 10000))]
pub body: String,
#[validate(url(message = "cover_url harus URL valid"))]
#[serde(default)]
pub cover_url: Option<String>,
#[validate(range(min = 1, max = 5))]
#[serde(default = "default_priority")]
pub priority: u8,
}
fn default_priority() -> u8 {
3
}Aturan yang tersedia cukup lengkap: length, range, url, email, required, contains, regex, dan lain-lain — dan kalian bisa menulis custom function untuk aturan spesifik.
Validasi dijalankan setelah deserialization, di dalam handler (atau lebih baik: helper yang reusable):
use axum::{extract::State, Json};
use crate::{
error::{AppError, AppResult},
models::CreatePost,
state::AppState,
};
use validator::Validate;
pub async fn create_post(
State(state): State<AppState>,
Json(body): Json<CreatePost>,
) -> AppResult<(axum::http::StatusCode, Json<serde_json::Value>)> {
body.validate()
.map_err(|err| AppError::BadRequest(validation_message(&err)))?;
// data sudah valid, lanjut simpan
let post = sqlx::query_as::<_, crate::models::Post>(
"INSERT INTO posts (title, body, priority) VALUES ($1, $2, $3)
RETURNING id, title, body, priority, created_at, updated_at",
)
.bind(&body.title)
.bind(&body.body)
.bind(body.priority)
.fetch_one(&state.db)
.await?;
Ok((axum::http::StatusCode::CREATED, Json(serde_json::json!(post))))
}
fn validation_message(err: &validator::ValidationErrors) -> String {
err.field_errors()
.iter()
.map(|(field, errors)| {
let msg = errors
.iter()
.filter_map(|e| e.message.as_ref())
.next()
.map(|m| m.to_string())
.unwrap_or_else(|| "nilai tidak valid".into());
format!("{field}: {msg}")
})
.collect::<Vec<_>>()
.join("; ")
}validate() mengembalikan ValidationErrors yang berisi detail per field — di atas kita ubah menjadi satu string pesan (bisa juga dikembalikan sebagai array JSON per field; pilih bentuk yang klien kalian lebih mudah konsumsi).
Note
Validasi harus kembali dengan 400 Bad Request, bukan 500. Karena AppError::BadRequest kita (episode 6) memetakan ke 400, menaruh map_err seperti di atas sudah cukup — error validasi tidak akan pernah jatuh ke cabang internal error.
Kadang aturan parsing default serde tidak cukup. Contoh nyata: normalisasi input saat deserialize — hapus spasi berlebih, ubah email ke lowercase:
use serde::{Deserialize, Deserializer};
fn normalize_email<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(s.trim().to_lowercase())
}
#[derive(Debug, Deserialize)]
pub struct SignupRequest {
#[serde(deserialize_with = "normalize_email")]
pub email: String,
#[serde(deserialize_with = "trim_or_default")]
pub display_name: String,
}
fn trim_or_default<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
let s = Option::<String>::deserialize(deserializer)?;
Ok(s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).unwrap_or_default())
}Dengan deserialize_with, kustomisasi parsing terkumpul di satu tempat — handler tidak perlu menormalisasi ulang. Contoh di atas menormalkan email ke lowercase saat parsing; hasilnya konsisten bahkan jika klien kirim ADMIN@X.Com.
Aturan yang tidak tercakup attribute validator ditulis sebagai fungsi:
use validator::ValidationError;
fn unique_username(value: &str) -> Result<(), ValidationError> {
if value == "admin" || value == "root" {
return Err(ValidationError::new("username tidak tersedia"));
}
Ok(())
}
#[derive(Deserialize, Validate)]
pub struct CreateUser {
#[validate(length(min = 3, custom = "unique_username"))]
pub username: String,
}Fungsi custom dipanggil saat validate() — hasilnya tergabung dalam ValidationErrors yang sama. Untuk validasi yang butuh database (misal email sudah terdaftar), jangan masukkan ke validate() — lakukan di handler dengan query, karena validate() tidak async dan tidak punya akses state.
Beberapa codebase membungkus validasi ke extractor custom bernama ValidatedJson — deserialize lalu validate() dalam satu langkah:
use axum::{
async_trait,
extract::{FromRequest, Request},
Json,
};
use validator::Validate;
pub struct ValidatedJson<T>(pub T);
#[async_trait]
impl<S, T> FromRequest<S> for ValidatedJson<T>
where
S: Send + Sync,
T: DeserializeOwned + Validate,
{
type Rejection = AppError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let Json(value) = Json::<T>::from_request(req, state).await?;
value.validate().map_err(|err| AppError::BadRequest(validation_message(&err)))?;
Ok(ValidatedJson(value))
}
}Handler cukup menulis ValidatedJson(body): ValidatedJson<CreatePost> dan validasi berjalan otomatis. Ini memuaskan secara ergonomis, tapi ada trade-off: validasi tersembunyi di dalam extractor, bukan di handler. Pilih salah satu pola dan konsisten — jangan mencampur ValidatedJson dan body.validate() manual di codebase yang sama.
Warning
Apapun pola yang dipilih, pastikan satu aturan: semua data dari klien melewati validasi sebelum menyentuh query, business logic, atau storage. Jalur input yang tidak tervalidasi (misal data dari header, query, atau WebSocket) sama berbahayanya dengan body — perlakukan semuanya tidak dipercaya.
Validasi fokus ke input; sisi output juga perlu disiplin. Serde memungkinkan menyembunyikan field internal:
use serde::Serialize;
#[derive(Serialize)]
pub struct PublicUser {
pub id: uuid::Uuid,
pub username: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
// password_hash TIDAK PERNAH dimasukkan ke struct yang di-Serialize ke publikAturan emas serialization: struct response tidak pernah membawa field yang tak boleh bocor — password hash, token refresh, alamat internal. Serde punya #[serde(skip)] sebagai pengaman ganda, tapi struct terpisah lebih aman karena impossible untuk bocor.
Pada episode 14 ini kalian telah mengendalikan data masuk dan keluar:
validator) dipisahkan dengan jelas.length, range, url, dan fungsi custom dalam Validate.AppError.ValidatedJson sebagai alternatif, dan konsistensi pola di codebase.Di episode 15 selanjutnya kita dokumentasikan semua ini: OpenAPI & documentation — auto-generate spesifikasi API dengan utoipa, plus Swagger UI dan ReDoc untuk dilihat manusia. Sampai jumpa di episode 15!