Desain dari Bagian 1 sekarang dihidupkan menjadi server yang menerima permintaan HTTP sungguhan. Kita memakai com.sun.net.httpserver.HttpServer — server kecil di dalam JDK, tanpa dependensi luar — lalu router sendiri membaca method dan path setiap permintaan. Operasi CRUD divalidasi, disimpan lewat interface TugasRepository, dan diuji dengan java.net.http.HttpClient dalam proses yang sama. Hasilnya: API yang berjalan dengan java ServerApi.java, keluarannya stabil karena server mendengarkan loopback di port pilihan sistem operasi.

HttpServer Bawaan JDK #

Kelas HttpServer tinggal di module jdk.httpserver dan didokumentasikan resmi oleh Oracle. Polanya sederhana: HttpServer.create(...) menyiapkan alamat pendengaran, createContext("/", handler) mendaftarkan satu HttpHandler untuk seluruh path, dan server.start() mulai menerima koneksi. Tiap permintaan tiba sebagai HttpExchange — wadah method, path, header, body permintaan, sekaligus pintu menulis status dan body respons. Satu handler melayani semua path, jadi pembagian endpoint menjadi tanggung jawab router — itulah routing manual inti bab ini.

Exception yang Membawa Status #

Kegagalan di dalam handler tidak boleh bocor sebagai stack trace. Solusinya: exception domain pembawa status HTTP, ditangkap di satu titik lalu diterjemahkan menjadi respons JSON.

/**
 * Exception unchecked pembawa status HTTP.
 * Ditangkap di satu titik pada batas handler, lalu diterjemahkan menjadi
 * respons JSON berstatus sesuai kebutuhan.
 */
public class ApiException extends RuntimeException {

    private final int status;

    public ApiException(int status, String pesan) {
        super(pesan);
        this.status = status;
    }

    public int status() {
        return status;
    }
}
/**
 * Payload permintaan tidak lolos validasi: field wajib hilang,
 * format tanggal salah, atau JSON tidak sah.
 * Diterjemahkan menjadi respons 400 Bad Request.
 */
public class ValidasiGagalException extends ApiException {

    public ValidasiGagalException(String pesan) {
        super(400, pesan);
    }
}
/**
 * Id tugas yang diminta tidak ada di penyimpanan.
 * Diterjemahkan menjadi respons 404 Not Found.
 */
public class TugasTidakDitemukanException extends ApiException {

    public TugasTidakDitemukanException(int id) {
        super(404, "tugas dengan id " + id + " tidak ditemukan");
    }
}

Pemetaannya:

Sumber kegagalanExceptionStatus
field hilang, format salah, JSON rusakValidasiGagalException400
invarian record Tugas / enum statusIllegalArgumentException400
id tidak ada di penyimpananTugasTidakDitemukanException404
method/endpoint tidak dikenalApiException405 / 404
kegagalan tak terdugaRuntimeException lain500

Ketiganya sengaja unchecked. Di lapisan web, penanganan kegagalan memang terjadi di batas handler; satu catch di handle sudah menjamin tidak ada kegagalan yang lewat begitu saja, sehingga deklarasi throws di tiap method hanya jadi gangguan.

Kontrak Penyimpanan: TugasRepository #

Handler tidak boleh tahu di mana data disimpan. Kontraknya ditulis sebagai interface lima operasi; Optional dipakai untuk operasi yang wajar gagal — cari dan simpan cukup mengembalikan Optional kosong, bukan exception.

import java.util.List;
import java.util.Optional;

/**
 * Kontrak lapisan penyimpanan untuk resource tugas.
 * Handler hanya mengenal interface ini, sehingga implementasi bebas ditukar:
 * memori untuk pengujian, berkas untuk persistensi, atau JDBC untuk database.
 */
public interface TugasRepository {

    /** Menyimpan tugas baru; id ditetapkan otomatis oleh penyimpanan. */
    Tugas tambah(Tugas tugas);

    /** Mencari satu tugas berdasarkan id. */
    Optional<Tugas> cari(int id);

    /** Seluruh tugas terurut menaik berdasarkan id. */
    List<Tugas> semua();

    /** Menyimpan perubahan tugas yang sudah ada; kosong bila id tidak ditemukan. */
    Optional<Tugas> simpan(Tugas tugas);

    /** Menghapus tugas; false bila id tidak ditemukan. */
    boolean hapus(int id);
}
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * Penyimpanan dalam memori: LinkedHashMap menjaga urutan sisip sama dengan
 * urutan id, dan penghitung sederhana menetapkan id berurutan mulai dari 1.
 */
public class RepositoryMemori implements TugasRepository {

    private final Map<Integer, Tugas> data = new LinkedHashMap<>();
    private int urutan = 0;

    @Override
    public Tugas tambah(Tugas tugas) {
        urutan++;
        Tugas tersimpan = tugas.denganId(urutan);
        data.put(urutan, tersimpan);
        return tersimpan;
    }

    @Override
    public Optional<Tugas> cari(int id) {
        return Optional.ofNullable(data.get(id));
    }

    @Override
    public List<Tugas> semua() {
        return new ArrayList<>(data.values());
    }

    @Override
    public Optional<Tugas> simpan(Tugas tugas) {
        if (!data.containsKey(tugas.id())) {
            return Optional.empty();
        }
        data.put(tugas.id(), tugas);
        return Optional.of(tugas);
    }

    @Override
    public boolean hapus(int id) {
        return data.remove(id) != null;
    }
}

Versi Tugas menambah dua method penyalin — denganId untuk id dari repository, denganStatus untuk perubahan status; StatusTugas dan JsonMini tidak berubah dari Bagian 1.

