Server Bagian 2 kehilangan seluruh tugas saat proses berhenti karena penyimpanan hanya hidup di memori. Bagian 3 menggantinya dengan RepositoryBerkas yang menulis satu tugas per baris ke berkas teks, lalu membuktikannya lewat dua server bergantian atas berkas yang sama. Inti logika router tidak berubah — bukti langsung nilai interface TugasRepository. Validasi payload juga naik kelas: dari berhenti pada galat pertama menjadi mengumpulkan seluruh galat dalam satu respons 400.

Penyimpanan Berkas Baris demi Baris #

Format penyimpanan dibuat dapat dibaca mata: satu baris T;id;judul;pemilik;status;tenggat, tanggal tetap ISO-8601 agar urutan leksikografis sama dengan urutan waktu. Konsekuensinya: ; pada judul atau pemilik merusak pembacaan kolom, sehingga penyimpanan menolaknya lebih awal dengan IllegalArgumentException yang router terjemahkan menjadi 400.

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * Penyimpanan berkas berbasis baris: satu tugas satu baris dengan format
 * T;id;judul;pemilik;status;tenggat. Seluruh berkas dimuat saat konstruksi
 * dan ditulis ulang pada setiap perubahan — sederhana dan tepat untuk data
 * seukuran proyek ini; database mengambil alih saat data bertambah besar.
 */
public class RepositoryBerkas implements TugasRepository {

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

    public RepositoryBerkas(Path berkas) {
        this.berkas = berkas;
        muat();
    }

