Struktur backend di Bagian 1 hanya menangani dua endpoint statis. Pada Bagian 2 kita membangun REST API yang sebenarnya: CRUD lengkap untuk entitas Tugas, dengan parsing body JSON otomatis dan validasi request yang menghasilkan pesan error terstruktur. Setiap endpoint mengembalikan status code HTTP yang sesuai dan body JSON yang konsisten — pola yang menjadi standar REST API modern.

Endpoint CRUD #

Lima endpoint yang akan kita bangun:

  • POST /api/tasks — buat tugas baru, body {"judul": "...", "selesai": false}, kembalikan 201 Created.
  • GET /api/tasks — daftar semua tugas, kembalikan 200 OK dengan array.
  • GET /api/tasks/{id} — ambil satu tugas, kembalikan 200 OK atau 404 Not Found.
  • PUT /api/tasks/{id} — perbarui tugas, kembalikan 200 OK atau 404.
  • DELETE /api/tasks/{id} — hapus tugas, kembalikan 204 No Content atau 404.

Status code ini mengikuti konvensi REST: 201 untuk created, 204 untuk delete sukses tanpa body, 404 untuk not found, 400 untuk validasi gagal.

Validasi Request #

Validasi adalah tempat banyak API pemula terjatuh. Validasi yang baik mengembalikan pesan yang spesifik per field, sehingga client dapat menampilkan pesan di sisi UI:

json Contoh response 400 Bad Request
{
  "errors": [
    {"field": "judul", "pesan": "judul wajib diisi dan tidak boleh kosong"},
    {"field": "selesai", "pesan": "selesai harus boolean"}
  ]
}

Pemisahan per field membuat client dapat menyorot field yang bermasalah di form, bukan hanya menampilkan satu pesan umum.

Implementasi Controller dan Validator #

Kode berikut mendemonstrasikan controller dengan parsing, validasi, dan response builder:

kotlin
// Bagian 2: REST API dan validasi request.
// Mendemonstrasikan parsing body JSON, validasi field dengan pesan
// terstruktur, dan response status code yang konsisten.

data class TugasRequest(
    val judul: String?,
    val selesai: Boolean?
)

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

data class ValidasiError(
    val field: String,
    val pesan: String
)

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

// === Parser JSON sederhana untuk demo ===
// Pada Ktor asli: install(ContentNegotiation) { json() }
// lalu terima parameter `@Body body: TugasRequest` di handler.
object JsonParser {
    private val FIELD_RE = Regex(""""(\w+)"\s*:\s*(?:"([^"]*)"|(true|false)|(\d+))""")

    fun parseTugasRequest(json: String): TugasRequest {
        val fields = mutableMapOf<String, String>()
        FIELD_RE.findAll(json).forEach { m ->
            val (key, strVal, boolVal, intVal) = m.destructured
            fields[key] = strVal.ifBlank { boolVal.ifBlank { intVal } }
        }
        return TugasRequest(
            judul = fields["judul"],
            selesai = fields["selesai"]?.toBooleanStrictOrNull()
        )
    }
}

object Validator {
    fun validasiTugas(req: TugasRequest): List<ValidasiError> {
        val errors = mutableListOf<ValidasiError>()
        if (req.judul.isNullOrBlank()) {
            errors.add(ValidasiError("judul", "judul wajib diisi dan tidak boleh kosong"))
        } else if (req.judul.length > 200) {
            errors.add(ValidasiError("judul", "judul maksimal 200 karakter"))
        }
        return errors
    }
}

class TugasRepo {
    private val daftar = mutableListOf<Tugas>()
    private var urutan = 0L

    fun semua(): List<Tugas> = daftar.toList()
    fun cari(id: Long): 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: Long, judul: String?, selesai: Boolean?): Tugas? {
        val lama = cari(id) ?: return null
        val baru = lama.copy(judul = judul ?: lama.judul, selesai = selesai ?: lama.selesai)
        val idx = daftar.indexOfFirst { it.id == id }
        daftar[idx] = baru
        return baru
    }
    fun hapus(id: Long): Boolean = daftar.removeIf { it.id == id }
}

class TugasController(private val repo: TugasRepo) {
    fun list(): HttpResponse =
        HttpResponse(200, """{"tasks":[${repo.semua().joinToString(",") { serializeTugas(it) }}]}""")

