Semua fondasi sudah tersedia: protokol HTTP kita pahami sampai ke format pesannya, servlet dan container kita kenal sampai siklus hidupnya, dan dependency injection merangkai komponen tanpa instansiasi manual. Bentuk aplikasi web yang paling banyak dibangun di atas fondasi tersebut adalah REST API — layanan yang memaparkan data lewat URI, dimanipulasi lewat method HTTP, dan berbicara JSON. Bab ini merangkai prinsip perancangan REST yang konsisten, lalu membuktikannya dengan API toko sungguhan: satu program yang menjalankan server HTTP di alamat loopback dan klien yang menguji seluruh operasi CRUD-nya, semuanya dari pustaka standar JDK.

Resource, Bukan Aksi #

Inti gaya REST adalah memodelkan API sebagai kumpulan resource — entitas bernama yang diakses lewat URI — bukan sebagai daftar perintah. Perbandingannya tajam:

Gaya RPC (dihindari)Gaya REST
POST /ambilDaftarProdukGET /produk
POST /tambahProdukPOST /produk
POST /ubahProdukPUT /produk/3
POST /hapusProduk?id=3DELETE /produk/3

URI berisi kata benda jamak (/produk, /produk/3), sedangkan verb diserahkan kepada method HTTP. Satu URI /produk/3 dijadikan empat operasi berbeda hanya dengan mengganti method. Karena GET safe dan PUT/DELETE idempotent (Chapter HTTP Fundamentals), klien otomatis tahu operasi mana yang aman diulang — jaminan yang tidak dimiliki gaya RPC karena seluruhnya menumpuk di POST.

Status code melengkapi kontrak semantik ini: 200 dengan body untuk baca dan ubah, 201 Created plus header Location untuk data baru, 204 No Content tanpa body untuk hapus, 404 saat resource tidak ada, dan 405 bila method tidak didukung. Pemetaan CRUD lengkapnya:

OperasiMethod + URISuksesGagal
Baca daftarGET /produk200 + array JSON—
Baca satuGET /produk/{id}200 + objek404
BuatPOST /produk201 + Location400
UbahPUT /produk/{id}200 + objek baru404
HapusDELETE /produk/{id}204404

API Toko Lengkap dalam Satu Program #

Program berikut menjalankan server HTTP bawaan JDK (com.sun.net.httpserver.HttpServer) pada alamat loopback 127.0.0.1 dengan port acak sementara, lalu menguji API tersebut dengan HttpClient. Karena server dan klien hidup dalam satu proses, contoh ini berjalan tanpa perangkat tambahan apa pun:

