Aplikasi modern selalu membutuhkan HTTP client—untuk panggilan REST API, unduh file, atau WebSocket. Di proyek native, kita menulis kode HTTP berbeda per platform: OkHttp di Android, NSURLSession di iOS, HttpURLConnection di JVM. KMP dengan ktor-client menyederhanakan ini: kode HTTP ditulis sekali di commonMain, engine dipilih per platform. Pada chapter ini kita akan melihat arsitektur ktor-client, cara mendeklarasikan engine per source set, dan demonstrasi tiga engine berbeda melayani satu API service.

Arsitektur ktor-client #

ktor-client terdiri dari dua lapisan. Lapisan atas berisi kode HTTP yang bersama—HttpClient, plugin seperti ContentNegotiation, Auth, Logging. Lapisan bawah berisi engine yang spesifik platform—OkHttp untuk JVM dan Android, Darwin untuk iOS, Js untuk browser. Konsumen kode commonMain cukup memakai HttpClient tanpa peduli engine apa yang aktif.

kotlin commonMain: kode HTTP bersama
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*

class ApiService(private val client: HttpClient) {
    suspend fun ambilPengguna(id: Int): String {
        return client.get("https://api.example.com/pengguna/$id").bodyAsText()
    }
}

Lapisan atas ini sama di semua target. Engine yang dipilih saat konstruksi HttpClient menentukan implementasi bawah.

Engine per Platform #

Engine ditentukan saat konstruksi HttpClient. Konfigurasi ini biasanya berada di source set spesifik karena setiap engine memiliki class yang berbeda.

kotlin androidMain: engine OkHttp
import io.ktor.client.engine.okhttp.OkHttp

fun buatHttpClient(): HttpClient = HttpClient(OkHttp) {
    engine { config { retryOnConnectionFailure(true) } }
}
kotlin iosMain: engine Darwin
import io.ktor.client.engine.darwin.Darwin

fun buatHttpClient(): HttpClient = HttpClient(Darwin) {
    engine { configureRequest { setAllowsCellularAccess(true) } }
}

Engine yang dipilih otomatis mengimplementasikan interface HttpEngineFactory di ktor-client-core. Kode commonMain hanya melihat HttpClient, tidak peduli engine.

Simulasi HTTP Client Multiplatform #

Demo berikut mensimulasikan arsitektur ktor-client dengan interface HttpEngine dan tiga implementasi. Pemilihan engine dilakukan secara eksplisit di main() untuk simulasi; di proyek nyata, ini dilakukan per source set via expect/actual.

kotlin
// multiplatform_networking.kt
// ktor-client untuk KMP: HTTP code di commonMain, engine per platform.
// Demo: simulasi HTTP client dengan engine engine-interface + 3 implementasi.

// --- commonMain: abstraksi engine dan HTTP client ---

sealed class HttpRespon {
    data class Sukses(val status: Int, val body: String) : HttpRespon()
    data class Gagal(val status: Int, val pesan: String) : HttpRespon()
    data class Error(val exception: Throwable) : HttpRespon()
}

data class HttpPermintaan(
    val url: String,
    val method: String = "GET",
    val headers: Map<String, String> = emptyMap(),
    val body: String? = null
)

interface HttpEngine {
    val nama: String
    fun kirim(permintaan: HttpPermintaan): HttpRespon
}

// Ktor Client: kelas di commonMain yang memakai engine
class KtorClient(private val engine: HttpEngine) {
    fun get(url: String, headers: Map<String, String> = emptyMap()): HttpRespon {
        return engine.kirim(HttpPermintaan(url, "GET", headers))
    }

    fun post(url: String, body: String, headers: Map<String, String> = emptyMap()): HttpRespon {
        return engine.kirim(HttpPermintaan(url, "POST", headers, body))
    }

    fun getJson(url: String): HttpRespon {
        return get(url, mapOf("Accept" to "application/json"))
    }
}

// --- commonMain: logika bisnis HTTP ---
class ApiService(private val client: KtorClient, private val baseUrl: String) {
    data class Pengguna(val id: Int, val nama: String)

    fun ambilPengguna(id: Int): Pengguna? {
        val resp = client.getJson("$baseUrl/api/pengguna/$id")
        return when (resp) {
            is HttpRespon.Sukses -> parsePengguna(resp.body)
            else -> null
        }
    }

