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:
@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:
== 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:
pecah(queryString): method memecah string pada&, lalu tiap pasangan pada=pertama denganindexOf. Tanda+diganti spasi — decoding sederhana dari form encoding. Kunci dipetakan keList<String>karena satu kunci boleh muncul berkali-kali, terlihat padatag = [arabika, toraja].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.- Cabang
tipeNilai == int.class: konversi tipe terjadi di sini —Integer.parseIntmengubah"2"menjadi2, mirror kecil dariConversionService. CabangList.classmengumpulkan seluruh nilai kunci sama; cabang lainnya mengambil nilai pertama. getDeclaredConstructor(tipeParam).newInstance(argumen): argumen yang sudah terkumpul dikembalikan lewat konstruktor kanonik record. HasilBinding penuhmenunjukkan seluruh komponen terisi;Binding sebagianmemperlihatkan perilaku tanpadefaultValue— angka jatuh ke0dan 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 #
- Spring Framework Reference: Annotated Controllers
- Spring Guides: Building a RESTful Web Service
- Spring Framework Reference Documentation
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
PengikatQuery.java
Unduh berkasimport 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());
}
}
}