Web Server #
Membangun web server di Kotlin berarti memilih dari tiga ekosistem yang matang: Ktor (Kotlin-native, coroutine-first, ringan), Spring Boot (enterprise-grade, ekosistem luas, konvensi kuat), dan Vert.x (reaktif, throughput tinggi, event-loop). Ketiganya bisa digunakan untuk membangun REST API yang handal, tapi punya filosofi yang berbeda. Artikel ini membahas ketiganya secara mendalam dengan contoh REST API lengkap — routing, serialisasi JSON, middleware, validasi, error handling — serta panduan memilih framework yang tepat untuk kebutuhanmu.
Memilih Framework #
flowchart TD
A{Jenis Proyek?} --> B{Sudah ada\nekosistem Spring?}
B -- Ya --> C["Spring Boot\nIntegrasi mulus, DI bawaan"]
B -- Tidak --> D{Prioritas utama?}
D -- "Kotlin-idiomatic\nringan, coroutine" --> E["Ktor\nFit sempurna untuk Kotlin"]
D -- "Throughput sangat tinggi\nreaktif" --> F["Vert.x\nEvent-loop, non-blocking"]
D -- "Startup cepat\nGraalVM native" --> G["Quarkus / Micronaut\nCloud-native optimized"]| Aspek | Ktor | Spring Boot | Vert.x |
|---|---|---|---|
| Bahasa desain | Kotlin-first | Java (Kotlin didukung) | Java (Kotlin didukung) |
| Concurrency | Coroutine | Thread-based + reaktif | Event loop |
| Startup time | Sangat cepat | Sedang | Cepat |
| Footprint memori | Kecil | Sedang-Besar | Kecil |
| Ekosistem | Berkembang | Sangat kaya | Kaya |
| Kurva belajar | Sedang | Rendah (dengan Spring) | Tinggi |
| Cocok untuk | Microservice, API modern | Enterprise, monolith | High-throughput API |
REST API dengan Ktor #
Ktor menggunakan plugin (dulu disebut feature) yang di-install secara eksplisit — kamu hanya memasang apa yang kamu butuhkan. Ini membuat aplikasi tetap ringan.
Dependensi #
// build.gradle.kts
dependencies {
val ktorVersion = "2.3.9"
implementation("io.ktor:ktor-server-core:$ktorVersion")
implementation("io.ktor:ktor-server-netty:$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-auth:$ktorVersion")
implementation("io.ktor:ktor-server-auth-jwt:$ktorVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("ch.qos.logback:logback-classic:1.5.3")
testImplementation("io.ktor:ktor-server-test-host:$ktorVersion")
testImplementation("org.jetbrains.kotlin:kotlin-test")
}
// Tambahkan di bagian plugins
plugins {
kotlin("plugin.serialization") version "2.0.0"
}
Model Data #
import kotlinx.serialization.Serializable
@Serializable
data class Produk(
val id: Int,
val nama: String,
val harga: Double,
val stok: Int,
val kategori: String
)
@Serializable
data class ProdukBaru(
val nama: String,
val harga: Double,
val stok: Int,
val kategori: String
)
@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
)
Aplikasi Ktor Lengkap #
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.callloging.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.json.Json
import org.slf4j.event.Level
// Simulasi database in-memory
object RepoProduks {
private val data = mutableListOf(
Produk(1, "Laptop Gaming", 15_000_000.0, 10, "Elektronik"),
Produk(2, "Mouse Wireless", 250_000.0, 50, "Elektronik"),
Produk(3, "Meja Kerja", 1_500_000.0, 8, "Furnitur")
)
private var nextId = 4
fun ambilSemua(kategori: String? = null, halaman: Int = 1, ukuran: Int = 10): ResponsPaginasi<Produk> {
val terfilter = if (kategori != null) data.filter { it.kategori == kategori } else data.toList()
val mulai = (halaman - 1) * ukuran
val hasil = terfilter.drop(mulai).take(ukuran)
return ResponsPaginasi(hasil, terfilter.size, halaman, ukuran)
}
fun ambilById(id: Int): Produk? = data.find { it.id == id }
fun tambah(baru: ProdukBaru): Produk {
val produk = Produk(nextId++, baru.nama, baru.harga, baru.stok, baru.kategori)
data.add(produk)
return produk
}
fun perbarui(id: Int, baru: ProdukBaru): Produk? {
val indeks = data.indexOfFirst { it.id == id }
if (indeks == -1) return null
val diperbarui = Produk(id, baru.nama, baru.harga, baru.stok, baru.kategori)
data[indeks] = diperbarui
return diperbarui
}
fun hapus(id: Int): Boolean = data.removeIf { it.id == id }
}
fun Application.konfigurasi() {
// Plugin: JSON serialization
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
// Plugin: Request logging
install(CallLogging) {
level = Level.INFO
filter { call -> call.request.path().startsWith("/api") }
}
// Plugin: Error handling terpusat
install(StatusPages) {
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 ->
call.application.log.error("Error tidak tertangani", cause)
call.respond(HttpStatusCode.InternalServerError, ResponsError(500, "Terjadi kesalahan internal"))
}
status(HttpStatusCode.NotFound) { call, _ ->
call.respond(HttpStatusCode.NotFound, ResponsError(404, "Endpoint tidak ditemukan"))
}
}
// Routing
routing {
route("/api/v1") {
produkRoutes()
}
// Health check endpoint
get("/health") {
call.respond(mapOf("status" to "ok", "waktu" to System.currentTimeMillis()))
}
}
}
fun Route.produkRoutes() {
route("/produk") {
// GET /api/v1/produk?kategori=Elektronik&halaman=1&ukuran=10
get {
val kategori = call.request.queryParameters["kategori"]
val halaman = call.request.queryParameters["halaman"]?.toIntOrNull() ?: 1
val ukuran = call.request.queryParameters["ukuran"]?.toIntOrNull() ?: 10
require(halaman > 0) { "Halaman harus lebih dari 0" }
require(ukuran in 1..100) { "Ukuran halaman harus antara 1 dan 100" }
val hasil = RepoProduks.ambilSemua(kategori, halaman, ukuran)
call.respond(hasil)
}
// GET /api/v1/produk/{id}
get("{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw IllegalArgumentException("ID harus berupa angka")
val produk = RepoProduks.ambilById(id)
?: throw NoSuchElementException("Produk dengan ID $id tidak ditemukan")
call.respond(produk)
}
// POST /api/v1/produk
post {
val baru = runCatching { call.receive<ProdukBaru>() }
.getOrElse { throw IllegalArgumentException("Body request tidak valid: ${it.message}") }
require(baru.nama.isNotBlank()) { "Nama produk tidak boleh kosong" }
require(baru.harga > 0) { "Harga harus lebih dari 0" }
require(baru.stok >= 0) { "Stok tidak boleh negatif" }
val produk = RepoProduks.tambah(baru)
call.respond(HttpStatusCode.Created, produk)
}
// PUT /api/v1/produk/{id}
put("{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw IllegalArgumentException("ID harus berupa angka")
val baru = runCatching { call.receive<ProdukBaru>() }
.getOrElse { throw IllegalArgumentException("Body request tidak valid") }
val diperbarui = RepoProduks.perbarui(id, baru)
?: throw NoSuchElementException("Produk dengan ID $id tidak ditemukan")
call.respond(diperbarui)
}
// DELETE /api/v1/produk/{id}
delete("{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw IllegalArgumentException("ID harus berupa angka")
val berhasil = RepoProduks.hapus(id)
if (!berhasil) throw NoSuchElementException("Produk dengan ID $id tidak ditemukan")
call.respond(HttpStatusCode.NoContent)
}
}
}
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
konfigurasi()
}.start(wait = true)
}
Middleware di Ktor #
Middleware di Ktor diimplementasikan sebagai ApplicationPlugin atau dengan intercept:
// Middleware sederhana: tambahkan header ke setiap respons
fun Application.pasangHeaderKustom() {
intercept(ApplicationCallPipeline.Plugins) {
proceed()
call.response.headers.append("X-Powered-By", "Ktor/Kotlin")
call.response.headers.append("X-Request-Id", java.util.UUID.randomUUID().toString())
}
}
// Rate limiting sederhana (per IP)
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
val hitungRequest = ConcurrentHashMap<String, AtomicInteger>()
fun Application.pasangRateLimiting(maks: Int = 100) {
intercept(ApplicationCallPipeline.Plugins) {
val ip = call.request.local.remoteAddress
val hitungan = hitungRequest.getOrPut(ip) { AtomicInteger(0) }
if (hitungan.incrementAndGet() > maks) {
call.respond(HttpStatusCode.TooManyRequests, ResponsError(429, "Terlalu banyak request"))
finish()
}
}
}
REST API dengan Spring Boot #
Spring Boot unggul dalam ekosistem enterprise dengan dependency injection, auto-configuration, dan integrasi dengan database, security, dan observability.
// build.gradle.kts
plugins {
kotlin("jvm") version "2.0.0"
kotlin("plugin.spring") version "2.0.0"
id("org.springframework.boot") version "3.2.4"
id("io.spring.dependency-management") version "1.1.4"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Service
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.*
import jakarta.validation.Valid
import jakarta.validation.constraints.*
@SpringBootApplication
class AplikasiWeb
fun main(args: Array<String>) {
runApplication<AplikasiWeb>(*args)
}
// Model dengan validasi
data class ProdukDto(
@field:NotBlank(message = "Nama tidak boleh kosong")
val nama: String,
@field:Positive(message = "Harga harus positif")
val harga: Double,
@field:PositiveOrZero(message = "Stok tidak boleh negatif")
val stok: Int,
@field:NotBlank(message = "Kategori tidak boleh kosong")
val kategori: String
)
data class ProdukResponse(
val id: Int,
val nama: String,
val harga: Double,
val stok: Int,
val kategori: String
)
// Service layer
@Service
class LayananProduk {
private val data = mutableListOf(
ProdukResponse(1, "Laptop Gaming", 15_000_000.0, 10, "Elektronik"),
ProdukResponse(2, "Mouse Wireless", 250_000.0, 50, "Elektronik")
)
private var nextId = 3
fun ambilSemua(kategori: String?): List<ProdukResponse> =
if (kategori != null) data.filter { it.kategori == kategori } else data.toList()
fun ambilById(id: Int): ProdukResponse =
data.find { it.id == id } ?: throw NoSuchElementException("Produk $id tidak ditemukan")
fun tambah(dto: ProdukDto): ProdukResponse {
val produk = ProdukResponse(nextId++, dto.nama, dto.harga, dto.stok, dto.kategori)
data.add(produk)
return produk
}
fun hapus(id: Int) {
val berhasil = data.removeIf { it.id == id }
if (!berhasil) throw NoSuchElementException("Produk $id tidak ditemukan")
}
}
// Controller
@RestController
@RequestMapping("/api/v1/produk")
@Validated
class KontrolerProduk(private val layanan: LayananProduk) {
@GetMapping
fun ambilSemua(@RequestParam(required = false) kategori: String?): List<ProdukResponse> =
layanan.ambilSemua(kategori)
@GetMapping("/{id}")
fun ambilById(@PathVariable id: Int): ProdukResponse =
layanan.ambilById(id)
@PostMapping
fun tambah(@Valid @RequestBody dto: ProdukDto): ResponseEntity<ProdukResponse> {
val produk = layanan.tambah(dto)
return ResponseEntity.status(HttpStatus.CREATED).body(produk)
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun hapus(@PathVariable id: Int) {
layanan.hapus(id)
}
}
// Global exception handler
@RestControllerAdvice
class PenangananError {
@ExceptionHandler(NoSuchElementException::class)
@ResponseStatus(HttpStatus.NOT_FOUND)
fun tanganiTidakDitemukan(e: NoSuchElementException) =
mapOf("kode" to 404, "pesan" to (e.message ?: "Tidak ditemukan"))
@ExceptionHandler(Exception::class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
fun tanganiError(e: Exception) =
mapOf("kode" to 500, "pesan" to "Terjadi kesalahan internal")
}
REST API dengan Vert.x #
Vert.x menggunakan model event loop — satu thread menangani banyak request secara non-blocking, sangat efisien untuk throughput tinggi:
import io.vertx.core.AbstractVerticle
import io.vertx.core.Vertx
import io.vertx.core.json.Json
import io.vertx.core.json.JsonObject
import io.vertx.ext.web.Router
import io.vertx.ext.web.handler.BodyHandler
class ApiVerticle : AbstractVerticle() {
private val produk = mutableListOf(
JsonObject().put("id", 1).put("nama", "Laptop").put("harga", 15_000_000),
JsonObject().put("id", 2).put("nama", "Mouse").put("harga", 250_000)
)
private var nextId = 3
override fun start() {
val router = Router.router(vertx)
// Middleware: parse body untuk POST/PUT
router.route().handler(BodyHandler.create())
// Middleware: CORS
router.route().handler { ctx ->
ctx.response()
.putHeader("Access-Control-Allow-Origin", "*")
.putHeader("Content-Type", "application/json")
ctx.next()
}
// Routes
router.get("/api/produk").handler { ctx ->
ctx.response().end(Json.encode(produk))
}
router.get("/api/produk/:id").handler { ctx ->
val id = ctx.pathParam("id").toIntOrNull()
if (id == null) {
ctx.response().setStatusCode(400).end("""{"pesan":"ID tidak valid"}""")
return@handler
}
val item = produk.find { it.getInteger("id") == id }
if (item == null) {
ctx.response().setStatusCode(404).end("""{"pesan":"Produk tidak ditemukan"}""")
} else {
ctx.response().end(item.encode())
}
}
router.post("/api/produk").handler { ctx ->
val body = ctx.body().asJsonObject()
val baru = JsonObject()
.put("id", nextId++)
.put("nama", body.getString("nama", ""))
.put("harga", body.getDouble("harga", 0.0))
produk.add(baru)
ctx.response().setStatusCode(201).end(baru.encode())
}
router.delete("/api/produk/:id").handler { ctx ->
val id = ctx.pathParam("id").toIntOrNull()
val berhasil = produk.removeIf { it.getInteger("id") == id }
if (berhasil) {
ctx.response().setStatusCode(204).end()
} else {
ctx.response().setStatusCode(404).end("""{"pesan":"Produk tidak ditemukan"}""")
}
}
vertx.createHttpServer()
.requestHandler(router)
.listen(8080) { hasil ->
if (hasil.succeeded()) println("Vert.x API server di port 8080")
else println("Gagal: ${hasil.cause().message}")
}
}
}
fun main() {
Vertx.vertx().deployVerticle(ApiVerticle())
}
Testing REST API di Ktor #
Ktor menyediakan testApplication yang memungkinkan test in-memory tanpa membuka port jaringan:
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlin.test.*
class TestApiProduk {
@Test
fun `GET produk mengembalikan daftar`() = testApplication {
application { konfigurasi() }
val respons = client.get("/api/v1/produk")
assertEquals(HttpStatusCode.OK, respons.status)
assertTrue(respons.bodyAsText().contains("\"data\""))
}
@Test
fun `GET produk dengan ID valid mengembalikan produk`() = testApplication {
application { konfigurasi() }
val respons = client.get("/api/v1/produk/1")
assertEquals(HttpStatusCode.OK, respons.status)
assertTrue(respons.bodyAsText().contains("\"id\":1"))
}
@Test
fun `GET produk dengan ID tidak ada mengembalikan 404`() = testApplication {
application { konfigurasi() }
val respons = client.get("/api/v1/produk/9999")
assertEquals(HttpStatusCode.NotFound, respons.status)
}
@Test
fun `POST produk baru berhasil`() = testApplication {
application { konfigurasi() }
val respons = client.post("/api/v1/produk") {
contentType(ContentType.Application.Json)
setBody("""{"nama":"Keyboard","harga":500000,"stok":20,"kategori":"Elektronik"}""")
}
assertEquals(HttpStatusCode.Created, respons.status)
assertTrue(respons.bodyAsText().contains("Keyboard"))
}
}
Ringkasan #
- Ktor untuk proyek Kotlin-native — coroutine sebagai primitif utama, plugin system yang modular (pasang hanya yang dibutuhkan), startup sangat cepat. Pilihan terbaik untuk microservice baru yang ditulis dari nol dengan Kotlin.
- Spring Boot untuk ekosistem enterprise — auto-configuration, dependency injection dengan
@Autowired/konstruktor, integrasi kelas satu dengan Spring Security, Spring Data, Actuator. Pilih jika tim sudah familiar atau proyek butuh ekosistem yang sangat kaya.- Vert.x untuk throughput sangat tinggi — event loop non-blocking seperti Node.js tapi di JVM. Cocok untuk API gateway, proxy, atau layanan yang butuh menangani ratusan ribu koneksi bersamaan.
- Pisahkan route ke fungsi extension — daripada semua route dalam satu blok
routing {}, ekstrak ke fungsifun Route.produkRoutes(). Kode lebih terorganisir dan mudah di-test.- Error handling terpusat — gunakan
StatusPagesdi Ktor atau@RestControllerAdvicedi Spring Boot. Jangan tangani exception satu per satu di setiap handler.- Validasi input di controller, bukan di service — reject input yang tidak valid sejak awal. Di Ktor gunakan
require(), di Spring Boot gunakan@Validdengan annotation Bean Validation.- Gunakan
testApplicationuntuk testing Ktor — test in-memory tanpa port jaringan, lebih cepat dan tidak perlu mock HTTP client. Spring Boot punya@WebMvcTestuntuk tujuan serupa.- Paginasi dari awal — endpoint yang mengembalikan list selalu butuh paginasi. Tambahkan
?halaman=1&ukuran=10sejak endpoint pertama dibuat, bukan setelah data bertambah banyak.