Pada Bagian 1 kita merancang kontrak. Sekarang kita mengimplementasikan routing yang melayani kontrak tersebut. Untuk belajar tanpa menjalankan server nyata, kita memakai simulasi router berbasis when yang menerima (metode, jalur) dan mengembalikan ResponsHttp. Pola ini memudahkan pengujian dan memperjelas logika routing.

Pola Routing #

Router memetakan pasangan (metode, jalur) ke handler. Untuk path parameter seperti /tasks/{id}, kita memakai regex untuk mengekstrak ID. Setiap handler memanggil repo dan mengembalikan response dengan status code yang sesuai.

Implementasi Router #

Kode berikut menunjukkan implementasi router lengkap beserta repo in-memory:

kotlin
// Bagian 2: Implementasi endpoint dengan Ktor routing (simulasi).
// Mendemonstrasikan struktur routing, parsing path parameter, dan
// response JSON tanpa menjalankan server nyata.

import java.io.File

data class Tugas(
    val id: Int,
    val judul: String,
    val selesai: Boolean = false
)

object RepoTugas {
    private val daftar = mutableListOf<Tugas>()
    private var urutan = 0

    fun semua(): List<Tugas> = daftar.toList()
    fun cari(id: Int): Tugas? = daftar.firstOrNull { it.id == id }
    fun tambah(judul: String): Tugas {
        urutan += 1
        val t = Tugas(urutan, judul)
        daftar.add(t)
        return t
    }
    fun perbarui(id: Int, judul: String?, selesai: Boolean?): Tugas? {
        val idx = daftar.indexOfFirst { it.id == id }
        if (idx == -1) return null
        val lama = daftar[idx]
        val baru = lama.copy(judul = judul ?: lama.judul, selesai = selesai ?: lama.selesai)
        daftar[idx] = baru
        return baru
    }
    fun hapus(id: Int): Boolean = daftar.removeIf { it.id == id }
}

object JsonRingkas {
    fun tugas(t: Tugas): String =
        "{\"id\":${t.id},\"judul\":\"${t.judul}\",\"selesai\":${t.selesai}}"

    fun daftarTugas(daftar: List<Tugas>): String =
        "[" + daftar.joinToString(",") { tugas(it) } + "]"

    private val RE = Regex("""\{"id":(\d+),"judul":"([^"]+)","selesai":(true|false)\}""")
    fun parseTugas(teks: String): Tugas? {
        val m = RE.find(teks) ?: return null
        val (id, judul, selesai) = m.destructured
        return Tugas(id.toInt(), judul, selesai.toBooleanStrict())
    }
}

data class ResponsHttp(val status: Int, val body: String)

object Router {
    fun route(metode: String, jalur: String, body: String = ""): ResponsHttp {
        return when (metode to jalur) {
            "GET" to "/tasks" -> ResponsHttp(200, JsonRingkas.daftarTugas(RepoTugas.semua()))
            "POST" to "/tasks" -> {
                val t = JsonRingkas.parseTugas(body)
                if (t == null || t.judul.isBlank())
                    return ResponsHttp(400, "{\"galat\":\"Judul tidak boleh kosong\"}")
                val baru = RepoTugas.tambah(t.judul)
                ResponsHttp(201, JsonRingkas.tugas(baru))
            }
            else -> {
                val matchId = Regex("""/tasks/(\d+)""").find(jalur)
                if (matchId != null) {
                    val id = matchId.groupValues[1].toInt()
                    return when (metode) {
                        "GET" -> {
                            val t = RepoTugas.cari(id)
                            if (t != null) ResponsHttp(200, JsonRingkas.tugas(t))
                            else ResponsHttp(404, "{\"galat\":\"Tugas id=$id tidak ditemukan\"}")
                        }
                        "DELETE" -> {
                            if (RepoTugas.hapus(id)) ResponsHttp(204, "")
                            else ResponsHttp(404, "{\"galat\":\"Tugas id=$id tidak ditemukan\"}")
                        }
                        else -> ResponsHttp(405, "{\"galat\":\"Metode tidak diizinkan\"}")
                    }
                }
                ResponsHttp(404, "{\"galat\":\"Endpoint tidak ditemukan\"}")
            }
        }
    }
}

