Web Server

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"]
AspekKtorSpring BootVert.x
Bahasa desainKotlin-firstJava (Kotlin didukung)Java (Kotlin didukung)
ConcurrencyCoroutineThread-based + reaktifEvent loop
Startup timeSangat cepatSedangCepat
Footprint memoriKecilSedang-BesarKecil
EkosistemBerkembangSangat kayaKaya
Kurva belajarSedangRendah (dengan Spring)Tinggi
Cocok untukMicroservice, API modernEnterprise, monolithHigh-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 fungsi fun Route.produkRoutes(). Kode lebih terorganisir dan mudah di-test.
  • Error handling terpusat — gunakan StatusPages di Ktor atau @RestControllerAdvice di 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 @Valid dengan annotation Bean Validation.
  • Gunakan testApplication untuk testing Ktor — test in-memory tanpa port jaringan, lebih cepat dan tidak perlu mock HTTP client. Spring Boot punya @WebMvcTest untuk tujuan serupa.
  • Paginasi dari awal — endpoint yang mengembalikan list selalu butuh paginasi. Tambahkan ?halaman=1&ukuran=10 sejak endpoint pertama dibuat, bukan setelah data bertambah banyak.

← Sebelumnya: Web Socket   Berikutnya: Unit Test →

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