Aplikasi keamanan yang kokoh belum berarti apa-apa bila tidak ada layanan yang bisa diakses dunia luar. REST (Representational State Transfer) adalah gaya arsitektur yang menjadikan HTTP sebagai kontrak: resource diidentifikasi lewat URL, dimanipulasi lewat verb baku, dan hasil operasi dilaporkan lewat status code. Pada Chapter ini kita rangkai REST API lengkap untuk resource produk — dari controller, service, hingga repository — lengkap dengan status code yang tepat, header Location, dan validasi input. Seluruh lapisan kita jalankan dalam simulasi Java murni agar setiap status code terlihat dari dalam.

Resource, Verb, dan Status Code #

REST memetakan operasi data ke verb HTTP yang bermakna tetap di mana-mana. Kombinasi verb dan path inilah “endpoint” yang dikonsumsi klien web maupun aplikasi seluler:

VerbPathPeranStatus sukses
GET/api/produkdaftar seluruh resource200
GET/api/produk/{id}ambil satu resource200
POST/api/produkbuat resource baru201 + header Location
PUT/api/produk/{id}ganti seluruh resource200
DELETE/api/produk/{id}hapus resource204 tanpa badan

Dua properti penting menyertai tabel itu. Pertama, idempotency: GET, PUT, dan DELETE boleh diulang tanpa mengubah hasil akhir — memanggil DELETE /api/produk/1 dua kali tetap menyisakan keadaan “produk 1 sudah terhapus”, meskipun status kedua bisa 404 karena resource tak lagi ada. Kedua, keselamatan: GET tidak boleh mengubah apa pun sehingga aman di-cache dan diulang. POST satu-satunya verb yang tidak idempoten; itulah alasan retry klien untuk POST memerlukan idempotency key di sistem pembayaran nyata.

Tiga Lapisan dan Tanggung Jawabnya #

REST API yang tertata membagi kode menjadi tiga lapisan dengan tanggung jawab yang tidak saling digadikan:

LapisanAnotasi khasTanggung jawab
Controller@RestControllerpenterjemah HTTP: binding, status code, header
Service@Serviceaturan bisnis dan validasi
Repository@Repositoryakses penyimpanan, satu-satunya yang tahu detail data