java ApiToko.java
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class ApiToko {

    record Produk(int id, String nama, int harga) {
        String json() {
            return "{\"id\":" + id + ",\"nama\":\"" + nama + "\",\"harga\":" + harga + "}";
        }
    }

    static final Map<Integer, Produk> SIMPANAN = new LinkedHashMap<>();
    static final AtomicInteger ID_BERIKUT = new AtomicInteger(3);

    static {
        SIMPANAN.put(1, new Produk(1, "Buku Java", 85000));
        SIMPANAN.put(2, new Produk(2, "Pena hitam", 5000));
    }

    static void kirim(HttpExchange exchange, int status, String json) throws IOException {
        if (json == null) {
            exchange.sendResponseHeaders(status, -1);
            exchange.close();
            return;
        }
        byte[] byteJson = json.getBytes(StandardCharsets.UTF_8);
        exchange.getResponseHeaders().set("Content-Type", "application/json");
        exchange.sendResponseHeaders(status, byteJson.length);
        try (OutputStream os = exchange.getResponseBody()) {
            os.write(byteJson);
        }
    }

    static String jsonDaftar() {
        StringBuilder b = new StringBuilder("{\"total\":").append(SIMPANAN.size())
                .append(",\"data\":[");
        int i = 0;
        for (Produk p : SIMPANAN.values()) {
            if (i++ > 0) {
                b.append(",");
            }
            b.append(p.json());
        }
        return b.append("]}").toString();
    }

    static void tangani(HttpExchange exchange) throws IOException {
        String metode = exchange.getRequestMethod();
        String path = exchange.getRequestURI().getPath();
        String[] segmen = path.split("/");
        // pola: /produk          -> daftar atau tambah
        // pola: /produk/{id}     -> satu, ubah, atau hapus
        if (segmen.length == 2) {
            switch (metode) {
                case "GET" -> kirim(exchange, 200, jsonDaftar());
                case "POST" -> {
                    String body = new String(exchange.getRequestBody().readAllBytes(),
                            StandardCharsets.UTF_8);
                    Matcher mNama = Pattern.compile("\"nama\"\\s*:\\s*\"([^\"]*)\"").matcher(body);
                    Matcher mHarga = Pattern.compile("\"harga\"\\s*:\\s*(\\d+)").matcher(body);
                    if (!mNama.find() || !mHarga.find()) {
                        kirim(exchange, 400, "{\"kesalahan\":\"nama dan harga wajib ada\"}");
                        return;
                    }
                    int id = ID_BERIKUT.getAndIncrement();
                    Produk baru = new Produk(id, mNama.group(1), Integer.parseInt(mHarga.group(1)));
                    SIMPANAN.put(id, baru);
                    exchange.getResponseHeaders().set("Location", "/produk/" + id);
                    kirim(exchange, 201, baru.json());
                }
                default -> kirim(exchange, 405, "{\"kesalahan\":\"method tidak didukung\"}");
            }
            return;
        }
        int id = Integer.parseInt(segmen[2]);
        Produk p = SIMPANAN.get(id);
        if (p == null) {
            kirim(exchange, 404, "{\"kesalahan\":\"produk " + id + " tidak ditemukan\"}");
            return;
        }
        switch (metode) {
            case "GET" -> kirim(exchange, 200, p.json());
            case "PUT" -> {
                String body = new String(exchange.getRequestBody().readAllBytes(),
                        StandardCharsets.UTF_8);
                Matcher mNama = Pattern.compile("\"nama\"\\s*:\\s*\"([^\"]*)\"").matcher(body);
                Matcher mHarga = Pattern.compile("\"harga\"\\s*:\\s*(\\d+)").matcher(body);
                Produk ubah = new Produk(id,
                        mNama.find() ? mNama.group(1) : p.nama(),
                        mHarga.find() ? Integer.parseInt(mHarga.group(1)) : p.harga());
                SIMPANAN.put(id, ubah);
                kirim(exchange, 200, ubah.json());
            }
            case "DELETE" -> {
                SIMPANAN.remove(id);
                kirim(exchange, 204, null);
            }
            default -> kirim(exchange, 405, "{\"kesalahan\":\"method tidak didukung\"}");
        }
    }

    public static void main(String[] args) throws Exception {
        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server.createContext("/produk", ApiToko::tangani);
        server.start();

        String dasar = "http://127.0.0.1:" + server.getAddress().getPort();
        HttpClient klien = HttpClient.newHttpClient();
        try {
            kirimCetak(klien, dasar, "GET", "/produk", null);
            kirimCetak(klien, dasar, "GET", "/produk/2", null);
            kirimCetak(klien, dasar, "GET", "/produk/99", null);
            kirimCetak(klien, dasar, "POST", "/produk",
                    "{\"nama\":\"Penggaris\",\"harga\":12000}");
            kirimCetak(klien, dasar, "PUT", "/produk/3",
                    "{\"nama\":\"Penggaris baja\",\"harga\":15000}");
            kirimCetak(klien, dasar, "DELETE", "/produk/1", null);
            kirimCetak(klien, dasar, "DELETE", "/produk/1", null);
            kirimCetak(klien, dasar, "GET", "/produk", null);
        } finally {
            server.stop(0);
        }
    }

    static void kirimCetak(HttpClient klien, String dasar, String metode,
                           String path, String body) throws Exception {
        HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(dasar + path));
        switch (metode) {
            case "GET" -> builder.GET();
            case "DELETE" -> builder.DELETE();
            default -> builder.method(metode,
                    HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
                    .header("Content-Type", "application/json");
        }
        HttpResponse<String> res = klien.send(builder.build(),
                HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
        String lokasi = res.headers().firstValue("Location")
                .map(l -> "  Location: " + l).orElse("");
        String isi = res.statusCode() == 204 ? "(tanpa body)" : res.body();
        System.out.printf("%-7s %-12s -> %d%s%n", metode, path, res.statusCode(), lokasi);
        System.out.println("   " + isi);
    }
}

Output yang Dihasilkan:

text
GET     /produk      -> 200
   {"total":2,"data":[{"id":1,"nama":"Buku Java","harga":85000},{"id":2,"nama":"Pena hitam","harga":5000}]}
GET     /produk/2    -> 200
   {"id":2,"nama":"Pena hitam","harga":5000}
GET     /produk/99   -> 404
   {"kesalahan":"produk 99 tidak ditemukan"}
POST    /produk      -> 201  Location: /produk/3
   {"id":3,"nama":"Penggaris","harga":12000}
PUT     /produk/3    -> 200
   {"id":3,"nama":"Penggaris baja","harga":15000}
DELETE  /produk/1    -> 204
   (tanpa body)
DELETE  /produk/1    -> 404
   {"kesalahan":"produk 1 tidak ditemukan"}
GET     /produk      -> 200
   {"total":2,"data":[{"id":2,"nama":"Pena hitam","harga":5000},{"id":3,"nama":"Penggaris baja","harga":15000}]}

Bedah baris-per-baris sisi server dan klien:

  1. Baris 25-31 (SIMPANAN dan ID_BERIKUT): penyimpanan in-memory memakai LinkedHashMap agar urutan JSON daftar produk stabil mengikuti urutan sisip. AtomicInteger menjamin pembuatan id aman meski container server memproses permintaan dari beberapa thread.
  2. Baris 33-45 (kirim): satu titik keluar balasan. Parameter json == null memicu status tanpa body (204) dengan panjang -1 — begitulah cara HttpExchange menyatakan “tidak ada body”. Untuk balasan berbody, Content-Type: application/json dipasang sebelum sendResponseHeaders karena header harus terkirim lebih dulu.
  3. Baris 60-66 (tangani): routing paling sederhana — path dipecah segmen; panjang segmen membedakan koleksi (/produk) dari anggotanya (/produk/{id}). Framework nyata menggantikan bagian ini dengan tabel routing berpola.
  4. Baris 72-73 (regex \"nama\" dan \"harga\"): pengurai JSON mini untuk body POST/PUT. Regex cukup untuk payload berisi satu-dua field; untuk produksi gunakan JSON-B atau Jackson — struktur data, bukan string, yang diurai.
  5. Baris 78-82 (alur POST): urutan lengkap operasi buat — id baru dari penghitung, simpan ke peta, pasang header Location: /produk/3 menunjuk resource baru, balas 201 dengan representasi resource. Pola yang sama dipakai semua API REST yang rapi.
  6. Baris 116-118 (pembuatan server): new InetSocketAddress("127.0.0.1", 0) mengikat server hanya ke loopback dengan port dipilih sistem — tidak ada layanan yang terbuka ke luar. server.stop(0) di blok finally memastikan server berhenti walau terjadi galat.
  7. Baris 150-151 (firstValue("Location")): klien membaca header Location untuk menampilkan resource yang barusan dibuat — kolaborasi status 201 + Location adalah cara REST memberi tahu klien alamat data baru tanpa menebak.

API kita kini bisa menerima data — namun menerima saja belum cukup. Body POST /produk dengan nama kosong atau harga negatif masih lolos tanpa periksa. Gerbang masuk yang memeriksa payload sebelum diproses adalah topik Chapter Validasi API.

Referensi #

Berkas Sumber

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

ApiToko.java

Unduh berkas
java
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class ApiToko {

    record Produk(int id, String nama, int harga) {
        String json() {
            return "{\"id\":" + id + ",\"nama\":\"" + nama + "\",\"harga\":" + harga + "}";
        }
    }

    static final Map<Integer, Produk> SIMPANAN = new LinkedHashMap<>();
    static final AtomicInteger ID_BERIKUT = new AtomicInteger(3);

    static {
        SIMPANAN.put(1, new Produk(1, "Buku Java", 85000));
        SIMPANAN.put(2, new Produk(2, "Pena hitam", 5000));
    }

    static void kirim(HttpExchange exchange, int status, String json) throws IOException {
        if (json == null) {
            exchange.sendResponseHeaders(status, -1);
            exchange.close();
            return;
        }
        byte[] byteJson = json.getBytes(StandardCharsets.UTF_8);
        exchange.getResponseHeaders().set("Content-Type", "application/json");
        exchange.sendResponseHeaders(status, byteJson.length);
        try (OutputStream os = exchange.getResponseBody()) {
            os.write(byteJson);
        }
    }

    static String jsonDaftar() {
        StringBuilder b = new StringBuilder("{\"total\":").append(SIMPANAN.size())
                .append(",\"data\":[");
        int i = 0;
        for (Produk p : SIMPANAN.values()) {
            if (i++ > 0) {
                b.append(",");
            }
            b.append(p.json());
        }
        return b.append("]}").toString();
    }

    static void tangani(HttpExchange exchange) throws IOException {
        String metode = exchange.getRequestMethod();
        String path = exchange.getRequestURI().getPath();
        String[] segmen = path.split("/");
        // pola: /produk          -> daftar atau tambah
        // pola: /produk/{id}     -> satu, ubah, atau hapus
        if (segmen.length == 2) {
            switch (metode) {
                case "GET" -> kirim(exchange, 200, jsonDaftar());
                case "POST" -> {
                    String body = new String(exchange.getRequestBody().readAllBytes(),
                            StandardCharsets.UTF_8);
                    Matcher mNama = Pattern.compile("\"nama\"\\s*:\\s*\"([^\"]*)\"").matcher(body);
                    Matcher mHarga = Pattern.compile("\"harga\"\\s*:\\s*(\\d+)").matcher(body);
                    if (!mNama.find() || !mHarga.find()) {
                        kirim(exchange, 400, "{\"kesalahan\":\"nama dan harga wajib ada\"}");
                        return;
                    }
                    int id = ID_BERIKUT.getAndIncrement();
                    Produk baru = new Produk(id, mNama.group(1), Integer.parseInt(mHarga.group(1)));
                    SIMPANAN.put(id, baru);
                    exchange.getResponseHeaders().set("Location", "/produk/" + id);
                    kirim(exchange, 201, baru.json());
                }
                default -> kirim(exchange, 405, "{\"kesalahan\":\"method tidak didukung\"}");
            }
            return;
        }
        int id = Integer.parseInt(segmen[2]);
        Produk p = SIMPANAN.get(id);
        if (p == null) {
            kirim(exchange, 404, "{\"kesalahan\":\"produk " + id + " tidak ditemukan\"}");
            return;
        }
        switch (metode) {
            case "GET" -> kirim(exchange, 200, p.json());
            case "PUT" -> {
                String body = new String(exchange.getRequestBody().readAllBytes(),
                        StandardCharsets.UTF_8);
                Matcher mNama = Pattern.compile("\"nama\"\\s*:\\s*\"([^\"]*)\"").matcher(body);
                Matcher mHarga = Pattern.compile("\"harga\"\\s*:\\s*(\\d+)").matcher(body);
                Produk ubah = new Produk(id,
                        mNama.find() ? mNama.group(1) : p.nama(),
                        mHarga.find() ? Integer.parseInt(mHarga.group(1)) : p.harga());
                SIMPANAN.put(id, ubah);
                kirim(exchange, 200, ubah.json());
            }
            case "DELETE" -> {
                SIMPANAN.remove(id);
                kirim(exchange, 204, null);
            }
            default -> kirim(exchange, 405, "{\"kesalahan\":\"method tidak didukung\"}");
        }
    }

    public static void main(String[] args) throws Exception {
        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server.createContext("/produk", ApiToko::tangani);
        server.start();

        String dasar = "http://127.0.0.1:" + server.getAddress().getPort();
        HttpClient klien = HttpClient.newHttpClient();
        try {
            kirimCetak(klien, dasar, "GET", "/produk", null);
            kirimCetak(klien, dasar, "GET", "/produk/2", null);
            kirimCetak(klien, dasar, "GET", "/produk/99", null);
            kirimCetak(klien, dasar, "POST", "/produk",
                    "{\"nama\":\"Penggaris\",\"harga\":12000}");
            kirimCetak(klien, dasar, "PUT", "/produk/3",
                    "{\"nama\":\"Penggaris baja\",\"harga\":15000}");
            kirimCetak(klien, dasar, "DELETE", "/produk/1", null);
            kirimCetak(klien, dasar, "DELETE", "/produk/1", null);
            kirimCetak(klien, dasar, "GET", "/produk", null);
        } finally {
            server.stop(0);
        }
    }

    static void kirimCetak(HttpClient klien, String dasar, String metode,
                           String path, String body) throws Exception {
        HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(dasar + path));
        switch (metode) {
            case "GET" -> builder.GET();
            case "DELETE" -> builder.DELETE();
            default -> builder.method(metode,
                    HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
                    .header("Content-Type", "application/json");
        }
        HttpResponse<String> res = klien.send(builder.build(),
                HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
        String lokasi = res.headers().firstValue("Location")
                .map(l -> "  Location: " + l).orElse("");
        String isi = res.statusCode() == 204 ? "(tanpa body)" : res.body();
        System.out.printf("%-7s %-12s -> %d%s%n", metode, path, res.statusCode(), lokasi);
        System.out.println("   " + isi);
    }
}