java Tugas.java
import java.time.LocalDate;

/**
 * Representasi satu tugas sebagai resource API.
 * Invarian dasar dijaga compact constructor sehingga objek Tugas tidak pernah
 * berada dalam keadaan tidak sah, apa pun asal datanya (kode atau payload klien).
 */
public record Tugas(int id, String judul, String pemilik, StatusTugas status, LocalDate tenggat) {

    public Tugas {
        if (judul == null || judul.strip().isEmpty()) {
            throw new IllegalArgumentException("judul tidak boleh kosong");
        }
        if (pemilik == null || pemilik.strip().isEmpty()) {
            throw new IllegalArgumentException("pemilik tidak boleh kosong");
        }
        if (tenggat == null) {
            throw new IllegalArgumentException("tenggat tidak boleh kosong");
        }
        judul = judul.strip();
        pemilik = pemilik.strip();
    }

    /** Salinan tugas ini dengan id baru; dipakai repository saat menetapkan id. */
    public Tugas denganId(int idBaru) {
        return new Tugas(idBaru, judul, pemilik, status, tenggat);
    }

    /** Salinan tugas ini dengan status baru; dipakai operasi ubah. */
    public Tugas denganStatus(StatusTugas statusBaru) {
        return new Tugas(id, judul, pemilik, statusBaru, tenggat);
    }
}

Router: Membaca Method dan Path #

Router adalah jantung bab ini: satu class pengganti mekanisme routing sebuah framework.

java RouterTugas.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.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.Map;

/**
 * Router CRUD untuk resource tugas: membaca method dan path dari setiap
 * permintaan, memvalidasi payload, lalu menghasilkan respons JSON.
 * Seluruh kegagalan diterjemahkan di satu titik menjadi respons berstatus.
 */
public class RouterTugas implements HttpHandler {

    private final TugasRepository repo;

    public RouterTugas(TugasRepository repo) {
        this.repo = repo;
    }

    @Override
    public void handle(HttpExchange exchange) throws IOException {
        try {
            tangani(exchange);
        } catch (ApiException e) {
            kirim(exchange, e.status(), JsonMini.error(e.getMessage()));
        } catch (IllegalArgumentException e) {
            // Validasi pada lapisan domain (record, enum) diterjemahkan jadi 400.
            kirim(exchange, 400, JsonMini.error(e.getMessage()));
        } catch (RuntimeException e) {
            kirim(exchange, 500, JsonMini.error("kesalahan internal: " + e.getMessage()));
        } finally {
            exchange.close();
        }
    }

    /** Membaca method dan path, lalu mengarahkan ke handler yang tepat. */
    private void tangani(HttpExchange ex) throws IOException {
        String metode = ex.getRequestMethod();
        String path = ex.getRequestURI().getPath();

        if (!path.equals("/api/tugas") && !path.startsWith("/api/tugas/")) {
            throw new ApiException(404, "endpoint tidak dikenal: " + path);
        }
        String sisa = path.substring("/api/tugas".length());

        if (sisa.isEmpty() || sisa.equals("/")) {
            switch (metode) {
                case "GET" -> daftar(ex);
                case "POST" -> tambah(ex);
                default -> lempar405(metode, path);
            }
        } else {
            int id = bacaId(sisa);
            switch (metode) {
                case "GET" -> detail(ex, id);
                case "PUT" -> ubah(ex, id);
                case "DELETE" -> hapus(ex, id);
                default -> lempar405(metode, path);
            }
        }
    }

    private void daftar(HttpExchange ex) throws IOException {
        kirim(ex, 200, JsonMini.daftar(repo.semua()));
    }

    /** POST: status awal selalu TODO; server yang menetapkannya, bukan klien. */
    private void tambah(HttpExchange ex) throws IOException {
        Map<String, Object> payload = bacaPayload(ex);
        Tugas baru = new Tugas(0, teks(payload, "judul"), teks(payload, "pemilik"),
                StatusTugas.TODO, tanggal(payload, "tenggat"));
        Tugas tersimpan = repo.tambah(baru);
        kirim(ex, 201, JsonMini.tugas(tersimpan));
    }

    private void detail(HttpExchange ex, int id) throws IOException {
        Tugas tugas = repo.cari(id).orElseThrow(() -> new TugasTidakDitemukanException(id));
        kirim(ex, 200, JsonMini.tugas(tugas));
    }

    /** PUT: penggantian utuh, kelima field dikirim kembali oleh klien. */
    private void ubah(HttpExchange ex, int id) throws IOException {
        repo.cari(id).orElseThrow(() -> new TugasTidakDitemukanException(id));
        Map<String, Object> payload = bacaPayload(ex);
        Tugas pengganti = new Tugas(id, teks(payload, "judul"), teks(payload, "pemilik"),
                status(payload), tanggal(payload, "tenggat"));
        Tugas tersimpan = repo.simpan(pengganti)
                .orElseThrow(() -> new TugasTidakDitemukanException(id));
        kirim(ex, 200, JsonMini.tugas(tersimpan));
    }

    private void hapus(HttpExchange ex, int id) throws IOException {
        if (!repo.hapus(id)) {
            throw new TugasTidakDitemukanException(id);
        }
        kirim(ex, 200, "{\"pesan\":\"tugas " + id + " dihapus\"}");
    }

    /** Membaca segmen path setelah /api/tugas/ sebagai id. */
    private static int bacaId(String sisa) {
        String teks = sisa.substring(1);
        if (teks.contains("/")) {
            throw new ApiException(404, "endpoint tidak dikenal: /api/tugas" + sisa);
        }
        long id;
        try {
            id = Long.parseLong(teks);
        } catch (NumberFormatException e) {
            throw new ValidasiGagalException("id harus angka, diterima: '" + teks + "'");
        }
        if (id < 1 || id > Integer.MAX_VALUE) {
            throw new ValidasiGagalException("id di luar rentang: " + id);
        }
        return (int) id;
    }