fun main() {
    // Setup: tambah beberapa tugas
    RepoTugas.tambah("Belajar Ktor")
    RepoTugas.tambah("Bangun REST API")

    println("=== GET /tasks ===")
    val r1 = Router.route("GET", "/tasks")
    println("Status: ${r1.status}")
    println("Body: ${r1.body}")

    println()
    println("=== POST /tasks (valid) ===")
    val r2 = Router.route("POST", "/tasks", "{\"id\":0,\"judul\":\"Tulis dokumentasi\",\"selesai\":false}")
    println("Status: ${r2.status}")
    println("Body: ${r2.body}")

    println()
    println("=== POST /tasks (invalid) ===")
    val r3 = Router.route("POST", "/tasks", "{\"id\":0,\"judul\":\"\",\"selesai\":false}")
    println("Status: ${r3.status}")
    println("Body: ${r3.body}")

    println()
    println("=== GET /tasks/1 ===")
    val r4 = Router.route("GET", "/tasks/1")
    println("Status: ${r4.status}")
    println("Body: ${r4.body}")

    println()
    println("=== GET /tasks/99 ===")
    val r5 = Router.route("GET", "/tasks/99")
    println("Status: ${r5.status}")
    println("Body: ${r5.body}")

    println()
    println("=== DELETE /tasks/1 ===")
    val r6 = Router.route("DELETE", "/tasks/1")
    println("Status: ${r6.status}")
    println("Body: ${r6.body}")
}

Output yang Dihasilkan:

text
=== GET /tasks ===
Status: 200
Body: [{"id":1,"judul":"Belajar Ktor","selesai":false},{"id":2,"judul":"Bangun REST API","selesai":false}]

=== POST /tasks (valid) ===
Status: 201
Body: {"id":3,"judul":"Tulis dokumentasi","selesai":false}

=== POST /tasks (invalid) ===
Status: 400
Body: {"galat":"Judul tidak boleh kosong"}

=== GET /tasks/1 ===
Status: 200
Body: {"id":1,"judul":"Belajar Ktor","selesai":false}

=== GET /tasks/99 ===
Status: 404
Body: {"galat":"Tugas id=99 tidak ditemukan"}

=== DELETE /tasks/1 ===
Status: 204
Body:

Mari kita bedah implementasi:

  1. when (metode to jalur): pola routing utama. Pasangan metode dan jalur cocok langsung dengan cabang-cabang. Untuk path dengan parameter, jatuh ke cabang else yang memproses regex.
  2. Regex("""/tasks/(\d+)""").find(jalur): mengekstrak ID numerik dari path. (\d+) menangkap satu atau lebih digit. Hasilnya berupa MatchResult? yang diperiksa nullability-nya.
  3. matchId.groupValues[1]: grup pertama dari regex (yang berada dalam kurung). Indeks 0 adalah seluruh match, indeks 1 dan seterusnya adalah grup tangkapan.
  4. JsonRingkas.tugas(t): serialisasi satu tugas ke JSON. Format string konsisten dengan regex parsing agar round-trip berhasil.
  5. JsonRingkas.daftarTugas(daftar): serialisasi list. Menggunakan joinToString(",") untuk menggabungkan.
  6. ResponsHttp(204, ""): response DELETE sukses mengembalikan body kosong. Status 204 No Content secara definisi tidak memiliki body.

Implementasi dengan Ktor Sebenarnya #

Pada implementasi Ktor nyata, routing ditulis dengan DSL yang lebih ringkas. Konsepnya sama; perbedaan hanya sintaks:

kotlin Routing dengan Ktor DSL
routing {
    route("/tasks") {
        get {
            call.respond(RepoTugas.semua())
        }
        post {
            val tugas = call.receive<PermintaanBuat>()
            val baru = RepoTugas.tambah(tugas.judul)
            call.respond(HttpStatusCode.Created, baru)
        }
        get("{id}") {
            val id = call.parameters["id"]?.toIntOrNull()
                ?: return@get call.respond(HttpStatusCode.BadRequest)
            val t = RepoTugas.cari(id)
                ?: return@get call.respond(HttpStatusCode.NotFound)
            call.respond(t)
        }
    }
}

Ktor DSL menggunakan lambda dengan receiver sehingga get, post, dan route dapat dirantai. call.respond() otomatis men-serialisasi objek ke JSON melalui plugin ContentNegotiation.

Validasi Input #

Validasi terjadi sebelum mutasi. Untuk POST /tasks, kita memeriksa apakah judul tidak kosong:

kotlin Validasi judul
val t = JsonRingkas.parseTugas(body)
if (t == null || t.judul.isBlank()) {
    return ResponsHttp(400, "{\"galat\":\"Judul tidak boleh kosong\"}")
}

Validasi mengembalikan 400 Bad Request jika gagal, sebelum repo menyentuh data. Pola ini mencegah data tidak valid masuk ke sistem.

Referensi Lanjutan #

  • Ktor requests — panduan membaca body dan parameter untuk endpoint pembuatan serta pembaruan tugas
  • Ktor responses — panduan respond() dan status code untuk mengembalikan hasil endpoint REST

Ringkasan Bagian 2 #

Routing kini berfungsi: Router.route(metode, jalur, body) memetakan request ke response dengan status code yang tepat. JsonRingkas menangani serialisasi manual, dan RepoTugas menyimpan data in-memory. Pada Bagian 3 kita mengganti repo in-memory dengan database SQLite dan menambahkan validasi yang lebih ketat.