Chapter sebelumnya memperlihatkan DispatcherServlet sebagai pintu tunggal; sekarang kita berdiri di sisi yang terlihat oleh pengembang: mendeklarasikan rute. Di Spring MVC, rute bukan tabel terpusat yang ditulis manual — rute adalah anotasi pada method kelas controller, dibaca saat startup untuk membentuk handler mapping. Pada Chapter ini kita pelajari keluarga anotasi pemetaan, perbedaan path variable dan query parameter, serta konvensi penamaan resource REST yang membuat API mudah ditebak konsumennya.

Keluarga Anotasi Pemetaan #

Anotasi @RequestMapping adalah induk semua pemetaan; anotasi kecil @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, dan @PatchMapping adalah singkatannya yang terikat satu HTTP method. Kombinasi lazimnya menumpuk anotasi kelas dan anotasi method — berikut controller produk lengkap, ditampilkan apa adanya untuk Spring Boot 3.4/3.5:

java
@RestController
@RequestMapping("/api/produk")
public class ProdukController {

    private final ProdukService service;

    public ProdukController(ProdukService service) {
        this.service = service;
    }

    @GetMapping
    public List<Produk> semua() {
        return service.semua();
    }

    @GetMapping("/{id}")
    public Produk satu(@PathVariable Long id) {
        return service.cari(id).orElseThrow();
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Produk simpan(@RequestBody PermintaanSimpan body) {
        return service.simpan(body);
    }

    @PutMapping("/{id}")
    public Produk perbarui(@PathVariable Long id, @RequestBody PermintaanSimpan body) {
        return service.perbarui(id, body);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void hapus(@PathVariable Long id) {
        service.hapus(id);
    }
}

Anotasi @RequestMapping("/api/produk") di kelas menjadi prefiks seluruh rute di dalamnya; anotasi method hanya menuliskan sisanya. Method semua() tanpa path tambahan menerima GET /api/produk, sedangkan satu() menerima GET /api/produk/{id}.

Path Variable versus Query Parameter #

Dua mekanisme parameter hidup di tempat berbeda. Path variable — segmen {id} yang diikat @PathVariable — mengidentifikasi resource itu sendiri: /api/produk/17 mengidentifikasi produk bernomor 17. Query parameter — yang diikat @RequestParam, misalnya /api/produk?kategori=kopi&halaman=2 — menyaring atau mengubah cara penyajian koleksi. Aturan praktisnya: jika tanpa parameter itu resource tidak bermakna, pakai path variable; jika resource tetap bermakna namun tampilannya menyempit, pakai query parameter.

java
@GetMapping
public List<Produk> cari(@RequestParam String kategori,
                         @RequestParam(defaultValue = "1") int halaman) {
    return service.cari(kategori, halaman);
}

Atribut defaultValue mengisi nilai saat parameter tidak dikirim — tanpa atribut ini, parameter wajib dan permintaan tanpa parameter menghasilkan respons 400.

Simulasi Tabel Routing #

Pencocokan rute layak dibedah secara mekanis. Program Java murni berikut membangun tabel routing dari pola /produk/{id}, mencocokkan lima permintaan, dan membedakan hasil 200, 405, dan 404:

java TabelRute.java
import java.util.LinkedHashMap;
import java.util.Map;

public class TabelRute {

    record Rute(String method, String pola) { }

    private final Map<Rute, String> handler = new LinkedHashMap<>();

    public static void main(String[] args) {
        TabelRute tabel = new TabelRute();
        tabel.daftar("GET", "/produk", "ProdukController.semua()");
        tabel.daftar("GET", "/produk/{id}", "ProdukController.satu(id)");
        tabel.daftar("POST", "/produk", "ProdukController.simpan()");
        tabel.daftar("PUT", "/produk/{id}", "ProdukController.perbarui(id)");
        tabel.daftar("DELETE", "/produk/{id}", "ProdukController.hapus(id)");

        tabel.cocokkan("GET /produk");
        tabel.cocokkan("GET /produk/17");
        tabel.cocokkan("PUT /produk/17");
        tabel.cocokkan("PATCH /produk/17");
        tabel.cocokkan("GET /produk/17/ulasan");
    }

    void daftar(String method, String pola, String handlerDescriptor) {
        handler.put(new Rute(method, pola), handlerDescriptor);
    }

    void cocokkan(String permintaan) {
        System.out.println("Request: " + permintaan);
        String[] bagian = permintaan.split(" ", 2);
        String method = bagian[0];
        String path = bagian[1];

        // Pass 1: method dan path harus cocok penuh.
        for (Map.Entry<Rute, String> e : handler.entrySet()) {
            Map<String, String> variabel = cocok(method, path, e.getKey());
            if (variabel != null) {
                System.out.println("  -> handler : " + e.getValue());
                variabel.forEach((k, v) -> System.out.println("  -> @" + k + " = " + v));
                System.out.println();
                return;
            }
        }

        // Pass 2: path cocok tetapi method salah -> 405.
        for (Map.Entry<Rute, String> e : handler.entrySet()) {
            if (cocok("GET", path, e.getKey()) != null || cocok("POST", path, e.getKey()) != null
                    || cocok("PUT", path, e.getKey()) != null || cocok("DELETE", path, e.getKey()) != null) {
                System.out.println("  -> 405 Method Not Allowed (method " + method
                        + " tidak terdaftar untuk path ini)");
                System.out.println();
                return;
            }
        }

        System.out.println("  -> 404 Not Found (tidak ada rute yang cocok)");
        System.out.println();
    }

    // Pencocokkan pola "/produk/{id}" terhadap "/produk/17".
    Map<String, String> cocok(String method, String path, Rute rute) {
        if (!rute.method().equals(method)) {
            return null;
        }
        String[] segPermintaan = path.split("/");
        String[] segPola = rute.pola().split("/");
        if (segPermintaan.length != segPola.length) {
            return null;
        }
        Map<String, String> variabel = new LinkedHashMap<>();
        for (int i = 0; i < segPola.length; i++) {
            String pola = segPola[i];
            String aktual = segPermintaan[i];
            if (pola.startsWith("{") && pola.endsWith("}")) {
                variabel.put("PathVariable " + pola.substring(1, pola.length() - 1), aktual);
            } else if (!pola.equals(aktual)) {
                return null;
            }
        }
        return variabel;
    }
}

Output yang Dihasilkan:

text
Request: GET /produk
  -> handler : ProdukController.semua()

Request: GET /produk/17
  -> handler : ProdukController.satu(id)
  -> @PathVariable id = 17

Request: PUT /produk/17
  -> handler : ProdukController.perbarui(id)
  -> @PathVariable id = 17

Request: PATCH /produk/17
  -> 405 Method Not Allowed (method PATCH tidak terdaftar untuk path ini)

Request: GET /produk/17/ulasan
  -> 404 Not Found (tidak ada rute yang cocok)

Mari kita bedah mekanismenya:

  1. cocok(String method, String path, Rute rute): method ini membandingkan dua hal — HTTP method harus sama, lalu path dipecah per segmen dengan split("/") dan panjang segmennya harus sama. Segmen pola {id} dianggap placeholder yang menampung nilai segmen aktual apa pun.
  2. variabel.put("PathVariable " + ...): nilai placeholder dikumpulkan ke map, persis yang dilakukan HandlerAdapter ketika menyiapkan argumen @PathVariable Long id — hanya saja Spring melanjutkannya dengan konversi "17" menjadi Long.
  3. Pass 2 di cocokkan: permintaan PATCH /produk/17 gagal di pass pertama karena method tidak cocok, tetapi pass kedua menemukan path-nya terdaftar dengan method lain — hasilnya 405 Method Not Allowed, bukan 404. Pembedaan ini penting: 404 berarti resource tidak dikenal, 405 berarti resource ada tetapi verb-nya salah.
  4. GET /produk/17/ulasan: jumlah segmen path melebihi pola mana pun, sehingga loop tidak menemukan kecocokan dan berujung 404. Panjang segmen adalah bagian dari identitas rute — Spring juga menyediakan /** untuk menangkap sisa path bila benar-benar diperlukan.

Konvensi Penamaan Resource REST #

Routing yang baik mengikuti konvensi yang bisa ditebak. Prinsip intinya: URL menunjuk noun (resource), HTTP method menjadi verb (aksi). Tabel berikut merangkum pemetaan standar untuk resource produk:

OperasiMethod dan pathStatus sukses
DaftarGET /api/produk200
DetailGET /api/produk/{id}200
BuatPOST /api/produk201
Ganti seluruhnyaPUT /api/produk/{id}200
HapusDELETE /api/produk/{id}204

Hindari menjejalkan verb ke path seperti POST /api/produk/hapus/17 — pola ini menggandakan cara menghapus dan memecah konvensi. Untuk aksi non-CRUD yang sungguh khas domain (misalnya POST /api/pesanan/{id}/pembatalan), sub-resource tetap sah asalkan konsisten. Dengan rute di tangan, Chapter berikutnya mengurai detail ikutan parameter: bagaimana path variable, query param, dan body JSON diikat menjadi objek Java.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

TabelRute.java

Unduh berkas
java
import java.util.LinkedHashMap;
import java.util.Map;

public class TabelRute {

    record Rute(String method, String pola) { }

    private final Map<Rute, String> handler = new LinkedHashMap<>();

    public static void main(String[] args) {
        TabelRute tabel = new TabelRute();
        tabel.daftar("GET", "/produk", "ProdukController.semua()");
        tabel.daftar("GET", "/produk/{id}", "ProdukController.satu(id)");
        tabel.daftar("POST", "/produk", "ProdukController.simpan()");
        tabel.daftar("PUT", "/produk/{id}", "ProdukController.perbarui(id)");
        tabel.daftar("DELETE", "/produk/{id}", "ProdukController.hapus(id)");

        tabel.cocokkan("GET /produk");
        tabel.cocokkan("GET /produk/17");
        tabel.cocokkan("PUT /produk/17");
        tabel.cocokkan("PATCH /produk/17");
        tabel.cocokkan("GET /produk/17/ulasan");
    }

    void daftar(String method, String pola, String handlerDescriptor) {
        handler.put(new Rute(method, pola), handlerDescriptor);
    }

    void cocokkan(String permintaan) {
        System.out.println("Request: " + permintaan);
        String[] bagian = permintaan.split(" ", 2);
        String method = bagian[0];
        String path = bagian[1];

        // Pass 1: method dan path harus cocok penuh.
        for (Map.Entry<Rute, String> e : handler.entrySet()) {
            Map<String, String> variabel = cocok(method, path, e.getKey());
            if (variabel != null) {
                System.out.println("  -> handler : " + e.getValue());
                variabel.forEach((k, v) -> System.out.println("  -> @" + k + " = " + v));
                System.out.println();
                return;
            }
        }

        // Pass 2: path cocok tetapi method salah -> 405.
        for (Map.Entry<Rute, String> e : handler.entrySet()) {
            if (cocok("GET", path, e.getKey()) != null || cocok("POST", path, e.getKey()) != null
                    || cocok("PUT", path, e.getKey()) != null || cocok("DELETE", path, e.getKey()) != null) {
                System.out.println("  -> 405 Method Not Allowed (method " + method
                        + " tidak terdaftar untuk path ini)");
                System.out.println();
                return;
            }
        }

        System.out.println("  -> 404 Not Found (tidak ada rute yang cocok)");
        System.out.println();
    }

    // Pencocokkan pola "/produk/{id}" terhadap "/produk/17".
    Map<String, String> cocok(String method, String path, Rute rute) {
        if (!rute.method().equals(method)) {
            return null;
        }
        String[] segPermintaan = path.split("/");
        String[] segPola = rute.pola().split("/");
        if (segPermintaan.length != segPola.length) {
            return null;
        }
        Map<String, String> variabel = new LinkedHashMap<>();
        for (int i = 0; i < segPola.length; i++) {
            String pola = segPola[i];
            String aktual = segPermintaan[i];
            if (pola.startsWith("{") && pola.endsWith("}")) {
                variabel.put("PathVariable " + pola.substring(1, pola.length() - 1), aktual);
            } else if (!pola.equals(aktual)) {
                return null;
            }
        }
        return variabel;
    }
}