Saat aplikasi menerima JSON dari sumber eksternal—REST API client, message queue, atau file konfigurasi—kita perlu memvalidasi sebelum memproses. Tanpa validasi, field hilang atau tipe salah dapat menyebabkan bug runtime, atau lebih buruk, celah keamanan. JSON Schema adalah standar industri untuk mendeskripsikan kontrak JSON—tipe field, field wajib, range nilai, format (email, date), enum, dan batasan ukuran. Library seperti everit-org/json-schema atau networknt/json-schema-validator menyediakan validator untuk JVM. Pada chapter ini kita akan memahami konsep JSON Schema, melihat validator manual yang menangani semua aturan umum, dan bagaimana validasi schema melindungi aplikasi dari input tidak sah.
Apa Itu JSON Schema #
JSON Schema adalah dokumen JSON yang mendeskripsikan kontrak JSON lain. Schema mendefinisikan: tipe setiap field (string, integer, number, boolean, array, object), field wajib (required), batasan nilai (minimum, maximum, minLength, maxLength), format (email, date, uri), enum (nilai yang diperbolehkan), dan struktur nested. Validator membandingkan JSON input terhadap schema dan melaporkan setiap pelanggaran.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "PendaftaranPengguna",
"type": "object",
"properties": {
"nama": {"type": "string", "minLength": 3, "maxLength": 50},
"email": {"type": "string", "format": "email"},
"umur": {"type": "integer", "minimum": 13, "maximum": 120},
"peran": {"type": "string", "enum": ["admin", "editor", "viewer"]}
},
"required": ["nama", "email", "umur", "peran"]
}Schema di atas menyatakan: object harus punya empat field wajib; nama 3-50 karakter; email format email valid; umur integer 13-120; peran salah satu dari admin, editor, viewer.
Validator Manual #
Demo berikut menunjukkan validator JSON Schema yang menangani semua aturan umum—tipe, required, range, format, enum, dan deteksi field tidak dikenali. Library produksi seperti everit-org/json-schema menyediakan fungsionalitas yang sama dengan dukungan penuh draft JSON Schema.
// json_schema_validation.kt
// Validasi JSON terhadap schema: manual + simulasi json-schema.
// Demo: validator untuk tipe, required fields, format, dan ukuran.
import java.time.LocalDate
import java.time.format.DateTimeParseException
// --- Model skema ---
data class FieldSchema(
val name: String,
val type: String, // "string", "integer", "number", "boolean", "array", "object"
val required: Boolean,
val minLength: Int? = null,
val maxLength: Int? = null,
val minimum: Long? = null,
val maximum: Long? = null,
val pattern: String? = null,
val format: String? = null, // "date", "email", "uri"
val enumValues: List<String>? = null,
val arrayItems: FieldSchema? = null
)
data class JsonSchema(
val title: String,
val fields: List<FieldSchema>
)
// --- Hasil validasi ---
sealed class HasilValidasi {
data class OK(val data: Map<String, Any?>) : HasilValidasi()
data class Gagal(val errors: List<String>) : HasilValidasi()
}
// --- Parser JSON sederhana (sudah ada di chapter sebelumnya, kita ringkas) ---
sealed class JsonValue {
data class Str(val v: String) : JsonValue()
data class Num(val v: Double) : JsonValue()
data class Bool(val v: Boolean) : JsonValue()
data class Arr(val items: List<JsonValue>) : JsonValue()
data class Obj(val entries: Map<String, JsonValue>) : JsonValue()
object Null : JsonValue()
}
object JsonParser {
fun parseObject(text: String): JsonValue.Obj {
val parser = Parser(text)
parser.skipWs()
val v = parser.parseValue()
return v as? JsonValue.Obj ?: throw IllegalArgumentException("Root harus object")
}
private class Parser(private val src: String) {
private var pos = 0
fun skipWs() { while (pos < src.length && src[pos].isWhitespace()) pos++ }
fun parseValue(): JsonValue {
skipWs()
return when (peek()) {
'"' -> JsonValue.Str(parseString())
't', 'f' -> parseBool()
'n' -> parseNull()
'[' -> parseArray()
'{' -> parseObject()
else -> if (peek() == '-' || peek().isDigit()) parseNumber()
else throw IllegalArgumentException("Unexpected: ${peek()}")
}
}
private fun parseString(): String {
pos++
val sb = StringBuilder()
while (peek() != '"') {
val c = src[pos++]
if (c == '\\') {
val e = src[pos++]
sb.append(when (e) {
'n' -> '\n'; 't' -> '\t'; '"' -> '"'; '\\' -> '\\'
'/' -> '/'; else -> e
})
} else sb.append(c)
}
pos++
return sb.toString()
}
private fun parseNumber(): JsonValue.Num {
val start = pos
if (peek() == '-') pos++
while (pos < src.length && (src[pos].isDigit() || src[pos] in ".eE+-")) pos++
return JsonValue.Num(src.substring(start, pos).toDouble())
}
private fun parseBool(): JsonValue.Bool {
if (src.startsWith("true", pos)) { pos += 4; return JsonValue.Bool(true) }
if (src.startsWith("false", pos)) { pos += 5; return JsonValue.Bool(false) }
throw IllegalArgumentException("Invalid bool")
}
private fun parseNull(): JsonValue.Null {
if (src.startsWith("null", pos)) { pos += 4; return JsonValue.Null }
throw IllegalArgumentException("Invalid null")
}
private fun parseArray(): JsonValue.Arr {
pos++
skipWs()
val items = mutableListOf<JsonValue>()
if (peek() == ']') { pos++; return JsonValue.Arr(items) }
while (true) {
items.add(parseValue())
skipWs()
when (peek()) {
',' -> { pos++; skipWs() }
']' -> { pos++; return JsonValue.Arr(items) }
else -> throw IllegalArgumentException("Expected , or ]")
}
}
}
private fun parseObject(): JsonValue.Obj {
pos++
skipWs()
val entries = mutableMapOf<String, JsonValue>()
if (peek() == '}') { pos++; return JsonValue.Obj(entries) }
while (true) {
skipWs()
val key = parseString()
skipWs()
if (peek() != ':') throw IllegalArgumentException("Expected :")
pos++
val value = parseValue()
entries[key] = value
skipWs()
when (peek()) {
',' -> { pos++; skipWs() }
'}' -> { pos++; return JsonValue.Obj(entries) }
else -> throw IllegalArgumentException("Expected , or }")
}
}
}
private fun peek(): Char = src[pos]
}
}
// --- Validator ---
class JsonSchemaValidator(private val schema: JsonSchema) {
fun validate(jsonText: String): HasilValidasi {
val errors = mutableListOf<String>()
val data = mutableMapOf<String, Any?>()
val root: JsonValue.Obj
try {
root = JsonParser.parseObject(jsonText)
} catch (e: Exception) {
return HasilValidasi.Gagal(listOf("JSON tidak valid: ${e.message}"))
}
// Cek semua field di schema
for (field in schema.fields) {
val raw = root.entries[field.name]
if (raw == null || raw is JsonValue.Null) {
if (field.required) {
errors.add("Field '${field.name}' wajib ada")
}
continue
}
val error = validateField(field, raw)
if (error != null) {
errors.add("Field '${field.name}': $error")
} else {
data[field.name] = extractValue(field, raw)
}
}
// Cek field yang tidak ada di schema (unknown)
for (key in root.entries.keys) {
if (schema.fields.none { it.name == key }) {
errors.add("Field '$key' tidak dikenali (tidak ada di schema)")
}
}
return if (errors.isEmpty()) HasilValidasi.OK(data) else HasilValidasi.Gagal(errors)
}
private fun validateField(field: FieldSchema, value: JsonValue): String? {
// Cek tipe
val typeOk = when (field.type) {
"string" -> value is JsonValue.Str
"integer" -> value is JsonValue.Num && value.v % 1.0 == 0.0
"number" -> value is JsonValue.Num
"boolean" -> value is JsonValue.Bool
"array" -> value is JsonValue.Arr
else -> true
}
if (!typeOk) return "harus bertipe ${field.type}"
when (value) {
is JsonValue.Str -> {
val s = value.v
if (field.minLength != null && s.length < field.minLength)
return "minimal ${field.minLength} karakter (saat ini ${s.length})"
if (field.maxLength != null && s.length > field.maxLength)
return "maksimal ${field.maxLength} karakter (saat ini ${s.length})"
if (field.pattern != null && !Regex(field.pattern).matches(s))
return "tidak cocok dengan pattern ${field.pattern}"
if (field.enumValues != null && s !in field.enumValues)
return "harus salah satu dari ${field.enumValues}"
if (field.format == "date") {
try { LocalDate.parse(s) } catch (e: DateTimeParseException) {
return "format tanggal tidak valid (yyyy-MM-dd)"
}
}
if (field.format == "email" && !Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$").matches(s))
return "format email tidak valid"
}
is JsonValue.Num -> {
if (field.minimum != null && value.v < field.minimum)
return "harus >= ${field.minimum}"
if (field.maximum != null && value.v > field.maximum)
return "harus <= ${field.maximum}"
}
is JsonValue.Arr -> {
if (field.arrayItems != null) {
for ((i, item) in value.items.withIndex()) {
val e = validateField(field.arrayItems, item)
if (e != null) return "item [$i]: $e"
}
}
}
else -> {}
}
return null
}
private fun extractValue(field: FieldSchema, value: JsonValue): Any? = when (value) {
is JsonValue.Str -> value.v
is JsonValue.Num -> if (field.type == "integer") value.v.toLong() else value.v
is JsonValue.Bool -> value.v
is JsonValue.Arr -> value.items.map { extractValue(field.arrayItems ?: field, it) }
else -> null
}
}
// --- Demo ---
fun main() {
println("=== JSON Schema Validation ===\n")
// Definisi schema
val schema = JsonSchema(
title = "PendaftaranPengguna",
fields = listOf(
FieldSchema("nama", "string", required = true, minLength = 3, maxLength = 50),
FieldSchema("email", "string", required = true, format = "email"),
FieldSchema("umur", "integer", required = true, minimum = 13, maximum = 120),
FieldSchema("peran", "string", required = true,
enumValues = listOf("admin", "editor", "viewer")),
FieldSchema("tanggal_lahir", "string", required = false, format = "date"),
FieldSchema("hobi", "array", required = false,
arrayItems = FieldSchema("hobi", "string", required = true))
)
)
val validator = JsonSchemaValidator(schema)
val testCases = listOf(
"valid lengkap" to """{"nama":"Budi Santoso","email":"[email protected]","umur":28,"peran":"admin","tanggal_lahir":"1997-04-15","hobi":["coding","musik"]}""",
"valid minimal" to """{"nama":"Siti","email":"[email protected]","umur":35,"peran":"editor"}""",
"nama terlalu pendek" to """{"nama":"Bo","email":"[email protected]","umur":20,"peran":"viewer"}""",
"email tidak valid" to """{"nama":"Budi","email":"bukan-email","umur":25,"peran":"admin"}""",
"umur di bawah minimum" to """{"nama":"Budi","email":"[email protected]","umur":10,"peran":"viewer"}""",
"peran tidak dikenal" to """{"nama":"Budi","email":"[email protected]","umur":25,"peran":"hacker"}""",
"field wajib hilang" to """{"nama":"Budi","email":"[email protected]"}""",
"field tidak dikenali" to """{"nama":"Budi","email":"[email protected]","umur":25,"peran":"admin","secret":"data"}""",
"tanggal lahir format salah" to """{"nama":"Budi","email":"[email protected]","umur":25,"peran":"admin","tanggal_lahir":"15/04/1997"}""",
"JSON rusak" to """{"nama":"Budi", umur: 25}"""
)
for ((label, json) in testCases) {
println("--- Kasus: $label ---")
println("Input: $json")
when (val hasil = validator.validate(json)) {
is HasilValidasi.OK -> {
println("Status: VALID")
println("Data : ${hasil.data}")
}
is HasilValidasi.Gagal -> {
println("Status: TIDAK VALID")
for (e in hasil.errors) println(" - $e")
}
}
println()
}
println("=== Ringkasan manfaat validasi schema ===")
println("1. Tolak input tidak sah SEBELUM diproses (defense in depth)")
println("2. Pesan error jelas membantu konsumen API memperbaiki input")
println("3. Skema menjadi dokumentasi kontrak API")
println("4. Mencegah bug runtime akibat field hilang atau tipe salah")
println("5. Pola yang sama untuk REST API, message queue, dan file konfigurasi")
}Output yang Dihasilkan:
=== JSON Schema Validation ===
--- Kasus: valid lengkap ---
Input: {"nama":"Budi Santoso","email":"[email protected]","umur":28,"peran":"admin","tanggal_lahir":"1997-04-15","hobi":["coding","musik"]}
Status: VALID
Data : {nama=Budi Santoso, [email protected], umur=28, peran=admin, tanggal_lahir=1997-04-15, hobi=[coding, musik]}
--- Kasus: valid minimal ---
Input: {"nama":"Siti","email":"[email protected]","umur":35,"peran":"editor"}
Status: VALID
Data : {nama=Siti, [email protected], umur=35, peran=editor}
--- Kasus: nama terlalu pendek ---
Input: {"nama":"Bo","email":"[email protected]","umur":20,"peran":"viewer"}
Status: TIDAK VALID
- Field 'nama': minimal 3 karakter (saat ini 2)
--- Kasus: email tidak valid ---
Input: {"nama":"Budi","email":"bukan-email","umur":25,"peran":"admin"}
Status: TIDAK VALID
- Field 'email': format email tidak valid
--- Kasus: umur di bawah minimum ---
Input: {"nama":"Budi","email":"[email protected]","umur":10,"peran":"viewer"}
Status: TIDAK VALID
- Field 'umur': harus >= 13
--- Kasus: peran tidak dikenal ---
Input: {"nama":"Budi","email":"[email protected]","umur":25,"peran":"hacker"}
Status: TIDAK VALID
- Field 'peran': harus salah satu dari [admin, editor, viewer]
--- Kasus: field wajib hilang ---
Input: {"nama":"Budi","email":"[email protected]"}
Status: TIDAK VALID
- Field 'umur' wajib ada
- Field 'peran' wajib ada
--- Kasus: field tidak dikenali ---
Input: {"nama":"Budi","email":"[email protected]","umur":25,"peran":"admin","secret":"data"}
Status: TIDAK VALID
- Field 'secret' tidak dikenali (tidak ada di schema)
--- Kasus: tanggal lahir format salah ---
Input: {"nama":"Budi","email":"[email protected]","umur":25,"peran":"admin","tanggal_lahir":"15/04/1997"}
Status: TIDAK VALID
- Field 'tanggal_lahir': format tanggal tidak valid (yyyy-MM-dd)
--- Kasus: JSON rusak ---
Input: {"nama":"Budi", umur: 25}
Status: TIDAK VALID
- JSON tidak valid: Index 25 out of bounds for length 25
=== Ringkasan manfaat validasi schema ===
1. Tolak input tidak sah SEBELUM diproses (defense in depth)
2. Pesan error jelas membantu konsumen API memperbaiki input
3. Skema menjadi dokumentasi kontrak API
4. Mencegah bug runtime akibat field hilang atau tipe salah
5. Pola yang sama untuk REST API, message queue, dan file konfigurasiMari kita bedah bagian kunci dari kode di atas:
data class FieldSchema: model satu field di schema. Propertytype,required,minLength,maxLength,minimum,maximum,pattern,format,enumValues, danarrayItems. Inilah atribut yang didukung JSON Schema draft standar.data class JsonSchema: model schema lengkap dengantitledan daftarfields. Pada library nyata, schema dapat berupa nested object—di sini kita fokus pada flat object untuk demonstrasi.sealed class JsonValue: model JSON value dengan enam subclass. Parser sederhana mengubah string JSON menjadi treeJsonValue.Obj.JsonParser: recursive descent parser yang sudah kita lihat pada chapter file-io. Mem-parse string JSON menjadiJsonValue.Objroot. Bila JSON rusak, exception dilempar dan ditangani validator.JsonSchemaValidator.validate(jsonText): langkah utama. Pertama, parse JSON—gagal = returnGagal. Kedua, iterasischema.fields: cek field wajib, cek tipe, cek range/format/enum. Ketiga, cek field tidak dikenali yang tidak ada di schema. Bila ada error, kembalikan daftar pesan.validateField(field, value): mengecek satu field. Pertama validasi tipe (string/integer/number/boolean/array). Kedua, untuk string—cek length, pattern, enum, format (date/email). Ketiga, untuk number—cek minimum/maximum. Keempat, untuk array—rekursi cek setiap item.- Output menunjukkan sepuluh test case: valid lengkap, valid minimal, nama terlalu pendek, email tidak valid, umur di bawah minimum, peran tidak dikenal, field wajib hilang, field tidak dikenali, tanggal lahir format salah, JSON rusak. Setiap kasus menghasilkan pesan error spesifik yang membantu konsumen API memperbaiki input.
Mengapa Validasi Schema Penting #
Tanpa validasi schema, kita bergantung pada exception runtime saat mengakses field yang tidak ada atau bertipe salah. Masalahnya, exception ini mungkin tidak muncul saat development—hanya saat produksi dengan data asli. Validasi schema memberikan beberapa manfaat: pertama, tolak input tidak sah sebelum diproses—defense in depth. Kedua, pesan error jelas membantu konsumen API memperbaiki input. Ketiga, schema menjadi dokumentasi kontrak API. Keempat, mencegah bug runtime akibat field hilang atau tipe salah.
| Manfaat | Tanpa Schema | Dengan Schema |
|---|---|---|
| Field wajib | NullPointerException saat akses | Pesan error saat validasi |
| Tipe salah | ClassCastException saat cast | Pesan error saat validasi |
| Range nilai | Bug bisnis tak terduga | Tolak di boundary |
| Format email/date | Bug saat kirim email | Tolak di boundary |
| Enum | Bug logika bisnis | Tolak di boundary |
| Dokumentasi | Tersebar di kode | Satu file schema |
Library untuk Produksi #
Untuk produksi, library berikut populer di JVM:
| Library | Ukuran | Fitur |
|---|---|---|
everit-org/json-schema | Sedang | Draft 4/6/7/2019-09 |
networknt/json-schema-validator | Ringan | Draft 2019-09, performant |
com.fasterxml.jackson.module:json-schema | Berat | Jackson integration |
Library kotlinx.serialization sendiri tidak menyediakan JSON Schema validation—library ini fokus pada serialization. Namun, kita dapat menggabungkan keduanya: validasi dengan networknt, lalu deserialize dengan kotlinx.serialization.
Referensi Lanjutan #
- kotlinx.serialization
— halaman proyek
kotlinx.serializationsebagai basis library validasi berbasis skema
Penutup #
JSON Schema validation melindungi aplikasi dari input tidak sah. Aturan—tipe, required, range, format, enum—didefinisikan di satu file schema; validator memeriksa setiap field dan melaporkan pelanggaran. Library produksi seperti everit-org/json-schema atau networknt menyediakan implementasi lengkap. Dengan validasi di boundary, kode bisnis dapat fokus pada logika tanpa defensif berlebihan. Chapter berikutnya akan beralih dari JSON ke CSV—format yang lebih sederhana tetapi tetap membutuhkan parsing yang hati-hati.