Spring MVC adalah modul web Spring yang mengimplementasi pola Model-View-Controller. Untuk REST API, Spring menyediakan @RestController — kombinasi @Controller dan @ResponseBody yang otomatis mengserialisasi return value ke JSON. Pada chapter ini kita akan memahami anotasi inti Spring MVC dan memodelkan DispatcherServlet sebagai class Kotlin biasa yang dapat dijalankan tanpa dependensi eksternal.

@RestController #

@RestController menandai class sebagai handler HTTP yang return value-nya langsung menjadi body respons. Berbeda dengan @Controller tradisional yang return view name, @RestController mengembalikan objek yang diserialisasi menjadi JSON via Jackson (default).

kotlin @RestController
@RestController
@RequestMapping("/users")
class UserController(private val service: UserService) {
    @GetMapping
    fun list(): List<User> = service.list()

    @GetMapping("/{id}")
    fun get(@PathVariable id: Int): User = service.get(id)

    @PostMapping
    fun create(@RequestBody req: CreateUserRequest): User = service.create(req)
}

Anotasi @RequestMapping("/users") di level class memberi prefix path untuk seluruh method. @GetMapping adalah shortcut untuk @RequestMapping(method = [GET]).

HTTP Method Annotations #

Spring menyediakan shortcut anotasi untuk setiap HTTP method:

AnotasiHTTP MethodPenggunaan
@GetMappingGETBaca resource
@PostMappingPOSTBuat resource baru
@PutMappingPUTUpdate lengkap
@PatchMappingPATCHUpdate parsial
@DeleteMappingDELETEHapus resource

Pemilihan method yang sesuai penting untuk semantik REST. GET harus idempoten (tidak mengubah state). POST untuk membuat. PUT untuk update lengkap. DELETE untuk hapus.

Path Variable dan Request Body #

@PathVariable mengikat segmen URL ke parameter method. @RequestBody mengikat body request ke objek Kotlin via deserializer Jackson. Kombinasi ini membuat handler ringkas — Spring mengurus parsing dan binding.

kotlin Path variable dan body
@GetMapping("/{id}")
fun get(@PathVariable id: Int): User { ... }

@PostMapping
fun create(@RequestBody req: CreateUserRequest): User { ... }

Validasi dapat ditambahkan dengan @Valid — anotasi @NotBlank, @Min, @Email pada field DTO memicu validasi otomatis sebelum handler dijalankan.

DispatcherServlet #

DispatcherServlet adalah front controller Spring MVC. Setiap request HTTP masuk ke servlet ini, yang kemudian memilih handler berdasarkan path dan method. Handler dijalankan, return value diserialisasi, dan respons dikirim kembali ke klien.

Spring Boot mengonfigurasi DispatcherServlet otomatis — kita tidak perlu menulis XML atau Java config. Tetapi memahami alurnya penting untuk debugging.

Demo REST Controller #

Kita akan memodelkan DispatcherServlet sebagai class yang memetakan (method, path) ke handler. Program ini mendemonstrasikan GET, POST, DELETE dengan path variable dan request body.

kotlin
// spring_mvc_dan_rest_api.kt
// Mendemonstrasikan Spring MVC untuk REST API: @RestController,
// @GetMapping, @PostMapping, @RequestBody, @PathVariable. Pada kode produksi,
// Spring DispatcherServlet memproses anotasi ini. Di sini kita memodelkannya.

// Mock anotasi Spring Web
@Target(AnnotationTarget.CLASS) annotation class RestController
@Target(AnnotationTarget.FUNCTION) annotation class GetMapping(val path: String)
@Target(AnnotationTarget.FUNCTION) annotation class PostMapping(val path: String)
@Target(AnnotationTarget.FUNCTION) annotation class PutMapping(val path: String)
@Target(AnnotationTarget.FUNCTION) annotation class DeleteMapping(val path: String)
@Target(AnnotationTarget.VALUE_PARAMETER) annotation class PathVariable(val name: String = "")
@Target(AnnotationTarget.VALUE_PARAMETER) annotation class RequestBody
@Target(AnnotationTarget.VALUE_PARAMETER) annotation class RequestParam(val name: String = "")