    /** Membaca seluruh body permintaan sebagai objek JSON datar. */
    private static Map<String, Object> bacaPayload(HttpExchange ex) throws IOException {
        String mentah = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
        if (mentah.isBlank()) {
            throw new ValidasiGagalException("body permintaan tidak boleh kosong");
        }
        try {
            return JsonMini.parseObjek(mentah);
        } catch (IllegalArgumentException e) {
            throw new ValidasiGagalException("JSON tidak valid: " + e.getMessage());
        }
    }

    /** Nilai field teks wajib; 400 bila field hilang atau bukan teks. */
    private static String teks(Map<String, Object> payload, String kunci) {
        Object nilai = payload.get(kunci);
        if (nilai == null) {
            throw new ValidasiGagalException(kunci + " wajib diisi");
        }
        if (!(nilai instanceof String teks)) {
            throw new ValidasiGagalException(kunci + " harus berupa teks");
        }
        return teks;
    }

    /** Nilai tanggal wajib berformat ISO-8601 seperti 2026-01-20. */
    private static LocalDate tanggal(Map<String, Object> payload, String kunci) {
        String teks = teks(payload, kunci);
        try {
            return LocalDate.parse(teks);
        } catch (DateTimeParseException e) {
            throw new ValidasiGagalException(
                    kunci + " harus format ISO-8601, contoh 2026-01-20, diterima: '" + teks + "'");
        }
    }

    /** Nilai status wajib TODO atau SELESAI. */
    private static StatusTugas status(Map<String, Object> payload) {
        return StatusTugas.dariTeks(teks(payload, "status"));
    }

    private static void lempar405(String metode, String path) {
        throw new ApiException(405, metode + " tidak didukung di " + path
                + "; gunakan GET, POST, PUT, atau DELETE");
    }

    /** Satu-satunya pintu menulis respons: header JSON, status, lalu body UTF-8. */
    private static void kirim(HttpExchange ex, int status, String json) throws IOException {
        byte[] isi = json.getBytes(StandardCharsets.UTF_8);
        ex.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
        ex.sendResponseHeaders(status, isi.length);
        try (OutputStream os = ex.getResponseBody()) {
            os.write(isi);
        }
    }
}
  1. Method handle: satu try melindungi seluruh operasi — ApiException dijawab status yang dibawanya, IllegalArgumentException dari invarian domain dijawab 400, RuntimeException lain dijawab 500; finally memastikan exchange.close() selalu terpanggil.
  2. Method tangani: membaca method dan path, menolak path di luar /api/tugas, lalu memilih operasi — tanpa id untuk GET/POST, dengan id untuk GET/PUT/DELETE; method lain jatuh ke lempar405.
  3. Method bacaId: segmen path dibaca sebagai angka — teks abc dijawab 400, path bertingkat dijawab 404, rentang id diperiksa.
  4. Method bacaPayload: body dibaca utuh dengan charset UTF-8 eksplisit; body kosong ditolak, JSON rusak dijawab 400 dengan posisi galat dari JsonMini.
  5. Method tambah: Tugas dibuat dengan id 0 dan status TODO — status awal diputuskan server, bukan klien — repository menetapkan id, handler menjawab 201.
  6. Method kirim: satu-satunya pintu menulis respons: set Content-Type: application/json, sendResponseHeaders(status, panjang), lalu body lewat OutputStream dalam try-with-resources.

Server dan Klien dalam Satu Proses #

Seluruh kontrak diuji tanpa alat luar: server dan klien HTTP berbagi satu proses, permintaan dikirim sekuensial.

java ServerApi.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.util.Locale;

/**
 * Titik masuk API manajemen tugas: HttpServer di alamat loopback dengan port
 * dipilih sistem operasi (port 0), lalu seluruh endpoint diuji lewat HttpClient
 * dalam satu proses yang sama — server dan klien berjalan tanpa jaringan luar.
 */
public class ServerApi {

    public static void main(String[] args) throws Exception {
        TugasRepository repo = new RepositoryMemori();
        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server.createContext("/", new RouterTugas(repo));
        server.start();

        String dasar = "http://127.0.0.1:" + server.getAddress().getPort();
        HttpClient klien = HttpClient.newHttpClient();

        try {
            System.out.println("=== Babak 1: membuat dua tugas ===");
            cetak(klien, "POST", dasar + "/api/tugas",
                    "{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\",\"tenggat\":\"2026-01-20\"}",
                    null);
            cetak(klien, "POST", dasar + "/api/tugas",
                    "{\"judul\":\"Tulis dokumentasi API\",\"pemilik\":\"Siti\",\"tenggat\":\"2026-02-01\"}",
                    null);

            System.out.println();
            System.out.println("=== Babak 2: membaca daftar dan detail ===");
            cetak(klien, "GET", dasar + "/api/tugas", null, null);
            cetak(klien, "GET", dasar + "/api/tugas/2", null, null);

            System.out.println();
            System.out.println("=== Babak 3: mengubah status tugas 1 ===");
            cetak(klien, "PUT", dasar + "/api/tugas/1",
                    "{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\","
                            + "\"status\":\"SELESAI\",\"tenggat\":\"2026-01-20\"}",
                    null);

            System.out.println();
            System.out.println("=== Babak 4: permintaan yang ditolak ===");
            cetak(klien, "GET", dasar + "/api/tugas/9", null, null);
            cetak(klien, "GET", dasar + "/api/pengguna", null, null);
            cetak(klien, "POST", dasar + "/api/tugas", "{\"judul\":", "rusak");
            cetak(klien, "POST", dasar + "/api/tugas",
                    "{\"judul\":\"Tugas tanpa pemilik\",\"tenggat\":\"2026-01-22\"}",
                    "tanpa pemilik");
            cetak(klien, "POST", dasar + "/api/tugas",
                    "{\"judul\":\"   \",\"pemilik\":\"Dewi\",\"tenggat\":\"2026-01-25\"}",
                    "judul kosong");
            cetak(klien, "PATCH", dasar + "/api/tugas/1", null, null);

            System.out.println();
            System.out.println("=== Babak 5: menghapus lalu melihat sisa ===");
            cetak(klien, "DELETE", dasar + "/api/tugas/2", null, null);
            cetak(klien, "GET", dasar + "/api/tugas", null, null);
        } finally {
            server.stop(0);
        }
    }

