Ktor

Ktor #

Ktor adalah framework web Kotlin-native yang dibuat oleh JetBrains — perusahaan yang sama yang menciptakan Kotlin. Ini menjadikannya framework paling idiomatis untuk Kotlin: semua API didesain untuk memanfaatkan fitur Kotlin sepenuhnya — coroutine, DSL, extension function, dan type safety. Ktor sangat modular: kamu hanya memasang plugin yang dibutuhkan (disebut feature di versi lama), sehingga aplikasi tetap ringan. Tidak ada magic annotation seperti di Spring Boot — semuanya eksplisit dan dapat ditelusuri. Artikel ini membahas Ktor secara mendalam: dari setup dan routing, plugin-plugin penting, autentikasi JWT, Ktor client, hingga testing dan deployment.

Mengapa Ktor #

PILIH Ktor jika:
  ✓ Menulis microservice Kotlin-native baru dari awal
  ✓ Tim menginginkan kode yang idiomatis dan eksplisit (tidak ada magic)
  ✓ Butuh coroutine sebagai primitif utama (async by default)
  ✓ Ingin kontrol penuh atas apa yang dipasang di aplikasi
  ✓ Butuh Ktor client untuk mengonsumsi API eksternal
  ✓ WebSocket yang terintegrasi baik dengan server

PERTIMBANGKAN lain jika:
  ✗ Tim besar dengan kurva belajar tinggi — Spring Boot lebih familiar
  ✗ Butuh ekosistem library yang sangat kaya — Spring lebih luas
  ✗ Butuh native image saat ini — Quarkus lebih matang untuk ini

Setup Proyek #

// build.gradle.kts
plugins {
    kotlin("jvm") version "2.0.0"
    kotlin("plugin.serialization") version "2.0.0"
    id("io.ktor.plugin") version "2.3.10"
    application
}

val ktorVersion = "2.3.10"

dependencies {
    // Engine — pilih salah satu
    implementation("io.ktor:ktor-server-netty:$ktorVersion")        // produksi (lebih banyak fitur)
    // implementation("io.ktor:ktor-server-cio:$ktorVersion")        // lightweight

    // Plugin utama
    implementation("io.ktor:ktor-server-core:$ktorVersion")
    implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
    implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
    implementation("io.ktor:ktor-server-status-pages:$ktorVersion")
    implementation("io.ktor:ktor-server-call-logging:$ktorVersion")
    implementation("io.ktor:ktor-server-cors:$ktorVersion")
    implementation("io.ktor:ktor-server-auth:$ktorVersion")
    implementation("io.ktor:ktor-server-auth-jwt:$ktorVersion")
    implementation("io.ktor:ktor-server-request-validation:$ktorVersion")

    // Ktor client (untuk memanggil API eksternal)
    implementation("io.ktor:ktor-client-core:$ktorVersion")
    implementation("io.ktor:ktor-client-cio:$ktorVersion")
    implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
    implementation("io.ktor:ktor-client-logging:$ktorVersion")

    // Database
    implementation("org.jetbrains.exposed:exposed-core:0.49.0")
    implementation("org.jetbrains.exposed:exposed-dao:0.49.0")
    implementation("org.jetbrains.exposed:exposed-jdbc:0.49.0")
    implementation("com.zaxxer:HikariCP:5.1.0")
    implementation("org.postgresql:postgresql:42.7.3")

    // Logging
    implementation("ch.qos.logback:logback-classic:1.5.3")

    // Testing
    testImplementation("io.ktor:ktor-server-test-host:$ktorVersion")
    testImplementation("org.jetbrains.kotlin:kotlin-test")
}

application {
    mainClass.set("com.myapp.MainKt")
}

Struktur Aplikasi #

// src/main/kotlin/com/myapp/Main.kt
package com.myapp

import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import com.myapp.plugins.*

fun main() {
    embeddedServer(
        factory = Netty,
        port = System.getenv("PORT")?.toInt() ?: 8080,
        host = "0.0.0.0",
        module = Application::module
    ).start(wait = true)
}

