API yang mudah dipakai klien lahir dari keputusan desain yang ditulis lebih dulu. Bagian 1 merancang kontrak API manajemen tugas: resource, endpoint, status code untuk tiap situasi, dan bentuk pesan JSON yang bolak balik antara klien dan server. Kontrak tersebut langsung diuji sebagai kode — record Tugas sebagai model resource dan JsonMini sebagai serializer sekaligus parser — sehingga desain tidak berhenti sebagai dokumen.

Resource Tugas dan Penamaan URI #

REST memandang aplikasi sebagai kumpulan resource — entitas yang dibaca dan diubah lewat URI. Resource utama proyek ini adalah tugas: pekerjaan dengan judul, pemilik (Budi, Siti, atau Dewi), status pengerjaan, dan tanggal tenggat. Konvensi URI kita: kata benda jamak di bawah awalan /api, pengidentifikasi di segmen path berikutnya, tanpa kata kerja.

MethodPathFungsiSuksesGagal
GET/api/tugasdaftar seluruh tugas200 + array JSON—
POST/api/tugasbuat tugas baru201 + tugas tersimpan400 payload tidak valid
GET/api/tugas/{id}detail satu tugas200 + objek tugas404 id tidak ada
PUT/api/tugas/{id}ganti isi tugas secara utuh200 + versi terbaru400, 404
DELETE/api/tugas/{id}hapus tugas200 + pesan konfirmasi404
method lainpath mana puntidak didukung—405 method ditolak

Status Code yang Berbicara Sendiri #

Status code adalah bahasa ringkas antara server dan klien. Desain kita menetapkan: 201 Created untuk resource baru, 200 OK untuk respons sukses lainnya, 400 Bad Request bila payload tidak lolos validasi, 404 Not Found bila id tidak ada, dan 405 untuk method yang tidak dikenal; Bagian 4 menambah 401 Unauthorized. Pembedanya tegas: 4xx berarti klien yang harus memperbaiki permintaan, sedangkan 5xx berarti server bermasalah. Pemetaan exception ke status sejak desain membuat Bagian 2 tinggal menerjemahkannya menjadi kode.

Model Resource: Record Tugas #

Model resource ditulis sebagai record karena isinya tetap; perubahan status menghasilkan objek baru. Compact constructor menjadi gerbang invarian terakhir: objek Tugas tidak pernah berada dalam keadaan tidak sah, apa pun asal datanya.

java StatusTugas.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");
    }
}
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();
    }
}
  1. Enum StatusTugas hanya punya dua nilai (TODO, SELESAI); method dariTeks menerjemahkan teks JSON menjadi enum dengan pesan galat yang jelas bila teks tidak dikenal.
  2. Compact constructor Tugas menolak judul dan pemilik kosong serta tenggat null, lalu memangkas spasi judul dan pemilik. Validasi di constructor membuat aturan yang sama berlaku untuk data dari kode maupun payload klien.

Format Pesan JSON dan Kelas JsonMini #

JSON dipilih karena ringkas dan menjadi format baku REST. Tanpa pustaka eksternal, kita menulis JsonMini: serializer mini yang meng-escape karakter wajib (", \, baris baru, tab) sekaligus parser objek datar untuk payload. Jackson atau Gson tetap pilihan tepat di proyek sungguhan; versi mini membuat mekanismenya terlihat langsung.

java JsonMini.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 + ")");
        }
    }
}
  1. Method escape dan string: satu-satunya tempat karakter khusus diloloskan — kutip ganda menjadi \", backslash menjadi \\, baris baru menjadi \n, karakter kendali menjadi \u00XX. Dengan satu pintu ini, JSON hasil JsonMini selalu sah.
  2. Method tugas dan daftar: menyusun objek dan array JSON dengan urutan field tetap — id, judul, pemilik, status, tenggat — sehingga keluaran deterministik dan mudah dibandingkan.
  3. Method error: amplop seragam {"error":"pesan"} untuk semua kegagalan, dari validasi sampai resource yang tidak ada.
  4. Method parseObjek dan kelas Pembaca: parser membaca karakter demi karakter ke LinkedHashMap agar urutan payload terjaga, lalu melempar IllegalArgumentException dengan posisi galat.

Demo Bolak Balik Format Pesan #

Demo berikut memutar siklus pesan utuh: Tugas menjadi respons JSON, payload POST diparsing menjadi field, lalu judul berisi kutip ganda dibuktikan lolos escaping.

java DemoPesan.java
import java.time.LocalDate;
import java.util.Map;

/** Bagian 1: memperagakan format pesan JSON untuk resource tugas. */
public class DemoPesan {