    /**
     * Mengirim satu permintaan lalu mencetak status dan body responsnya.
     * Nomor port sengaja tidak dicetak agar keluaran tetap deterministik.
     */
    private static void cetak(HttpClient klien, String metode, String url,
            String body, String keterangan) throws IOException, InterruptedException {
        HttpRequest.Builder b = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .timeout(Duration.ofSeconds(5));
        if (body == null) {
            b.method(metode, HttpRequest.BodyPublishers.noBody());
        } else {
            b.header("Content-Type", "application/json")
                    .method(metode, HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
        }
        HttpResponse<String> respons =
                klien.send(b.build(), HttpResponse.BodyHandlers.ofString());

        String path = URI.create(url).getPath();
        String target = keterangan == null ? path : path + " (" + keterangan + ")";
        System.out.printf(Locale.US, "%-6s %-28s -> %d %s%n",
                metode, target, respons.statusCode(), respons.body());
    }
}

Output yang Dihasilkan:

text
=== Babak 1: membuat dua tugas ===
POST   /api/tugas                   -> 201 {"id":1,"judul":"Belajar HttpServer","pemilik":"Budi","status":"TODO","tenggat":"2026-01-20"}
POST   /api/tugas                   -> 201 {"id":2,"judul":"Tulis dokumentasi API","pemilik":"Siti","status":"TODO","tenggat":"2026-02-01"}

=== Babak 2: membaca daftar dan detail ===
GET    /api/tugas                   -> 200 [{"id":1,"judul":"Belajar HttpServer","pemilik":"Budi","status":"TODO","tenggat":"2026-01-20"},{"id":2,"judul":"Tulis dokumentasi API","pemilik":"Siti","status":"TODO","tenggat":"2026-02-01"}]
GET    /api/tugas/2                 -> 200 {"id":2,"judul":"Tulis dokumentasi API","pemilik":"Siti","status":"TODO","tenggat":"2026-02-01"}

=== Babak 3: mengubah status tugas 1 ===
PUT    /api/tugas/1                 -> 200 {"id":1,"judul":"Belajar HttpServer","pemilik":"Budi","status":"SELESAI","tenggat":"2026-01-20"}

=== Babak 4: permintaan yang ditolak ===
GET    /api/tugas/9                 -> 404 {"error":"tugas dengan id 9 tidak ditemukan"}
GET    /api/pengguna                -> 404 {"error":"endpoint tidak dikenal: /api/pengguna"}
POST   /api/tugas (rusak)           -> 400 {"error":"JSON tidak valid: teks berakhir sebelum waktunya (posisi 9)"}
POST   /api/tugas (tanpa pemilik)   -> 400 {"error":"pemilik wajib diisi"}
POST   /api/tugas (judul kosong)    -> 400 {"error":"judul tidak boleh kosong"}
PATCH  /api/tugas/1                 -> 405 {"error":"PATCH tidak didukung di /api/tugas/1; gunakan GET, POST, PUT, atau DELETE"}

=== Babak 5: menghapus lalu melihat sisa ===
DELETE /api/tugas/2                 -> 200 {"pesan":"tugas 2 dihapus"}
GET    /api/tugas                   -> 200 [{"id":1,"judul":"Belajar HttpServer","pemilik":"Budi","status":"SELESAI","tenggat":"2026-01-20"}]
  1. HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0): server hanya mendengarkan loopback; port 0 meminta sistem operasi memilih port bebas — tidak ada bentrok port, tidak ada akses dari luar mesin.
  2. Tanpa setExecutor: handler dieksekusi pada satu thread dispatcher bawaan sehingga permintaan sekuensial diproses berurutan; bila dipasang executor khusus, keamanan thread repository ikut dipertimbangkan.
  3. Method cetak: HttpRequest.Builder dengan timeout 5 detik; body dikirim hanya bila ada, lalu method, path, status, dan body dicetak — nomor port sengaja tidak dicetak agar keluaran deterministik.
  4. finally { server.stop(0); }: server selalu dimatikan walau skenario gagal.
  5. Babak 4 memperagakan seluruh penolakan: 404 untuk id dan endpoint tak dikenal; 400 untuk JSON rusak, field hilang, judul kosong dari compact constructor; 405 untuk PATCH.

Model, router, dan penyimpanan kini bekerja sebagai API utuh. Kelemahannya: proses berhenti berarti seluruh tugas hilang. Bagian 3 menutupnya dengan penyimpanan yang bertahan, dan kontrak TugasRepository membuat pergantian penyimpanan tidak menyentuh satu baris router pun.

Referensi #

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 handler, lalu diterjemahkan menjadi
 * respons JSON berstatus sesuai kebutuhan.
 */
