Mengonversi objek Kotlin ke JSON secara manual lewat string interpolation adalah sumber bug yang sering muncul: kutipan yang tidak di-escape, angka yang salah dikonversi, dan field yang terlupa. Plugin ContentNegotiation Ktor mengatasi hal tersebut dengan mendelegasikan serialisasi ke serializer yang terdaftar per tipe. Pada chapter ini kita akan memahami cara kerja ContentNegotiation dan memodelkannya dalam program Kotlin yang dapat dijalankan tanpa dependensi eksternal.

Plugin ContentNegotiation #

ContentNegotiation adalah plugin Ktor yang ditempatkan pada fase Content pipeline. Saat handler memanggil call.respond(objek), plugin mencari serializer terdaftar untuk tipe objek tersebut. Jika ditemukan, plugin mengonversi objek ke JSON (atau format lain seperti XML/CBOR), mengatur header Content-Type: application/json, dan menulis body. Sebaliknya, saat handler memanggil call.receive<T>(), plugin membaca body request dan mengonversi JSON kembali ke objek T.

Pada kode produksi, pemasangan plugin terlihat seperti berikut:

kotlin install(ContentNegotiation)
install(ContentNegotiation) {
    json(Json {
        ignoreUnknownKeys = true
        prettyPrint = false
    })
}

Blok json { } adalah konfigurasi khusus untuk serializer JSON. Ktor mendukung beberapa serializer: kotlinx.serialization, Gson, dan Jackson. kotlinx.serialization adalah pilihan default karena native Kotlin dan menghasilkan kode compile-time safe.

Serializer per Tipe #

ContentNegotiation memelihara registry yang memetakan tipe Kotlin ke serializer. Saat call.respond(Pengguna(1, "Budi", "[email protected]")) dipanggil, plugin mencari JsonSerializer<Pengguna> di registry. Jika tidak ada, plugin melempar IllegalStateException.

Registry ini diisi otomatis oleh kompilator kotlinx.serialization saat anotasi @Serializable ditemukan di class. Kompilator menghasilkan Pengguna$$serializer yang dipanggil oleh plugin — proses ini transparan bagi pengembang.

Demo ContentNegotiation #

Karena kotlinx.serialization nyata membutuhkan kompilator plugin, kita akan memodelkan ContentNegotiation sebagai class Kotlin biasa dengan registry manual. Program ini mendaftarkan PenggunaSerializer dan kemudian memakainya untuk serialize/deserialize.

kotlin
// ktor_content_negotiation.kt
// Mendemonstrasikan plugin ContentNegotiation pada Ktor yang mengonversi
// objek Kotlin <-> JSON otomatis. Pada kode produksi, plugin memakai
// kotlinx.serialization atau Gson. Di sini kita memakai parser JSON manual.

import kotlin.reflect.KClass

// Antarmuka serialisasi (mirip kotlinx.serialization KSerializer)
interface JsonSerializer<T> {
    fun serialize(value: T): String
    fun deserialize(text: String): T
}

// Mock data class
data class Pengguna(val id: Int, val nama: String, val email: String)

