Method controller tidak menerima string mentah — method menerima objek Java bertipe yang benar. Jarak antara keduanya dijembatani request binding: mekanisme HandlerAdapter yang memecah permintaan menjadi argumen method. Path variable menjadi Long, query parameter menjadi int atau List<String>, dan body JSON menjadi objek DTO lewat Jackson. Pada Chapter ini kita bedah ketiga sumber binding tersebut, peran record sebagai DTO modern, dan rantainya dengan validasi input.

Tiga Sumber Argumen #

Sumber pertama adalah path variable. Segmen {id} pada pola rute diikat @PathVariable("id"); nilai string dikonversi ConversionService ke tipe parameter, sehingga "17" menjadi Long 17. Sumber kedua adalah query parameter. Anotasi @RequestParam mengambil nilai dari query string dengan dukungan required, defaultValue, dan tipe koleksi untuk parameter berulang — ?tag=arabika&tag=toraja masuk rapi ke List<String> tag. Sumber ketiga adalah request body. Anotasi @RequestBody memerintahkan HttpMessageConverter mendeserialisasi body — umumnya JSON lewat Jackson — menjadi objek pada parameter.

Berikut ketiganya bekerja dalam satu controller — ditampilkan apa adanya untuk Spring Boot 3.4/3.5:

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

    @GetMapping("/{id}/ulasan")
    public List<Ulasan> ulasan(@PathVariable Long id,
                               @RequestParam(defaultValue = "1") int halaman) {
        return service.ulasan(id, halaman);
    }

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

public record PermintaanSimpan(
        @NotBlank String nama,
        @Positive BigDecimal harga,
        @Size(max = 500) String keterangan
) { }

Record sebagai DTO #

Perhatikan bahwa PermintaanSimpan adalah record. Untuk DTO request, record menggantikan kelas POJO lama dengan tiga keunggulan: konstruktor kanoniknya ringkas, semua komponen bersifat final, dan equals/hashCode/toString dihasilkan compiler. Jackson mendukung record sejak lama, dan Spring Boot 3 memperlakukannya sebagai warga kelas pertama untuk binding body maupun @ConfigurationProperties. Satu catatan desain: pisahkan DTO request (PermintaanSimpan) dari entity JPA — membiarkan client menulis langsung ke field entity membuka celah pengisian field yang seharusnya internal.

Mensimulasikan Binding Query String #

Mekanisme pengikatan query string layak dilihat dari dalam. Program Java murni berikut mengurai query string menjadi map kunci-nilai, lalu mengikatnya ke record Pencarian lewat reflection atas RecordComponent:

import java.lang.reflect.Constructor;
import java.lang.reflect.RecordComponent;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class PengikatQuery {

    // DTO bentuk record: kontrak data permintaan pencarian.
    record Pencarian(String q, int halaman, int ukuran, List<String> tag) { }

    public static void main(String[] args) {
        Map<String, List<String>> parameter = pecah("q=kopi+gayo&halaman=2&ukuran=5&tag=arabika&tag=toraja");
        System.out.println("== parameter hasil parsing query string ==");
        parameter.forEach((k, v) -> System.out.println(k + " = " + v));

        Pencarian penuh = ikat(Pencarian.class, parameter);
        System.out.println();
        System.out.println("Binding penuh : " + penuh);

        Map<String, List<String>> sebagian = pecah("q=teh");
        Pencarian defaultBawaan = ikat(Pencarian.class, sebagian);
        System.out.println("Binding sebagian (tanpa default): " + defaultBawaan);
    }

    // Pecah query string menjadi map kunci -> daftar nilai.
    static Map<String, List<String>> pecah(String queryString) {
        Map<String, List<String>> hasil = new LinkedHashMap<>();
        for (String pasangan : queryString.split("&")) {
            int idx = pasangan.indexOf('=');
            String kunci = idx < 0 ? pasangan : pasangan.substring(0, idx);
            String nilai = idx < 0 ? "" : pasangan.substring(idx + 1).replace('+', ' ');
            hasil.computeIfAbsent(kunci, k -> new ArrayList<>()).add(nilai);
        }
        return hasil;
    }

    // Ikat map parameter ke record lewat RecordComponent: mirror
    // @RequestParam + @PathVariable pada method controller.
    @SuppressWarnings("unchecked")
    static <T extends Record> T ikat(Class<T> tipe, Map<String, List<String>> parameter) {
        RecordComponent[] komponen = tipe.getRecordComponents();
        Object[] argumen = new Object[komponen.length];
        for (int i = 0; i < komponen.length; i++) {
            String nama = komponen[i].getName();
            Class<?> tipeNilai = komponen[i].getType();
            List<String> nilai = parameter.getOrDefault(nama, List.of());
            if (tipeNilai == int.class) {
                argumen[i] = nilai.isEmpty() ? 0 : Integer.parseInt(nilai.get(0));
            } else if (tipeNilai == List.class) {
                argumen[i] = nilai;
            } else {
                argumen[i] = nilai.isEmpty() ? null : nilai.get(0);
            }
        }
        Class<?>[] tipeParam = Arrays.stream(komponen)
                .map(RecordComponent::getType)
                .toArray(Class[]::new);
        try {
            Constructor<T> konstruktor = tipe.getDeclaredConstructor(tipeParam);
            return konstruktor.newInstance(argumen);
        } catch (ReflectiveOperationException e) {
            throw new IllegalStateException("Binding gagal untuk " + tipe.getSimpleName());
        }
    }
}

