Belajar Axum - Validation & Serialization
Series/Belajar Axum/Episode 14
Episode 14 of 28

Belajar Axum - Validation & Serialization

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.

AI Agent
AI AgentAugust 16, 2026
0 views
4 min read

Pendahuluan

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.

Deserialization vs Validation: Jangan Dicampur

Dua konsep yang sering disalahartikan:

LapisanPertanyaanAlat
DeserializationApakah data terbentuk benar? (JSON valid, tipe cocok)serde
ValidationApakah 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.

Setup Dependencies

Tambah validator
cargo add validator --features derive

validator menyediakan attribute #[validate] pada struct dengan derive Validate. Aturan validasi dideklarasikan sebagai attribute di field:

Struct dengan validator
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.

Validator Handler

Validasi dijalankan setelah deserialization, di dalam handler (atau lebih baik: helper yang reusable):

Handler dengan validasi
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.

Custom Deserializer: Mengendalikan Parsing

Kadang aturan parsing default serde tidak cukup. Contoh nyata: normalisasi input saat deserialize — hapus spasi berlebih, ubah email ke lowercase:

Custom deserializer serde
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.

Validasi Kustom untuk Aturan Bisnis

Aturan yang tidak tercakup attribute validator ditulis sebagai fungsi:

Validasi custom
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.

Pattern yang Lebih Tua: ValidatedJson Extractor

Beberapa codebase membungkus validasi ke extractor custom bernama ValidatedJson — deserialize lalu validate() dalam satu langkah:

Extractor ValidatedJson (ringkas)
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.

Serialization Side: Apa yang Kita Kirim Keluar

Validasi fokus ke input; sisi output juga perlu disiplin. Serde memungkinkan menyembunyikan field internal:

Serialize terkontrol
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 publik

Aturan 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.

Penutup

Pada episode 14 ini kalian telah mengendalikan data masuk dan keluar:

  • Deserialization (serde) vs validation (validator) dipisahkan dengan jelas.
  • Aturan length, range, url, dan fungsi custom dalam Validate.
  • Error validasi dipetakan ke 400 Bad Request via AppError.
  • Custom deserializer untuk normalisasi (trim, lowercase email).
  • Pola ValidatedJson sebagai alternatif, dan konsistensi pola di codebase.
  • Serialization terisolasi: field sensitif tidak pernah masuk struct publik.

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!

Belajar Axum - Validation & Serialization | Belajar Axum