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 /ambilDaftarProduk | GET /produk |
POST /tambahProduk | POST /produk |
POST /ubahProduk | PUT /produk/3 |
POST /hapusProduk?id=3 | DELETE /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:
| Operasi | Method + URI | Sukses | Gagal |
|---|---|---|---|
| Baca daftar | GET /produk | 200 + array JSON | — |
| Baca satu | GET /produk/{id} | 200 + objek | 404 |
| Buat | POST /produk | 201 + Location | 400 |
| Ubah | PUT /produk/{id} | 200 + objek baru | 404 |
| Hapus | DELETE /produk/{id} | 204 | 404 |
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:
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:
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:
- Baris 25-31 (
SIMPANANdanID_BERIKUT): penyimpanan in-memory memakaiLinkedHashMapagar urutan JSON daftar produk stabil mengikuti urutan sisip.AtomicIntegermenjamin pembuatan id aman meski container server memproses permintaan dari beberapa thread. - Baris 33-45 (
kirim): satu titik keluar balasan. Parameterjson == nullmemicu status tanpa body (204) dengan panjang-1— begitulah caraHttpExchangemenyatakan “tidak ada body”. Untuk balasan berbody,Content-Type: application/jsondipasang sebelumsendResponseHeaderskarena header harus terkirim lebih dulu. - 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. - 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. - Baris 78-82 (alur POST): urutan lengkap operasi buat — id baru dari penghitung, simpan ke peta, pasang header
Location: /produk/3menunjuk resource baru, balas201dengan representasi resource. Pola yang sama dipakai semua API REST yang rapi. - 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 blokfinallymemastikan server berhenti walau terjadi galat. - Baris 150-151 (
firstValue("Location")): klien membaca headerLocationuntuk 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 #
- RFC 9110: HTTP Semantics
- Jakarta RESTful Web Services 4.0 Specification
- Oracle: Class HttpClient (Java SE 25)
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
ApiToko.java
Unduh berkasimport 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);
}
}