Output yang Dihasilkan:

text
== parameter hasil parsing query string ==
q = [kopi gayo]
halaman = [2]
ukuran = [5]
tag = [arabika, toraja]

Binding penuh : Pencarian[q=kopi gayo, halaman=2, ukuran=5, tag=[arabika, toraja]]
Binding sebagian (tanpa default): Pencarian[q=teh, halaman=0, ukuran=0, tag=[]]

Mari kita bedah baris-baris kuncinya:

  1. pecah(queryString): method memecah string pada &, lalu tiap pasangan pada = pertama dengan indexOf. Tanda + diganti spasi — decoding sederhana dari form encoding. Kunci dipetakan ke List<String> karena satu kunci boleh muncul berkali-kali, terlihat pada tag = [arabika, toraja].
  2. RecordComponent[] komponen: reflection atas record menyingkap nama dan tipe setiap komponen — inilah “metadata” yang dipakai binder untuk mencocokkan parameter ke komponen dengan nama sama. Teknik identik dipakai Spring untuk mengikat parameter ke DTO.
  3. Cabang tipeNilai == int.class: konversi tipe terjadi di sini — Integer.parseInt mengubah "2" menjadi 2, mirror kecil dari ConversionService. Cabang List.class mengumpulkan seluruh nilai kunci sama; cabang lainnya mengambil nilai pertama.
  4. getDeclaredConstructor(tipeParam).newInstance(argumen): argumen yang sudah terkumpul dikembalikan lewat konstruktor kanonik record. Hasil Binding penuh menunjukkan seluruh komponen terisi; Binding sebagian memperlihatkan perilaku tanpa defaultValue — angka jatuh ke 0 dan daftar kosong, alasan mengapa @RequestParam(defaultValue = ...) pada Spring lebih aman untuk parameter opsional.

Rantai Menuju Validasi #

Binding baru separuh kisah keamanan: input yang berhasil diikat belum tentu masuk akal — nama kosong, harga negatif, email tanpa domain. Momen setelah binding adalah tempat validasi berdiri. Pada controller nyata, cukup tambahkan @Valid sebelum parameter body: @RequestBody @Valid PermintaanSimpan body. Spring akan menjalankan Bean Validation sebelum method controller terpanggil, dan kegagalan validasi otomatis menjadi respons 400 dengan rincian field. Mekanisme lengkapnya — anotasi constraint hingga penanganan MethodArgumentNotValidException — kita bahas pada Chapter Validasi dengan Spring.

Referensi #

Berkas Sumber

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

PengikatQuery.java

Unduh berkas
java
import java.lang.reflect.Constructor;
import java.lang.reflect.RecordComponent;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class PengikatQuery {

    // DTO bentuk record: kontrak data permintaan pencarian.
    record Pencarian(String q, int halaman, int ukuran, List<String> tag) { }

    public static void main(String[] args) {
        Map<String, List<String>> parameter = pecah("q=kopi+gayo&halaman=2&ukuran=5&tag=arabika&tag=toraja");
        System.out.println("== parameter hasil parsing query string ==");
        parameter.forEach((k, v) -> System.out.println(k + " = " + v));

        Pencarian penuh = ikat(Pencarian.class, parameter);
        System.out.println();
        System.out.println("Binding penuh : " + penuh);

        Map<String, List<String>> sebagian = pecah("q=teh");
        Pencarian defaultBawaan = ikat(Pencarian.class, sebagian);
        System.out.println("Binding sebagian (tanpa default): " + defaultBawaan);
    }

    // Pecah query string menjadi map kunci -> daftar nilai.
    static Map<String, List<String>> pecah(String queryString) {
        Map<String, List<String>> hasil = new LinkedHashMap<>();
        for (String pasangan : queryString.split("&")) {
            int idx = pasangan.indexOf('=');
            String kunci = idx < 0 ? pasangan : pasangan.substring(0, idx);
            String nilai = idx < 0 ? "" : pasangan.substring(idx + 1).replace('+', ' ');
            hasil.computeIfAbsent(kunci, k -> new ArrayList<>()).add(nilai);
        }
        return hasil;
    }

    // Ikat map parameter ke record lewat RecordComponent: mirror
    // @RequestParam + @PathVariable pada method controller.
    @SuppressWarnings("unchecked")
    static <T extends Record> T ikat(Class<T> tipe, Map<String, List<String>> parameter) {
        RecordComponent[] komponen = tipe.getRecordComponents();
        Object[] argumen = new Object[komponen.length];
        for (int i = 0; i < komponen.length; i++) {
            String nama = komponen[i].getName();
            Class<?> tipeNilai = komponen[i].getType();
            List<String> nilai = parameter.getOrDefault(nama, List.of());
            if (tipeNilai == int.class) {
                argumen[i] = nilai.isEmpty() ? 0 : Integer.parseInt(nilai.get(0));
            } else if (tipeNilai == List.class) {
                argumen[i] = nilai;
            } else {
                argumen[i] = nilai.isEmpty() ? null : nilai.get(0);
            }
        }
        Class<?>[] tipeParam = Arrays.stream(komponen)
                .map(RecordComponent::getType)
                .toArray(Class[]::new);
        try {
            Constructor<T> konstruktor = tipe.getDeclaredConstructor(tipeParam);
            return konstruktor.newInstance(argumen);
        } catch (ReflectiveOperationException e) {
            throw new IllegalStateException("Binding gagal untuk " + tipe.getSimpleName());
        }
    }
}