data class User(val id: Int, val nama: String, val email: String)
data class UserRequest(val nama: String, val email: String)

// Mock repository in-memory
object UserRepository {
    private val users = mutableListOf(
        User(1, "Budi", "[email protected]"),
        User(2, "Siti", "[email protected]")
    )
    private var nextId = 100

    fun findAll(): List<User> = users.toList()
    fun findById(id: Int): User? = users.firstOrNull { it.id == id }
    fun save(nama: String, email: String): User {
        val u = User(nextId++, nama, email); users.add(u); return u
    }
    fun delete(id: Int): Boolean = users.removeIf { it.id == id }
}

// HTTP request representation
data class HttpRequest(
    val method: String,
    val path: String,
    val body: String = "",
    val queryParams: Map<String, String> = emptyMap()
)
data class HttpResponse(val status: Int, val body: String, val contentType: String = "application/json")

// DispatcherServlet mini: memetakan (method, path) ke handler
class DispatcherServlet {
    private val routes = mutableListOf<RouteHandler>()

    fun register(handler: RouteHandler) { routes.add(handler) }

    fun dispatch(req: HttpRequest): HttpResponse {
        for (route in routes) {
            val match = route.match(req.method, req.path)
            if (match != null) {
                val result = route.invoke(req, match)
                return HttpResponse(result.first, result.second)
            }
        }
        return HttpResponse(404, """{"error":"Not Found"}""")
    }
}

data class RouteHandler(
    val method: String,
    val pathPattern: String,
    val invoke: (HttpRequest, Map<String, String>) -> Pair<Int, String>
) {
    fun match(method: String, path: String): Map<String, String>? {
        if (method != this.method) return null
        val patternSegs = pathPattern.split("/").filter { it.isNotEmpty() }
        val pathSegs = path.split("/").filter { it.isNotEmpty() }
        if (patternSegs.size != pathSegs.size) return null
        val params = mutableMapOf<String, String>()
        for (i in patternSegs.indices) {
            val p = patternSegs[i]
            if (p.startsWith("{") && p.endsWith("}")) {
                params[p.removeSurrounding("{", "}")] = pathSegs[i]
            } else if (p != pathSegs[i]) return null
        }
        return params
    }
}

fun jsonUser(u: User) = """{"id":${u.id},"nama":"${u.nama}","email":"${u.email}"}"""
fun jsonUsers(list: List<User>) = list.joinToString(prefix = "[", postfix = "]") { jsonUser(it) }

// UserController: REST controller
@RestController
class UserController