public class ApiException extends RuntimeException {

    private final int status;

    public ApiException(int status, String pesan) {
        super(pesan);
        this.status = status;
    }

    public int status() {
        return status;
    }
}

JsonMini.java

Unduh berkas
java
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * Serializer dan parser JSON mini untuk kebutuhan proyek ini:
 * objek datar dengan nilai berupa teks, angka, boolean, atau null.
 * Pustaka JSON umum (Jackson, Gson) menangani kasus jauh lebih banyak;
 * versi mini ini cukup untuk payload dan respons API tugas sekaligus
 * memperlihatkan cara kerja escaping dan parsing secara langsung.
 */
public final class JsonMini {

    private JsonMini() {
        // Kelas utilitas: seluruh anggota static, tidak perlu instance.
    }

    /** Meng-escape karakter yang wajib diloloskan di dalam string JSON. */
    public static String escape(String teks) {
        StringBuilder hasil = new StringBuilder();
        for (int i = 0; i < teks.length(); i++) {
            char c = teks.charAt(i);
            switch (c) {
                case '"' -> hasil.append("\\\"");
                case '\\' -> hasil.append("\\\\");
                case '\n' -> hasil.append("\\n");
                case '\t' -> hasil.append("\\t");
                case '\r' -> hasil.append("\\r");
                default -> {
                    if (c < 0x20) {
                        hasil.append(String.format("\\u%04x", (int) c));
                    } else {
                        hasil.append(c);
                    }
                }
            }
        }
        return hasil.toString();
    }

    /** Membungkus teks menjadi string JSON yang sah (dengan kutip dan escape). */
    public static String string(String teks) {
        return "\"" + escape(teks) + "\"";
    }

    /** Mengubah satu Tugas menjadi objek JSON dengan field yang urutannya tetap. */
    public static String tugas(Tugas t) {
        return "{\"id\":" + t.id()
                + ",\"judul\":" + string(t.judul())
                + ",\"pemilik\":" + string(t.pemilik())
                + ",\"status\":\"" + t.status() + "\""
                + ",\"tenggat\":\"" + t.tenggat() + "\"}";
    }

    /** Mengubah daftar Tugas menjadi array JSON. */
    public static String daftar(List<Tugas> semua) {
        return semua.stream()
                .map(JsonMini::tugas)
                .collect(Collectors.joining(",", "[", "]"));
    }

    /** Amplop pesan error yang seragam untuk seluruh respons gagal. */
    public static String error(String pesan) {
        return "{\"error\":" + string(pesan) + "}";
    }

    /**
     * Mem-parsing objek JSON datar menjadi Map; urutan field dipertahankan.
     * Nilai berupa String, Long, Double, Boolean, atau null.
     * Melempar IllegalArgumentException berbahasa Indonesia bila JSON tidak sah.
     */
    public static Map<String, Object> parseObjek(String json) {
        Pembaca baca = new Pembaca(json);
        Map<String, Object> hasil = baca.objek();
        baca.spasi();
        if (!baca.habis()) {
            throw baca.galat("konten tambahan setelah objek");
        }
        return hasil;
    }

    /** Pembaca JSON karakter demi karakter dengan posisi yang terlacak. */
    private static final class Pembaca {

        private final String teks;
        private int pos = 0;

        Pembaca(String teks) {
            this.teks = teks;
        }

        /** Membaca '{' sampai '}' menjadi LinkedHashMap. */
        Map<String, Object> objek() {
            spasi();
            harap('{');
            Map<String, Object> hasil = new LinkedHashMap<>();
            spasi();
            if (lihat() == '}') {
                pos++;
                return hasil;
            }
            while (true) {
                spasi();
                String kunci = string();
                spasi();
                harap(':');
                spasi();
                hasil.put(kunci, nilai());
                spasi();
                if (lihat() == ',') {
                    pos++;
                    continue;
                }
                harap('}');
                return hasil;
            }
        }

        /** Satu nilai: string, true, false, null, atau angka. */
        Object nilai() {
            char c = lihat();
            if (c == '"') {
                return string();
            }
            if (c == 't') {
                harapKata("true");
                return Boolean.TRUE;
            }
            if (c == 'f') {
                harapKata("false");
                return Boolean.FALSE;
            }
            if (c == 'n') {
                harapKata("null");
                return null;
            }
            return angka();
        }

        /** Membaca string berikut kutip pembuka dan penutupnya. */
        String string() {
            harap('"');
            StringBuilder hasil = new StringBuilder();
            while (pos < teks.length()) {
                char c = teks.charAt(pos++);
                if (c == '"') {
                    return hasil.toString();
                }
                if (c == '\\') {
                    hasil.append(escapeBerikutnya());
                } else {
                    hasil.append(c);
                }
            }
            throw galat("string tidak ditutup kutip");
        }

        /** Membaca satu karakter hasil escape, misalnya \n atau \". */
        char escapeBerikutnya() {
            if (pos >= teks.length()) {
                throw galat("escape berada di ujung string");
            }
            char c = teks.charAt(pos++);
            return switch (c) {
                case '"' -> '"';
                case '\\' -> '\\';
                case '/' -> '/';
                case 'n' -> '\n';
                case 't' -> '\t';
                case 'r' -> '\r';
                case 'b' -> '\b';
                case 'f' -> '\f';
                default -> throw galat("escape tidak dikenal: \\" + c);
            };
        }

