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
HttpClientyang di-share di seluruh aplikasi. Ia thread-safe dan mendukung semua operasi HTTP secara suspend.testApplicationuntuk testing in-memory — tidak perlu membuka port jaringan untuk testing.testApplicationmenjalankan 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.