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:
// 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:
=== 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:
when (metode to jalur): pola routing utama. Pasangan metode dan jalur cocok langsung dengan cabang-cabang. Untuk path dengan parameter, jatuh ke cabangelseyang memproses regex.Regex("""/tasks/(\d+)""").find(jalur): mengekstrak ID numerik dari path.(\d+)menangkap satu atau lebih digit. Hasilnya berupaMatchResult?yang diperiksa nullability-nya.matchId.groupValues[1]: grup pertama dari regex (yang berada dalam kurung). Indeks 0 adalah seluruh match, indeks 1 dan seterusnya adalah grup tangkapan.JsonRingkas.tugas(t): serialisasi satu tugas ke JSON. Format string konsisten dengan regex parsing agar round-trip berhasil.JsonRingkas.daftarTugas(daftar): serialisasi list. MenggunakanjoinToString(",")untuk menggabungkan.ResponsHttp(204, ""): responseDELETEsukses mengembalikan body kosong. Status204 No Contentsecara definisi tidak memiliki body.
Implementasi dengan Ktor Sebenarnya #
Pada implementasi Ktor nyata, routing ditulis dengan DSL yang lebih ringkas. Konsepnya sama; perbedaan hanya sintaks:
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:
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.