Desain dari Bagian 1 sekarang dihidupkan menjadi server yang menerima permintaan HTTP sungguhan. Kita memakai com.sun.net.httpserver.HttpServer — server kecil di dalam JDK, tanpa dependensi luar — lalu router sendiri membaca method dan path setiap permintaan. Operasi CRUD divalidasi, disimpan lewat interface TugasRepository, dan diuji dengan java.net.http.HttpClient dalam proses yang sama. Hasilnya: API yang berjalan dengan java ServerApi.java, keluarannya stabil karena server mendengarkan loopback di port pilihan sistem operasi.
HttpServer Bawaan JDK #
Kelas HttpServer tinggal di module jdk.httpserver dan didokumentasikan resmi oleh Oracle. Polanya sederhana: HttpServer.create(...) menyiapkan alamat pendengaran, createContext("/", handler) mendaftarkan satu HttpHandler untuk seluruh path, dan server.start() mulai menerima koneksi. Tiap permintaan tiba sebagai HttpExchange — wadah method, path, header, body permintaan, sekaligus pintu menulis status dan body respons. Satu handler melayani semua path, jadi pembagian endpoint menjadi tanggung jawab router — itulah routing manual inti bab ini.
Exception yang Membawa Status #
Kegagalan di dalam handler tidak boleh bocor sebagai stack trace. Solusinya: exception domain pembawa status HTTP, ditangkap di satu titik lalu diterjemahkan menjadi respons JSON.
/**
* Exception unchecked pembawa status HTTP.
* Ditangkap di satu titik pada batas handler, lalu diterjemahkan menjadi
* respons JSON berstatus sesuai kebutuhan.
*/
public class ApiException extends RuntimeException {
private final int status;
public ApiException(int status, String pesan) {
super(pesan);
this.status = status;
}
public int status() {
return status;
}
}/**
* Payload permintaan tidak lolos validasi: field wajib hilang,
* format tanggal salah, atau JSON tidak sah.
* Diterjemahkan menjadi respons 400 Bad Request.
*/
public class ValidasiGagalException extends ApiException {
public ValidasiGagalException(String pesan) {
super(400, pesan);
}
}/**
* Id tugas yang diminta tidak ada di penyimpanan.
* Diterjemahkan menjadi respons 404 Not Found.
*/
public class TugasTidakDitemukanException extends ApiException {
public TugasTidakDitemukanException(int id) {
super(404, "tugas dengan id " + id + " tidak ditemukan");
}
}Pemetaannya:
| Sumber kegagalan | Exception | Status |
|---|---|---|
| field hilang, format salah, JSON rusak | ValidasiGagalException | 400 |
invarian record Tugas / enum status | IllegalArgumentException | 400 |
| id tidak ada di penyimpanan | TugasTidakDitemukanException | 404 |
| method/endpoint tidak dikenal | ApiException | 405 / 404 |
| kegagalan tak terduga | RuntimeException lain | 500 |
Ketiganya sengaja unchecked. Di lapisan web, penanganan kegagalan memang terjadi di batas handler; satu catch di handle sudah menjamin tidak ada kegagalan yang lewat begitu saja, sehingga deklarasi throws di tiap method hanya jadi gangguan.
Kontrak Penyimpanan: TugasRepository #
Handler tidak boleh tahu di mana data disimpan. Kontraknya ditulis sebagai interface lima operasi; Optional dipakai untuk operasi yang wajar gagal — cari dan simpan cukup mengembalikan Optional kosong, bukan exception.
import java.util.List;
import java.util.Optional;
/**
* Kontrak lapisan penyimpanan untuk resource tugas.
* Handler hanya mengenal interface ini, sehingga implementasi bebas ditukar:
* memori untuk pengujian, berkas untuk persistensi, atau JDBC untuk database.
*/
public interface TugasRepository {
/** Menyimpan tugas baru; id ditetapkan otomatis oleh penyimpanan. */
Tugas tambah(Tugas tugas);
/** Mencari satu tugas berdasarkan id. */
Optional<Tugas> cari(int id);
/** Seluruh tugas terurut menaik berdasarkan id. */
List<Tugas> semua();
/** Menyimpan perubahan tugas yang sudah ada; kosong bila id tidak ditemukan. */
Optional<Tugas> simpan(Tugas tugas);
/** Menghapus tugas; false bila id tidak ditemukan. */
boolean hapus(int id);
}import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Penyimpanan dalam memori: LinkedHashMap menjaga urutan sisip sama dengan
* urutan id, dan penghitung sederhana menetapkan id berurutan mulai dari 1.
*/
public class RepositoryMemori implements TugasRepository {
private final Map<Integer, Tugas> data = new LinkedHashMap<>();
private int urutan = 0;
@Override
public Tugas tambah(Tugas tugas) {
urutan++;
Tugas tersimpan = tugas.denganId(urutan);
data.put(urutan, tersimpan);
return tersimpan;
}
@Override
public Optional<Tugas> cari(int id) {
return Optional.ofNullable(data.get(id));
}
@Override
public List<Tugas> semua() {
return new ArrayList<>(data.values());
}
@Override
public Optional<Tugas> simpan(Tugas tugas) {
if (!data.containsKey(tugas.id())) {
return Optional.empty();
}
data.put(tugas.id(), tugas);
return Optional.of(tugas);
}
@Override
public boolean hapus(int id) {
return data.remove(id) != null;
}
}Versi Tugas menambah dua method penyalin — denganId untuk id dari repository, denganStatus untuk perubahan status; StatusTugas dan JsonMini tidak berubah dari Bagian 1.
import java.time.LocalDate;
/**
* Representasi satu tugas sebagai resource API.
* Invarian dasar dijaga compact constructor sehingga objek Tugas tidak pernah
* berada dalam keadaan tidak sah, apa pun asal datanya (kode atau payload klien).
*/
public record Tugas(int id, String judul, String pemilik, StatusTugas status, LocalDate tenggat) {
public Tugas {
if (judul == null || judul.strip().isEmpty()) {
throw new IllegalArgumentException("judul tidak boleh kosong");
}
if (pemilik == null || pemilik.strip().isEmpty()) {
throw new IllegalArgumentException("pemilik tidak boleh kosong");
}
if (tenggat == null) {
throw new IllegalArgumentException("tenggat tidak boleh kosong");
}
judul = judul.strip();
pemilik = pemilik.strip();
}
/** Salinan tugas ini dengan id baru; dipakai repository saat menetapkan id. */
public Tugas denganId(int idBaru) {
return new Tugas(idBaru, judul, pemilik, status, tenggat);
}
/** Salinan tugas ini dengan status baru; dipakai operasi ubah. */
public Tugas denganStatus(StatusTugas statusBaru) {
return new Tugas(id, judul, pemilik, statusBaru, tenggat);
}
}Router: Membaca Method dan Path #
Router adalah jantung bab ini: satu class pengganti mekanisme routing sebuah framework.
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.Map;
/**
* Router CRUD untuk resource tugas: membaca method dan path dari setiap
* permintaan, memvalidasi payload, lalu menghasilkan respons JSON.
* Seluruh kegagalan diterjemahkan di satu titik menjadi respons berstatus.
*/
public class RouterTugas implements HttpHandler {
private final TugasRepository repo;
public RouterTugas(TugasRepository repo) {
this.repo = repo;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
try {
tangani(exchange);
} catch (ApiException e) {
kirim(exchange, e.status(), JsonMini.error(e.getMessage()));
} catch (IllegalArgumentException e) {
// Validasi pada lapisan domain (record, enum) diterjemahkan jadi 400.
kirim(exchange, 400, JsonMini.error(e.getMessage()));
} catch (RuntimeException e) {
kirim(exchange, 500, JsonMini.error("kesalahan internal: " + e.getMessage()));
} finally {
exchange.close();
}
}
/** Membaca method dan path, lalu mengarahkan ke handler yang tepat. */
private void tangani(HttpExchange ex) throws IOException {
String metode = ex.getRequestMethod();
String path = ex.getRequestURI().getPath();
if (!path.equals("/api/tugas") && !path.startsWith("/api/tugas/")) {
throw new ApiException(404, "endpoint tidak dikenal: " + path);
}
String sisa = path.substring("/api/tugas".length());
if (sisa.isEmpty() || sisa.equals("/")) {
switch (metode) {
case "GET" -> daftar(ex);
case "POST" -> tambah(ex);
default -> lempar405(metode, path);
}
} else {
int id = bacaId(sisa);
switch (metode) {
case "GET" -> detail(ex, id);
case "PUT" -> ubah(ex, id);
case "DELETE" -> hapus(ex, id);
default -> lempar405(metode, path);
}
}
}
private void daftar(HttpExchange ex) throws IOException {
kirim(ex, 200, JsonMini.daftar(repo.semua()));
}
/** POST: status awal selalu TODO; server yang menetapkannya, bukan klien. */
private void tambah(HttpExchange ex) throws IOException {
Map<String, Object> payload = bacaPayload(ex);
Tugas baru = new Tugas(0, teks(payload, "judul"), teks(payload, "pemilik"),
StatusTugas.TODO, tanggal(payload, "tenggat"));
Tugas tersimpan = repo.tambah(baru);
kirim(ex, 201, JsonMini.tugas(tersimpan));
}
private void detail(HttpExchange ex, int id) throws IOException {
Tugas tugas = repo.cari(id).orElseThrow(() -> new TugasTidakDitemukanException(id));
kirim(ex, 200, JsonMini.tugas(tugas));
}
/** PUT: penggantian utuh, kelima field dikirim kembali oleh klien. */
private void ubah(HttpExchange ex, int id) throws IOException {
repo.cari(id).orElseThrow(() -> new TugasTidakDitemukanException(id));
Map<String, Object> payload = bacaPayload(ex);
Tugas pengganti = new Tugas(id, teks(payload, "judul"), teks(payload, "pemilik"),
status(payload), tanggal(payload, "tenggat"));
Tugas tersimpan = repo.simpan(pengganti)
.orElseThrow(() -> new TugasTidakDitemukanException(id));
kirim(ex, 200, JsonMini.tugas(tersimpan));
}
private void hapus(HttpExchange ex, int id) throws IOException {
if (!repo.hapus(id)) {
throw new TugasTidakDitemukanException(id);
}
kirim(ex, 200, "{\"pesan\":\"tugas " + id + " dihapus\"}");
}
/** Membaca segmen path setelah /api/tugas/ sebagai id. */
private static int bacaId(String sisa) {
String teks = sisa.substring(1);
if (teks.contains("/")) {
throw new ApiException(404, "endpoint tidak dikenal: /api/tugas" + sisa);
}
long id;
try {
id = Long.parseLong(teks);
} catch (NumberFormatException e) {
throw new ValidasiGagalException("id harus angka, diterima: '" + teks + "'");
}
if (id < 1 || id > Integer.MAX_VALUE) {
throw new ValidasiGagalException("id di luar rentang: " + id);
}
return (int) id;
}
/** Membaca seluruh body permintaan sebagai objek JSON datar. */
private static Map<String, Object> bacaPayload(HttpExchange ex) throws IOException {
String mentah = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
if (mentah.isBlank()) {
throw new ValidasiGagalException("body permintaan tidak boleh kosong");
}
try {
return JsonMini.parseObjek(mentah);
} catch (IllegalArgumentException e) {
throw new ValidasiGagalException("JSON tidak valid: " + e.getMessage());
}
}
/** Nilai field teks wajib; 400 bila field hilang atau bukan teks. */
private static String teks(Map<String, Object> payload, String kunci) {
Object nilai = payload.get(kunci);
if (nilai == null) {
throw new ValidasiGagalException(kunci + " wajib diisi");
}
if (!(nilai instanceof String teks)) {
throw new ValidasiGagalException(kunci + " harus berupa teks");
}
return teks;
}
/** Nilai tanggal wajib berformat ISO-8601 seperti 2026-01-20. */
private static LocalDate tanggal(Map<String, Object> payload, String kunci) {
String teks = teks(payload, kunci);
try {
return LocalDate.parse(teks);
} catch (DateTimeParseException e) {
throw new ValidasiGagalException(
kunci + " harus format ISO-8601, contoh 2026-01-20, diterima: '" + teks + "'");
}
}
/** Nilai status wajib TODO atau SELESAI. */
private static StatusTugas status(Map<String, Object> payload) {
return StatusTugas.dariTeks(teks(payload, "status"));
}
private static void lempar405(String metode, String path) {
throw new ApiException(405, metode + " tidak didukung di " + path
+ "; gunakan GET, POST, PUT, atau DELETE");
}
/** Satu-satunya pintu menulis respons: header JSON, status, lalu body UTF-8. */
private static void kirim(HttpExchange ex, int status, String json) throws IOException {
byte[] isi = json.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
ex.sendResponseHeaders(status, isi.length);
try (OutputStream os = ex.getResponseBody()) {
os.write(isi);
}
}
}- Method
handle: satutrymelindungi seluruh operasi —ApiExceptiondijawab status yang dibawanya,IllegalArgumentExceptiondari invarian domain dijawab 400,RuntimeExceptionlain dijawab 500;finallymemastikanexchange.close()selalu terpanggil. - Method
tangani: membaca method dan path, menolak path di luar/api/tugas, lalu memilih operasi — tanpa id untukGET/POST, dengan id untukGET/PUT/DELETE; method lain jatuh kelempar405. - Method
bacaId: segmen path dibaca sebagai angka — teksabcdijawab 400, path bertingkat dijawab 404, rentang id diperiksa. - Method
bacaPayload: body dibaca utuh dengan charset UTF-8 eksplisit; body kosong ditolak, JSON rusak dijawab 400 dengan posisi galat dariJsonMini. - Method
tambah:Tugasdibuat dengan id 0 dan statusTODO— status awal diputuskan server, bukan klien — repository menetapkan id, handler menjawab 201. - Method
kirim: satu-satunya pintu menulis respons: setContent-Type: application/json,sendResponseHeaders(status, panjang), lalu body lewatOutputStreamdalam try-with-resources.
Server dan Klien dalam Satu Proses #
Seluruh kontrak diuji tanpa alat luar: server dan klien HTTP berbagi satu proses, permintaan dikirim sekuensial.
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
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.time.Duration;
import java.util.Locale;
/**
* Titik masuk API manajemen tugas: HttpServer di alamat loopback dengan port
* dipilih sistem operasi (port 0), lalu seluruh endpoint diuji lewat HttpClient
* dalam satu proses yang sama — server dan klien berjalan tanpa jaringan luar.
*/
public class ServerApi {
public static void main(String[] args) throws Exception {
TugasRepository repo = new RepositoryMemori();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", new RouterTugas(repo));
server.start();
String dasar = "http://127.0.0.1:" + server.getAddress().getPort();
HttpClient klien = HttpClient.newHttpClient();
try {
System.out.println("=== Babak 1: membuat dua tugas ===");
cetak(klien, "POST", dasar + "/api/tugas",
"{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\",\"tenggat\":\"2026-01-20\"}",
null);
cetak(klien, "POST", dasar + "/api/tugas",
"{\"judul\":\"Tulis dokumentasi API\",\"pemilik\":\"Siti\",\"tenggat\":\"2026-02-01\"}",
null);
System.out.println();
System.out.println("=== Babak 2: membaca daftar dan detail ===");
cetak(klien, "GET", dasar + "/api/tugas", null, null);
cetak(klien, "GET", dasar + "/api/tugas/2", null, null);
System.out.println();
System.out.println("=== Babak 3: mengubah status tugas 1 ===");
cetak(klien, "PUT", dasar + "/api/tugas/1",
"{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\","
+ "\"status\":\"SELESAI\",\"tenggat\":\"2026-01-20\"}",
null);
System.out.println();
System.out.println("=== Babak 4: permintaan yang ditolak ===");
cetak(klien, "GET", dasar + "/api/tugas/9", null, null);
cetak(klien, "GET", dasar + "/api/pengguna", null, null);
cetak(klien, "POST", dasar + "/api/tugas", "{\"judul\":", "rusak");
cetak(klien, "POST", dasar + "/api/tugas",
"{\"judul\":\"Tugas tanpa pemilik\",\"tenggat\":\"2026-01-22\"}",
"tanpa pemilik");
cetak(klien, "POST", dasar + "/api/tugas",
"{\"judul\":\" \",\"pemilik\":\"Dewi\",\"tenggat\":\"2026-01-25\"}",
"judul kosong");
cetak(klien, "PATCH", dasar + "/api/tugas/1", null, null);
System.out.println();
System.out.println("=== Babak 5: menghapus lalu melihat sisa ===");
cetak(klien, "DELETE", dasar + "/api/tugas/2", null, null);
cetak(klien, "GET", dasar + "/api/tugas", null, null);
} finally {
server.stop(0);
}
}
/**
* Mengirim satu permintaan lalu mencetak status dan body responsnya.
* Nomor port sengaja tidak dicetak agar keluaran tetap deterministik.
*/
private static void cetak(HttpClient klien, String metode, String url,
String body, String keterangan) throws IOException, InterruptedException {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(5));
if (body == null) {
b.method(metode, HttpRequest.BodyPublishers.noBody());
} else {
b.header("Content-Type", "application/json")
.method(metode, HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
}
HttpResponse<String> respons =
klien.send(b.build(), HttpResponse.BodyHandlers.ofString());
String path = URI.create(url).getPath();
String target = keterangan == null ? path : path + " (" + keterangan + ")";
System.out.printf(Locale.US, "%-6s %-28s -> %d %s%n",
metode, target, respons.statusCode(), respons.body());
}
}Output yang Dihasilkan:
=== Babak 1: membuat dua tugas ===
POST /api/tugas -> 201 {"id":1,"judul":"Belajar HttpServer","pemilik":"Budi","status":"TODO","tenggat":"2026-01-20"}
POST /api/tugas -> 201 {"id":2,"judul":"Tulis dokumentasi API","pemilik":"Siti","status":"TODO","tenggat":"2026-02-01"}
=== Babak 2: membaca daftar dan detail ===
GET /api/tugas -> 200 [{"id":1,"judul":"Belajar HttpServer","pemilik":"Budi","status":"TODO","tenggat":"2026-01-20"},{"id":2,"judul":"Tulis dokumentasi API","pemilik":"Siti","status":"TODO","tenggat":"2026-02-01"}]
GET /api/tugas/2 -> 200 {"id":2,"judul":"Tulis dokumentasi API","pemilik":"Siti","status":"TODO","tenggat":"2026-02-01"}
=== Babak 3: mengubah status tugas 1 ===
PUT /api/tugas/1 -> 200 {"id":1,"judul":"Belajar HttpServer","pemilik":"Budi","status":"SELESAI","tenggat":"2026-01-20"}
=== Babak 4: permintaan yang ditolak ===
GET /api/tugas/9 -> 404 {"error":"tugas dengan id 9 tidak ditemukan"}
GET /api/pengguna -> 404 {"error":"endpoint tidak dikenal: /api/pengguna"}
POST /api/tugas (rusak) -> 400 {"error":"JSON tidak valid: teks berakhir sebelum waktunya (posisi 9)"}
POST /api/tugas (tanpa pemilik) -> 400 {"error":"pemilik wajib diisi"}
POST /api/tugas (judul kosong) -> 400 {"error":"judul tidak boleh kosong"}
PATCH /api/tugas/1 -> 405 {"error":"PATCH tidak didukung di /api/tugas/1; gunakan GET, POST, PUT, atau DELETE"}
=== Babak 5: menghapus lalu melihat sisa ===
DELETE /api/tugas/2 -> 200 {"pesan":"tugas 2 dihapus"}
GET /api/tugas -> 200 [{"id":1,"judul":"Belajar HttpServer","pemilik":"Budi","status":"SELESAI","tenggat":"2026-01-20"}]HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0): server hanya mendengarkan loopback; port0meminta sistem operasi memilih port bebas — tidak ada bentrok port, tidak ada akses dari luar mesin.- Tanpa
setExecutor: handler dieksekusi pada satu thread dispatcher bawaan sehingga permintaan sekuensial diproses berurutan; bila dipasang executor khusus, keamanan thread repository ikut dipertimbangkan. - Method
cetak:HttpRequest.Builderdengan timeout 5 detik; body dikirim hanya bila ada, lalu method, path, status, dan body dicetak — nomor port sengaja tidak dicetak agar keluaran deterministik. finally { server.stop(0); }: server selalu dimatikan walau skenario gagal.- Babak 4 memperagakan seluruh penolakan: 404 untuk id dan endpoint tak dikenal; 400 untuk JSON rusak, field hilang, judul kosong dari compact constructor; 405 untuk
PATCH.
Model, router, dan penyimpanan kini bekerja sebagai API utuh. Kelemahannya: proses berhenti berarti seluruh tugas hilang. Bagian 3 menutupnya dengan penyimpanan yang bertahan, dan kontrak TugasRepository membuat pergantian penyimpanan tidak menyentuh satu baris router pun.
Referensi #
- Oracle: HttpServer (Java SE 25 API)
- Oracle: HttpExchange (Java SE 25 API)
- Oracle: HttpClient (Java SE 25 API)
- OpenJDK: JEP 458
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
ApiException.java
Unduh berkas/**
* Exception unchecked pembawa status HTTP.
* Ditangkap di satu titik pada batas handler, lalu diterjemahkan menjadi
* respons JSON berstatus sesuai kebutuhan.
*/
public class ApiException extends RuntimeException {
private final int status;
public ApiException(int status, String pesan) {
super(pesan);
this.status = status;
}
public int status() {
return status;
}
}
JsonMini.java
Unduh berkasimport java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Serializer dan parser JSON mini untuk kebutuhan proyek ini:
* objek datar dengan nilai berupa teks, angka, boolean, atau null.
* Pustaka JSON umum (Jackson, Gson) menangani kasus jauh lebih banyak;
* versi mini ini cukup untuk payload dan respons API tugas sekaligus
* memperlihatkan cara kerja escaping dan parsing secara langsung.
*/
public final class JsonMini {
private JsonMini() {
// Kelas utilitas: seluruh anggota static, tidak perlu instance.
}
/** Meng-escape karakter yang wajib diloloskan di dalam string JSON. */
public static String escape(String teks) {
StringBuilder hasil = new StringBuilder();
for (int i = 0; i < teks.length(); i++) {
char c = teks.charAt(i);
switch (c) {
case '"' -> hasil.append("\\\"");
case '\\' -> hasil.append("\\\\");
case '\n' -> hasil.append("\\n");
case '\t' -> hasil.append("\\t");
case '\r' -> hasil.append("\\r");
default -> {
if (c < 0x20) {
hasil.append(String.format("\\u%04x", (int) c));
} else {
hasil.append(c);
}
}
}
}
return hasil.toString();
}
/** Membungkus teks menjadi string JSON yang sah (dengan kutip dan escape). */
public static String string(String teks) {
return "\"" + escape(teks) + "\"";
}
/** Mengubah satu Tugas menjadi objek JSON dengan field yang urutannya tetap. */
public static String tugas(Tugas t) {
return "{\"id\":" + t.id()
+ ",\"judul\":" + string(t.judul())
+ ",\"pemilik\":" + string(t.pemilik())
+ ",\"status\":\"" + t.status() + "\""
+ ",\"tenggat\":\"" + t.tenggat() + "\"}";
}
/** Mengubah daftar Tugas menjadi array JSON. */
public static String daftar(List<Tugas> semua) {
return semua.stream()
.map(JsonMini::tugas)
.collect(Collectors.joining(",", "[", "]"));
}
/** Amplop pesan error yang seragam untuk seluruh respons gagal. */
public static String error(String pesan) {
return "{\"error\":" + string(pesan) + "}";
}
/**
* Mem-parsing objek JSON datar menjadi Map; urutan field dipertahankan.
* Nilai berupa String, Long, Double, Boolean, atau null.
* Melempar IllegalArgumentException berbahasa Indonesia bila JSON tidak sah.
*/
public static Map<String, Object> parseObjek(String json) {
Pembaca baca = new Pembaca(json);
Map<String, Object> hasil = baca.objek();
baca.spasi();
if (!baca.habis()) {
throw baca.galat("konten tambahan setelah objek");
}
return hasil;
}
/** Pembaca JSON karakter demi karakter dengan posisi yang terlacak. */
private static final class Pembaca {
private final String teks;
private int pos = 0;
Pembaca(String teks) {
this.teks = teks;
}
/** Membaca '{' sampai '}' menjadi LinkedHashMap. */
Map<String, Object> objek() {
spasi();
harap('{');
Map<String, Object> hasil = new LinkedHashMap<>();
spasi();
if (lihat() == '}') {
pos++;
return hasil;
}
while (true) {
spasi();
String kunci = string();
spasi();
harap(':');
spasi();
hasil.put(kunci, nilai());
spasi();
if (lihat() == ',') {
pos++;
continue;
}
harap('}');
return hasil;
}
}
/** Satu nilai: string, true, false, null, atau angka. */
Object nilai() {
char c = lihat();
if (c == '"') {
return string();
}
if (c == 't') {
harapKata("true");
return Boolean.TRUE;
}
if (c == 'f') {
harapKata("false");
return Boolean.FALSE;
}
if (c == 'n') {
harapKata("null");
return null;
}
return angka();
}
/** Membaca string berikut kutip pembuka dan penutupnya. */
String string() {
harap('"');
StringBuilder hasil = new StringBuilder();
while (pos < teks.length()) {
char c = teks.charAt(pos++);
if (c == '"') {
return hasil.toString();
}
if (c == '\\') {
hasil.append(escapeBerikutnya());
} else {
hasil.append(c);
}
}
throw galat("string tidak ditutup kutip");
}
/** Membaca satu karakter hasil escape, misalnya \n atau \". */
char escapeBerikutnya() {
if (pos >= teks.length()) {
throw galat("escape berada di ujung string");
}
char c = teks.charAt(pos++);
return switch (c) {
case '"' -> '"';
case '\\' -> '\\';
case '/' -> '/';
case 'n' -> '\n';
case 't' -> '\t';
case 'r' -> '\r';
case 'b' -> '\b';
case 'f' -> '\f';
default -> throw galat("escape tidak dikenal: \\" + c);
};
}
/** Membaca angka bulat atau pecahan tanpa notasi ilmiah. */
Object angka() {
int awal = pos;
if (lihat() == '-') {
pos++;
}
while (pos < teks.length() && "0123456789.".indexOf(teks.charAt(pos)) >= 0) {
pos++;
}
String teksAngka = teks.substring(awal, pos);
if (teksAngka.isEmpty() || teksAngka.equals("-")) {
throw galat("nilai tidak dikenal mulai di posisi " + awal);
}
try {
if (teksAngka.indexOf('.') >= 0) {
return Double.valueOf(teksAngka);
}
return Long.valueOf(teksAngka);
} catch (NumberFormatException e) {
throw galat("angka tidak sah: " + teksAngka);
}
}
/** Memastikan kata kunci (true/false/null) ada di posisi sekarang. */
void harapKata(String kata) {
if (!teks.startsWith(kata, pos)) {
throw galat("kata kunci '" + kata + "' tidak ditemukan");
}
pos += kata.length();
}
/** Melewati spasi dan whitespace lain. */
void spasi() {
while (pos < teks.length() && Character.isWhitespace(teks.charAt(pos))) {
pos++;
}
}
/** Melihat karakter pada posisi sekarang tanpa memajukannya. */
char lihat() {
if (pos >= teks.length()) {
throw galat("teks berakhir sebelum waktunya");
}
return teks.charAt(pos);
}
/** Memastikan karakter yang diharapkan ada, lalu memajukan posisi. */
void harap(char diharapkan) {
char c = lihat();
if (c != diharapkan) {
throw galat("diharapkan '" + diharapkan + "', ditemukan '" + c + "'");
}
pos++;
}
boolean habis() {
return pos >= teks.length();
}
IllegalArgumentException galat(String pesan) {
return new IllegalArgumentException(pesan + " (posisi " + pos + ")");
}
}
}
RepositoryMemori.java
Unduh berkasimport java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Penyimpanan dalam memori: LinkedHashMap menjaga urutan sisip sama dengan
* urutan id, dan penghitung sederhana menetapkan id berurutan mulai dari 1.
*/
public class RepositoryMemori implements TugasRepository {
private final Map<Integer, Tugas> data = new LinkedHashMap<>();
private int urutan = 0;
@Override
public Tugas tambah(Tugas tugas) {
urutan++;
Tugas tersimpan = tugas.denganId(urutan);
data.put(urutan, tersimpan);
return tersimpan;
}
@Override
public Optional<Tugas> cari(int id) {
return Optional.ofNullable(data.get(id));
}
@Override
public List<Tugas> semua() {
return new ArrayList<>(data.values());
}
@Override
public Optional<Tugas> simpan(Tugas tugas) {
if (!data.containsKey(tugas.id())) {
return Optional.empty();
}
data.put(tugas.id(), tugas);
return Optional.of(tugas);
}
@Override
public boolean hapus(int id) {
return data.remove(id) != null;
}
}
RouterTugas.java
Unduh berkasimport com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.Map;
/**
* Router CRUD untuk resource tugas: membaca method dan path dari setiap
* permintaan, memvalidasi payload, lalu menghasilkan respons JSON.
* Seluruh kegagalan diterjemahkan di satu titik menjadi respons berstatus.
*/
public class RouterTugas implements HttpHandler {
private final TugasRepository repo;
public RouterTugas(TugasRepository repo) {
this.repo = repo;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
try {
tangani(exchange);
} catch (ApiException e) {
kirim(exchange, e.status(), JsonMini.error(e.getMessage()));
} catch (IllegalArgumentException e) {
// Validasi pada lapisan domain (record, enum) diterjemahkan jadi 400.
kirim(exchange, 400, JsonMini.error(e.getMessage()));
} catch (RuntimeException e) {
kirim(exchange, 500, JsonMini.error("kesalahan internal: " + e.getMessage()));
} finally {
exchange.close();
}
}
/** Membaca method dan path, lalu mengarahkan ke handler yang tepat. */
private void tangani(HttpExchange ex) throws IOException {
String metode = ex.getRequestMethod();
String path = ex.getRequestURI().getPath();
if (!path.equals("/api/tugas") && !path.startsWith("/api/tugas/")) {
throw new ApiException(404, "endpoint tidak dikenal: " + path);
}
String sisa = path.substring("/api/tugas".length());
if (sisa.isEmpty() || sisa.equals("/")) {
switch (metode) {
case "GET" -> daftar(ex);
case "POST" -> tambah(ex);
default -> lempar405(metode, path);
}
} else {
int id = bacaId(sisa);
switch (metode) {
case "GET" -> detail(ex, id);
case "PUT" -> ubah(ex, id);
case "DELETE" -> hapus(ex, id);
default -> lempar405(metode, path);
}
}
}
private void daftar(HttpExchange ex) throws IOException {
kirim(ex, 200, JsonMini.daftar(repo.semua()));
}
/** POST: status awal selalu TODO; server yang menetapkannya, bukan klien. */
private void tambah(HttpExchange ex) throws IOException {
Map<String, Object> payload = bacaPayload(ex);
Tugas baru = new Tugas(0, teks(payload, "judul"), teks(payload, "pemilik"),
StatusTugas.TODO, tanggal(payload, "tenggat"));
Tugas tersimpan = repo.tambah(baru);
kirim(ex, 201, JsonMini.tugas(tersimpan));
}
private void detail(HttpExchange ex, int id) throws IOException {
Tugas tugas = repo.cari(id).orElseThrow(() -> new TugasTidakDitemukanException(id));
kirim(ex, 200, JsonMini.tugas(tugas));
}
/** PUT: penggantian utuh, kelima field dikirim kembali oleh klien. */
private void ubah(HttpExchange ex, int id) throws IOException {
repo.cari(id).orElseThrow(() -> new TugasTidakDitemukanException(id));
Map<String, Object> payload = bacaPayload(ex);
Tugas pengganti = new Tugas(id, teks(payload, "judul"), teks(payload, "pemilik"),
status(payload), tanggal(payload, "tenggat"));
Tugas tersimpan = repo.simpan(pengganti)
.orElseThrow(() -> new TugasTidakDitemukanException(id));
kirim(ex, 200, JsonMini.tugas(tersimpan));
}
private void hapus(HttpExchange ex, int id) throws IOException {
if (!repo.hapus(id)) {
throw new TugasTidakDitemukanException(id);
}
kirim(ex, 200, "{\"pesan\":\"tugas " + id + " dihapus\"}");
}
/** Membaca segmen path setelah /api/tugas/ sebagai id. */
private static int bacaId(String sisa) {
String teks = sisa.substring(1);
if (teks.contains("/")) {
throw new ApiException(404, "endpoint tidak dikenal: /api/tugas" + sisa);
}
long id;
try {
id = Long.parseLong(teks);
} catch (NumberFormatException e) {
throw new ValidasiGagalException("id harus angka, diterima: '" + teks + "'");
}
if (id < 1 || id > Integer.MAX_VALUE) {
throw new ValidasiGagalException("id di luar rentang: " + id);
}
return (int) id;
}
/** Membaca seluruh body permintaan sebagai objek JSON datar. */
private static Map<String, Object> bacaPayload(HttpExchange ex) throws IOException {
String mentah = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
if (mentah.isBlank()) {
throw new ValidasiGagalException("body permintaan tidak boleh kosong");
}
try {
return JsonMini.parseObjek(mentah);
} catch (IllegalArgumentException e) {
throw new ValidasiGagalException("JSON tidak valid: " + e.getMessage());
}
}
/** Nilai field teks wajib; 400 bila field hilang atau bukan teks. */
private static String teks(Map<String, Object> payload, String kunci) {
Object nilai = payload.get(kunci);
if (nilai == null) {
throw new ValidasiGagalException(kunci + " wajib diisi");
}
if (!(nilai instanceof String teks)) {
throw new ValidasiGagalException(kunci + " harus berupa teks");
}
return teks;
}
/** Nilai tanggal wajib berformat ISO-8601 seperti 2026-01-20. */
private static LocalDate tanggal(Map<String, Object> payload, String kunci) {
String teks = teks(payload, kunci);
try {
return LocalDate.parse(teks);
} catch (DateTimeParseException e) {
throw new ValidasiGagalException(
kunci + " harus format ISO-8601, contoh 2026-01-20, diterima: '" + teks + "'");
}
}
/** Nilai status wajib TODO atau SELESAI. */
private static StatusTugas status(Map<String, Object> payload) {
return StatusTugas.dariTeks(teks(payload, "status"));
}
private static void lempar405(String metode, String path) {
throw new ApiException(405, metode + " tidak didukung di " + path
+ "; gunakan GET, POST, PUT, atau DELETE");
}
/** Satu-satunya pintu menulis respons: header JSON, status, lalu body UTF-8. */
private static void kirim(HttpExchange ex, int status, String json) throws IOException {
byte[] isi = json.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
ex.sendResponseHeaders(status, isi.length);
try (OutputStream os = ex.getResponseBody()) {
os.write(isi);
}
}
}
ServerApi.java
Unduh berkasimport com.sun.net.httpserver.HttpServer;
import java.io.IOException;
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.time.Duration;
import java.util.Locale;
/**
* Titik masuk API manajemen tugas: HttpServer di alamat loopback dengan port
* dipilih sistem operasi (port 0), lalu seluruh endpoint diuji lewat HttpClient
* dalam satu proses yang sama — server dan klien berjalan tanpa jaringan luar.
*/
public class ServerApi {
public static void main(String[] args) throws Exception {
TugasRepository repo = new RepositoryMemori();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", new RouterTugas(repo));
server.start();
String dasar = "http://127.0.0.1:" + server.getAddress().getPort();
HttpClient klien = HttpClient.newHttpClient();
try {
System.out.println("=== Babak 1: membuat dua tugas ===");
cetak(klien, "POST", dasar + "/api/tugas",
"{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\",\"tenggat\":\"2026-01-20\"}",
null);
cetak(klien, "POST", dasar + "/api/tugas",
"{\"judul\":\"Tulis dokumentasi API\",\"pemilik\":\"Siti\",\"tenggat\":\"2026-02-01\"}",
null);
System.out.println();
System.out.println("=== Babak 2: membaca daftar dan detail ===");
cetak(klien, "GET", dasar + "/api/tugas", null, null);
cetak(klien, "GET", dasar + "/api/tugas/2", null, null);
System.out.println();
System.out.println("=== Babak 3: mengubah status tugas 1 ===");
cetak(klien, "PUT", dasar + "/api/tugas/1",
"{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\","
+ "\"status\":\"SELESAI\",\"tenggat\":\"2026-01-20\"}",
null);
System.out.println();
System.out.println("=== Babak 4: permintaan yang ditolak ===");
cetak(klien, "GET", dasar + "/api/tugas/9", null, null);
cetak(klien, "GET", dasar + "/api/pengguna", null, null);
cetak(klien, "POST", dasar + "/api/tugas", "{\"judul\":", "rusak");
cetak(klien, "POST", dasar + "/api/tugas",
"{\"judul\":\"Tugas tanpa pemilik\",\"tenggat\":\"2026-01-22\"}",
"tanpa pemilik");
cetak(klien, "POST", dasar + "/api/tugas",
"{\"judul\":\" \",\"pemilik\":\"Dewi\",\"tenggat\":\"2026-01-25\"}",
"judul kosong");
cetak(klien, "PATCH", dasar + "/api/tugas/1", null, null);
System.out.println();
System.out.println("=== Babak 5: menghapus lalu melihat sisa ===");
cetak(klien, "DELETE", dasar + "/api/tugas/2", null, null);
cetak(klien, "GET", dasar + "/api/tugas", null, null);
} finally {
server.stop(0);
}
}
/**
* Mengirim satu permintaan lalu mencetak status dan body responsnya.
* Nomor port sengaja tidak dicetak agar keluaran tetap deterministik.
*/
private static void cetak(HttpClient klien, String metode, String url,
String body, String keterangan) throws IOException, InterruptedException {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(5));
if (body == null) {
b.method(metode, HttpRequest.BodyPublishers.noBody());
} else {
b.header("Content-Type", "application/json")
.method(metode, HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
}
HttpResponse<String> respons =
klien.send(b.build(), HttpResponse.BodyHandlers.ofString());
String path = URI.create(url).getPath();
String target = keterangan == null ? path : path + " (" + keterangan + ")";
System.out.printf(Locale.US, "%-6s %-28s -> %d %s%n",
metode, target, respons.statusCode(), respons.body());
}
}
StatusTugas.java
Unduh berkas/** Status pengerjaan sebuah tugas. */
public enum StatusTugas {
TODO,
SELESAI;
/**
* Mengubah teks JSON menjadi status.
* Melempar IllegalArgumentException dengan pesan jelas bila teks tidak dikenal.
*/
public static StatusTugas dariTeks(String teks) {
for (StatusTugas s : values()) {
if (s.name().equals(teks)) {
return s;
}
}
throw new IllegalArgumentException(
"status '" + teks + "' tidak dikenal, gunakan TODO atau SELESAI");
}
}
Tugas.java
Unduh berkasimport java.time.LocalDate;
/**
* Representasi satu tugas sebagai resource API.
* Invarian dasar dijaga compact constructor sehingga objek Tugas tidak pernah
* berada dalam keadaan tidak sah, apa pun asal datanya (kode atau payload klien).
*/
public record Tugas(int id, String judul, String pemilik, StatusTugas status, LocalDate tenggat) {
public Tugas {
if (judul == null || judul.strip().isEmpty()) {
throw new IllegalArgumentException("judul tidak boleh kosong");
}
if (pemilik == null || pemilik.strip().isEmpty()) {
throw new IllegalArgumentException("pemilik tidak boleh kosong");
}
if (tenggat == null) {
throw new IllegalArgumentException("tenggat tidak boleh kosong");
}
judul = judul.strip();
pemilik = pemilik.strip();
}
/** Salinan tugas ini dengan id baru; dipakai repository saat menetapkan id. */
public Tugas denganId(int idBaru) {
return new Tugas(idBaru, judul, pemilik, status, tenggat);
}
/** Salinan tugas ini dengan status baru; dipakai operasi ubah. */
public Tugas denganStatus(StatusTugas statusBaru) {
return new Tugas(id, judul, pemilik, statusBaru, tenggat);
}
}
TugasRepository.java
Unduh berkasimport java.util.List;
import java.util.Optional;
/**
* Kontrak lapisan penyimpanan untuk resource tugas.
* Handler hanya mengenal interface ini, sehingga implementasi bebas ditukar:
* memori untuk pengujian, berkas untuk persistensi, atau JDBC untuk database.
*/
public interface TugasRepository {
/** Menyimpan tugas baru; id ditetapkan otomatis oleh penyimpanan. */
Tugas tambah(Tugas tugas);
/** Mencari satu tugas berdasarkan id. */
Optional<Tugas> cari(int id);
/** Seluruh tugas terurut menaik berdasarkan id. */
List<Tugas> semua();
/** Menyimpan perubahan tugas yang sudah ada; kosong bila id tidak ditemukan. */
Optional<Tugas> simpan(Tugas tugas);
/** Menghapus tugas; false bila id tidak ditemukan. */
boolean hapus(int id);
}
TugasTidakDitemukanException.java
Unduh berkas/**
* Id tugas yang diminta tidak ada di penyimpanan.
* Diterjemahkan menjadi respons 404 Not Found.
*/
public class TugasTidakDitemukanException extends ApiException {
public TugasTidakDitemukanException(int id) {
super(404, "tugas dengan id " + id + " tidak ditemukan");
}
}
ValidasiGagalException.java
Unduh berkas/**
* Payload permintaan tidak lolos validasi: field wajib hilang,
* format tanggal salah, atau JSON tidak sah.
* Diterjemahkan menjadi respons 400 Bad Request.
*/
public class ValidasiGagalException extends ApiException {
public ValidasiGagalException(String pesan) {
super(400, pesan);
}
}