    public static void main(String[] args) {
        Tugas tugas = new Tugas(1, "Belajar desain REST API", "Budi",
                StatusTugas.TODO, LocalDate.of(2026, 1, 20));

        // Respons untuk GET /api/tugas/1
        System.out.println("Respons GET /api/tugas/1 (200):");
        System.out.println(JsonMini.tugas(tugas));

        // Payload yang dikirim klien pada POST /api/tugas
        String permintaan = "{\"judul\":\"Tulis dokumentasi API\","
                + "\"pemilik\":\"Siti\",\"tenggat\":\"2026-02-01\"}";
        Map<String, Object> payload = JsonMini.parseObjek(permintaan);
        System.out.println();
        System.out.println("Payload POST /api/tugas setelah diparsing:");
        System.out.println("judul   = " + payload.get("judul"));
        System.out.println("pemilik = " + payload.get("pemilik"));
        System.out.println("tenggat = " + payload.get("tenggat"));

        // Server menyimpan payload, lalu menjawab dengan tugas yang sudah ber-id
        Tugas baru = new Tugas(2, (String) payload.get("judul"), (String) payload.get("pemilik"),
                StatusTugas.TODO, LocalDate.parse((String) payload.get("tenggat")));
        System.out.println();
        System.out.println("Respons POST /api/tugas (201):");
        System.out.println(JsonMini.tugas(baru));

        // Amplop error yang sama untuk semua respons gagal
        System.out.println();
        System.out.println("Respons GET /api/tugas/9 (404):");
        System.out.println(JsonMini.error("tugas dengan id 9 tidak ditemukan"));

        // Parser juga mengenali angka, boolean, null, dan angka pecahan
        Map<String, Object> campuran = JsonMini.parseObjek(
                "{\"versi\":2,\"stabil\":true,\"catatan\":null,\"skor\":4.5}");
        System.out.println();
        System.out.println("Parser nilai campuran : " + campuran);

        // Judul yang memuat kutip ganda dan backslash wajib di-escape
        String judulAneh = "Belajar \"escape\" di \\ JSON";
        System.out.println("Judul di-escape       : " + JsonMini.string(judulAneh));
    }
}

Output yang Dihasilkan:

text
Respons GET /api/tugas/1 (200):
{"id":1,"judul":"Belajar desain REST API","pemilik":"Budi","status":"TODO","tenggat":"2026-01-20"}

Payload POST /api/tugas setelah diparsing:
judul   = Tulis dokumentasi API
pemilik = Siti
tenggat = 2026-02-01

Respons POST /api/tugas (201):
{"id":2,"judul":"Tulis dokumentasi API","pemilik":"Siti","status":"TODO","tenggat":"2026-02-01"}

Respons GET /api/tugas/9 (404):
{"error":"tugas dengan id 9 tidak ditemukan"}

Parser nilai campuran : {versi=2, stabil=true, catatan=null, skor=4.5}
Judul di-escape       : "Belajar \"escape\" di \\ JSON"
  1. Dua respons sukses memakai bentuk objek yang persis sama — bukan kebetulan, melainkan kontrak; klien cukup menulis satu parser untuk seluruh operasi.
  2. Parser nilai campuran mengenali angka bulat, boolean, null, dan angka pecahan; validasi di Bagian 2 bisa membedakan field yang hilang dari field berisi nilai salah.
  3. Baris terakhir membuktikan escaping bekerja: judul yang memuat kutip ganda dan backslash keluar sebagai string JSON sah untuk diparsing ulang.

Kontrak kini tertulis dalam tabel, status code, dan kode yang teruji. Bagian 2 menghidupkannya: server HTTP bawaan JDK menerima permintaan nyata, router membaca method dan path, dan seluruh operasi CRUD diuji lewat HttpClient dalam satu proses.

Referensi #

Berkas Sumber

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

DemoPesan.java

Unduh berkas
java
import java.time.LocalDate;
import java.util.Map;

/** Bagian 1: memperagakan format pesan JSON untuk resource tugas. */
public class DemoPesan {

    public static void main(String[] args) {
        Tugas tugas = new Tugas(1, "Belajar desain REST API", "Budi",
                StatusTugas.TODO, LocalDate.of(2026, 1, 20));

        // Respons untuk GET /api/tugas/1
        System.out.println("Respons GET /api/tugas/1 (200):");
        System.out.println(JsonMini.tugas(tugas));

        // Payload yang dikirim klien pada POST /api/tugas
        String permintaan = "{\"judul\":\"Tulis dokumentasi API\","
                + "\"pemilik\":\"Siti\",\"tenggat\":\"2026-02-01\"}";
        Map<String, Object> payload = JsonMini.parseObjek(permintaan);
        System.out.println();
        System.out.println("Payload POST /api/tugas setelah diparsing:");
        System.out.println("judul   = " + payload.get("judul"));
        System.out.println("pemilik = " + payload.get("pemilik"));
        System.out.println("tenggat = " + payload.get("tenggat"));

        // Server menyimpan payload, lalu menjawab dengan tugas yang sudah ber-id
        Tugas baru = new Tugas(2, (String) payload.get("judul"), (String) payload.get("pemilik"),
                StatusTugas.TODO, LocalDate.parse((String) payload.get("tenggat")));
        System.out.println();
        System.out.println("Respons POST /api/tugas (201):");
        System.out.println(JsonMini.tugas(baru));

        // Amplop error yang sama untuk semua respons gagal
        System.out.println();
        System.out.println("Respons GET /api/tugas/9 (404):");
        System.out.println(JsonMini.error("tugas dengan id 9 tidak ditemukan"));

        // Parser juga mengenali angka, boolean, null, dan angka pecahan
        Map<String, Object> campuran = JsonMini.parseObjek(
                "{\"versi\":2,\"stabil\":true,\"catatan\":null,\"skor\":4.5}");
        System.out.println();
        System.out.println("Parser nilai campuran : " + campuran);

        // Judul yang memuat kutip ganda dan backslash wajib di-escape
        String judulAneh = "Belajar \"escape\" di \\ JSON";
        System.out.println("Judul di-escape       : " + JsonMini.string(judulAneh));
    }
}

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 + ")");
        }
    }
}

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();
    }
}