        /** Membaca angka bulat atau pecahan tanpa notasi ilmiah. */
        Object angka() {
            int awal = pos;
            if (lihat() == '-') {
                pos++;
            }
            while (pos < teks.length() && "0123456789.".indexOf(teks.charAt(pos)) >= 0) {
                pos++;
            }
            String teksAngka = teks.substring(awal, pos);
            if (teksAngka.isEmpty() || teksAngka.equals("-")) {
                throw galat("nilai tidak dikenal mulai di posisi " + awal);
            }
            try {
                if (teksAngka.indexOf('.') >= 0) {
                    return Double.valueOf(teksAngka);
                }
                return Long.valueOf(teksAngka);
            } catch (NumberFormatException e) {
                throw galat("angka tidak sah: " + teksAngka);
            }
        }

        /** Memastikan kata kunci (true/false/null) ada di posisi sekarang. */
        void harapKata(String kata) {
            if (!teks.startsWith(kata, pos)) {
                throw galat("kata kunci '" + kata + "' tidak ditemukan");
            }
            pos += kata.length();
        }

        /** Melewati spasi dan whitespace lain. */
        void spasi() {
            while (pos < teks.length() && Character.isWhitespace(teks.charAt(pos))) {
                pos++;
            }
        }

        /** Melihat karakter pada posisi sekarang tanpa memajukannya. */
        char lihat() {
            if (pos >= teks.length()) {
                throw galat("teks berakhir sebelum waktunya");
            }
            return teks.charAt(pos);
        }

        /** Memastikan karakter yang diharapkan ada, lalu memajukan posisi. */
        void harap(char diharapkan) {
            char c = lihat();
            if (c != diharapkan) {
                throw galat("diharapkan '" + diharapkan + "', ditemukan '" + c + "'");
            }
            pos++;
        }

        boolean habis() {
            return pos >= teks.length();
        }

        IllegalArgumentException galat(String pesan) {
            return new IllegalArgumentException(pesan + " (posisi " + pos + ")");
        }
    }
}

RepositoryMemori.java

Unduh 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, dan penghitung sederhana menetapkan id berurutan mulai dari 1.
 */
public class RepositoryMemori implements TugasRepository {

    private final Map<Integer, Tugas> data = new LinkedHashMap<>();
    private int urutan = 0;

    @Override
    public Tugas tambah(Tugas tugas) {
        urutan++;
        Tugas tersimpan = tugas.denganId(urutan);
        data.put(urutan, tersimpan);
        return tersimpan;
    }

    @Override
    public Optional<Tugas> cari(int id) {
        return Optional.ofNullable(data.get(id));
    }

    @Override
    public List<Tugas> semua() {
        return new ArrayList<>(data.values());
    }

    @Override
    public Optional<Tugas> simpan(Tugas tugas) {
        if (!data.containsKey(tugas.id())) {
            return Optional.empty();
        }
        data.put(tugas.id(), tugas);
        return Optional.of(tugas);
    }

    @Override
    public boolean hapus(int id) {
        return data.remove(id) != null;
    }
}

RouterTugas.java

Unduh 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.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.Map;

/**
 * Router CRUD untuk resource tugas: membaca method dan path dari setiap
 * permintaan, memvalidasi payload, lalu menghasilkan respons JSON.
 * Seluruh kegagalan diterjemahkan di satu titik menjadi respons berstatus.
 */
public class RouterTugas implements HttpHandler {

    private final TugasRepository repo;

    public RouterTugas(TugasRepository repo) {
        this.repo = repo;
    }

    @Override
    public void handle(HttpExchange exchange) throws IOException {
        try {
            tangani(exchange);
        } catch (ApiException e) {
            kirim(exchange, e.status(), JsonMini.error(e.getMessage()));
        } catch (IllegalArgumentException e) {
            // Validasi pada lapisan domain (record, enum) diterjemahkan jadi 400.
            kirim(exchange, 400, JsonMini.error(e.getMessage()));
        } catch (RuntimeException e) {
            kirim(exchange, 500, JsonMini.error("kesalahan internal: " + e.getMessage()));
        } finally {
            exchange.close();
        }
    }

    /** Membaca method dan path, lalu mengarahkan ke handler yang tepat. */
    private void tangani(HttpExchange ex) throws IOException {
        String metode = ex.getRequestMethod();
        String path = ex.getRequestURI().getPath();

        if (!path.equals("/api/tugas") && !path.startsWith("/api/tugas/")) {
            throw new ApiException(404, "endpoint tidak dikenal: " + path);
        }
        String sisa = path.substring("/api/tugas".length());

        if (sisa.isEmpty() || sisa.equals("/")) {
            switch (metode) {
                case "GET" -> daftar(ex);
                case "POST" -> tambah(ex);
                default -> lempar405(metode, path);
            }
        } else {
            int id = bacaId(sisa);
            switch (metode) {
                case "GET" -> detail(ex, id);
                case "PUT" -> ubah(ex, id);
                case "DELETE" -> hapus(ex, id);
                default -> lempar405(metode, path);
            }
        }
    }

    private void daftar(HttpExchange ex) throws IOException {
        kirim(ex, 200, JsonMini.daftar(repo.semua()));
    }

    /** POST: status awal selalu TODO; server yang menetapkannya, bukan klien. */
    private void tambah(HttpExchange ex) throws IOException {
        Map<String, Object> payload = bacaPayload(ex);
        Tugas baru = new Tugas(0, teks(payload, "judul"), teks(payload, "pemilik"),
                StatusTugas.TODO, tanggal(payload, "tenggat"));
        Tugas tersimpan = repo.tambah(baru);
        kirim(ex, 201, JsonMini.tugas(tersimpan));
    }

    private void detail(HttpExchange ex, int id) throws IOException {
        Tugas tugas = repo.cari(id).orElseThrow(() -> new TugasTidakDitemukanException(id));
        kirim(ex, 200, JsonMini.tugas(tugas));
    }