fun buildUserControllerRoutes(ds: DispatcherServlet) {
    // @GetMapping("/users")
    ds.register(RouteHandler("GET", "/users") { req, _ ->
        200 to jsonUsers(UserRepository.findAll())
    })
    // @GetMapping("/users/{id}")
    ds.register(RouteHandler("GET", "/users/{id}") { _, params ->
        val id = params["id"]?.toIntOrNull() ?: return@RouteHandler 400 to """{"error":"Invalid id"}"""
        val u = UserRepository.findById(id) ?: return@RouteHandler 404 to """{"error":"Not Found"}"""
        200 to jsonUser(u)
    })
    // @PostMapping("/users")
    ds.register(RouteHandler("POST", "/users") { req, _ ->
        val nama = Regex(""""nama":"([^"]+)"""").find(req.body)?.groupValues?.get(1) ?: ""
        val email = Regex(""""email":"([^"]+)"""").find(req.body)?.groupValues?.get(1) ?: ""
        if (nama.isBlank() || email.isBlank()) return@RouteHandler 400 to """{"error":"Invalid body"}"""
        val saved = UserRepository.save(nama, email)
        201 to jsonUser(saved)
    })
    // @DeleteMapping("/users/{id}")
    ds.register(RouteHandler("DELETE", "/users/{id}") { _, params ->
        val id = params["id"]?.toIntOrNull() ?: return@RouteHandler 400 to """{"error":"Invalid id"}"""
        if (UserRepository.delete(id)) 204 to "" else 404 to """{"error":"Not Found"}"""
    })
}

fun main() {
    val ds = DispatcherServlet()
    buildUserControllerRoutes(ds)

    println("=== GET /users ===")
    val r1 = ds.dispatch(HttpRequest("GET", "/users"))
    println("Status: ${r1.status}")
    println("Body: ${r1.body}")

    println("\n=== GET /users/1 ===")
    val r2 = ds.dispatch(HttpRequest("GET", "/users/1"))
    println("Status: ${r2.status}")
    println("Body: ${r2.body}")

    println("\n=== POST /users ===")
    val r3 = ds.dispatch(HttpRequest("POST", "/users", body = """{"nama":"Andi","email":"[email protected]"}"""))
    println("Status: ${r3.status}")
    println("Body: ${r3.body}")

    println("\n=== DELETE /users/2 ===")
    val r4 = ds.dispatch(HttpRequest("DELETE", "/users/2"))
    println("Status: ${r4.status}")
    println("Body: ${if (r4.body.isEmpty()) "(empty)" else r4.body}")

    println("\n=== GET /users/999 (not found) ===")
    val r5 = ds.dispatch(HttpRequest("GET", "/users/999"))
    println("Status: ${r5.status}")
    println("Body: ${r5.body}")
}

Output yang Dihasilkan:

text
=== GET /users ===
Status: 200
Body: [{"id":1,"nama":"Budi","email":"[email protected]"}, {"id":2,"nama":"Siti","email":"[email protected]"}]

=== GET /users/1 ===
Status: 200
Body: {"id":1,"nama":"Budi","email":"[email protected]"}

=== POST /users ===
Status: 201
Body: {"id":100,"nama":"Andi","email":"[email protected]"}

=== DELETE /users/2 ===
Status: 204
Body: (empty)

=== GET /users/999 (not found) ===
Status: 404
Body: {"error":"Not Found"}

Mari kita bedah bagian penting kode di atas:

  1. @Target(AnnotationTarget.FUNCTION) annotation class GetMapping(val path: String): mock anotasi yang menyimpan path. Pada produksi, Spring membaca anotasi via refleksi dan mendaftarkan route ke RequestMappingHandlerMapping.
  2. fun match(method, path): Map<String, String>?: algoritma pencocokan path. Pattern /users/{id} cocok dengan /users/42, dan id="42" dimasukkan ke map. Bila pattern tidak cocok, kembalikan null agar dispatcher mencoba route berikutnya.
  3. return@RouteHandler 404 to ...: return label dari lambda. Lambda RouteHandler.invoke mengembalikan Pair<Int, String> — label @RouteHandler memastikan return dievaluasi sebagai Pair, bukan diterjemahkan sebagai return dari function terdekat.
  4. 204 to "": DELETE berhasil mengembalikan 204 No Content dengan body kosong. Pola REST yang benar — DELETE tidak mengembalikan representasi resource yang sudah dihapus.
  5. val r3 = ds.dispatch(HttpRequest("POST", "/users", body=...)): simulasi POST dengan body. Pada produksi, Spring memakai Jackson untuk deserialize body ke UserRequest — di sini kita parse manual dengan regex.

Response Status Code #

Spring MVC memilih status code berdasarkan konvensi: 200 OK untuk GET berhasil, 201 Created untuk POST yang membuat resource, 204 No Content untuk DELETE. Untuk override, gunakan @ResponseStatus(HttpStatus.CREATED) pada method atau ResponseEntity<T> sebagai return type.

Referensi Lanjutan #

Selanjutnya #

Setelah memahami REST controller, langkah berikutnya adalah persistensi. Chapter Spring Data JPA akan membahas JpaRepository<T, ID>, derived query methods, dan bagaimana Spring menghasilkan implementasi repository otomatis dari interface.