    @Override
    public Tugas tambah(Tugas tugas) {
        tolakPemisah(tugas);
        urutan++;
        Tugas tersimpan = tugas.denganId(urutan);
        data.put(urutan, tersimpan);
        tulisUlang();
        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();
        }
        tolakPemisah(tugas);
        data.put(tugas.id(), tugas);
        tulisUlang();
        return Optional.of(tugas);
    }

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

    /** Memuat seluruh baris; baris cacat menghentikan proses karena integritas diragukan. */
    private void muat() {
        if (!Files.exists(berkas)) {
            return;   // berkas belum pernah dibuat: mulai dari kondisi kosong
        }
        List<String> baris;
        try {
            baris = Files.readAllLines(berkas, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new UncheckedIOException("gagal membaca " + berkas, e);
        }
        for (int i = 0; i < baris.size(); i++) {
            String satu = baris.get(i);
            if (satu.isBlank()) {
                continue;
            }
            Tugas tugas = parseBaris(satu, i + 1);
            urutan = Math.max(urutan, tugas.id());
            data.put(tugas.id(), tugas);
        }
    }

    /** Menulis ulang seluruh berkas dalam charset UTF-8. */
    private void tulisUlang() {
        List<String> baris = new ArrayList<>();
        for (Tugas t : data.values()) {
            baris.add("T;" + t.id() + ";" + t.judul() + ";" + t.pemilik()
                    + ";" + t.status() + ";" + t.tenggat());
        }
        try {
            Files.write(berkas, baris, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new UncheckedIOException("gagal menulis " + berkas, e);
        }
    }

    /**
     * Karakter ';' dipakai sebagai pemisah kolom, sehingga judul dan pemilik
     * yang memuatnya ditolak lebih awal agar berkas tetap bisa dibaca ulang.
     */
    private static void tolakPemisah(Tugas tugas) {
        if (tugas.judul().indexOf(';') >= 0 || tugas.pemilik().indexOf(';') >= 0) {
            throw new IllegalArgumentException("judul dan pemilik tidak boleh mengandung "
                    + "';' karena karakter itu dipakai sebagai pemisah kolom berkas");
        }
    }

    /** Mengubah satu baris berkas menjadi Tugas dengan laporan nomor baris. */
    private static Tugas parseBaris(String baris, int nomor) {
        if (!baris.startsWith("T;")) {
            throw new IllegalStateException("baris " + nomor + ": awalan tidak dikenal");
        }
        String[] kolom = baris.split(";", -1);
        if (kolom.length != 6) {
            throw new IllegalStateException("baris " + nomor + ": jumlah kolom "
                    + kolom.length + ", harap 6");
        }
        int id;
        try {
            id = Integer.parseInt(kolom[1]);
        } catch (NumberFormatException e) {
            throw new IllegalStateException("baris " + nomor + ": id '"
                    + kolom[1] + "' bukan angka");
        }
        LocalDate tenggat;
        try {
            tenggat = LocalDate.parse(kolom[5]);
        } catch (DateTimeParseException e) {
            throw new IllegalStateException("baris " + nomor + ": tenggat '"
                    + kolom[5] + "' tidak valid");
        }
        StatusTugas status;
        try {
            status = StatusTugas.dariTeks(kolom[4]);
        } catch (IllegalArgumentException e) {
            throw new IllegalStateException("baris " + nomor + ": " + e.getMessage());
        }
        return new Tugas(id, kolom[2], kolom[3], status, tenggat);
    }
}
  1. Constructor memuat seluruh berkas sekali: muat() membaca baris valid ke LinkedHashMap; urutan diangkat ke id terbesar sehingga id baru tidak menabrak id lama setelah restart.
  2. tulisUlang() pada setiap perubahan: tambah, ubah, dan hapus selalu menulis ulang seluruh berkas UTF-8 — paling sederhana untuk puluhan tugas.
  3. parseBaris bersifat ketat: awalan tak dikenal, jumlah kolom salah, id bukan angka, tanggal rusak, dan status asing melempar IllegalStateException dengan nomor baris — lebih baik berhenti daripada kehilangan data diam-diam.
  4. tolakPemisah berdiri di lapis penyimpanan, bukan di record Tugas, karena larangan ; adalah konsekuensi format berkas — ganti ke SQLite dan larangan otomatis hilang.

Restart yang Membuktikan Persistensi #

Demo berikut menjalankan dua server bergantian di atas satu berkas tugas-api.txt. Babak 1 menulis dua tugas dan mengubah status tugas pertama; babak 2 membuat server baru — mensimulasikan restart — lalu membaca data yang sama dan melanjutkan id dari 3.

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.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Locale;

/**
 * Bagian 3: server dengan penyimpanan berkas. Babak 1 menulis data lewat
 * server pertama, lalu babak 2 membuat server baru atas berkas yang sama
 * untuk membuktikan data bertahan melewati "restart".
 */
public class ServerApi {

    public static void main(String[] args) throws Exception {
        Path berkas = Path.of("tugas-api.txt");

        System.out.println("=== Babak 1: server pertama menulis ke berkas ===");
        HttpServer server1 = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server1.createContext("/", new RouterTugas(new RepositoryBerkas(berkas)));
        server1.start();
        String dasar1 = "http://127.0.0.1:" + server1.getAddress().getPort();
        HttpClient klien1 = HttpClient.newHttpClient();
        try {
            cetak(klien1, "POST", dasar1 + "/api/tugas",
                    "{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\",\"tenggat\":\"2026-01-20\"}",
                    null);
            cetak(klien1, "POST", dasar1 + "/api/tugas",
                    "{\"judul\":\"Tulis dokumentasi API\",\"pemilik\":\"Siti\",\"tenggat\":\"2026-02-01\"}",
                    null);
            cetak(klien1, "PUT", dasar1 + "/api/tugas/1",
                    "{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\","
                            + "\"status\":\"SELESAI\",\"tenggat\":\"2026-01-20\"}",
                    null);
            cetak(klien1, "POST", dasar1 + "/api/tugas",
                    "{\"pemilik\":\"Dewi\",\"tenggat\":\"20-01-2026\"}",
                    "dua field bermasalah");
        } finally {
            server1.stop(0);
        }

        System.out.println();
        System.out.println("=== Isi tugas-api.txt setelah server berhenti ===");
        System.out.print(Files.readString(berkas, StandardCharsets.UTF_8));

        System.out.println();
        System.out.println("=== Babak 2: server baru memuat ulang berkas ===");
        HttpServer server2 = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server2.createContext("/", new RouterTugas(new RepositoryBerkas(berkas)));
        server2.start();
        String dasar2 = "http://127.0.0.1:" + server2.getAddress().getPort();
        HttpClient klien2 = HttpClient.newHttpClient();   // klien segar: koneksi lama tidak berlaku lagi
        try {
            cetak(klien2, "GET", dasar2 + "/api/tugas", null, null);
            cetak(klien2, "POST", dasar2 + "/api/tugas",
                    "{\"judul\":\"Rapat tim mingguan\",\"pemilik\":\"Dewi\",\"tenggat\":\"2026-02-10\"}",
                    null);
            cetak(klien2, "POST", dasar2 + "/api/tugas",
                    "{\"judul\":\"Rapat;belanja bulanan\",\"pemilik\":\"Dewi\",\"tenggat\":\"2026-02-15\"}",
                    "memuat pemisah ;");
        } finally {
            server2.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 %-30s -> %d %s%n",
                metode, target, respons.statusCode(), respons.body());
    }
}

Output yang Dihasilkan:

text
=== Babak 1: server pertama menulis ke berkas ===
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"}
PUT    /api/tugas/1                   -> 200 {"id":1,"judul":"Belajar HttpServer","pemilik":"Budi","status":"SELESAI","tenggat":"2026-01-20"}
POST   /api/tugas (dua field bermasalah) -> 400 {"error":"judul wajib diisi; tenggat '20-01-2026' harus ISO-8601, contoh 2026-01-20"}

=== Isi tugas-api.txt setelah server berhenti ===
T;1;Belajar HttpServer;Budi;SELESAI;2026-01-20
T;2;Tulis dokumentasi API;Siti;TODO;2026-02-01

=== Babak 2: server baru memuat ulang berkas ===
GET    /api/tugas                     -> 200 [{"id":1,"judul":"Belajar HttpServer","pemilik":"Budi","status":"SELESAI","tenggat":"2026-01-20"},{"id":2,"judul":"Tulis dokumentasi API","pemilik":"Siti","status":"TODO","tenggat":"2026-02-01"}]
POST   /api/tugas                     -> 201 {"id":3,"judul":"Rapat tim mingguan","pemilik":"Dewi","status":"TODO","tenggat":"2026-02-10"}
POST   /api/tugas (memuat pemisah ;)  -> 400 {"error":"judul dan pemilik tidak boleh mengandung ';' karena karakter itu dipakai sebagai pemisah kolom berkas"}
  1. Baris keempat babak 1 memperagakan validasi pengumpul galat: payload tanpa judul sekaligus tenggat terbalik dijawab satu 400 berisi dua pesan.
  2. Blok isi berkas memperlihatkan format barisnya langsung — status SELESAI dari PUT ikut tersimpan.
  3. Babak 2, GET mengembalikan kedua tugas, lalu POST menerima id 3 — penghitung dilanjutkan dari isi berkas.
  4. Permintaan terakhir ditolak 400 karena judulnya memuat ; — berkas tetap terbaca ulang.
  5. klien2 dibuat segar karena klien pertama masih menyimpan koneksi keep-alive ke server yang sudah dimatikan.

Validasi yang Mengumpulkan Semua Galat #

Pada Bagian 2, payload rusak dijawab pesan pertama yang ditemui. ValidasiTugas memeriksa seluruh field lebih dulu dan mengumpulkan galat ke satu List<String>; router menggabungkannya dengan String.join("; ", ...) menjadi satu respons.

import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * Validasi payload tugas yang mengumpulkan seluruh pelanggaran sekaligus,
 * bukan berhenti pada pelanggaran pertama. Hasilnya: satu respons 400
 * yang menyebut semua masalah sehingga klien cukup memperbaiki sekali.
 */
public final class ValidasiTugas {

    private ValidasiTugas() {
        // Kelas utilitas: seluruh anggota static.
    }

    /** Validasi POST: judul, pemilik, dan tenggat wajib; status tidak dibaca. */
    public static List<String> galatBuat(Map<String, Object> payload) {
        List<String> galat = new ArrayList<>();
        cekJudul(payload, galat);
        cekPemilik(payload, galat);
        cekTenggat(payload, galat);
        return galat;
    }

    /** Validasi PUT: seluruh field wajib, termasuk status. */
    public static List<String> galatUbah(Map<String, Object> payload) {
        List<String> galat = new ArrayList<>(galatBuat(payload));
        String status = teks(payload, "status");
        if (status == null) {
            galat.add("status wajib diisi (TODO/SELESAI)");
        } else {
            try {
                StatusTugas.dariTeks(status);
            } catch (IllegalArgumentException e) {
                galat.add(e.getMessage());
            }
        }
        return galat;
    }

    private static void cekJudul(Map<String, Object> payload, List<String> galat) {
        String judul = teks(payload, "judul");
        if (judul == null) {
            galat.add("judul wajib diisi");
        } else if (judul.isEmpty()) {
            galat.add("judul tidak boleh kosong");
        } else if (judul.length() > Tugas.PANJANG_JUDUL_MAKS) {
            galat.add("judul " + judul.length() + " karakter, maksimal "
                    + Tugas.PANJANG_JUDUL_MAKS);
        }
    }

    private static void cekPemilik(Map<String, Object> payload, List<String> galat) {
        String pemilik = teks(payload, "pemilik");
        if (pemilik == null) {
            galat.add("pemilik wajib diisi");
        } else if (pemilik.isEmpty()) {
            galat.add("pemilik tidak boleh kosong");
        }
    }

    private static void cekTenggat(Map<String, Object> payload, List<String> galat) {
        String tenggat = teks(payload, "tenggat");
        if (tenggat == null) {
            galat.add("tenggat wajib diisi");
        } else {
            try {
                LocalDate.parse(tenggat);
            } catch (DateTimeParseException e) {
                galat.add("tenggat '" + tenggat + "' harus ISO-8601, contoh 2026-01-20");
            }
        }
    }

    /** Nilai field sebagai String yang sudah dipangkas; null bila hilang atau bukan teks. */
    private static String teks(Map<String, Object> payload, String kunci) {
        if (payload.get(kunci) instanceof String s) {
            return s.strip();
        }
        return null;
    }
}

Dua hal patut dicermati. teks() mengembalikan null untuk field hilang sekaligus field yang bukan string — pemanggil tidak perlu membedakannya. Record Tugas tetap gerbang terakhir: batas judul dipegang Tugas.PANJANG_JUDUL_MAKS dan compact constructor menolak judul kepanjangan — lapis pertama memberi pesan lengkap, constructor menjadi jaring pengaman bila ada jalur terlewat.

Dari Berkas ke Database: Pola DAO #

Judul bagian ini menyebut SQLite dan JDBC. JDBC memang bagian dari JDK (java.sql), tetapi driver SQLite bukan — driver adalah .jar luar yang harus hadir di classpath. Lingkungan kursus ini tidak memuatnya, sehingga implementasi berikut tampil sebagai konsep tanpa klaim eksekusi. Polanya bernama DAO (Data Access Object): class yang merangkum akses ke sumber data di belakang interface yang sudah kita punya.

java RepositorySqlite.java — padanan JDBC (konsep, tidak dieksekusi)
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

/**
 * Implementasi TugasRepository di atas SQLite lewat JDBC.
 * Sebelum dipakai: letakkan driver sqlite-jdbc di classpath, misalnya
 * java -cp sqlite-jdbc.jar:. ServerApi.java
 */
public class RepositorySqlite implements TugasRepository {

    private final Connection koneksi;

    public RepositorySqlite(String url) throws SQLException {
        // URL contoh: "jdbc:sqlite:tugas.db"
        this.koneksi = DriverManager.getConnection(url);
        try (PreparedStatement ps = koneksi.prepareStatement(
                "CREATE TABLE IF NOT EXISTS tugas ("
                        + "id INTEGER PRIMARY KEY AUTOINCREMENT,"
                        + "judul TEXT NOT NULL,"
                        + "pemilik TEXT NOT NULL,"
                        + "status TEXT NOT NULL,"
                        + "tenggat TEXT NOT NULL)")) {
            ps.executeUpdate();
        }
    }

    @Override
    public Tugas tambah(Tugas tugas) {
        try (PreparedStatement ps = koneksi.prepareStatement(
                "INSERT INTO tugas (judul, pemilik, status, tenggat) VALUES (?, ?, ?, ?)",
                Statement.RETURN_GENERATED_KEYS)) {
            ps.setString(1, tugas.judul());
            ps.setString(2, tugas.pemilik());
            ps.setString(3, tugas.status().name());
            ps.setString(4, tugas.tenggat().toString());
            ps.executeUpdate();
            try (ResultSet kunci = ps.getGeneratedKeys()) {
                kunci.next();
                return tugas.denganId(kunci.getInt(1));
            }
        } catch (SQLException e) {
            throw new IllegalStateException("gagal menyimpan tugas", e);
        }
    }

    @Override
    public Optional<Tugas> cari(int id) {
        // SELECT ... WHERE id = ? dengan parameter terikat, lalu petakan ResultSet ke Tugas.
        throw new UnsupportedOperationException("latihan untuk pembaca");
    }

    @Override
    public List<Tugas> semua() {
        // SELECT id, judul, pemilik, status, tenggat FROM tugas ORDER BY id
        throw new UnsupportedOperationException("latihan untuk pembaca");
    }

    @Override
    public Optional<Tugas> simpan(Tugas tugas) {
        // UPDATE tugas SET judul = ?, pemilik = ?, status = ?, tenggat = ? WHERE id = ?
        throw new UnsupportedOperationException("latihan untuk pembaca");
    }

    @Override
    public boolean hapus(int id) {
        // DELETE FROM tugas WHERE id = ?
        throw new UnsupportedOperationException("latihan untuk pembaca");
    }

    /** Transaksi: memindahkan seluruh tugas satu pemilik dalam satu kesatuan. */
    public void pindahkanPemilik(String lama, String baru) throws SQLException {
        koneksi.setAutoCommit(false);
        try (PreparedStatement ps = koneksi.prepareStatement(
                "UPDATE tugas SET pemilik = ? WHERE pemilik = ?")) {
            ps.setString(1, baru);
            ps.setString(2, lama);
            ps.executeUpdate();
            koneksi.commit();
        } catch (SQLException e) {
            koneksi.rollback();
            throw e;
        } finally {
            koneksi.setAutoCommit(true);
        }
    }
}

Pola intinya: DriverManager.getConnection membuka Connection; PreparedStatement ditutup otomatis try-with-resources; ? adalah tempat nilai terikat; setAutoCommit(false) … commit()/rollback() membungkus perubahan menjadi satu transaksi. Ketika tambah versi JDBC menggantikan versi berkas, tidak ada baris router yang berubah — keuntungan nyata arsitektur berlapis.

Data kini bertahan, validasi memberi laporan utuh. Satu pintu masih terbuka: siapa pun yang menghubungi server boleh mengubah data. Bagian 4 menutupnya dengan token akses sederhana, lalu menutup proyek dengan harness pengujian seluruh endpoint.

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

RepositoryBerkas.java

Unduh berkas
java
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * Penyimpanan berkas berbasis baris: satu tugas satu baris dengan format
 * T;id;judul;pemilik;status;tenggat. Seluruh berkas dimuat saat konstruksi
 * dan ditulis ulang pada setiap perubahan — sederhana dan tepat untuk data
 * seukuran proyek ini; database mengambil alih saat data bertambah besar.
 */
public class RepositoryBerkas implements TugasRepository {

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

    public RepositoryBerkas(Path berkas) {
        this.berkas = berkas;
        muat();
    }

    @Override
    public Tugas tambah(Tugas tugas) {
        tolakPemisah(tugas);
        urutan++;
        Tugas tersimpan = tugas.denganId(urutan);
        data.put(urutan, tersimpan);
        tulisUlang();
        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();
        }
        tolakPemisah(tugas);
        data.put(tugas.id(), tugas);
        tulisUlang();
        return Optional.of(tugas);
    }

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

    /** Memuat seluruh baris; baris cacat menghentikan proses karena integritas diragukan. */
    private void muat() {
        if (!Files.exists(berkas)) {
            return;   // berkas belum pernah dibuat: mulai dari kondisi kosong
        }
        List<String> baris;
        try {
            baris = Files.readAllLines(berkas, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new UncheckedIOException("gagal membaca " + berkas, e);
        }
        for (int i = 0; i < baris.size(); i++) {
            String satu = baris.get(i);
            if (satu.isBlank()) {
                continue;
            }
            Tugas tugas = parseBaris(satu, i + 1);
            urutan = Math.max(urutan, tugas.id());
            data.put(tugas.id(), tugas);
        }
    }

    /** Menulis ulang seluruh berkas dalam charset UTF-8. */
    private void tulisUlang() {
        List<String> baris = new ArrayList<>();
        for (Tugas t : data.values()) {
            baris.add("T;" + t.id() + ";" + t.judul() + ";" + t.pemilik()
                    + ";" + t.status() + ";" + t.tenggat());
        }
        try {
            Files.write(berkas, baris, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new UncheckedIOException("gagal menulis " + berkas, e);
        }
    }

    /**
     * Karakter ';' dipakai sebagai pemisah kolom, sehingga judul dan pemilik
     * yang memuatnya ditolak lebih awal agar berkas tetap bisa dibaca ulang.
     */
    private static void tolakPemisah(Tugas tugas) {
        if (tugas.judul().indexOf(';') >= 0 || tugas.pemilik().indexOf(';') >= 0) {
            throw new IllegalArgumentException("judul dan pemilik tidak boleh mengandung "
                    + "';' karena karakter itu dipakai sebagai pemisah kolom berkas");
        }
    }

    /** Mengubah satu baris berkas menjadi Tugas dengan laporan nomor baris. */
    private static Tugas parseBaris(String baris, int nomor) {
        if (!baris.startsWith("T;")) {
            throw new IllegalStateException("baris " + nomor + ": awalan tidak dikenal");
        }
        String[] kolom = baris.split(";", -1);
        if (kolom.length != 6) {
            throw new IllegalStateException("baris " + nomor + ": jumlah kolom "
                    + kolom.length + ", harap 6");
        }
        int id;
        try {
            id = Integer.parseInt(kolom[1]);
        } catch (NumberFormatException e) {
            throw new IllegalStateException("baris " + nomor + ": id '"
                    + kolom[1] + "' bukan angka");
        }
        LocalDate tenggat;
        try {
            tenggat = LocalDate.parse(kolom[5]);
        } catch (DateTimeParseException e) {
            throw new IllegalStateException("baris " + nomor + ": tenggat '"
                    + kolom[5] + "' tidak valid");
        }
        StatusTugas status;
        try {
            status = StatusTugas.dariTeks(kolom[4]);
        } catch (IllegalArgumentException e) {
            throw new IllegalStateException("baris " + nomor + ": " + e.getMessage());
        }
        return new Tugas(id, kolom[2], kolom[3], status, tenggat);
    }
}

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.util.List;
import java.util.Map;

/**
 * Router CRUD untuk resource tugas: membaca method dan path dari setiap
 * permintaan, memvalidasi payload lewat ValidasiTugas, 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, penyimpanan) 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);
        List<String> galat = ValidasiTugas.galatBuat(payload);
        if (!galat.isEmpty()) {
            throw new ValidasiGagalException(String.join("; ", galat));
        }
        Tugas baru = new Tugas(0, teks(payload, "judul"), teks(payload, "pemilik"),
                StatusTugas.TODO, LocalDate.parse(teks(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);
        List<String> galat = ValidasiTugas.galatUbah(payload);
        if (!galat.isEmpty()) {
            throw new ValidasiGagalException(String.join("; ", galat));
        }
        Tugas pengganti = new Tugas(id, teks(payload, "judul"), teks(payload, "pemilik"),
                StatusTugas.dariTeks(teks(payload, "status")),
                LocalDate.parse(teks(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 yang sudah divalidasi; tidak akan melempar pada pemanggilan sah. */
    private static String teks(Map<String, Object> payload, String kunci) {
        return (String) payload.get(kunci);
    }

    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.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Locale;

/**
 * Bagian 3: server dengan penyimpanan berkas. Babak 1 menulis data lewat
 * server pertama, lalu babak 2 membuat server baru atas berkas yang sama
 * untuk membuktikan data bertahan melewati "restart".
 */
public class ServerApi {

    public static void main(String[] args) throws Exception {
        Path berkas = Path.of("tugas-api.txt");

        System.out.println("=== Babak 1: server pertama menulis ke berkas ===");
        HttpServer server1 = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server1.createContext("/", new RouterTugas(new RepositoryBerkas(berkas)));
        server1.start();
        String dasar1 = "http://127.0.0.1:" + server1.getAddress().getPort();
        HttpClient klien1 = HttpClient.newHttpClient();
        try {
            cetak(klien1, "POST", dasar1 + "/api/tugas",
                    "{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\",\"tenggat\":\"2026-01-20\"}",
                    null);
            cetak(klien1, "POST", dasar1 + "/api/tugas",
                    "{\"judul\":\"Tulis dokumentasi API\",\"pemilik\":\"Siti\",\"tenggat\":\"2026-02-01\"}",
                    null);
            cetak(klien1, "PUT", dasar1 + "/api/tugas/1",
                    "{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\","
                            + "\"status\":\"SELESAI\",\"tenggat\":\"2026-01-20\"}",
                    null);
            cetak(klien1, "POST", dasar1 + "/api/tugas",
                    "{\"pemilik\":\"Dewi\",\"tenggat\":\"20-01-2026\"}",
                    "dua field bermasalah");
        } finally {
            server1.stop(0);
        }

        System.out.println();
        System.out.println("=== Isi tugas-api.txt setelah server berhenti ===");
        System.out.print(Files.readString(berkas, StandardCharsets.UTF_8));

        System.out.println();
        System.out.println("=== Babak 2: server baru memuat ulang berkas ===");
        HttpServer server2 = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server2.createContext("/", new RouterTugas(new RepositoryBerkas(berkas)));
        server2.start();
        String dasar2 = "http://127.0.0.1:" + server2.getAddress().getPort();
        HttpClient klien2 = HttpClient.newHttpClient();   // klien segar: koneksi lama tidak berlaku lagi
        try {
            cetak(klien2, "GET", dasar2 + "/api/tugas", null, null);
            cetak(klien2, "POST", dasar2 + "/api/tugas",
                    "{\"judul\":\"Rapat tim mingguan\",\"pemilik\":\"Dewi\",\"tenggat\":\"2026-02-10\"}",
                    null);
            cetak(klien2, "POST", dasar2 + "/api/tugas",
                    "{\"judul\":\"Rapat;belanja bulanan\",\"pemilik\":\"Dewi\",\"tenggat\":\"2026-02-15\"}",
                    "memuat pemisah ;");
        } finally {
            server2.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 %-30s -> %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) {

    /** Batas panjang judul; berlaku juga sebagai aturan lapis pertama di ValidasiTugas. */
    public static final int PANJANG_JUDUL_MAKS = 120;

    public Tugas {
        if (judul == null || judul.strip().isEmpty()) {
            throw new IllegalArgumentException("judul tidak boleh kosong");
        }
        if (judul.strip().length() > PANJANG_JUDUL_MAKS) {
            throw new IllegalArgumentException("judul " + judul.strip().length()
                    + " karakter, maksimal " + PANJANG_JUDUL_MAKS);
        }
        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);
    }
}

ValidasiTugas.java

Unduh berkas
java
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * Validasi payload tugas yang mengumpulkan seluruh pelanggaran sekaligus,
 * bukan berhenti pada pelanggaran pertama. Hasilnya: satu respons 400
 * yang menyebut semua masalah sehingga klien cukup memperbaiki sekali.
 */
public final class ValidasiTugas {

    private ValidasiTugas() {
        // Kelas utilitas: seluruh anggota static.
    }

    /** Validasi POST: judul, pemilik, dan tenggat wajib; status tidak dibaca. */
    public static List<String> galatBuat(Map<String, Object> payload) {
        List<String> galat = new ArrayList<>();
        cekJudul(payload, galat);
        cekPemilik(payload, galat);
        cekTenggat(payload, galat);
        return galat;
    }

    /** Validasi PUT: seluruh field wajib, termasuk status. */
    public static List<String> galatUbah(Map<String, Object> payload) {
        List<String> galat = new ArrayList<>(galatBuat(payload));
        String status = teks(payload, "status");
        if (status == null) {
            galat.add("status wajib diisi (TODO/SELESAI)");
        } else {
            try {
                StatusTugas.dariTeks(status);
            } catch (IllegalArgumentException e) {
                galat.add(e.getMessage());
            }
        }
        return galat;
    }

    private static void cekJudul(Map<String, Object> payload, List<String> galat) {
        String judul = teks(payload, "judul");
        if (judul == null) {
            galat.add("judul wajib diisi");
        } else if (judul.isEmpty()) {
            galat.add("judul tidak boleh kosong");
        } else if (judul.length() > Tugas.PANJANG_JUDUL_MAKS) {
            galat.add("judul " + judul.length() + " karakter, maksimal "
                    + Tugas.PANJANG_JUDUL_MAKS);
        }
    }

    private static void cekPemilik(Map<String, Object> payload, List<String> galat) {
        String pemilik = teks(payload, "pemilik");
        if (pemilik == null) {
            galat.add("pemilik wajib diisi");
        } else if (pemilik.isEmpty()) {
            galat.add("pemilik tidak boleh kosong");
        }
    }

    private static void cekTenggat(Map<String, Object> payload, List<String> galat) {
        String tenggat = teks(payload, "tenggat");
        if (tenggat == null) {
            galat.add("tenggat wajib diisi");
        } else {
            try {
                LocalDate.parse(tenggat);
            } catch (DateTimeParseException e) {
                galat.add("tenggat '" + tenggat + "' harus ISO-8601, contoh 2026-01-20");
            }
        }
    }

    /** Nilai field sebagai String yang sudah dipangkas; null bila hilang atau bukan teks. */
    private static String teks(Map<String, Object> payload, String kunci) {
        if (payload.get(kunci) instanceof String s) {
            return s.strip();
        }
        return null;
    }
}