    private fun parsePengguna(body: String): Pengguna? {
        // Parser manual sederhana untuk JSON {"id":1,"nama":"Budi"}
        val idMatch = Regex("\"id\"\\s*:\\s*(\\d+)").find(body)
        val namaMatch = Regex("\"nama\"\\s*:\\s*\"([^\"]+)\"").find(body)
        if (idMatch != null && namaMatch != null) {
            return Pengguna(idMatch.groupValues[1].toInt(), namaMatch.groupValues[1])
        }
        return null
    }
}

// --- Platform engines ---

class OkHttpEngine(private val mockResponses: Map<String, String>) : HttpEngine {
    override val nama: String = "OkHttp"
    override fun kirim(permintaan: HttpPermintaan): HttpRespon {
        // Di JVM/Android nyata: okhttp3.OkHttpClient.newCall(Request)
        println("  [OkHttp] ${permintaan.method} ${permintaan.url}")
        val body = mockResponses[permintaan.url]
        return if (body != null) {
            HttpRespon.Sukses(200, body)
        } else {
            HttpRespon.Gagal(404, "Not Found")
        }
    }
}

class DarwinEngine(private val mockResponses: Map<String, String>) : HttpEngine {
    override val nama: String = "Darwin"
    override fun kirim(permintaan: HttpPermintaan): HttpRespon {
        // Di iOS nyata: Foundation.NSURLSession
        println("  [Darwin] ${permintaan.method} ${permintaan.url}")
        val body = mockResponses[permintaan.url]
        return if (body != null) {
            HttpRespon.Sukses(200, body)
        } else {
            HttpRespon.Gagal(404, "Not Found")
        }
    }
}

class MockEngine(private val mockResponses: Map<String, String>) : HttpEngine {
    override val nama: String = "Mock"
    override fun kirim(permintaan: HttpPermintaan): HttpRespon {
        println("  [Mock] ${permintaan.method} ${permintaan.url}")
        val body = mockResponses[permintaan.url]
        return if (body != null) {
            HttpRespon.Sukses(200, body)
        } else {
            HttpRespon.Gagal(404, "Not Found")
        }
    }
}

// --- Demo ---

fun main() {
    val mockResponses = mapOf(
        "https://api.example.com/api/pengguna/1" to """{"id":1,"nama":"Budi"}""",
        "https://api.example.com/api/pengguna/2" to """{"id":2,"nama":"Siti"}""",
        "https://api.example.com/api/pengguna/3" to """{"id":3,"nama":"Dewi"}"""
    )

    val engines = listOf<Pair<String, HttpEngine>>(
        "JVM" to OkHttpEngine(mockResponses),
        "Android" to OkHttpEngine(mockResponses),
        "iOS" to DarwinEngine(mockResponses)
    )

    println("=== Multiplatform HTTP client (ktor-client simulasi) ===\n")

    for ((platform, engine) in engines) {
        println("--- Platform: $platform ---")
        val client = KtorClient(engine)
        val api = ApiService(client, "https://api.example.com")

        for (id in 1..3) {
            val pengguna = api.ambilPengguna(id)
            println("  Pengguna(id=$id) = $pengguna")
        }

        // Test 404
        val tidakAda = api.ambilPengguna(999)
        println("  Pengguna(id=999) = $tidakAda")
        println()
    }

    // Demo test dengan MockEngine
    println("--- Pengujian dengan MockEngine ---")
    val testClient = KtorClient(MockEngine(mockResponses))
    val testApi = ApiService(testClient, "https://api.example.com")
    val uji = testApi.ambilPengguna(1)
    println("  Hasil test: $uji")
    val ujiNotFound = testApi.ambilPengguna(999)
    println("  Test 404: ${ujiNotFound ?: "(null sesuai harapan)"}")
}

Output yang Dihasilkan:

text
=== Multiplatform HTTP client (ktor-client simulasi) ===

--- Platform: JVM ---
  [OkHttp] GET https://api.example.com/api/pengguna/1
  Pengguna(id=1) = Pengguna(id=1, nama=Budi)
  [OkHttp] GET https://api.example.com/api/pengguna/2
  Pengguna(id=2) = Pengguna(id=2, nama=Siti)
  [OkHttp] GET https://api.example.com/api/pengguna/3
  Pengguna(id=3) = Pengguna(id=3, nama=Dewi)
  [OkHttp] GET https://api.example.com/api/pengguna/999
  Pengguna(id=999) = null

