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.

json Contoh JSON Schema (ringkas)
{
  "$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.

kotlin
// 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:

text
=== 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 konfigurasi

Mari kita bedah bagian kunci dari kode di atas:

  1. data class FieldSchema: model satu field di schema. Property type, required, minLength, maxLength, minimum, maximum, pattern, format, enumValues, dan arrayItems. Inilah atribut yang didukung JSON Schema draft standar.
  2. data class JsonSchema: model schema lengkap dengan title dan daftar fields. Pada library nyata, schema dapat berupa nested object—di sini kita fokus pada flat object untuk demonstrasi.
  3. sealed class JsonValue: model JSON value dengan enam subclass. Parser sederhana mengubah string JSON menjadi tree JsonValue.Obj.
  4. JsonParser: recursive descent parser yang sudah kita lihat pada chapter file-io. Mem-parse string JSON menjadi JsonValue.Obj root. Bila JSON rusak, exception dilempar dan ditangani validator.
  5. JsonSchemaValidator.validate(jsonText): langkah utama. Pertama, parse JSON—gagal = return Gagal. Kedua, iterasi schema.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.
  6. 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.
  7. 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.

ManfaatTanpa SchemaDengan Schema
Field wajibNullPointerException saat aksesPesan error saat validasi
Tipe salahClassCastException saat castPesan error saat validasi
Range nilaiBug bisnis tak terdugaTolak di boundary
Format email/dateBug saat kirim emailTolak di boundary
EnumBug logika bisnisTolak di boundary
DokumentasiTersebar di kodeSatu file schema

Library untuk Produksi #

Untuk produksi, library berikut populer di JVM:

LibraryUkuranFitur
everit-org/json-schemaSedangDraft 4/6/7/2019-09
networknt/json-schema-validatorRinganDraft 2019-09, performant
com.fasterxml.jackson.module:json-schemaBeratJackson 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.serialization sebagai 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.