// Serializer sederhana untuk Pengguna
object PenggunaSerializer : JsonSerializer<Pengguna> {
    override fun serialize(value: Pengguna): String {
        return """{"id":${value.id},"nama":"${value.nama}","email":"${value.email}"}"""
    }
    override fun deserialize(text: String): Pengguna {
        // Parser regex sederhana untuk demo
        val idMatch = Regex(""""id":(\d+)""").find(text)?.groupValues?.get(1)?.toInt() ?: 0
        val namaMatch = Regex(""""nama":"([^"]+)"""").find(text)?.groupValues?.get(1) ?: ""
        val emailMatch = Regex(""""email":"([^"]+)"""").find(text)?.groupValues?.get(1) ?: ""
        return Pengguna(idMatch, namaMatch, emailMatch)
    }
}

// Plugin ContentNegotiation mendaftarkan serializer per tipe
class ContentNegotiation {
    private val serializers = mutableMapOf<KClass<*>, JsonSerializer<*>>()

    fun <T : Any> register(kclass: KClass<T>, serializer: JsonSerializer<T>) {
        serializers[kclass] = serializer
    }

    inline fun <reified T : Any> register(serializer: JsonSerializer<T>) {
        register(T::class, serializer)
    }

    @Suppress("UNCHECKED_CAST")
    fun <T : Any> serialize(value: T): String {
        val serializer = serializers[value::class] as? JsonSerializer<T>
            ?: throw IllegalStateException("Tidak ada serializer untuk ${value::class}")
        return serializer.serialize(value)
    }

    @Suppress("UNCHECKED_CAST")
    fun <T : Any> deserialize(text: String, kclass: KClass<T>): T {
        val serializer = serializers[kclass] as? JsonSerializer<T>
            ?: throw IllegalStateException("Tidak ada serializer untuk $kclass")
        return serializer.deserialize(text)
    }
}

// Simulasi ApplicationCall.respond(value)
fun ApplicationCall.respondJson(value: Any, negotiation: ContentNegotiation) {
    val json = negotiation.serialize(value)
    responseBody = json
    responseHeaders["Content-Type"] = "application/json"
}

fun ApplicationCall.receiveJson(negotiation: ContentNegotiation, kclass: KClass<*>): Any {
    return negotiation.deserialize(requestBody, kclass)
}

class ApplicationCall(
    val method: String = "GET",
    val path: String = "/",
    val requestBody: String = ""
) {
    var responseStatus: Int = 200
    var responseHeaders: MutableMap<String, String> = mutableMapOf()
    var responseBody: String = ""
}

fun main() {
    val negotiation = ContentNegotiation()
    negotiation.register<Pengguna>(PenggunaSerializer)

    println("=== Serialize Pengguna ke JSON ===")
    val budi = Pengguna(1, "Budi", "[email protected]")
    val call1 = ApplicationCall()
    call1.respondJson(budi, negotiation)
    println("Body: ${call1.responseBody}")
    println("Content-Type: ${call1.responseHeaders["Content-Type"]}")

    println("\n=== Deserialize JSON ke Pengguna ===")
    val json = """{"id":2,"nama":"Siti","email":"[email protected]"}"""
    val call2 = ApplicationCall(method = "POST", path = "/users", requestBody = json)
    val received: Pengguna = call2.receiveJson(negotiation, Pengguna::class) as Pengguna
    println("Received: $received")
    println("Nama: ${received.nama}, Email: ${received.email}")
}

Output yang Dihasilkan:

text
=== Serialize Pengguna ke JSON ===
Body: {"id":1,"nama":"Budi","email":"[email protected]"}
Content-Type: application/json

=== Deserialize JSON ke Pengguna ===
Received: Pengguna(id=2, nama=Siti, [email protected])
Nama: Siti, Email: [email protected]

Mari kita bedah bagian penting kode di atas:

  1. interface JsonSerializer<T>: kontrak serializer dengan dua method: serialize (objek ke JSON) dan deserialize (JSON ke objek). Generic T memungkinkan satu interface untuk banyak tipe.
  2. object PenggunaSerializer : JsonSerializer<Pengguna>: implementasi konkret untuk Pengguna. Menggunakan regex untuk parsing JSON — pada produksi, kotlinx.serialization menghasilkan parser yang efisien dan benar secara format.
  3. inline fun <reified T : Any> register(serializer): mendaftar serializer dengan tipe T yang ditentukan kompilator. reified memungkinkan akses T::class pada runtime — fitur yang dipakai kotlinx.serialization untuk mencari serializer.
  4. fun serialize(value: T): String: lookup serializer berdasarkan value::class. Bila tidak ada, lempar IllegalStateException dengan pesan yang menjelaskan tipe yang hilang.
  5. call2.receiveJson(negotiation, Pengguna::class): simulasi call.receive<Pengguna>(). Pada Ktor nyata, reified membuat pemanggilan tidak perlu Pengguna::class eksplisit.

Content-Type Negotiation #

Sesuai namanya, ContentNegotiation sebenarnya bernegosiasi dengan klien berdasarkan header Accept dan Content-Type. Jika klien mengirim Accept: application/xml, plugin akan memilih serializer XML (jika terdaftar). Untuk sebagian besar API modern, JSON adalah format tunggal, sehingga negosiasi terlihat sederhana — tetapi arsitekturnya tetap mendukung multi-format.

Referensi Lanjutan #

Selanjutnya #

Setelah memahami mekanisme ContentNegotiation, langkah berikutnya adalah memahami sisi serializer itu sendiri. Chapter Ktor Serialization akan membahas kotlinx.serialization, anotasi @Serializable, dan bagaimana kompilator menghasilkan serializer otomatis.