Input dari klien tidak boleh dipercaya. Validasi mencegah data invalid masuk ke database, logika bisnis, atau layanan downstream. Ktor tidak menyertakan validator bawaan — kerangka ini mengandalkan library terpisah seperti valikator atau validator kustom. Pada chapter ini kita akan membangun validator kustom yang mengumpulkan semua error dalam satu respons dan memformat respons error mengikuti RFC 7807 Problem Details.
Strategi Validasi #
Validasi dapat ditempatkan di beberapa lapisan: DTO (Data Transfer Object), service, atau database constraint. Praktik terbaik backend adalah memvalidasi di lapisan DTO — sebelum data masuk ke service. Hal ini memungkinkan respons 400 cepat tanpa membebani service layer atau database.
Validator yang baik mengumpulkan semua error sekaligus, bukan berhenti pada error pertama. Klien dapat memperbaiki seluruh masukan dalam satu iterasi, mengurangi round-trip antara klien dan server. Pola ini disebut fail-collect.
Validator Kustom #
Validator adalah class yang menerima objek request dan mengembalikan ValidationResult — berisi daftar error (bila ada). Setiap entitas memiliki validator sendiri agar aturan domain terkonsentrasi di satu tempat.
data class ValidationError(val field: String, val message: String)
class ValidationResult {
val errors = mutableListOf<ValidationError>()
val isValid: Boolean get() = errors.isEmpty()
fun addError(field: String, message: String) {
errors.add(ValidationError(field, message))
}
}
class CreateUserValidator {
fun validate(req: CreateUserRequest): ValidationResult {
val result = ValidationResult()
// ... aturan validasi ...
return result
}
}Pola ini mirip dengan library Hibernate Validator di Spring — tetapi ditulis dengan idiom Kotlin murni tanpa anotasi.
RFC 7807 Problem Details #
RFC 7807 adalah standar IETF untuk respons error HTTP yang terstruktur. Format ini menggunakan field type, title, status, dan errors untuk menjelaskan masalah secara mesin-readable. Klien dapat memparse respons untuk menampilkan pesan error per field.
{
"type": "https://example.com/errors/validation",
"title": "Validasi gagal",
"status": 400,
"errors": [
{"field": "nama", "message": "Nama minimal 3 karakter"},
{"field": "email", "message": "Email tidak valid"}
]
}Penggunaan Problem Details menjadikan API konsisten dan memungkinkan tooling otomatis seperti Swagger UI untuk mendokumentasikan format error.
Demo Validation Lengkap #
Kita akan menulis validator CreateUserValidator yang memeriksa nama, email, dan umur. Validator mengumpulkan semua error dan menghasilkan respons ProblemDetails jika validasi gagal.
// ktor_validation.kt
// Mendemonstrasikan validasi body request pada Ktor: validator kustom,
// pengumpulan error, dan respons dengan detail.
data class ValidationError(val field: String, val message: String)
data class CreateUserRequest(
val nama: String,
val email: String,
val umur: Int
)
class ValidationResult {
val errors = mutableListOf<ValidationError>()
val isValid: Boolean get() = errors.isEmpty()
fun addError(field: String, message: String) {
errors.add(ValidationError(field, message))
}
}
// Validator kustom per entitas
class CreateUserValidator {
fun validate(req: CreateUserRequest): ValidationResult {
val result = ValidationResult()
if (req.nama.isBlank()) {
result.addError("nama", "Nama tidak boleh kosong")
} else if (req.nama.length < 3) {
result.addError("nama", "Nama minimal 3 karakter")
}
if (!req.email.contains("@") || !req.email.contains(".")) {
result.addError("email", "Email tidak valid")
}
if (req.umur < 13) {
result.addError("umur", "Umur minimal 13 tahun")
} else if (req.umur > 120) {
result.addError("umur", "Umur tidak realistis")
}
return result
}
}
// Format respons error mengikuti RFC 7807 Problem Details
data class ProblemDetails(
val type: String,
val title: String,
val status: Int,
val errors: List<ValidationError>
)
// Mock ApplicationCall
class ApplicationCall(val requestBody: String = "") {
var responseStatus: Int = 200
var responseBody: String = ""
}
fun handleCreateUser(call: ApplicationCall, body: CreateUserRequest) {
val validator = CreateUserValidator()
val result = validator.validate(body)
if (!result.isValid) {
val problem = ProblemDetails(
type = "https://example.com/errors/validation",
title = "Validasi gagal",
status = 400,
errors = result.errors
)
call.responseStatus = 400
call.responseBody = formatProblem(problem)
return
}
call.responseStatus = 201
call.responseBody = """{"status":"created","nama":"${body.nama}"}"""
}
fun formatProblem(p: ProblemDetails): String {
val errStr = p.errors.joinToString(",") { """{"field":"${it.field}","message":"${it.message}"}""" }
return """{"type":"${p.type}","title":"${p.title}","status":${p.status},"errors":[$errStr]}"""
}
fun main() {
val validator = CreateUserValidator()
println("=== Valid: Budi ===")
val c1 = ApplicationCall()
handleCreateUser(c1, CreateUserRequest("Budi", "[email protected]", 25))
println("Status: ${c1.responseStatus}")
println("Body: ${c1.responseBody}")
println("\n=== Invalid: nama kosong ===")
val c2 = ApplicationCall()
handleCreateUser(c2, CreateUserRequest("", "[email protected]", 25))
println("Status: ${c2.responseStatus}")
println("Body: ${c2.responseBody}")
println("\n=== Invalid: banyak error ===")
val c3 = ApplicationCall()
handleCreateUser(c3, CreateUserRequest("A", "budi-no-at-sign", 5))
println("Status: ${c3.responseStatus}")
println("Body: ${c3.responseBody}")
println("\n=== Hasil validasi detail ===")
val result = validator.validate(CreateUserRequest("A", "bad", 5))
result.errors.forEach { println(" ${it.field}: ${it.message}") }
}Output yang Dihasilkan:
=== Valid: Budi ===
Status: 201
Body: {"status":"created","nama":"Budi"}
=== Invalid: nama kosong ===
Status: 400
Body: {"type":"https://example.com/errors/validation","title":"Validasi gagal","status":400,"errors":[{"field":"nama","message":"Nama tidak boleh kosong"}]}
=== Invalid: banyak error ===
Status: 400
Body: {"type":"https://example.com/errors/validation","title":"Validasi gagal","status":400,"errors":[{"field":"nama","message":"Nama minimal 3 karakter"},{"field":"email","message":"Email tidak valid"},{"field":"umur","message":"Umur minimal 13 tahun"}]}
=== Hasil validasi detail ===
nama: Nama minimal 3 karakter
email: Email tidak valid
umur: Umur minimal 13 tahunMari kita bedah bagian penting kode di atas:
data class ValidationError(val field, val message): pasangan field dan pesan error. Fieldfieldmengidentifikasi property input yang bermasalah.class ValidationResult { val errors; val isValid }: akumulator error. PropertyisValidadalah computed property (get() = errors.isEmpty()) yang selalu sinkron dengan daftar error.if (req.nama.isBlank()) ... else if (req.nama.length < 3): validasi berurutan — pertama cek kosong, lalu cek panjang. Polaelse ifmemastikan hanya satu pesan per field, tidak duplikat.if (!req.email.contains("@") || !req.email.contains(".")): validasi format email sederhana. Pada produksi, gunakan regex atau library khusus untuk validasi RFC 5322.formatProblem(p): serializer manual ke JSON yang mengikuti RFC 7807. Pada produksi,kotlinx.serializationakan menangani serialisasi otomatis.
Validasi vs Aturan Bisnis #
Validasi di lapisan DTO berbeda dengan aturan bisnis di lapisan service. Validasi memeriksa format input (apakah email valid, apakah umur >= 13). Aturan bisnis memeriksa konsistensi domain (apakah user dengan email ini sudah ada, apakah stok mencukupi). Validasi mengembalikan 400, aturan bisnis mengembalikan 409 Conflict atau 422 Unprocessable Entity.
Referensi Lanjutan #
- Ktor requests — panduan resmi membaca dan memvalidasi parameter request di Ktor
- Ktor routing
— panduan resmi struktur route sebagai tempat meletakkan validasi
receive()
Selanjutnya #
Setelah memvalidasi input, langkah berikutnya adalah menangani error yang tetap lolos. Chapter Ktor Error Handling akan membahas plugin StatusPages yang memetakan exception menjadi respons HTTP yang konsisten.