fun Application.module() {
    // Pasang plugin secara terurut
    konfigurasiSerialisasi()
    konfigurasiAuth()
    konfigurasiCors()
    konfigurasiValidasi()
    konfigurasiStatusPages()
    konfigurasiLogging()
    konfigurasiDatabase()
    konfigurasiRouting()
}

Plugin Serialisasi #

// src/main/kotlin/com/myapp/plugins/Serialisasi.kt
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json

fun Application.konfigurasiSerialisasi() {
    install(ContentNegotiation) {
        json(Json {
            prettyPrint = false
            isLenient = true
            ignoreUnknownKeys = true
            encodeDefaults = false  // jangan kirim null yang merupakan default
        })
    }
}

Routing Bertingkat #

// src/main/kotlin/com/myapp/plugins/Routing.kt
import io.ktor.server.application.*
import io.ktor.server.routing.*
import com.myapp.route.*

fun Application.konfigurasiRouting() {
    routing {
        // Health check — tidak perlu auth
        get("/health") {
            call.respond(mapOf("status" to "ok", "waktu" to System.currentTimeMillis()))
        }

        // Versi API
        route("/api/v1") {
            produkRoutes()
            penggunaRoutes()
        }
    }
}
// src/main/kotlin/com/myapp/route/ProdukRoute.kt
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.transactions.transaction

@Serializable
data class ProdukDto(
    val id: Long = 0,
    val nama: String,
    val harga: Double,
    val stok: Int = 0,
    val kategori: String? = null
)

@Serializable
data class ResponsError(val kode: Int, val pesan: String)

@Serializable
data class ResponsPaginasi<T>(
    val data: List<T>,
    val total: Int,
    val halaman: Int,
    val ukuranHalaman: Int
)

fun Route.produkRoutes() {
    route("/produk") {

        get {
            val halaman = call.request.queryParameters["halaman"]?.toIntOrNull() ?: 1
            val ukuran = call.request.queryParameters["ukuran"]?.toIntOrNull() ?: 20
            val kategori = call.request.queryParameters["kategori"]

            require(halaman > 0) { "Halaman harus lebih dari 0" }
            require(ukuran in 1..100) { "Ukuran halaman 1-100" }

            val produk = transaction { ambilSemuaProduk(kategori, halaman, ukuran) }
            call.respond(produk)
        }

        get("/{id}") {
            val id = call.parameters["id"]?.toLongOrNull()
                ?: return@get call.respond(
                    HttpStatusCode.BadRequest,
                    ResponsError(400, "ID harus berupa angka")
                )

            val produk = transaction { ambilProdukById(id) }
                ?: return@get call.respond(
                    HttpStatusCode.NotFound,
                    ResponsError(404, "Produk $id tidak ditemukan")
                )

            call.respond(produk)
        }

        post {
            val dto = runCatching { call.receive<ProdukDto>() }.getOrElse {
                return@post call.respond(
                    HttpStatusCode.BadRequest,
                    ResponsError(400, "Body tidak valid: ${it.message}")
                )
            }

            val disimpan = transaction { simpanProduk(dto) }
            call.respond(HttpStatusCode.Created, disimpan)
        }

        put("/{id}") {
            val id = call.parameters["id"]?.toLongOrNull()
                ?: return@put call.respond(HttpStatusCode.BadRequest, ResponsError(400, "ID tidak valid"))

            val dto = call.receive<ProdukDto>()
            val diperbarui = transaction { perbaruiProduk(id, dto) }
                ?: return@put call.respond(HttpStatusCode.NotFound, ResponsError(404, "Produk tidak ditemukan"))

            call.respond(diperbarui)
        }

        delete("/{id}") {
            val id = call.parameters["id"]?.toLongOrNull()
                ?: return@delete call.respond(HttpStatusCode.BadRequest, ResponsError(400, "ID tidak valid"))

            val berhasil = transaction { hapusProduk(id) }
            if (berhasil) call.respond(HttpStatusCode.NoContent)
            else call.respond(HttpStatusCode.NotFound, ResponsError(404, "Produk tidak ditemukan"))
        }
    }
}

// Fungsi database placeholder
private fun ambilSemuaProduk(kategori: String?, halaman: Int, ukuran: Int): ResponsPaginasi<ProdukDto> =
    ResponsPaginasi(emptyList(), 0, halaman, ukuran)
