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:

java RouterWeb.java
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:

  1. Konstruktor sebagai pendaftaran rute: enam baris rute.put(...) adalah seluruh tabel endpoint aplikasi — padanan enam anotasi pemetaan pada satu controller.
  2. Method cocokkanPola: pola dan path dipecah per segmen /; segmen {id} hanya menyerap digit, sehingga /api/tugas/abc jatuh ke 404, bukan galat parsing.
  3. Method cocokkan: iterasi mencari pasangan metode+path yang cocok; bila gagal, pathTerdaftar memeriksa apakah path dikenal dengan metode lain sebelum memutuskan 405 atau 404.
  4. Method handle: satu blok try menangkap empat jenis kegagalan berjenjang — ValidasiGagalException (400 dengan rincian), ApiException (status bawaan), IllegalArgumentException (400, misalnya JSON rusak), dan sisa RuntimeException (500).

Pemetaan galat ke status kini lengkap:

Sumber kegagalanExceptionStatus
field hilang, terlalu pendek, format tanggal salahValidasiGagalException400 + daftar galat
tenggat lampau (butuh konteks hariIni)ValidasiGagalException400
id tidak ada di penyimpananTugasTidakDitemukanException404
status sudah SELESAI, batas tugas aktif terlampauiAturanBisnisException409
metode salah / endpoint asingApiException405 / 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:

java ValidasiWeb.java
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:

java JsonWeb.java
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:

java AplikasiWeb.java
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:

text
=== 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 klien

Mari kita baca hasilnya baris demi baris:

  1. Dua POST pertama: masing-masing dijawab 201 dengan header Location menunjuk URI tugas baru — konvensi REST untuk sumber daya yang baru tercipta.
  2. POST selesai kedua kali: dijawab 409 karena tugas #1 sudah SELESAI — aturan transisi status Bagian 1 kini berlaku lewat HTTP.
  3. POST dengan tiga field salah: dijawab 400 dengan array galat berisi tiga pesan sekaligus — validasi mengumpulkan, bukan berhenti di galat pertama.
  4. POST dengan JSON setengah: parser mini melempar IllegalArgumentException yang diterjemahkan 400 dengan posisi galat — tetap terstruktur.
  5. 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:

java
@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:

java
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) {
}
java
@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 #

Berkas Sumber

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

ApiException.java

Unduh berkas
java
/**
 * 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 berkas
java
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);
    }
}

AturanBisnisException.java

Unduh berkas
java
/**
 * 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 berkas
java
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 + ")");
        }
    }
}

LayananTugasWeb.java

Unduh berkas
java
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;
    }
}

PermintaanTugas.java

Unduh berkas
java
/**
 * 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 berkas
java
import 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 berkas
java
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, 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 berkas
java
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();
        }
    }
}

StatusTugasWeb.java

Unduh berkas
java
/**
 * 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
java
/**
 * 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 berkas
java
import 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 berkas
java
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;
    }
}

ValidasiWeb.java

Unduh berkas
java
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;
    }
}