    fun create(body: String): HttpResponse {
        val req = JsonParser.parseTugasRequest(body)
        val errors = Validator.validasiTugas(req)
        if (errors.isNotEmpty()) {
            val errBody = errors.joinToString(",") { """{"field":"${it.field}","pesan":"${it.pesan}"}""" }
            return HttpResponse(400, """{"errors":[$errBody]}""")
        }
        val t = repo.tambah(req.judul!!)
        return HttpResponse(201, serializeTugas(t))
    }

    fun get(id: Long): HttpResponse {
        val t = repo.cari(id)
            ?: return HttpResponse(404, """{"error":"tugas $id tidak ditemukan"}""")
        return HttpResponse(200, serializeTugas(t))
    }

    fun update(id: Long, body: String): HttpResponse {
        val req = JsonParser.parseTugasRequest(body)
        val errors = Validator.validasiTugas(req)
        if (errors.isNotEmpty()) {
            val errBody = errors.joinToString(",") { """{"field":"${it.field}","pesan":"${it.pesan}"}""" }
            return HttpResponse(400, """{"errors":[$errBody]}""")
        }
        val t = repo.perbarui(id, req.judul, req.selesai)
            ?: return HttpResponse(404, """{"error":"tugas $id tidak ditemukan"}""")
        return HttpResponse(200, serializeTugas(t))
    }

    fun delete(id: Long): HttpResponse {
        val ok = repo.hapus(id)
        return if (ok) HttpResponse(204, "") else HttpResponse(404, """{"error":"tugas $id tidak ditemukan"}""")
    }

    private fun serializeTugas(t: Tugas): String =
        """{"id":${t.id},"judul":"${t.judul}","selesai":${t.selesai}}"""
}

fun main() {
    val controller = TugasController(TugasRepo())

    println("=== POST /api/tasks (valid) ===")
    val r1 = controller.create("""{"judul":"Belajar Ktor"}""")
    println("  Status: ${r1.status}")
    println("  Body  : ${r1.body}")

    println()
    println("=== POST /api/tasks (valid dengan selesai) ===")
    val r2 = controller.create("""{"judul":"Bangun REST API","selesai":false}""")
    println("  Status: ${r2.status}")
    println("  Body  : ${r2.body}")

    println()
    println("=== POST /api/tasks (invalid: judul kosong) ===")
    val r3 = controller.create("""{"judul":""}""")
    println("  Status: ${r3.status}")
    println("  Body  : ${r3.body}")

    println()
    println("=== POST /api/tasks (invalid: field judul hilang) ===")
    val r4 = controller.create("""{"selesai":true}""")
    println("  Status: ${r4.status}")
    println("  Body  : ${r4.body}")

    println()
    println("=== GET /api/tasks ===")
    val r5 = controller.list()
    println("  Status: ${r5.status}")
    println("  Body  : ${r5.body}")

    println()
    println("=== GET /api/tasks/1 ===")
    val r6 = controller.get(1)
    println("  Status: ${r6.status}")
    println("  Body  : ${r6.body}")

    println()
    println("=== PUT /api/tasks/1 (tandai selesai) ===")
    val r7 = controller.update(1, """{"judul":"Belajar Ktor","selesai":true}""")
    println("  Status: ${r7.status}")
    println("  Body  : ${r7.body}")

    println()
    println("=== GET /api/tasks/999 (not found) ===")
    val r8 = controller.get(999)
    println("  Status: ${r8.status}")
    println("  Body  : ${r8.body}")

    println()
    println("=== DELETE /api/tasks/2 ===")
    val r9 = controller.delete(2)
    println("  Status: ${r9.status} (204 = no content)")
}

Output yang Dihasilkan:

text
=== POST /api/tasks (valid) ===
  Status: 201
  Body  : {"id":1,"judul":"Belajar Ktor","selesai":false}

=== POST /api/tasks (valid dengan selesai) ===
  Status: 201
  Body  : {"id":2,"judul":"Bangun REST API","selesai":false}

=== POST /api/tasks (invalid: judul kosong) ===
  Status: 400
  Body  : {"errors":[{"field":"judul","pesan":"judul wajib diisi dan tidak boleh kosong"}]}

=== POST /api/tasks (invalid: field judul hilang) ===
  Status: 400
  Body  : {"errors":[{"field":"judul","pesan":"judul wajib diisi dan tidak boleh kosong"}]}

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

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