private fun ambilProdukById(id: Long): ProdukDto? = null
private fun simpanProduk(dto: ProdukDto): ProdukDto = dto.copy(id = 1)
private fun perbaruiProduk(id: Long, dto: ProdukDto): ProdukDto? = dto.copy(id = id)
private fun hapusProduk(id: Long): Boolean = true

Autentikasi JWT #

// src/main/kotlin/com/myapp/plugins/Auth.kt
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.response.*
import io.ktor.http.*
import kotlinx.serialization.Serializable
import java.util.Date

object KonfigurasiJwt {
    val SECRET = System.getenv("JWT_SECRET") ?: "rahasia-development"
    val ISSUER = "myapp"
    val AUDIENCE = "myapp-users"
    val ALGORITMA = Algorithm.HMAC256(SECRET)
    val EXPIRY_MS = 24 * 60 * 60 * 1000L  // 24 jam
}

fun buatToken(penggunaId: Long, email: String): String {
    return JWT.create()
        .withIssuer(KonfigurasiJwt.ISSUER)
        .withAudience(KonfigurasiJwt.AUDIENCE)
        .withClaim("id", penggunaId)
        .withClaim("email", email)
        .withExpiresAt(Date(System.currentTimeMillis() + KonfigurasiJwt.EXPIRY_MS))
        .sign(KonfigurasiJwt.ALGORITMA)
}

fun Application.konfigurasiAuth() {
    install(Authentication) {
        jwt("auth-jwt") {
            realm = "myapp"
            verifier(
                JWT.require(KonfigurasiJwt.ALGORITMA)
                    .withIssuer(KonfigurasiJwt.ISSUER)
                    .withAudience(KonfigurasiJwt.AUDIENCE)
                    .build()
            )
            validate { credential ->
                if (credential.payload.getClaim("email").asString().isNotEmpty()) {
                    JWTPrincipal(credential.payload)
                } else null
            }
            challenge { _, _ ->
                call.respond(HttpStatusCode.Unauthorized,
                    mapOf("error" to "Token tidak valid atau sudah kedaluwarsa"))
            }
        }
    }
}

// Gunakan di route yang perlu dilindungi
fun Route.penggunaRoutes() {
    route("/auth") {
        post("/login") {
            @Serializable data class LoginRequest(val email: String, val sandi: String)
            @Serializable data class LoginResponse(val token: String, val penggunaId: Long)

            val req = call.receive<LoginRequest>()
            // Verifikasi kredensial (implementasi di service)
            val penggunaId = 1L  // placeholder

            val token = buatToken(penggunaId, req.email)
            call.respond(LoginResponse(token, penggunaId))
        }
    }

    // Route yang dilindungi JWT
    authenticate("auth-jwt") {
        route("/profil") {
            get {
                val principal = call.principal<JWTPrincipal>()!!
                val email = principal.payload.getClaim("email").asString()
                val id = principal.payload.getClaim("id").asLong()
                call.respond(mapOf("id" to id, "email" to email))
            }
        }
    }
}

Plugin CORS dan Status Pages #

import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.http.*

fun Application.konfigurasiCors() {
    install(CORS) {
        allowMethod(HttpMethod.Options)
        allowMethod(HttpMethod.Get)
        allowMethod(HttpMethod.Post)
        allowMethod(HttpMethod.Put)
        allowMethod(HttpMethod.Delete)
        allowHeader(HttpHeaders.Authorization)
        allowHeader(HttpHeaders.ContentType)
        allowCredentials = true
        allowHost("localhost:3000")              // development
        allowHost("myapp.com", schemes = listOf("https"))  // production
    }
}

