Lapisan web kini menghidupkan struktur Bagian 1: RouterWeb melayani REST API CRUD di atas HttpServer bawaan JDK, lengkap dengan validasi payload dan pesan galat terstruktur. Seluruh berkas Bagian 1 hadir kembali tanpa perubahan kecuali LayananTugasWeb yang kini memanggil validasi sebelum bekerja. Di akhir halaman, padanan @RestController dan Bean Validation berdiri di samping kode yang benar-benar berjalan.
Peta Endpoint sebagai Router #
DispatcherServlet Spring MVC bekerja sebagai peta: anotasi pemetaan didaftarkan sebagai pasangan method + path → method handler, lalu setiap permintaan dicocokkan ke peta itu. RouterWeb meniru arsitektur itu secara mini — LinkedHashMap berisi kunci "METODE /pola" dan lambda Endpoint, dengan pola {id} yang menyerap satu segmen angka:
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.util.LinkedHashMap;
import java.util.Map;
/**
* Lapisan web inti telanjang: satu HttpHandler yang menyimpan peta endpoint
* "METODE /pola" -> kode, lengkap dengan pencocokan pola {id}. Struktur ini
* adalah miniatur DispatcherServlet Spring MVC: peta handler menggantikan
* anotasi @GetMapping/@PostMapping, dan satu blok catch di batas menggantikan
* @ExceptionHandler.
*/
public class RouterWeb implements HttpHandler {
/** Satu endpoint terdaftar: pola "METODE /api/tugas/{id}". */
@FunctionalInterface
public interface Endpoint {
/** Memproses satu permintaan; parameter berisi nilai {id} hasil cocok pola. */
ResponsWeb tangani(Map<String, String> parameter, String body);
}
/** Respons siap kirim: status HTTP, body JSON, dan Location untuk 201. */
public record ResponsWeb(int status, String json, String lokasi) {
static ResponsWeb ok(String json) {
return new ResponsWeb(200, json, null);
}
static ResponsWeb dibuat(TugasWeb tugas) {
return new ResponsWeb(201, JsonWeb.tugas(tugas), "/api/tugas/" + tugas.id());
}
static ResponsWeb tanpaIsi() {
return new ResponsWeb(204, null, null);
}
}
private final LayananTugasWeb layanan;
private final Map<String, Endpoint> rute = new LinkedHashMap<>();
public RouterWeb(LayananTugasWeb layanan) {
this.layanan = layanan;
rute.put("GET /api/tugas", (p, b) -> ResponsWeb.ok(JsonWeb.daftar(layanan.daftar())));
rute.put("POST /api/tugas", (p, b) -> ResponsWeb.dibuat(layanan.buat(bacaPermintaan(b))));
rute.put("GET /api/tugas/{id}", (p, b) ->
ResponsWeb.ok(JsonWeb.tugas(layanan.cari(id(p)))));
rute.put("PUT /api/tugas/{id}", (p, b) ->
ResponsWeb.ok(JsonWeb.tugas(layanan.ubah(id(p), bacaPermintaan(b)))));
rute.put("POST /api/tugas/{id}/selesai", (p, b) ->
ResponsWeb.ok(JsonWeb.tugas(layanan.tandaiSelesai(id(p)))));
rute.put("DELETE /api/tugas/{id}", (p, b) -> {
layanan.hapus(id(p));
return ResponsWeb.tanpaIsi();
});
}
@Override
public void handle(HttpExchange exchange) throws IOException {
// Body dibaca selalu agar koneksi sehat walau permintaan ditolak.
String body = new String(exchange.getRequestBody().readAllBytes(),
StandardCharsets.UTF_8);
String metode = exchange.getRequestMethod();
String path = exchange.getRequestURI().getPath();
try {
kirim(exchange, cocokkan(metode, path, body));
} catch (ValidasiGagalException e) {
kirim(exchange, new ResponsWeb(400,
JsonWeb.galat(400, "validasi gagal", e.galat()), null));
} catch (ApiException e) {
kirim(exchange, new ResponsWeb(e.status(),
JsonWeb.galat(e.status(), e.getMessage()), null));
} catch (IllegalArgumentException e) {
kirim(exchange, new ResponsWeb(400,
JsonWeb.galat(400, e.getMessage()), null));
} catch (RuntimeException e) {
kirim(exchange, new ResponsWeb(500,
JsonWeb.galat(500, "kegagalan tak terduga: " + e), null));
}
}
/** Mencocokkan metode dan path dengan peta rute; 405 bila path dikenal
* dengan metode lain, 404 bila path memang tidak terdaftar. */
private ResponsWeb cocokkan(String metode, String path, String body) {
for (Map.Entry<String, Endpoint> rute : this.rute.entrySet()) {
String[] kunci = rute.getKey().split(" ", 2);
Map<String, String> parameter = cocokkanPola(kunci[1], path);
if (parameter != null && kunci[0].equals(metode)) {
return rute.getValue().tangani(parameter, body);
}
}
if (pathTerdaftar(path)) {
throw new ApiException(405, "metode " + metode + " tidak didukung untuk " + path);
}
throw new ApiException(404, "endpoint tidak dikenal: " + metode + " " + path);
}
/** Mencocokkan pola bersegmen dengan path; {id} menyerap satu segmen angka. */
private static Map<String, String> cocokkanPola(String pola, String path) {
String[] segPola = pola.split("/");
String[] segPath = path.split("/");
if (segPola.length != segPath.length) {
return null;
}
Map<String, String> parameter = new LinkedHashMap<>();
for (int i = 0; i < segPola.length; i++) {
String p = segPola[i];
if (p.startsWith("{") && p.endsWith("}")) {
if (!segPath[i].matches("\\d+")) {
return null;
}
parameter.put(p.substring(1, p.length() - 1), segPath[i]);
} else if (!p.equals(segPath[i])) {
return null;
}
}
return parameter;
}
/** Apakah path cocok dengan pola rute mana pun (metode apa pun). */
private boolean pathTerdaftar(String path) {
for (String kunci : rute.keySet()) {
if (cocokkanPola(kunci.split(" ", 2)[1], path) != null) {
return true;
}
}
return false;
}
/** Id numerik dari parameter hasil cocok pola. */
private static int id(Map<String, String> parameter) {
return Integer.parseInt(parameter.get("id"));
}
/** Membaca body JSON menjadi PermintaanTugas. */
private static PermintaanTugas bacaPermintaan(String body) {
if (body == null || body.isBlank()) {
throw new ValidasiGagalException("body permintaan wajib berisi JSON objek");
}
Map<String, Object> data = JsonWeb.parseObjek(body);
return new PermintaanTugas(teks(data, "judul"), teks(data, "pemilik"),
teks(data, "tenggat"));
}
/** Nilai field teks; null bila field hilang, 400 bila bukan teks. */
private static String teks(Map<String, Object> data, String field) {
Object nilai = data.get(field);
if (nilai == null) {
return null;
}
if (nilai instanceof String s) {
return s;
}
throw new ApiException(400, "field " + field + " harus berupa teks");
}
/** Menulis respons: body null berarti 204 tanpa isi. */
private static void kirim(HttpExchange exchange, ResponsWeb respons) throws IOException {
if (respons.json() == null) {
exchange.sendResponseHeaders(respons.status(), -1);
exchange.close();
return;
}
byte[] isi = respons.json().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
if (respons.lokasi() != null) {
exchange.getResponseHeaders().set("Location", respons.lokasi());
}
exchange.sendResponseHeaders(respons.status(), isi.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(isi);
} finally {
exchange.close();
}
}
}Empat bagian kunci router:
- Konstruktor sebagai pendaftaran rute: enam baris
rute.put(...)adalah seluruh tabel endpoint aplikasi — padanan enam anotasi pemetaan pada satu controller. - Method
cocokkanPola: pola dan path dipecah per segmen/; segmen{id}hanya menyerap digit, sehingga/api/tugas/abcjatuh ke 404, bukan galat parsing. - Method
cocokkan: iterasi mencari pasangan metode+path yang cocok; bila gagal,pathTerdaftarmemeriksa apakah path dikenal dengan metode lain sebelum memutuskan 405 atau 404. - Method
handle: satu bloktrymenangkap empat jenis kegagalan berjenjang —ValidasiGagalException(400 dengan rincian),ApiException(status bawaan),IllegalArgumentException(400, misalnya JSON rusak), dan sisaRuntimeException(500).
Pemetaan galat ke status kini lengkap:
| Sumber kegagalan | Exception | Status |
|---|---|---|
| field hilang, terlalu pendek, format tanggal salah | ValidasiGagalException | 400 + daftar galat |
tenggat lampau (butuh konteks hariIni) | ValidasiGagalException | 400 |
| id tidak ada di penyimpanan | TugasTidakDitemukanException | 404 |
| status sudah SELESAI, batas tugas aktif terlampaui | AturanBisnisException | 409 |
| metode salah / endpoint asing | ApiException | 405 / 404 |
Validasi Payload dengan Pesan Terstruktur #
Validasi dibagi dua lapis. Lapisan bentuk — panjang judul, isi pemilik, format ISO tanggal, larangan karakter | yang dipakai format berkas Bagian 3 — tinggal di ValidasiWeb yang memeriksa seluruh field sekaligus; klien yang mengirim tiga field salah menerima tiga pesan dalam satu respons. Lapisan konteks tetap di layanan karena membutuhkan hariIni dan isi repository:
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
/**
* Validasi bentuk field muatan tugas: panjang judul, isi pemilik, format
* tanggal, dan karakter yang dilarang format penyimpanan Bagian 3. Seluruh
* galat dikumpulkan sekaligus agar klien tahu semua kesalahan dalam satu
* respons, bukan satu per satu. Aturan yang butuh konteks (tenggat lampau,
* batas tugas aktif) tetap di LayananTugasWeb.
*/
public final class ValidasiWeb {
private ValidasiWeb() {
// Kelas utilitas: seluruh anggota static.
}
/**
* Memeriksa muatan permintaan dan mengembalikan daftar galat.
* Daftar kosong berarti seluruh field lolos pemeriksaan bentuk.
*/
public static List<String> periksa(PermintaanTugas permintaan) {
List<String> galat = new ArrayList<>();
String judul = permintaan.judul() == null ? "" : permintaan.judul().strip();
if (judul.length() < 3 || judul.length() > 100) {
galat.add("judul wajib berisi 3-100 karakter");
}
if (judul.contains("|")) {
galat.add("judul tidak boleh memuat karakter '|' (pemisah format penyimpanan)");
}
String pemilik = permintaan.pemilik() == null ? "" : permintaan.pemilik().strip();
if (pemilik.isEmpty() || pemilik.length() > 60) {
galat.add("pemilik wajib diisi, maksimum 60 karakter");
}
if (pemilik.contains("|")) {
galat.add("pemilik tidak boleh memuat karakter '|'");
}
if (permintaan.tenggat() == null) {
galat.add("tenggat wajib berformat ISO yyyy-MM-dd");
} else {
try {
LocalDate.parse(permintaan.tenggat().strip());
} catch (DateTimeParseException e) {
galat.add("tenggat wajib berformat ISO yyyy-MM-dd");
}
}
return galat;
}
}import java.util.List;
/**
* Muatan permintaan tidak lolos pemeriksaan. Membawa daftar galat per field
* agar router bisa menyusun respons 400 dengan rincian terstruktur — padanan
* MethodArgumentNotValidException pada Spring MVC.
*/
public class ValidasiGagalException extends ApiException {
private final List<String> galat;
public ValidasiGagalException(String pesan) {
this(List.of(pesan));
}
public ValidasiGagalException(List<String> galat) {
super(400, "validasi gagal: " + String.join("; ", galat));
this.galat = List.copyOf(galat);
}
/** Rincian galat per field, siap ditampilkan sebagai array JSON. */
public List<String> galat() {
return galat;
}
}import java.time.LocalDate;
import java.util.List;
/**
* Lapisan layanan: pemilik aturan bisnis aplikasi web manajemen tugas.
* Seluruh operasi lewat sini; router tidak pernah menyentuh repository
* secara langsung, dan repository tidak pernah tahu bahwa HTTP ada.
* Hari operasional disuntik lewat konstruktor agar aturan "tenggat lampau"
* bisa diuji dengan tanggal beku, bukan LocalDate.now().
*/
public class LayananTugasWeb {
/** Batas tugas berstatus TODO per pemilik: aturan bisnis lapisan layanan. */
public static final int BATAS_TUGAS_AKTIF = 5;
private final RepositoryTugasWeb repository;
private final LocalDate hariIni;
public LayananTugasWeb(RepositoryTugasWeb repository, LocalDate hariIni) {
this.repository = repository;
this.hariIni = hariIni;
}
/** Hari operasional aplikasi, disuntik lewat konstruktor. */
public LocalDate hariIni() {
return hariIni;
}
/**
* Menyusun tugas baru setelah validasi bentuk field dan aturan kontekstual:
* status selalu TODO, tenggat wajib belum lampau, dan pemilik belum
* menyentuh batas tugas aktif.
*/
public TugasWeb buat(PermintaanTugas permintaan) {
LocalDate tenggat = validasi(permintaan);
TugasWeb baru = new TugasWeb(0, permintaan.judul(), permintaan.pemilik(),
StatusTugasWeb.TODO, tenggat);
return repository.simpanBaru(baru);
}
/** Seluruh tugas terurut id. */
public List<TugasWeb> daftar() {
return repository.semua();
}
/** Satu tugas berdasarkan id; 404 bila tidak ada. */
public TugasWeb cari(int id) {
return repository.cari(id)
.orElseThrow(() -> new TugasTidakDitemukanException(id));
}
/**
* Memperbarui judul, pemilik, dan tenggat; status dipertahankan apa adanya
* karena transisi status hanya boleh lewat tandaiSelesai.
*/
public TugasWeb ubah(int id, PermintaanTugas permintaan) {
TugasWeb tugas = cari(id);
LocalDate tenggat = validasi(permintaan);
TugasWeb baru = new TugasWeb(id, permintaan.judul(), permintaan.pemilik(),
tugas.status(), tenggat);
return repository.perbarui(baru)
.orElseThrow(() -> new TugasTidakDitemukanException(id));
}
/** Menandai tugas selesai; 409 bila tugas memang sudah SELESAI. */
public TugasWeb tandaiSelesai(int id) {
TugasWeb tugas = cari(id);
if (tugas.status() == StatusTugasWeb.SELESAI) {
throw new AturanBisnisException("tugas #" + id
+ " sudah SELESAI, tidak bisa ditandai selesai ulang");
}
TugasWeb selesai = tugas.denganStatus(StatusTugasWeb.SELESAI);
return repository.perbarui(selesai)
.orElseThrow(() -> new TugasTidakDitemukanException(id));
}
/** Menghapus tugas; 404 bila id tidak ada. */
public void hapus(int id) {
cari(id);
repository.hapus(id);
}
/**
* Validasi menyeluruh satu muatan: bentuk field lewat ValidasiWeb
* (galat dikumpulkan), lalu dua aturan kontekstual — tenggat wajib
* belum lampau dan pemilik belum menyentuh batas tugas aktif.
* Mengembalikan tenggat terurai untuk dipakai pemanggil.
*/
private LocalDate validasi(PermintaanTugas permintaan) {
List<String> galat = ValidasiWeb.periksa(permintaan);
if (!galat.isEmpty()) {
throw new ValidasiGagalException(galat);
}
LocalDate tenggat = LocalDate.parse(permintaan.tenggat().strip());
if (tenggat.isBefore(hariIni)) {
throw new ValidasiGagalException("tenggat " + tenggat
+ " sudah lampau, hari operasional " + hariIni);
}
long aktif = repository.semua().stream()
.filter(t -> t.pemilik().equals(permintaan.pemilik().strip()))
.filter(t -> t.status() == StatusTugasWeb.TODO)
.count();
if (aktif >= BATAS_TUGAS_AKTIF) {
throw new AturanBisnisException("pemilik " + permintaan.pemilik()
+ " sudah memiliki " + BATAS_TUGAS_AKTIF + " tugas TODO");
}
return tenggat;
}
}Method validasi di layanan merangkai kedua lapis: hasil ValidasiWeb.periksa yang tidak kosong menjadi exception pembawa daftar galat; tenggat dibandingkan dengan hariIni; jumlah tugas TODO pemilik dihitung untuk aturan batas. Pada Spring, Bean Validation menangani lapisan bentuk lewat anotasi, sedangkan aturan kontekstual ditulis manual di @Service.
Pertukaran data dijembatani JsonWeb, parser dan serializer mini untuk objek datar:
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Serializer dan parser JSON mini untuk inti telanjang aplikasi web:
* objek datar dengan nilai teks, angka, boolean, atau null. Objek dan array
* bersarang sengaja tidak didukung agar kelas ini tetap ringkas; pustaka
* seperti Jackson menangani seluruh kasus pada aplikasi Spring Boot asli.
*/
public final class JsonWeb {
private JsonWeb() {
// Kelas utilitas: seluruh anggota static.
}
/** Meloloskan karakter yang wajib di-escape 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 sah (kutip plus escape). */
public static String teks(String nilai) {
return "\"" + escape(nilai) + "\"";
}
/** Satu tugas menjadi objek JSON dengan urutan field tetap. */
public static String tugas(TugasWeb t) {
return "{\"id\":" + t.id()
+ ",\"judul\":" + teks(t.judul())
+ ",\"pemilik\":" + teks(t.pemilik())
+ ",\"status\":\"" + t.status() + "\""
+ ",\"tenggat\":\"" + t.tenggat() + "\"}";
}
/** Daftar tugas menjadi array JSON. */
public static String daftar(List<TugasWeb> semua) {
StringBuilder hasil = new StringBuilder("[");
for (TugasWeb t : semua) {
if (hasil.length() > 1) {
hasil.append(",");
}
hasil.append(tugas(t));
}
return hasil.append("]").toString();
}
/** Amplop galat tunggal. */
public static String galat(int status, String pesan) {
return "{\"status\":" + status + ",\"pesan\":" + teks(pesan) + "}";
}
/** Amplop galat validasi dengan rincian per field. */
public static String galat(int status, String pesan, List<String> rincian) {
if (rincian == null || rincian.isEmpty()) {
return galat(status, pesan);
}
StringBuilder hasil = new StringBuilder("{\"status\":").append(status)
.append(",\"pesan\":").append(teks(pesan)).append(",\"galat\":[");
boolean pertama = true;
for (String g : rincian) {
if (!pertama) {
hasil.append(",");
}
pertama = false;
hasil.append(teks(g));
}
return hasil.append("]}").toString();
}
/**
* Mem-parsing objek JSON datar menjadi Map dengan urutan field terjaga.
* Nilai berupa String, Long, Double, Boolean, atau null; nilai bersarang
* ditolak. Melempar IllegalArgumentException bila teks 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 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 datar. */
Object nilai() {
char c = lihat();
if (c == '"') {
return string();
}
if (c == '{' || c == '[') {
throw galat("objek/array bersarang tidak didukung parser mini");
}
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 + ")");
}
}
}CRUD In-Process lewat HttpClient #
AplikasiWeb menaikkan server di 127.0.0.1 port 0, lalu mengirim dua belas permintaan sekuensial dari HttpClient dalam proses yang sama — pola in-process yang membuat API teruji sungguhan tanpa port tetap:
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.time.LocalDate;
import java.util.Locale;
/**
* Titik masuk inti telanjang Bagian 2: REST API CRUD berjalan di atas
* HttpServer bawaan JDK lalu diuji in-process — server dan HttpClient
* berbagi satu proses, server mendengarkan 127.0.0.1 pada port pilihan
* sistem operasi (nomor port sengaja tidak dicetak agar keluaran stabil).
* Seluruh permintaan sekuensial dari satu klien.
*/
public class AplikasiWeb {
public static void main(String[] args) throws Exception {
System.out.println("=== AplikasiWeb Bagian 2: REST API inti telanjang ===");
LayananTugasWeb layanan = new LayananTugasWeb(
new RepositoryTugasWebMemori(), LocalDate.of(2026, 1, 15));
RouterWeb router = new RouterWeb(layanan);
System.out.println("[Bootstrap] repository -> layanan -> router tersambung");
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", router);
server.start();
System.out.println("[Bootstrap] server mendengarkan di 127.0.0.1"
+ " (port dipilih sistem operasi)");
System.out.println();
HttpClient klien = HttpClient.newHttpClient();
String dasar = "http://127.0.0.1:" + server.getAddress().getPort();
try {
kirim(klien, dasar, "POST", "/api/tugas",
"{\"judul\":\"Belajar arsitektur berlapis\",\"pemilik\":\"Budi\","
+ "\"tenggat\":\"2026-02-01\"}");
kirim(klien, dasar, "POST", "/api/tugas",
"{\"judul\":\"Menulis dokumentasi API\",\"pemilik\":\"Siti\","
+ "\"tenggat\":\"2026-01-20\"}");
kirim(klien, dasar, "GET", "/api/tugas", null);
kirim(klien, dasar, "GET", "/api/tugas/1", null);
kirim(klien, dasar, "PUT", "/api/tugas/1",
"{\"judul\":\"Belajar arsitektur berlapis (revisi)\","
+ "\"pemilik\":\"Budi\",\"tenggat\":\"2026-02-08\"}");
kirim(klien, dasar, "POST", "/api/tugas/1/selesai", null);
kirim(klien, dasar, "POST", "/api/tugas/1/selesai", null);
kirim(klien, dasar, "POST", "/api/tugas",
"{\"judul\":\"ab\",\"pemilik\":\"\",\"tenggat\":\"bukan-tanggal\"}");
kirim(klien, dasar, "POST", "/api/tugas", "{\"judul\":");
kirim(klien, dasar, "GET", "/api/tugas/99", null);
kirim(klien, dasar, "PATCH", "/api/tugas/1", null);
kirim(klien, dasar, "DELETE", "/api/tugas/2", null);
kirim(klien, dasar, "GET", "/api/tugas", null);
} finally {
server.stop(0);
}
System.out.println();
System.out.println("[Bootstrap] server berhenti; seluruh permintaan"
+ " sekuensial dari satu klien");
}
/** Mengirim satu permintaan lalu mencetak status, Location, dan body respons. */
private static void kirim(HttpClient klien, String dasar, String metode,
String path, String body) throws IOException, InterruptedException {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(dasar + path))
.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> r = klien.send(b.build(),
HttpResponse.BodyHandlers.ofString());
String lokasi = r.headers().firstValue("Location")
.map(l -> " Location=" + l).orElse("");
String isi = r.body().isEmpty() ? "(tanpa body)" : r.body();
System.out.printf(Locale.US, "%-7s %-26s -> %d%s %s%n",
metode, path, r.statusCode(), lokasi, isi);
}
}Output yang Dihasilkan:
=== AplikasiWeb Bagian 2: REST API inti telanjang ===
[Bootstrap] repository -> layanan -> router tersambung
[Bootstrap] server mendengarkan di 127.0.0.1 (port dipilih sistem operasi)
POST /api/tugas -> 201 Location=/api/tugas/1 {"id":1,"judul":"Belajar arsitektur berlapis","pemilik":"Budi","status":"TODO","tenggat":"2026-02-01"}
POST /api/tugas -> 201 Location=/api/tugas/2 {"id":2,"judul":"Menulis dokumentasi API","pemilik":"Siti","status":"TODO","tenggat":"2026-01-20"}
GET /api/tugas -> 200 [{"id":1,"judul":"Belajar arsitektur berlapis","pemilik":"Budi","status":"TODO","tenggat":"2026-02-01"},{"id":2,"judul":"Menulis dokumentasi API","pemilik":"Siti","status":"TODO","tenggat":"2026-01-20"}]
GET /api/tugas/1 -> 200 {"id":1,"judul":"Belajar arsitektur berlapis","pemilik":"Budi","status":"TODO","tenggat":"2026-02-01"}
PUT /api/tugas/1 -> 200 {"id":1,"judul":"Belajar arsitektur berlapis (revisi)","pemilik":"Budi","status":"TODO","tenggat":"2026-02-08"}
POST /api/tugas/1/selesai -> 200 {"id":1,"judul":"Belajar arsitektur berlapis (revisi)","pemilik":"Budi","status":"SELESAI","tenggat":"2026-02-08"}
POST /api/tugas/1/selesai -> 409 {"status":409,"pesan":"tugas #1 sudah SELESAI, tidak bisa ditandai selesai ulang"}
POST /api/tugas -> 400 {"status":400,"pesan":"validasi gagal","galat":["judul wajib berisi 3-100 karakter","pemilik wajib diisi, maksimum 60 karakter","tenggat wajib berformat ISO yyyy-MM-dd"]}
POST /api/tugas -> 400 {"status":400,"pesan":"teks berakhir sebelum waktunya (posisi 9)"}
GET /api/tugas/99 -> 404 {"status":404,"pesan":"tugas dengan id 99 tidak ditemukan"}
PATCH /api/tugas/1 -> 405 {"status":405,"pesan":"metode PATCH tidak didukung untuk /api/tugas/1"}
DELETE /api/tugas/2 -> 204 (tanpa body)
GET /api/tugas -> 200 [{"id":1,"judul":"Belajar arsitektur berlapis (revisi)","pemilik":"Budi","status":"SELESAI","tenggat":"2026-02-08"}]
[Bootstrap] server berhenti; seluruh permintaan sekuensial dari satu klienMari kita baca hasilnya baris demi baris:
- Dua POST pertama: masing-masing dijawab 201 dengan header
Locationmenunjuk URI tugas baru — konvensi REST untuk sumber daya yang baru tercipta. - POST selesai kedua kali: dijawab 409 karena tugas #1 sudah SELESAI — aturan transisi status Bagian 1 kini berlaku lewat HTTP.
- POST dengan tiga field salah: dijawab 400 dengan array
galatberisi tiga pesan sekaligus — validasi mengumpulkan, bukan berhenti di galat pertama. - POST dengan JSON setengah: parser mini melempar
IllegalArgumentExceptionyang diterjemahkan 400 dengan posisi galat — tetap terstruktur. - PATCH dan DELETE: 405 untuk metode yang tidak terdaftar pada path yang dikenal, lalu 204 tanpa body untuk hapus yang berhasil.
Padanan @RestController dan Bean Validation #
Pada Spring Boot, seluruh pekerjaan router di atas tersebar di controller dan anotasi. Blok berikut tampil sebagai rancangan dan tidak dieksekusi di lingkungan ini:
@RestController
@RequestMapping("/api/tugas")
public class TugasController {
private final LayananTugas layanan;
public TugasController(LayananTugas layanan) {
this.layanan = layanan;
}
@GetMapping
public List<Tugas> daftar() {
return layanan.daftar();
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Tugas buat(@Valid @RequestBody PermintaanTugasDto permintaan) {
return layanan.buat(permintaan);
}
@GetMapping("/{id}")
public Tugas cari(@PathVariable int id) {
return layanan.cari(id);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void hapus(@PathVariable int id) {
layanan.hapus(id);
}
}Validasi bentuk pindah ke DTO sebagai anotasi Bean Validation, dan galatnya dikumpulkan otomatis oleh Spring MVC:
public record PermintaanTugasDto(
@NotBlank
@Size(min = 3, max = 100)
String judul,
@NotBlank
@Size(max = 60)
String pemilik,
@NotNull
@Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "tenggat wajib berformat ISO yyyy-MM-dd")
String tenggat) {
}@RestControllerAdvice
public class PenanganGalat {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, Object> validasi(MethodArgumentNotValidException e) {
List<String> galat = e.getBindingResult().getFieldErrors().stream()
.map(galat -> galat.getField() + ": " + galat.getDefaultMessage())
.toList();
return Map.of("status", 400, "pesan", "validasi gagal", "galat", galat);
}
}Padanannya presisi: lambda Endpoint menjadi method bertanda @GetMapping; {id} pada pola menjadi @PathVariable; bacaPermintaan menjadi @RequestBody; @Valid memicu Bean Validation di titik yang sama dengan ValidasiWeb.periksa; blok catch di handle menjadi @RestControllerAdvice.
API kini hidup: enam endpoint, validasi berlapis, pesan galat terstruktur — tetapi setiap eksekusi ulang memulai dari data kosong karena penyimpanan masih di memori. Bagian 3 memberi aplikasi ingatan lewat repository berkas yang bertahan lintas restart, sekaligus gerbang keamanan di depan router — dua hal yang di Spring dipegang Spring Data JPA dan Spring Security.
Referensi #
- Spring Boot Reference: Web Servlet Applications
- Spring Framework Reference: Validation
- Oracle: Class HttpServer (Java SE 25 API)
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
* router, lalu diterjemahkan menjadi respons JSON berstatus sesuai. Peran ini
* dipegang @ExceptionHandler pada aplikasi Spring Boot sungguhan.
*/
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;
}
}
AplikasiWeb.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.time.LocalDate;
import java.util.Locale;
/**
* Titik masuk inti telanjang Bagian 2: REST API CRUD berjalan di atas
* HttpServer bawaan JDK lalu diuji in-process — server dan HttpClient
* berbagi satu proses, server mendengarkan 127.0.0.1 pada port pilihan
* sistem operasi (nomor port sengaja tidak dicetak agar keluaran stabil).
* Seluruh permintaan sekuensial dari satu klien.
*/
public class AplikasiWeb {
public static void main(String[] args) throws Exception {
System.out.println("=== AplikasiWeb Bagian 2: REST API inti telanjang ===");
LayananTugasWeb layanan = new LayananTugasWeb(
new RepositoryTugasWebMemori(), LocalDate.of(2026, 1, 15));
RouterWeb router = new RouterWeb(layanan);
System.out.println("[Bootstrap] repository -> layanan -> router tersambung");
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", router);
server.start();
System.out.println("[Bootstrap] server mendengarkan di 127.0.0.1"
+ " (port dipilih sistem operasi)");
System.out.println();
HttpClient klien = HttpClient.newHttpClient();
String dasar = "http://127.0.0.1:" + server.getAddress().getPort();
try {
kirim(klien, dasar, "POST", "/api/tugas",
"{\"judul\":\"Belajar arsitektur berlapis\",\"pemilik\":\"Budi\","
+ "\"tenggat\":\"2026-02-01\"}");
kirim(klien, dasar, "POST", "/api/tugas",
"{\"judul\":\"Menulis dokumentasi API\",\"pemilik\":\"Siti\","
+ "\"tenggat\":\"2026-01-20\"}");
kirim(klien, dasar, "GET", "/api/tugas", null);
kirim(klien, dasar, "GET", "/api/tugas/1", null);
kirim(klien, dasar, "PUT", "/api/tugas/1",
"{\"judul\":\"Belajar arsitektur berlapis (revisi)\","
+ "\"pemilik\":\"Budi\",\"tenggat\":\"2026-02-08\"}");
kirim(klien, dasar, "POST", "/api/tugas/1/selesai", null);
kirim(klien, dasar, "POST", "/api/tugas/1/selesai", null);
kirim(klien, dasar, "POST", "/api/tugas",
"{\"judul\":\"ab\",\"pemilik\":\"\",\"tenggat\":\"bukan-tanggal\"}");
kirim(klien, dasar, "POST", "/api/tugas", "{\"judul\":");
kirim(klien, dasar, "GET", "/api/tugas/99", null);
kirim(klien, dasar, "PATCH", "/api/tugas/1", null);
kirim(klien, dasar, "DELETE", "/api/tugas/2", null);
kirim(klien, dasar, "GET", "/api/tugas", null);
} finally {
server.stop(0);
}
System.out.println();
System.out.println("[Bootstrap] server berhenti; seluruh permintaan"
+ " sekuensial dari satu klien");
}
/** Mengirim satu permintaan lalu mencetak status, Location, dan body respons. */
private static void kirim(HttpClient klien, String dasar, String metode,
String path, String body) throws IOException, InterruptedException {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(dasar + path))
.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> r = klien.send(b.build(),
HttpResponse.BodyHandlers.ofString());
String lokasi = r.headers().firstValue("Location")
.map(l -> " Location=" + l).orElse("");
String isi = r.body().isEmpty() ? "(tanpa body)" : r.body();
System.out.printf(Locale.US, "%-7s %-26s -> %d%s %s%n",
metode, path, r.statusCode(), lokasi, isi);
}
}
AturanBisnisException.java
Unduh berkas/**
* Operasi menabrak aturan bisnis yang bergantung pada kondisi terkini:
* transisi status yang sudah lampau atau batas tugas aktif per pemilik.
* Diterjemahkan router menjadi respons 409 Conflict.
*/
public class AturanBisnisException extends ApiException {
public AturanBisnisException(String pesan) {
super(409, pesan);
}
}
JsonWeb.java
Unduh berkasimport java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Serializer dan parser JSON mini untuk inti telanjang aplikasi web:
* objek datar dengan nilai teks, angka, boolean, atau null. Objek dan array
* bersarang sengaja tidak didukung agar kelas ini tetap ringkas; pustaka
* seperti Jackson menangani seluruh kasus pada aplikasi Spring Boot asli.
*/
public final class JsonWeb {
private JsonWeb() {
// Kelas utilitas: seluruh anggota static.
}
/** Meloloskan karakter yang wajib di-escape 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 sah (kutip plus escape). */
public static String teks(String nilai) {
return "\"" + escape(nilai) + "\"";
}
/** Satu tugas menjadi objek JSON dengan urutan field tetap. */
public static String tugas(TugasWeb t) {
return "{\"id\":" + t.id()
+ ",\"judul\":" + teks(t.judul())
+ ",\"pemilik\":" + teks(t.pemilik())
+ ",\"status\":\"" + t.status() + "\""
+ ",\"tenggat\":\"" + t.tenggat() + "\"}";
}
/** Daftar tugas menjadi array JSON. */
public static String daftar(List<TugasWeb> semua) {
StringBuilder hasil = new StringBuilder("[");
for (TugasWeb t : semua) {
if (hasil.length() > 1) {
hasil.append(",");
}
hasil.append(tugas(t));
}
return hasil.append("]").toString();
}
/** Amplop galat tunggal. */
public static String galat(int status, String pesan) {
return "{\"status\":" + status + ",\"pesan\":" + teks(pesan) + "}";
}
/** Amplop galat validasi dengan rincian per field. */
public static String galat(int status, String pesan, List<String> rincian) {
if (rincian == null || rincian.isEmpty()) {
return galat(status, pesan);
}
StringBuilder hasil = new StringBuilder("{\"status\":").append(status)
.append(",\"pesan\":").append(teks(pesan)).append(",\"galat\":[");
boolean pertama = true;
for (String g : rincian) {
if (!pertama) {
hasil.append(",");
}
pertama = false;
hasil.append(teks(g));
}
return hasil.append("]}").toString();
}
/**
* Mem-parsing objek JSON datar menjadi Map dengan urutan field terjaga.
* Nilai berupa String, Long, Double, Boolean, atau null; nilai bersarang
* ditolak. Melempar IllegalArgumentException bila teks 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 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 datar. */
Object nilai() {
char c = lihat();
if (c == '"') {
return string();
}
if (c == '{' || c == '[') {
throw galat("objek/array bersarang tidak didukung parser mini");
}
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 + ")");
}
}
}
LayananTugasWeb.java
Unduh berkasimport java.time.LocalDate;
import java.util.List;
/**
* Lapisan layanan: pemilik aturan bisnis aplikasi web manajemen tugas.
* Seluruh operasi lewat sini; router tidak pernah menyentuh repository
* secara langsung, dan repository tidak pernah tahu bahwa HTTP ada.
* Hari operasional disuntik lewat konstruktor agar aturan "tenggat lampau"
* bisa diuji dengan tanggal beku, bukan LocalDate.now().
*/
public class LayananTugasWeb {
/** Batas tugas berstatus TODO per pemilik: aturan bisnis lapisan layanan. */
public static final int BATAS_TUGAS_AKTIF = 5;
private final RepositoryTugasWeb repository;
private final LocalDate hariIni;
public LayananTugasWeb(RepositoryTugasWeb repository, LocalDate hariIni) {
this.repository = repository;
this.hariIni = hariIni;
}
/** Hari operasional aplikasi, disuntik lewat konstruktor. */
public LocalDate hariIni() {
return hariIni;
}
/**
* Menyusun tugas baru setelah validasi bentuk field dan aturan kontekstual:
* status selalu TODO, tenggat wajib belum lampau, dan pemilik belum
* menyentuh batas tugas aktif.
*/
public TugasWeb buat(PermintaanTugas permintaan) {
LocalDate tenggat = validasi(permintaan);
TugasWeb baru = new TugasWeb(0, permintaan.judul(), permintaan.pemilik(),
StatusTugasWeb.TODO, tenggat);
return repository.simpanBaru(baru);
}
/** Seluruh tugas terurut id. */
public List<TugasWeb> daftar() {
return repository.semua();
}
/** Satu tugas berdasarkan id; 404 bila tidak ada. */
public TugasWeb cari(int id) {
return repository.cari(id)
.orElseThrow(() -> new TugasTidakDitemukanException(id));
}
/**
* Memperbarui judul, pemilik, dan tenggat; status dipertahankan apa adanya
* karena transisi status hanya boleh lewat tandaiSelesai.
*/
public TugasWeb ubah(int id, PermintaanTugas permintaan) {
TugasWeb tugas = cari(id);
LocalDate tenggat = validasi(permintaan);
TugasWeb baru = new TugasWeb(id, permintaan.judul(), permintaan.pemilik(),
tugas.status(), tenggat);
return repository.perbarui(baru)
.orElseThrow(() -> new TugasTidakDitemukanException(id));
}
/** Menandai tugas selesai; 409 bila tugas memang sudah SELESAI. */
public TugasWeb tandaiSelesai(int id) {
TugasWeb tugas = cari(id);
if (tugas.status() == StatusTugasWeb.SELESAI) {
throw new AturanBisnisException("tugas #" + id
+ " sudah SELESAI, tidak bisa ditandai selesai ulang");
}
TugasWeb selesai = tugas.denganStatus(StatusTugasWeb.SELESAI);
return repository.perbarui(selesai)
.orElseThrow(() -> new TugasTidakDitemukanException(id));
}
/** Menghapus tugas; 404 bila id tidak ada. */
public void hapus(int id) {
cari(id);
repository.hapus(id);
}
/**
* Validasi menyeluruh satu muatan: bentuk field lewat ValidasiWeb
* (galat dikumpulkan), lalu dua aturan kontekstual — tenggat wajib
* belum lampau dan pemilik belum menyentuh batas tugas aktif.
* Mengembalikan tenggat terurai untuk dipakai pemanggil.
*/
private LocalDate validasi(PermintaanTugas permintaan) {
List<String> galat = ValidasiWeb.periksa(permintaan);
if (!galat.isEmpty()) {
throw new ValidasiGagalException(galat);
}
LocalDate tenggat = LocalDate.parse(permintaan.tenggat().strip());
if (tenggat.isBefore(hariIni)) {
throw new ValidasiGagalException("tenggat " + tenggat
+ " sudah lampau, hari operasional " + hariIni);
}
long aktif = repository.semua().stream()
.filter(t -> t.pemilik().equals(permintaan.pemilik().strip()))
.filter(t -> t.status() == StatusTugasWeb.TODO)
.count();
if (aktif >= BATAS_TUGAS_AKTIF) {
throw new AturanBisnisException("pemilik " + permintaan.pemilik()
+ " sudah memiliki " + BATAS_TUGAS_AKTIF + " tugas TODO");
}
return tenggat;
}
}
PermintaanTugas.java
Unduh berkas/**
* Muatan permintaan pembuatan dan pembaruan tugas: teks mentah dari klien
* sebelum divalidasi. Record ini berperan seperti DTO pada controller
* Spring; pemeriksaan isinya dilakukan lapisan layanan, bukan record ini.
*/
public record PermintaanTugas(String judul, String pemilik, String tenggat) {
}
RepositoryTugasWeb.java
Unduh berkasimport java.util.List;
import java.util.Optional;
/**
* Kontrak lapisan penyimpanan untuk resource tugas. Layanan hanya mengenal
* interface ini sehingga implementasi bebas ditukar: memori untuk pengujian,
* berkas untuk persistensi Bagian 3, atau JDBC/JPA pada aplikasi nyata.
*/
public interface RepositoryTugasWeb {
/** Menyimpan tugas baru; id ditetapkan otomatis oleh penyimpanan. */
TugasWeb simpanBaru(TugasWeb tugas);
/** Mencari satu tugas berdasarkan id. */
Optional<TugasWeb> cari(int id);
/** Seluruh tugas terurut menaik berdasarkan id. */
List<TugasWeb> semua();
/** Menyimpan perubahan tugas yang sudah ada; kosong bila id tidak ada. */
Optional<TugasWeb> perbarui(TugasWeb tugas);
/** Menghapus tugas; false bila id tidak ada. */
boolean hapus(int id);
}
RepositoryTugasWebMemori.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, penghitung sederhana menetapkan id berurutan mulai dari 1.
*/
public class RepositoryTugasWebMemori implements RepositoryTugasWeb {
private final Map<Integer, TugasWeb> data = new LinkedHashMap<>();
private int urutan = 0;
@Override
public TugasWeb simpanBaru(TugasWeb tugas) {
urutan++;
TugasWeb tersimpan = tugas.denganId(urutan);
data.put(urutan, tersimpan);
return tersimpan;
}
@Override
public Optional<TugasWeb> cari(int id) {
return Optional.ofNullable(data.get(id));
}
@Override
public List<TugasWeb> semua() {
return new ArrayList<>(data.values());
}
@Override
public Optional<TugasWeb> perbarui(TugasWeb 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;
}
}
RouterWeb.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.util.LinkedHashMap;
import java.util.Map;
/**
* Lapisan web inti telanjang: satu HttpHandler yang menyimpan peta endpoint
* "METODE /pola" -> kode, lengkap dengan pencocokan pola {id}. Struktur ini
* adalah miniatur DispatcherServlet Spring MVC: peta handler menggantikan
* anotasi @GetMapping/@PostMapping, dan satu blok catch di batas menggantikan
* @ExceptionHandler.
*/
public class RouterWeb implements HttpHandler {
/** Satu endpoint terdaftar: pola "METODE /api/tugas/{id}". */
@FunctionalInterface
public interface Endpoint {
/** Memproses satu permintaan; parameter berisi nilai {id} hasil cocok pola. */
ResponsWeb tangani(Map<String, String> parameter, String body);
}
/** Respons siap kirim: status HTTP, body JSON, dan Location untuk 201. */
public record ResponsWeb(int status, String json, String lokasi) {
static ResponsWeb ok(String json) {
return new ResponsWeb(200, json, null);
}
static ResponsWeb dibuat(TugasWeb tugas) {
return new ResponsWeb(201, JsonWeb.tugas(tugas), "/api/tugas/" + tugas.id());
}
static ResponsWeb tanpaIsi() {
return new ResponsWeb(204, null, null);
}
}
private final LayananTugasWeb layanan;
private final Map<String, Endpoint> rute = new LinkedHashMap<>();
public RouterWeb(LayananTugasWeb layanan) {
this.layanan = layanan;
rute.put("GET /api/tugas", (p, b) -> ResponsWeb.ok(JsonWeb.daftar(layanan.daftar())));
rute.put("POST /api/tugas", (p, b) -> ResponsWeb.dibuat(layanan.buat(bacaPermintaan(b))));
rute.put("GET /api/tugas/{id}", (p, b) ->
ResponsWeb.ok(JsonWeb.tugas(layanan.cari(id(p)))));
rute.put("PUT /api/tugas/{id}", (p, b) ->
ResponsWeb.ok(JsonWeb.tugas(layanan.ubah(id(p), bacaPermintaan(b)))));
rute.put("POST /api/tugas/{id}/selesai", (p, b) ->
ResponsWeb.ok(JsonWeb.tugas(layanan.tandaiSelesai(id(p)))));
rute.put("DELETE /api/tugas/{id}", (p, b) -> {
layanan.hapus(id(p));
return ResponsWeb.tanpaIsi();
});
}
@Override
public void handle(HttpExchange exchange) throws IOException {
// Body dibaca selalu agar koneksi sehat walau permintaan ditolak.
String body = new String(exchange.getRequestBody().readAllBytes(),
StandardCharsets.UTF_8);
String metode = exchange.getRequestMethod();
String path = exchange.getRequestURI().getPath();
try {
kirim(exchange, cocokkan(metode, path, body));
} catch (ValidasiGagalException e) {
kirim(exchange, new ResponsWeb(400,
JsonWeb.galat(400, "validasi gagal", e.galat()), null));
} catch (ApiException e) {
kirim(exchange, new ResponsWeb(e.status(),
JsonWeb.galat(e.status(), e.getMessage()), null));
} catch (IllegalArgumentException e) {
kirim(exchange, new ResponsWeb(400,
JsonWeb.galat(400, e.getMessage()), null));
} catch (RuntimeException e) {
kirim(exchange, new ResponsWeb(500,
JsonWeb.galat(500, "kegagalan tak terduga: " + e), null));
}
}
/** Mencocokkan metode dan path dengan peta rute; 405 bila path dikenal
* dengan metode lain, 404 bila path memang tidak terdaftar. */
private ResponsWeb cocokkan(String metode, String path, String body) {
for (Map.Entry<String, Endpoint> rute : this.rute.entrySet()) {
String[] kunci = rute.getKey().split(" ", 2);
Map<String, String> parameter = cocokkanPola(kunci[1], path);
if (parameter != null && kunci[0].equals(metode)) {
return rute.getValue().tangani(parameter, body);
}
}
if (pathTerdaftar(path)) {
throw new ApiException(405, "metode " + metode + " tidak didukung untuk " + path);
}
throw new ApiException(404, "endpoint tidak dikenal: " + metode + " " + path);
}
/** Mencocokkan pola bersegmen dengan path; {id} menyerap satu segmen angka. */
private static Map<String, String> cocokkanPola(String pola, String path) {
String[] segPola = pola.split("/");
String[] segPath = path.split("/");
if (segPola.length != segPath.length) {
return null;
}
Map<String, String> parameter = new LinkedHashMap<>();
for (int i = 0; i < segPola.length; i++) {
String p = segPola[i];
if (p.startsWith("{") && p.endsWith("}")) {
if (!segPath[i].matches("\\d+")) {
return null;
}
parameter.put(p.substring(1, p.length() - 1), segPath[i]);
} else if (!p.equals(segPath[i])) {
return null;
}
}
return parameter;
}
/** Apakah path cocok dengan pola rute mana pun (metode apa pun). */
private boolean pathTerdaftar(String path) {
for (String kunci : rute.keySet()) {
if (cocokkanPola(kunci.split(" ", 2)[1], path) != null) {
return true;
}
}
return false;
}
/** Id numerik dari parameter hasil cocok pola. */
private static int id(Map<String, String> parameter) {
return Integer.parseInt(parameter.get("id"));
}
/** Membaca body JSON menjadi PermintaanTugas. */
private static PermintaanTugas bacaPermintaan(String body) {
if (body == null || body.isBlank()) {
throw new ValidasiGagalException("body permintaan wajib berisi JSON objek");
}
Map<String, Object> data = JsonWeb.parseObjek(body);
return new PermintaanTugas(teks(data, "judul"), teks(data, "pemilik"),
teks(data, "tenggat"));
}
/** Nilai field teks; null bila field hilang, 400 bila bukan teks. */
private static String teks(Map<String, Object> data, String field) {
Object nilai = data.get(field);
if (nilai == null) {
return null;
}
if (nilai instanceof String s) {
return s;
}
throw new ApiException(400, "field " + field + " harus berupa teks");
}
/** Menulis respons: body null berarti 204 tanpa isi. */
private static void kirim(HttpExchange exchange, ResponsWeb respons) throws IOException {
if (respons.json() == null) {
exchange.sendResponseHeaders(respons.status(), -1);
exchange.close();
return;
}
byte[] isi = respons.json().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
if (respons.lokasi() != null) {
exchange.getResponseHeaders().set("Location", respons.lokasi());
}
exchange.sendResponseHeaders(respons.status(), isi.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(isi);
} finally {
exchange.close();
}
}
}
StatusTugasWeb.java
Unduh berkas/**
* Status daur hidup tugas pada aplikasi web manajemen tugas.
* TODO berarti tugas masih terbuka, SELESAI berarti tugas tuntas.
*/
public enum StatusTugasWeb {
TODO,
SELESAI
}
TugasTidakDitemukanException.java
Unduh berkas/**
* Id tugas yang diminta tidak ada di penyimpanan.
* Diterjemahkan router menjadi respons 404 Not Found.
*/
public class TugasTidakDitemukanException extends ApiException {
public TugasTidakDitemukanException(int id) {
super(404, "tugas dengan id " + id + " tidak ditemukan");
}
}
TugasWeb.java
Unduh berkasimport java.time.LocalDate;
/**
* Satu tugas sebagai entitas domain: judul, pemilik, status, dan tenggat.
* Record dipilih karena tugas adalah nilai yang tidak berubah di tempat;
* perubahan dilakukan lewat method salinan di bawah, persis pola wither.
*/
public record TugasWeb(int id, String judul, String pemilik, StatusTugasWeb status,
LocalDate tenggat) {
public TugasWeb {
if (judul == null || judul.isBlank()) {
throw new IllegalArgumentException("judul tugas wajib diisi");
}
if (pemilik == null || pemilik.isBlank()) {
throw new IllegalArgumentException("pemilik tugas wajib diisi");
}
if (status == null) {
throw new IllegalArgumentException("status tugas wajib diisi");
}
if (tenggat == null) {
throw new IllegalArgumentException("tenggat tugas wajib diisi");
}
judul = judul.strip();
pemilik = pemilik.strip();
}
/** Salinan tugas dengan id berbeda; dipakai repository saat menetapkan id. */
public TugasWeb denganId(int idBaru) {
return new TugasWeb(idBaru, judul, pemilik, status, tenggat);
}
/** Salinan tugas dengan status berbeda; dipakai layanan saat transisi status. */
public TugasWeb denganStatus(StatusTugasWeb statusBaru) {
return new TugasWeb(id, judul, pemilik, statusBaru, tenggat);
}
/** Ringkasan satu baris untuk pencatatan di terminal. */
public String formatRingkas() {
return "#" + id + " [" + status + "] " + judul
+ " (pemilik " + pemilik + ", tenggat " + tenggat + ")";
}
}
ValidasiGagalException.java
Unduh berkasimport java.util.List;
/**
* Muatan permintaan tidak lolos pemeriksaan. Membawa daftar galat per field
* agar router bisa menyusun respons 400 dengan rincian terstruktur — padanan
* MethodArgumentNotValidException pada Spring MVC.
*/
public class ValidasiGagalException extends ApiException {
private final List<String> galat;
public ValidasiGagalException(String pesan) {
this(List.of(pesan));
}
public ValidasiGagalException(List<String> galat) {
super(400, "validasi gagal: " + String.join("; ", galat));
this.galat = List.copyOf(galat);
}
/** Rincian galat per field, siap ditampilkan sebagai array JSON. */
public List<String> galat() {
return galat;
}
}
ValidasiWeb.java
Unduh berkasimport java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
/**
* Validasi bentuk field muatan tugas: panjang judul, isi pemilik, format
* tanggal, dan karakter yang dilarang format penyimpanan Bagian 3. Seluruh
* galat dikumpulkan sekaligus agar klien tahu semua kesalahan dalam satu
* respons, bukan satu per satu. Aturan yang butuh konteks (tenggat lampau,
* batas tugas aktif) tetap di LayananTugasWeb.
*/
public final class ValidasiWeb {
private ValidasiWeb() {
// Kelas utilitas: seluruh anggota static.
}
/**
* Memeriksa muatan permintaan dan mengembalikan daftar galat.
* Daftar kosong berarti seluruh field lolos pemeriksaan bentuk.
*/
public static List<String> periksa(PermintaanTugas permintaan) {
List<String> galat = new ArrayList<>();
String judul = permintaan.judul() == null ? "" : permintaan.judul().strip();
if (judul.length() < 3 || judul.length() > 100) {
galat.add("judul wajib berisi 3-100 karakter");
}
if (judul.contains("|")) {
galat.add("judul tidak boleh memuat karakter '|' (pemisah format penyimpanan)");
}
String pemilik = permintaan.pemilik() == null ? "" : permintaan.pemilik().strip();
if (pemilik.isEmpty() || pemilik.length() > 60) {
galat.add("pemilik wajib diisi, maksimum 60 karakter");
}
if (pemilik.contains("|")) {
galat.add("pemilik tidak boleh memuat karakter '|'");
}
if (permintaan.tenggat() == null) {
galat.add("tenggat wajib berformat ISO yyyy-MM-dd");
} else {
try {
LocalDate.parse(permintaan.tenggat().strip());
} catch (DateTimeParseException e) {
galat.add("tenggat wajib berformat ISO yyyy-MM-dd");
}
}
return galat;
}
}