=== PUT /api/tasks/1 (tandai selesai) ===
  Status: 200
  Body  : {"id":1,"judul":"Belajar Ktor","selesai":true}

=== GET /api/tasks/999 (not found) ===
  Status: 404
  Body  : {"error":"tugas 999 tidak ditemukan"}

=== DELETE /api/tasks/2 ===
  Status: 204 (204 = no content)

Mari kita bedah komponen utama:

  1. object JsonParser dengan parseTugasRequest(json: String): parser JSON minimal yang memakai regex untuk mengekstrak field. Di Ktor asli, fungsi ini di-handle oleh ContentNegotiation + kotlinx.serialization — handler menerima parameter body: TugasRequest langsung, tanpa parsing manual.
  2. object Validator.validasiTugas(req): mengembalikan List<ValidasiError>, bukan Boolean atau exception. Pola ini memungkinkan beberapa error diakumulasi sekaligus (judul kosong + selesai bukan boolean) dan dikembalikan dalam satu response 400.
  3. repo.cari(id) ?: return HttpResponse(404, ...): pola Elvis untuk menangani null. Jika cari mengembalikan null (tugas tidak ada), langsung return 404. Ekspresi ini lebih ringkas daripada if (t == null) return ... else ....
  4. lama.copy(judul = judul ?: lama.judul, selesai = selesai ?: lama.selesai): immutable update dengan copy(). Field yang null (tidak disertakan di request) mempertahankan nilai lama via Elvis. Pola ini cocok untuk PATCH/PUT partial update.
  5. HttpResponse(204, ""): status 204 No Content untuk DELETE sukses. Body kosong; client tahu resource telah dihapus tanpa perlu menerima data baru.

Idempotency REST #

Empat dari lima endpoint bersifat idempotent: GET, PUT, DELETE dapat dipanggil berulang dengan hasil yang sama. Hanya POST yang tidak idempotent — POST dua kali dengan body yang sama membuat dua tugas. Konsep ini penting karena:

  • Client dapat safely retry GET/PUT/DELETE jika network gagal,
  • POST harus hati-hati: retry dapat membuat duplikat. Untuk POST idempotent, gunakan idempotency key di header.

Validasi dengan kotlinx.serialization #

Di Ktor asli, validasi otomatis via @Serializable dan kontrak:

kotlin Handler Ktor asli dengan @Body
@Serializable
data class TugasRequest(
    @SerialName("judul") val judul: String,
    @SerialName("selesai") val selesai: Boolean = false
)

post("/api/tasks") {
    val req = call.receive<TugasRequest>()
    val errors = Validator.validasi(req)
    if (errors.isNotEmpty()) {
        call.respond(HttpStatusCode.BadRequest, mapOf("errors" to errors))
        return@post
    }
    val t = repo.tambah(req.judul)
    call.respond(HttpStatusCode.Created, t)
}

call.receive<TugasRequest>() otomatis mem-parsing body JSON menjadi object. Jika JSON tidak valid (misal field judul hilang), Ktor melempar BadRequestException yang ditangkap oleh StatusPages plugin dan dikonversi menjadi response 400.

Status Code yang Konsisten #

Tabel berikut merangkum status code yang dipakai endpoint CRUD:

OperasiSuksesTidak ditemukanValidasi gagal
POST201 Created—400 Bad Request
GET200 OK404 Not Found—
PUT200 OK404 Not Found400 Bad Request
DELETE204 No Content404 Not Found—

Konsistensi status code memudahkan client menulis handler yang seragam. Misalnya, client dapat selalu memeriksa if (status == 404) tampilkan pesan "tidak ditemukan" tanpa peduli endpoint mana yang dipanggil.

Referensi Lanjutan #

  • Ktor routing — panduan routing Ktor untuk menata endpoint REST per sumber daya
  • Ktor requests — panduan membaca body dan query parameter untuk validasi input endpoint

Ringkasan Bagian 2 #

REST API CRUD kini lengkap dengan validasi terstruktur. TugasController menangani lima endpoint dengan parsing, validasi, dan response builder yang konsisten. Validator mengakumulasi error per field. Status code mengikuti konvensi REST. Pada Bagian 3 kita menambahkan database via Exposed dan authentication JWT, sehingga data tersimpan persisten dan endpoint terlindungi.