--- Platform: Android ---
  [OkHttp] GET https://api.example.com/api/pengguna/1
  Pengguna(id=1) = Pengguna(id=1, nama=Budi)
  [OkHttp] GET https://api.example.com/api/pengguna/2
  Pengguna(id=2) = Pengguna(id=2, nama=Siti)
  [OkHttp] GET https://api.example.com/api/pengguna/3
  Pengguna(id=3) = Pengguna(id=3, nama=Dewi)
  [OkHttp] GET https://api.example.com/api/pengguna/999
  Pengguna(id=999) = null

--- Platform: iOS ---
  [Darwin] GET https://api.example.com/api/pengguna/1
  Pengguna(id=1) = Pengguna(id=1, nama=Budi)
  [Darwin] GET https://api.example.com/api/pengguna/2
  Pengguna(id=2) = Pengguna(id=2, nama=Siti)
  [Darwin] GET https://api.example.com/api/pengguna/3
  Pengguna(id=3) = Pengguna(id=3, nama=Dewi)
  [Darwin] GET https://api.example.com/api/pengguna/999
  Pengguna(id=999) = null

--- Pengujian dengan MockEngine ---
  [Mock] GET https://api.example.com/api/pengguna/1
  Hasil test: Pengguna(id=1, nama=Budi)
  [Mock] GET https://api.example.com/api/pengguna/999
  Test 404: (null sesuai harapan)

Mari kita bedah bagian kunci dari kode di atas:

  1. sealed class HttpRespon: model tiga kemungkinan hasil HTTP—Sukses(200, body), Gagal(404, "Not Found"), Error(exception). Pola sealed memaksa pemanggil menangani semua kasus di when.
  2. data class HttpPermintaan: model untuk satu request—URL, method, headers, body. Immutable sehingga aman dipassing antar thread.
  3. interface HttpEngine: abstraksi engine di commonMain. Hanya satu method kirim(permintaan): HttpRespon. Engine spesifik platform (OkHttpEngine, DarwinEngine) mengimplementasikan interface ini.
  4. class KtorClient: HTTP client yang menerima engine via constructor. Memiliki method get, post, getJson—semuanya mendelegasikan ke engine.kirim(). Pada ktor-client nyata, class ini juga memuat plugin pipeline (ContentNegotiation, Auth, Logging).
  5. class ApiService: kode bisnis di commonMain. Memakai KtorClient untuk GET request, lalu memparse JSON respons menjadi Pengguna. Pada proyek nyata, parsing digantikan plugin ContentNegotiation + kotlinx.serialization.
  6. OkHttpEngine, DarwinEngine, MockEngine: tiga implementasi engine. Di JVM/Android nyata, OkHttpEngine memakai okhttp3.OkHttpClient; di iOS nyata, DarwinEngine memakai NSURLSession; MockEngine untuk testing.
  7. Output menunjukkan tiga platform: kode ApiService.ambilPengguna() yang sama menghasilkan struktur respons identik—hanya prefix log engine yang berbeda ([OkHttp] vs [Darwin]).

Konfigurasi Gradle #

Library ktor-client dipublikasi sebagai beberapa artifact. commonMain membutuhkan ktor-client-core dan plugin seperti ktor-client-content-negotiation. Source set spesifik menambah engine: ktor-client-okhttp untuk jvmMain dan androidMain, ktor-client-darwin untuk iosMain.

kotlin build.gradle.kts: ktor dependencies
sourceSets {
    val commonMain by getting {
        dependencies {
            implementation("io.ktor:ktor-client-core:2.3.7")
            implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
            implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")
        }
    }
    val androidMain by getting {
        dependencies { implementation("io.ktor:ktor-client-okhttp:2.3.7") }
    }
    val iosMain by getting {
        dependencies { implementation("io.ktor:ktor-client-darwin:2.3.7") }
    }
    val jvmMain by getting {
        dependencies { implementation("io.ktor:ktor-client-okhttp:2.3.7") }
    }
}

Engine tidak pernah ditambahkan ke commonMain—engine selalu spesifik platform.

Referensi Lanjutan #

Penutup #

ktor-client mengikuti pola klasik KMP—kode HTTP bersama di commonMain, engine spesifik per platform. Pemisahan ini membuat kode bisnis dapat diuji dengan MockEngine, sementara produksi memakai engine native terbaik. Chapter berikutnya akan masuk ke persistence: bagaimana SQLDelight dan Room KMP menyediakan akses database lintas platform dengan driver spesifik.