Berikut bentuk controller Spring Boot yang utuh — ditampilkan apa adanya untuk Spring Boot 3.4/3.5. Perhatikan bahwa controller tidak memuat satu aturan bisnis pun:

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 ResponseEntity<Produk> satu(@PathVariable long id) {
        return service.cari(id)
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<Produk> buat(@Valid @RequestBody DtoProduk dto,
            UriComponentsBuilder ucb) {
        Produk p = service.tambah(dto);
        URI lokasi = ucb.path("/api/produk/{id}").buildAndExpand(p.getId()).toUri();
        return ResponseEntity.created(lokasi).body(p);
    }

    @PutMapping("/{id}")
    public ResponseEntity<Produk> ubah(@PathVariable long id,
            @Valid @RequestBody DtoProduk dto) {
        return service.ubah(id, dto)
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> hapus(@PathVariable long id) {
        return service.hapus(id)
                ? ResponseEntity.noContent().build()
                : ResponseEntity.notFound().build();
    }
}

ResponseEntity memberi kontrol penuh atas status dan header: created(lokasi) menghasilkan 201 plus header Location yang menunjuk resource baru — begitu pentingnya sampai spesifikasi HTTP mewajibkannya untuk POST sukses. noContent() menghasilkan 204, penanda “berhasil dan tidak ada badan respons”. Validasi @Valid akan menolak input cacat menjadi 400 sebelum service dipanggil — perilaku yang sudah kita bahas tuntas pada Chapter Validation dengan Spring.

Simulasi CRUD Berlapis #

Karena Spring MVC membutuhkan servlet container, kita bangun ulang arsitektur tiga lapis itu dengan Java murni: repository berbasis LinkedHashMap, service dengan validasi, controller yang menghasilkan Respons, dan router mini ala DispatcherServlet yang memilah method serta path. Dua belas request dengan nasib berbeda dilewatkan sekaligus:

java ApiProduk.java
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

public class ApiProduk {

    // ==== Lapisan domain: bentuk data yang dipertukarkan ====
    record DtoProduk(String nama, long harga) { }

    record Produk(long id, String nama, long harga) { }

    // Respons HTTP mini: status, header Location, dan isi badan.
    record Respons(int status, String lokasi, String isi) { }

    // ==== Lapisan repository: satu-satunya yang menyentuh penyimpanan ====
    static class RepoProduk {

        private final Map<Long, Produk> tabel = new LinkedHashMap<>();
        private long urut = 0;

        Produk tambah(DtoProduk dto) {
            Produk p = new Produk(++urut, dto.nama(), dto.harga());
            tabel.put(p.id(), p);
            return p;
        }

        Optional<Produk> cari(long id) {
            return Optional.ofNullable(tabel.get(id));
        }

        List<Produk> semua() {
            return List.copyOf(tabel.values());
        }

        Optional<Produk> ubah(long id, DtoProduk dto) {
            Produk baru = new Produk(id, dto.nama(), dto.harga());
            return tabel.replace(id, baru) == null
                    ? Optional.empty()
                    : Optional.of(baru);
        }

        boolean hapus(long id) {
            return tabel.remove(id) != null;
        }
    }

    // ==== Lapisan service: aturan bisnis dan validasi ====
    static class LayananProduk {

        private final RepoProduk repo;

        LayananProduk(RepoProduk repo) {
            this.repo = repo;
        }

        List<String> validasi(DtoProduk dto) {
            List<String> galat = new ArrayList<>();
            if (dto.nama() == null || dto.nama().isBlank()) {
                galat.add("nama tidak boleh kosong");
            }
            if (dto.harga() <= 0) {
                galat.add("harga harus positif");
            }
            return galat;
        }

        List<Produk> semua() {
            return repo.semua();
        }

        Optional<Produk> cari(long id) {
            return repo.cari(id);
        }

        Produk tambah(DtoProduk dto) {
            return repo.tambah(dto);
        }

        Optional<Produk> ubah(long id, DtoProduk dto) {
            return repo.ubah(id, dto);
        }

        boolean hapus(long id) {
            return repo.hapus(id);
        }
    }

    // ==== Lapisan controller: penterjemah request -> Respons ====
    static class KontrolProduk {

        private final LayananProduk layanan;

        KontrolProduk(LayananProduk layanan) {
            this.layanan = layanan;
        }

        Respons semua() {
            return new Respons(200, null, layanan.semua().toString());
        }

        Respons satu(long id) {
            return layanan.cari(id)
                    .map(p -> new Respons(200, null, p.toString()))
                    .orElseGet(() -> new Respons(404, null, "{\"galat\":\"produk tidak ditemukan\"}"));
        }

        Respons buat(DtoProduk dto) {
            List<String> galat = layanan.validasi(dto);
            if (!galat.isEmpty()) {
                return new Respons(400, null, "{\"galat\":" + galat + "}");
            }
            Produk p = layanan.tambah(dto);
            return new Respons(201, "/api/produk/" + p.id(), p.toString());
        }

        Respons ubah(long id, DtoProduk dto) {
            List<String> galat = layanan.validasi(dto);
            if (!galat.isEmpty()) {
                return new Respons(400, null, "{\"galat\":" + galat + "}");
            }
            return layanan.ubah(id, dto)
                    .map(p -> new Respons(200, null, p.toString()))
                    .orElseGet(() -> new Respons(404, null, "{\"galat\":\"produk tidak ditemukan\"}"));
        }

        Respons hapus(long id) {
            return layanan.hapus(id)
                    ? new Respons(204, null, "")
                    : new Respons(404, null, "{\"galat\":\"produk tidak ditemukan\"}");
        }
    }

    // ==== Router mini ala DispatcherServlet ====
    static class Router {

        private final KontrolProduk kontrol;

        Router(KontrolProduk kontrol) {
            this.kontrol = kontrol;
        }

        Respons arahkan(String method, String path, String body) {
            String[] segmen = path.split("/");
            // path selalu berbentuk /api/produk[/id]
            if (segmen.length == 3 && segmen[1].equals("api") && segmen[2].equals("produk")) {
                return switch (method) {
                    case "GET" -> kontrol.semua();
                    case "POST" -> kontrol.buat(parse(body));
                    default -> new Respons(405, null, "");
                };
            }
            if (segmen.length == 4 && segmen[2].equals("produk")) {
                long id = Long.parseLong(segmen[3]);
                return switch (method) {
                    case "GET" -> kontrol.satu(id);
                    case "PUT" -> kontrol.ubah(id, parse(body));
                    case "DELETE" -> kontrol.hapus(id);
                    default -> new Respons(405, null, "");
                };
            }
            return new Respons(404, null, "");
        }

        // Format badan sederhana: nama=...;harga=...
        static DtoProduk parse(String body) {
            String nama = null;
            long harga = 0;
            for (String pasangan : body.split(";")) {
                String[] kv = pasangan.split("=", 2);
                if (kv.length == 2) {
                    if (kv[0].trim().equals("nama")) nama = kv[1];
                    if (kv[0].trim().equals("harga")) harga = Long.parseLong(kv[1]);
                }
            }
            return new DtoProduk(nama, harga);
        }
    }

    public static void main(String[] args) {
        Router router = new Router(new KontrolProduk(new LayananProduk(new RepoProduk())));

        kirim(router, "GET", "/api/produk", null);
        kirim(router, "POST", "/api/produk", "nama=Kopi Gayo 250g;harga=85000");
        kirim(router, "POST", "/api/produk", "nama=Teh Melati 100g;harga=45000");
        kirim(router, "POST", "/api/produk", "nama=;harga=-1");
        kirim(router, "GET", "/api/produk", null);
        kirim(router, "GET", "/api/produk/1", null);
        kirim(router, "GET", "/api/produk/99", null);
        kirim(router, "PUT", "/api/produk/2", "nama=Teh Melati 100g;harga=50000");
        kirim(router, "PUT", "/api/produk/99", "nama=X;harga=1");
        kirim(router, "DELETE", "/api/produk/1", null);
        kirim(router, "DELETE", "/api/produk/1", null);
        kirim(router, "GET", "/api/produk", null);
    }

    static void kirim(Router router, String method, String path, String body) {
        Respons r = router.arahkan(method, path, body == null ? "" : body);
        StringBuilder baris = new StringBuilder(
                String.format("%-7s %-18s -> %d", method, path, r.status()));
        if (r.lokasi() != null) {
            baris.append("  Location: ").append(r.lokasi());
        }
        if (!r.isi().isEmpty()) {
            baris.append("  isi: ").append(r.isi());
        }
        System.out.println(baris);
    }
}

Output yang Dihasilkan:

text
GET     /api/produk        -> 200  isi: []
POST    /api/produk        -> 201  Location: /api/produk/1  isi: Produk[id=1, nama=Kopi Gayo 250g, harga=85000]
POST    /api/produk        -> 201  Location: /api/produk/2  isi: Produk[id=2, nama=Teh Melati 100g, harga=45000]
POST    /api/produk        -> 400  isi: {"galat":[nama tidak boleh kosong, harga harus positif]}
GET     /api/produk        -> 200  isi: [Produk[id=1, nama=Kopi Gayo 250g, harga=85000], Produk[id=2, nama=Teh Melati 100g, harga=45000]]
GET     /api/produk/1      -> 200  isi: Produk[id=1, nama=Kopi Gayo 250g, harga=85000]
GET     /api/produk/99     -> 404  isi: {"galat":"produk tidak ditemukan"}
PUT     /api/produk/2      -> 200  isi: Produk[id=2, nama=Teh Melati 100g, harga=50000]
PUT     /api/produk/99     -> 404  isi: {"galat":"produk tidak ditemukan"}
DELETE  /api/produk/1      -> 204
DELETE  /api/produk/1      -> 404  isi: {"galat":"produk tidak ditemukan"}
GET     /api/produk        -> 200  isi: [Produk[id=2, nama=Teh Melati 100g, harga=50000]]

Mari kita bedah lapisan-lapisannya:

  1. Router.arahkan(method, path, body): padanan DispatcherServlet plus @RequestMapping. Path dipecah menjadi segmen; tiga segmen berarti kumpulan (/api/produk), empat segmen berarti item tunggal dengan {id} dipecah dari segmen keempat — persis kerja @PathVariable. Switch over method menggantikan pemetaan @GetMapping/@PostMapping.
  2. KontrolProduk.buat(dto): controller menjalankan urutan tetap — validasi, panggil service, rangkap respons. Validasi gagal menghasilkan 400 dengan daftar galat (dua pelanggaran tercetak sekaligus untuk nama=;harga=-1); sukses menghasilkan 201 dengan header Location: /api/produk/1 dan representasi resource pada baris yang sama.
  3. LayananProduk.validasi(dto): aturan bisnis hidup di service, bukan controller — sehingga scheduler atau message listener bisa memanggil tambah(...) dengan perlindungan yang sama tanpa harus lewat HTTP. Controller tinggal menerjemahkan daftar galat menjadi respons 400.
  4. RepoProduk.ubah(id, dto): Map.replace mengembalikan nilai lama atau null; nilai null diterjemahkan Optional.empty() sehingga controller cukup memilih antara 200 dan 404 tanpa memeriksa keberadaan dua kali.
  5. Baris DELETE: penghapusan pertama menghasilkan 204 (sukses, tanpa badan), penghapusan kedua menghasilkan 404 — dan keduanya menyisakan keadaan akhir yang identik, demonstrasi langsung idempotency efek di atas perbedaan status.

Ada satu masalah yang belum disentuh tabel di atas: GET /api/produk mengembalikan seluruh resource. Dengan 10.000 produk, satu respons berukuran puluhan megabyte membebani jaringan, basis data, dan ponsel pengguna sekaligus. Solusinya adalah memotong hasil menjadi halaman — kerja Pageable dan Page yang menjadi pokok bahasan Chapter Pagination dan Sorting berikutnya.

Referensi #

Berkas Sumber

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

ApiProduk.java

Unduh berkas
java
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

public class ApiProduk {

    // ==== Lapisan domain: bentuk data yang dipertukarkan ====
    record DtoProduk(String nama, long harga) { }

    record Produk(long id, String nama, long harga) { }

    // Respons HTTP mini: status, header Location, dan isi badan.
    record Respons(int status, String lokasi, String isi) { }

    // ==== Lapisan repository: satu-satunya yang menyentuh penyimpanan ====
    static class RepoProduk {

        private final Map<Long, Produk> tabel = new LinkedHashMap<>();
        private long urut = 0;

        Produk tambah(DtoProduk dto) {
            Produk p = new Produk(++urut, dto.nama(), dto.harga());
            tabel.put(p.id(), p);
            return p;
        }

        Optional<Produk> cari(long id) {
            return Optional.ofNullable(tabel.get(id));
        }

        List<Produk> semua() {
            return List.copyOf(tabel.values());
        }

        Optional<Produk> ubah(long id, DtoProduk dto) {
            Produk baru = new Produk(id, dto.nama(), dto.harga());
            return tabel.replace(id, baru) == null
                    ? Optional.empty()
                    : Optional.of(baru);
        }

        boolean hapus(long id) {
            return tabel.remove(id) != null;
        }
    }

    // ==== Lapisan service: aturan bisnis dan validasi ====
    static class LayananProduk {

        private final RepoProduk repo;

        LayananProduk(RepoProduk repo) {
            this.repo = repo;
        }

        List<String> validasi(DtoProduk dto) {
            List<String> galat = new ArrayList<>();
            if (dto.nama() == null || dto.nama().isBlank()) {
                galat.add("nama tidak boleh kosong");
            }
            if (dto.harga() <= 0) {
                galat.add("harga harus positif");
            }
            return galat;
        }

        List<Produk> semua() {
            return repo.semua();
        }

        Optional<Produk> cari(long id) {
            return repo.cari(id);
        }

        Produk tambah(DtoProduk dto) {
            return repo.tambah(dto);
        }

        Optional<Produk> ubah(long id, DtoProduk dto) {
            return repo.ubah(id, dto);
        }

        boolean hapus(long id) {
            return repo.hapus(id);
        }
    }

    // ==== Lapisan controller: penterjemah request -> Respons ====
    static class KontrolProduk {

        private final LayananProduk layanan;

        KontrolProduk(LayananProduk layanan) {
            this.layanan = layanan;
        }

        Respons semua() {
            return new Respons(200, null, layanan.semua().toString());
        }

        Respons satu(long id) {
            return layanan.cari(id)
                    .map(p -> new Respons(200, null, p.toString()))
                    .orElseGet(() -> new Respons(404, null, "{\"galat\":\"produk tidak ditemukan\"}"));
        }

        Respons buat(DtoProduk dto) {
            List<String> galat = layanan.validasi(dto);
            if (!galat.isEmpty()) {
                return new Respons(400, null, "{\"galat\":" + galat + "}");
            }
            Produk p = layanan.tambah(dto);
            return new Respons(201, "/api/produk/" + p.id(), p.toString());
        }

        Respons ubah(long id, DtoProduk dto) {
            List<String> galat = layanan.validasi(dto);
            if (!galat.isEmpty()) {
                return new Respons(400, null, "{\"galat\":" + galat + "}");
            }
            return layanan.ubah(id, dto)
                    .map(p -> new Respons(200, null, p.toString()))
                    .orElseGet(() -> new Respons(404, null, "{\"galat\":\"produk tidak ditemukan\"}"));
        }

        Respons hapus(long id) {
            return layanan.hapus(id)
                    ? new Respons(204, null, "")
                    : new Respons(404, null, "{\"galat\":\"produk tidak ditemukan\"}");
        }
    }

    // ==== Router mini ala DispatcherServlet ====
    static class Router {

        private final KontrolProduk kontrol;

        Router(KontrolProduk kontrol) {
            this.kontrol = kontrol;
        }

        Respons arahkan(String method, String path, String body) {
            String[] segmen = path.split("/");
            // path selalu berbentuk /api/produk[/id]
            if (segmen.length == 3 && segmen[1].equals("api") && segmen[2].equals("produk")) {
                return switch (method) {
                    case "GET" -> kontrol.semua();
                    case "POST" -> kontrol.buat(parse(body));
                    default -> new Respons(405, null, "");
                };
            }
            if (segmen.length == 4 && segmen[2].equals("produk")) {
                long id = Long.parseLong(segmen[3]);
                return switch (method) {
                    case "GET" -> kontrol.satu(id);
                    case "PUT" -> kontrol.ubah(id, parse(body));
                    case "DELETE" -> kontrol.hapus(id);
                    default -> new Respons(405, null, "");
                };
            }
            return new Respons(404, null, "");
        }

        // Format badan sederhana: nama=...;harga=...
        static DtoProduk parse(String body) {
            String nama = null;
            long harga = 0;
            for (String pasangan : body.split(";")) {
                String[] kv = pasangan.split("=", 2);
                if (kv.length == 2) {
                    if (kv[0].trim().equals("nama")) nama = kv[1];
                    if (kv[0].trim().equals("harga")) harga = Long.parseLong(kv[1]);
                }
            }
            return new DtoProduk(nama, harga);
        }
    }

    public static void main(String[] args) {
        Router router = new Router(new KontrolProduk(new LayananProduk(new RepoProduk())));

        kirim(router, "GET", "/api/produk", null);
        kirim(router, "POST", "/api/produk", "nama=Kopi Gayo 250g;harga=85000");
        kirim(router, "POST", "/api/produk", "nama=Teh Melati 100g;harga=45000");
        kirim(router, "POST", "/api/produk", "nama=;harga=-1");
        kirim(router, "GET", "/api/produk", null);
        kirim(router, "GET", "/api/produk/1", null);
        kirim(router, "GET", "/api/produk/99", null);
        kirim(router, "PUT", "/api/produk/2", "nama=Teh Melati 100g;harga=50000");
        kirim(router, "PUT", "/api/produk/99", "nama=X;harga=1");
        kirim(router, "DELETE", "/api/produk/1", null);
        kirim(router, "DELETE", "/api/produk/1", null);
        kirim(router, "GET", "/api/produk", null);
    }

    static void kirim(Router router, String method, String path, String body) {
        Respons r = router.arahkan(method, path, body == null ? "" : body);
        StringBuilder baris = new StringBuilder(
                String.format("%-7s %-18s -> %d", method, path, r.status()));
        if (r.lokasi() != null) {
            baris.append("  Location: ").append(r.lokasi());
        }
        if (!r.isi().isEmpty()) {
            baris.append("  isi: ").append(r.isi());
        }
        System.out.println(baris);
    }
}