    /** PUT: penggantian utuh, kelima field dikirim kembali oleh klien. */
    private void ubah(HttpExchange ex, int id) throws IOException {
        repo.cari(id).orElseThrow(() -> new TugasTidakDitemukanException(id));
        Map<String, Object> payload = bacaPayload(ex);
        Tugas pengganti = new Tugas(id, teks(payload, "judul"), teks(payload, "pemilik"),
                status(payload), tanggal(payload, "tenggat"));
        Tugas tersimpan = repo.simpan(pengganti)
                .orElseThrow(() -> new TugasTidakDitemukanException(id));
        kirim(ex, 200, JsonMini.tugas(tersimpan));
    }

    private void hapus(HttpExchange ex, int id) throws IOException {
        if (!repo.hapus(id)) {
            throw new TugasTidakDitemukanException(id);
        }
        kirim(ex, 200, "{\"pesan\":\"tugas " + id + " dihapus\"}");
    }

    /** Membaca segmen path setelah /api/tugas/ sebagai id. */
    private static int bacaId(String sisa) {
        String teks = sisa.substring(1);
        if (teks.contains("/")) {
            throw new ApiException(404, "endpoint tidak dikenal: /api/tugas" + sisa);
        }
        long id;
        try {
            id = Long.parseLong(teks);
        } catch (NumberFormatException e) {
            throw new ValidasiGagalException("id harus angka, diterima: '" + teks + "'");
        }
        if (id < 1 || id > Integer.MAX_VALUE) {
            throw new ValidasiGagalException("id di luar rentang: " + id);
        }
        return (int) id;
    }

    /** Membaca seluruh body permintaan sebagai objek JSON datar. */
    private static Map<String, Object> bacaPayload(HttpExchange ex) throws IOException {
        String mentah = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
        if (mentah.isBlank()) {
            throw new ValidasiGagalException("body permintaan tidak boleh kosong");
        }
        try {
            return JsonMini.parseObjek(mentah);
        } catch (IllegalArgumentException e) {
            throw new ValidasiGagalException("JSON tidak valid: " + e.getMessage());
        }
    }

    /** Nilai field teks wajib; 400 bila field hilang atau bukan teks. */
    private static String teks(Map<String, Object> payload, String kunci) {
        Object nilai = payload.get(kunci);
        if (nilai == null) {
            throw new ValidasiGagalException(kunci + " wajib diisi");
        }
        if (!(nilai instanceof String teks)) {
            throw new ValidasiGagalException(kunci + " harus berupa teks");
        }
        return teks;
    }

    /** Nilai tanggal wajib berformat ISO-8601 seperti 2026-01-20. */
    private static LocalDate tanggal(Map<String, Object> payload, String kunci) {
        String teks = teks(payload, kunci);
        try {
            return LocalDate.parse(teks);
        } catch (DateTimeParseException e) {
            throw new ValidasiGagalException(
                    kunci + " harus format ISO-8601, contoh 2026-01-20, diterima: '" + teks + "'");
        }
    }

    /** Nilai status wajib TODO atau SELESAI. */
    private static StatusTugas status(Map<String, Object> payload) {
        return StatusTugas.dariTeks(teks(payload, "status"));
    }

    private static void lempar405(String metode, String path) {
        throw new ApiException(405, metode + " tidak didukung di " + path
                + "; gunakan GET, POST, PUT, atau DELETE");
    }

    /** Satu-satunya pintu menulis respons: header JSON, status, lalu body UTF-8. */
    private static void kirim(HttpExchange ex, int status, String json) throws IOException {
        byte[] isi = json.getBytes(StandardCharsets.UTF_8);
        ex.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
        ex.sendResponseHeaders(status, isi.length);
        try (OutputStream os = ex.getResponseBody()) {
            os.write(isi);
        }
    }
}

ServerApi.java

Unduh 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.util.Locale;

/**
 * Titik masuk API manajemen tugas: HttpServer di alamat loopback dengan port
 * dipilih sistem operasi (port 0), lalu seluruh endpoint diuji lewat HttpClient
 * dalam satu proses yang sama — server dan klien berjalan tanpa jaringan luar.
 */
public class ServerApi {

    public static void main(String[] args) throws Exception {
        TugasRepository repo = new RepositoryMemori();
        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server.createContext("/", new RouterTugas(repo));
        server.start();

        String dasar = "http://127.0.0.1:" + server.getAddress().getPort();
        HttpClient klien = HttpClient.newHttpClient();

        try {
            System.out.println("=== Babak 1: membuat dua tugas ===");
            cetak(klien, "POST", dasar + "/api/tugas",
                    "{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\",\"tenggat\":\"2026-01-20\"}",
                    null);
            cetak(klien, "POST", dasar + "/api/tugas",
                    "{\"judul\":\"Tulis dokumentasi API\",\"pemilik\":\"Siti\",\"tenggat\":\"2026-02-01\"}",
                    null);

            System.out.println();
            System.out.println("=== Babak 2: membaca daftar dan detail ===");
            cetak(klien, "GET", dasar + "/api/tugas", null, null);
            cetak(klien, "GET", dasar + "/api/tugas/2", null, null);

            System.out.println();
            System.out.println("=== Babak 3: mengubah status tugas 1 ===");
            cetak(klien, "PUT", dasar + "/api/tugas/1",
                    "{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\","
                            + "\"status\":\"SELESAI\",\"tenggat\":\"2026-01-20\"}",
                    null);

            System.out.println();
            System.out.println("=== Babak 4: permintaan yang ditolak ===");
            cetak(klien, "GET", dasar + "/api/tugas/9", null, null);
            cetak(klien, "GET", dasar + "/api/pengguna", null, null);
            cetak(klien, "POST", dasar + "/api/tugas", "{\"judul\":", "rusak");
            cetak(klien, "POST", dasar + "/api/tugas",
                    "{\"judul\":\"Tugas tanpa pemilik\",\"tenggat\":\"2026-01-22\"}",
                    "tanpa pemilik");
            cetak(klien, "POST", dasar + "/api/tugas",
                    "{\"judul\":\"   \",\"pemilik\":\"Dewi\",\"tenggat\":\"2026-01-25\"}",
                    "judul kosong");
            cetak(klien, "PATCH", dasar + "/api/tugas/1", null, null);

            System.out.println();
            System.out.println("=== Babak 5: menghapus lalu melihat sisa ===");
            cetak(klien, "DELETE", dasar + "/api/tugas/2", null, null);
            cetak(klien, "GET", dasar + "/api/tugas", null, null);
        } finally {
            server.stop(0);
        }
    }