fun Application.konfigurasiStatusPages() {
    install(StatusPages) {
        // Tangani exception secara terpusat
        exception<IllegalArgumentException> { call, cause ->
            call.respond(HttpStatusCode.BadRequest, ResponsError(400, cause.message ?: "Input tidak valid"))
        }
        exception<NoSuchElementException> { call, cause ->
            call.respond(HttpStatusCode.NotFound, ResponsError(404, cause.message ?: "Tidak ditemukan"))
        }
        exception<Throwable> { call, cause ->
            application.log.error("Error tidak tertangani", cause)
            call.respond(HttpStatusCode.InternalServerError, ResponsError(500, "Terjadi kesalahan internal"))
        }

        // Tangani status code HTTP
        status(HttpStatusCode.NotFound) { call, _ ->
            call.respond(HttpStatusCode.NotFound, ResponsError(404, "Endpoint tidak ditemukan"))
        }
        status(HttpStatusCode.MethodNotAllowed) { call, _ ->
            call.respond(HttpStatusCode.MethodNotAllowed, ResponsError(405, "Metode HTTP tidak diizinkan"))
        }
    }
}

Ktor Client — Memanggil API Eksternal #

Ktor bukan hanya server — ia juga punya HTTP client yang sangat baik, berbasis coroutine:

import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable

@Serializable
data class ResponsApi<T>(val data: T, val status: String)

@Serializable
data class InfoCuaca(val kota: String, val suhu: Double, val deskripsi: String)

// Buat client yang bisa digunakan ulang (bukan dibuat per request)
val httpClient = HttpClient(CIO) {
    install(ContentNegotiation) {
        json(Json { ignoreUnknownKeys = true })
    }
    install(Logging) {
        logger = Logger.DEFAULT
        level = LogLevel.INFO
    }
    install(HttpTimeout) {
        requestTimeoutMillis = 10_000  // 10 detik
        connectTimeoutMillis = 5_000
        socketTimeoutMillis = 10_000
    }
    defaultRequest {
        header("Accept", "application/json")
        header("User-Agent", "MyKotlinApp/1.0")
    }
}

class LayananCuacaEksternal {

    suspend fun ambilCuaca(kota: String): InfoCuaca {
        val apiKey = System.getenv("WEATHER_API_KEY") ?: throw IllegalStateException("API key tidak ada")

        return httpClient.get("https://api.cuaca.example.com/current") {
            parameter("q", kota)
            parameter("appid", apiKey)
            parameter("units", "metric")
        }.body()
    }

    suspend fun kirimWebhook(url: String, payload: Map<String, String>) {
        httpClient.post(url) {
            contentType(io.ktor.http.ContentType.Application.Json)
            setBody(payload)
        }
    }

    // POST dengan body JSON yang diketik
    suspend fun buatPesanan(payload: ProdukDto): ProdukDto {
        return httpClient.post("https://api.eksternal.com/pesanan") {
            contentType(io.ktor.http.ContentType.Application.Json)
            setBody(payload)
        }.body()
    }
}

Plugin Kustom (Middleware) #

import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.util.*

// Plugin kustom untuk request ID
val RequestIdPlugin = createApplicationPlugin("RequestId") {
    onCall { call ->
        val requestId = call.request.header("X-Request-Id")
            ?: java.util.UUID.randomUUID().toString()
        call.response.header("X-Request-Id", requestId)
    }
}

// Plugin rate limiting sederhana (untuk production gunakan library khusus)
val RateLimitPlugin = createApplicationPlugin("RateLimit") {
    val hitungan = java.util.concurrent.ConcurrentHashMap<String, java.util.concurrent.atomic.AtomicInteger>()

    onCall { call ->
        val ip = call.request.local.remoteAddress
        val count = hitungan.getOrPut(ip) { java.util.concurrent.atomic.AtomicInteger(0) }
        if (count.incrementAndGet() > 100) {
            call.respond(io.ktor.http.HttpStatusCode.TooManyRequests, "Rate limit terlampaui")
            finish()
        }
    }
}

// Pasang di aplikasi
fun Application.konfigurasiPlugin() {
    install(RequestIdPlugin)
    install(RateLimitPlugin)
}

Testing dengan testApplication #

import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlinx.serialization.json.Json
import kotlin.test.*

class ProdukRouteTest {

    @Test
    fun `GET health mengembalikan ok`() = testApplication {
        application { module() }

        val respons = client.get("/health")
        assertEquals(HttpStatusCode.OK, respons.status)
        assertTrue(respons.bodyAsText().contains("ok"))
    }

