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);
}
}- Constructor memuat seluruh berkas sekali:
muat()membaca baris valid keLinkedHashMap;urutandiangkat ke id terbesar sehingga id baru tidak menabrak id lama setelah restart. tulisUlang()pada setiap perubahan: tambah, ubah, dan hapus selalu menulis ulang seluruh berkas UTF-8 — paling sederhana untuk puluhan tugas.parseBarisbersifat ketat: awalan tak dikenal, jumlah kolom salah, id bukan angka, tanggal rusak, dan status asing melemparIllegalStateExceptiondengan nomor baris — lebih baik berhenti daripada kehilangan data diam-diam.tolakPemisahberdiri di lapis penyimpanan, bukan di recordTugas, 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.
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:
=== 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"}- Baris keempat babak 1 memperagakan validasi pengumpul galat: payload tanpa
judulsekaligustenggatterbalik dijawab satu 400 berisi dua pesan. - Blok isi berkas memperlihatkan format barisnya langsung — status
SELESAIdariPUTikut tersimpan. - Babak 2,
GETmengembalikan kedua tugas, laluPOSTmenerimaid3 — penghitung dilanjutkan dari isi berkas. - Permintaan terakhir ditolak 400 karena judulnya memuat
;— berkas tetap terbaca ulang. klien2dibuat 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.
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 #
- Oracle: java.sql (Java SE 25 API)
- Oracle: PreparedStatement (Java SE 25 API)
- Oracle: Files (Java SE 25 API)
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
ApiException.java
Unduh berkas/**
* Exception unchecked pembawa status HTTP.
* Ditangkap di satu titik pada batas 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 berkasimport 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 berkasimport 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 berkasimport java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Penyimpanan dalam memori: LinkedHashMap menjaga urutan sisip sama dengan
* urutan id, 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 berkasimport com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.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 berkasimport com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.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/** 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 berkasimport 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 berkasimport 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/**
* 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/**
* 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 berkasimport 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;
}
}