    /**
     * Mengirim satu permintaan lalu mencetak status dan body responsnya.
     * Nomor port sengaja tidak dicetak agar keluaran tetap deterministik.
     */
    private static void cetak(HttpClient klien, String metode, String url,
            String body, String keterangan) throws IOException, InterruptedException {
        HttpRequest.Builder b = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .timeout(Duration.ofSeconds(5));
        if (body == null) {
            b.method(metode, HttpRequest.BodyPublishers.noBody());
        } else {
            b.header("Content-Type", "application/json")
                    .method(metode, HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
        }
        HttpResponse<String> respons =
                klien.send(b.build(), HttpResponse.BodyHandlers.ofString());

        String path = URI.create(url).getPath();
        String target = keterangan == null ? path : path + " (" + keterangan + ")";
        System.out.printf(Locale.US, "%-6s %-28s -> %d %s%n",
                metode, target, respons.statusCode(), respons.body());
    }
}

StatusTugas.java

Unduh berkas
java
/** Status pengerjaan sebuah tugas. */
public enum StatusTugas {
    TODO,
    SELESAI;

    /**
     * Mengubah teks JSON menjadi status.
     * Melempar IllegalArgumentException dengan pesan jelas bila teks tidak dikenal.
     */
    public static StatusTugas dariTeks(String teks) {
        for (StatusTugas s : values()) {
            if (s.name().equals(teks)) {
                return s;
            }
        }
        throw new IllegalArgumentException(
                "status '" + teks + "' tidak dikenal, gunakan TODO atau SELESAI");
    }
}

Tugas.java

Unduh berkas
java
import java.time.LocalDate;

/**
 * Representasi satu tugas sebagai resource API.
 * Invarian dasar dijaga compact constructor sehingga objek Tugas tidak pernah
 * berada dalam keadaan tidak sah, apa pun asal datanya (kode atau payload klien).
 */
public record Tugas(int id, String judul, String pemilik, StatusTugas status, LocalDate tenggat) {

    public Tugas {
        if (judul == null || judul.strip().isEmpty()) {
            throw new IllegalArgumentException("judul tidak boleh kosong");
        }
        if (pemilik == null || pemilik.strip().isEmpty()) {
            throw new IllegalArgumentException("pemilik tidak boleh kosong");
        }
        if (tenggat == null) {
            throw new IllegalArgumentException("tenggat tidak boleh kosong");
        }
        judul = judul.strip();
        pemilik = pemilik.strip();
    }

    /** Salinan tugas ini dengan id baru; dipakai repository saat menetapkan id. */
    public Tugas denganId(int idBaru) {
        return new Tugas(idBaru, judul, pemilik, status, tenggat);
    }

    /** Salinan tugas ini dengan status baru; dipakai operasi ubah. */
    public Tugas denganStatus(StatusTugas statusBaru) {
        return new Tugas(id, judul, pemilik, statusBaru, tenggat);
    }
}

TugasRepository.java

Unduh berkas
java
import java.util.List;
import java.util.Optional;

/**
 * Kontrak lapisan penyimpanan untuk resource tugas.
 * Handler hanya mengenal interface ini, sehingga implementasi bebas ditukar:
 * memori untuk pengujian, berkas untuk persistensi, atau JDBC untuk database.
 */
public interface TugasRepository {

    /** Menyimpan tugas baru; id ditetapkan otomatis oleh penyimpanan. */
    Tugas tambah(Tugas tugas);

    /** Mencari satu tugas berdasarkan id. */
    Optional<Tugas> cari(int id);

    /** Seluruh tugas terurut menaik berdasarkan id. */
    List<Tugas> semua();

    /** Menyimpan perubahan tugas yang sudah ada; kosong bila id tidak ditemukan. */
    Optional<Tugas> simpan(Tugas tugas);

    /** Menghapus tugas; false bila id tidak ditemukan. */
    boolean hapus(int id);
}

TugasTidakDitemukanException.java

Unduh berkas
java
/**
 * Id tugas yang diminta tidak ada di penyimpanan.
 * Diterjemahkan menjadi respons 404 Not Found.
 */
public class TugasTidakDitemukanException extends ApiException {

    public TugasTidakDitemukanException(int id) {
        super(404, "tugas dengan id " + id + " tidak ditemukan");
    }
}

ValidasiGagalException.java

Unduh berkas
java
/**
 * Payload permintaan tidak lolos validasi: field wajib hilang,
 * format tanggal salah, atau JSON tidak sah.
 * Diterjemahkan menjadi respons 400 Bad Request.
 */
public class ValidasiGagalException extends ApiException {

    public ValidasiGagalException(String pesan) {
        super(400, pesan);
    }
}