    @Test
    fun `GET produk mengembalikan list kosong`() = testApplication {
        application { module() }

        val respons = client.get("/api/v1/produk")
        assertEquals(HttpStatusCode.OK, respons.status)
    }

    @Test
    fun `POST produk baru berhasil`() = testApplication {
        application { module() }

        val respons = client.post("/api/v1/produk") {
            contentType(ContentType.Application.Json)
            setBody("""{"nama":"Laptop","harga":15000000.0,"stok":10}""")
        }
        assertEquals(HttpStatusCode.Created, respons.status)
    }

    @Test
    fun `GET produk dengan ID invalid mengembalikan 400`() = testApplication {
        application { module() }

        val respons = client.get("/api/v1/produk/bukan-angka")
        assertEquals(HttpStatusCode.BadRequest, respons.status)
    }

    @Test
    fun `Endpoint tidak ada mengembalikan 404`() = testApplication {
        application { module() }

        val respons = client.get("/api/v1/tidak-ada")
        assertEquals(HttpStatusCode.NotFound, respons.status)
    }

    @Test
    fun `POST dengan token JWT valid berhasil`() = testApplication {
        application { module() }

        // Buat token untuk test
        val token = buatToken(1L, "[email protected]")

        val respons = client.get("/api/v1/profil") {
            header("Authorization", "Bearer $token")
        }
        assertEquals(HttpStatusCode.OK, respons.status)
    }
}

Deployment #

Docker #

# Dockerfile
FROM gradle:8.7-jdk17 AS build
WORKDIR /app
COPY . .
RUN gradle shadowJar --no-daemon

FROM openjdk:17-jre-slim
WORKDIR /app
COPY --from=build /app/build/libs/*-all.jar app.jar
EXPOSE 8080
ENV JAVA_OPTS="-Xms64m -Xmx256m"
CMD ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
// build.gradle.kts — konfigurasi fat JAR
plugins {
    id("com.github.johnrengelman.shadow") version "8.1.1"
}

tasks.shadowJar {
    manifest {
        attributes("Main-Class" to "com.myapp.MainKt")
    }
    mergeServiceFiles()
}

Konfigurasi via Environment Variable #

// application.conf (HOCON)
ktor {
    deployment {
        port = 8080
        port = ${?PORT}  // override dengan env var PORT
    }
    application {
        modules = [ com.myapp.MainKt.module ]
    }
}

database {
    url = ${?DATABASE_URL}
    user = ${?DATABASE_USER}
    password = ${?DATABASE_PASSWORD}
}

Ringkasan #

  • Ktor adalah framework Kotlin-first — semua API dirancang memanfaatkan fitur Kotlin: coroutine untuk async, DSL untuk routing dan konfigurasi, extension function untuk keterbacaan. Hasilnya adalah kode yang sangat idiomatis.
  • Plugin system yang modular — pasang hanya yang dibutuhkan: ContentNegotiation untuk JSON, Auth untuk autentikasi, CORS untuk cross-origin, StatusPages untuk error handling terpusat. Tidak ada yang dipasang tanpa kamu tahu.
  • StatusPages untuk error handling terpusat — tangani semua exception dan HTTP status code di satu tempat daripada tersebar di setiap handler. Kode lebih bersih dan konsisten.
  • Routing sebagai fungsi extension — gunakan fun Route.entityRoutes() untuk memisahkan route per entitas. Ini membuat routing mudah diorganisir tanpa annotation.
  • JWT dengan authenticate("auth-jwt") — lindungi route dengan authentication block. call.principal<JWTPrincipal>() untuk mengakses claims dari token.
  • Ktor client berbasis coroutine — gunakan satu instance HttpClient yang di-share di seluruh aplikasi. Ia thread-safe dan mendukung semua operasi HTTP secara suspend.
  • testApplication untuk testing in-memory — tidak perlu membuka port jaringan untuk testing. testApplication menjalankan seluruh stack in-process sehingga test cepat dan deterministic.
  • Shadow JAR untuk deployment — build fat JAR dengan semua dependensi menggunakan Shadow plugin, lalu deploy ke Docker atau server manapun yang punya JRE.

← Sebelumnya: Quarkus   Berikutnya: Vert.x →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact