API tugas kini utuh sebagai sistem, tetapi satu pintu masih terbuka: siapa pun yang menghubungi server boleh membuat, mengubah, bahkan menghapus tugas. Bagian 4 menutup pintu itu dengan token akses bertanda tangan HMAC yang diperiksa sebelum permintaan menyentuh router, lalu menutup proyek dengan harness UjiApi yang menguji seluruh endpoint dari luar. Seluruh berkas Bagian 3 hadir tanpa satu baris perubahan — autentikasi masuk sebagai lapisan baru di luar router.
Token Akses Bertanda Tangan HMAC #
HTTP menyediakan status khusus untuk permintaan yang identitasnya belum dibuktikan: 401 Unauthorized. Berbeda dari 400 dan 404 yang menyalahkan bentuk permintaan, 401 meminta kredensial lewat header Authorization, lazimnya skema Bearer diikuti token. Desain kita memilih token bertanda tangan: muatan nama:saldo dihitung HMAC-SHA256-nya dengan kunci rahasia server, lalu dirangkai menjadi nama:saldo:tandaTangan — bentuk yang menyerupai JWT tanpa pengodean base64url.
Solusi yang biasanya terlintas lebih dulu adalah token statis yang dibandingkan dengan equals. String.equals berhenti pada karakter pertama yang berbeda, sehingga lama pembandingan membocorkan panjang awalan yang benar — pintu masuk serangan waktu (timing attack). Token statis juga tidak membawa muatan: server tidak tahu siapa pemegangnya dan berapa kuotanya.
// RENTAN: equals berhenti pada karakter pertama yang berbeda, sehingga
// lama pembandingan membocorkan panjang awalan token yang benar.
boolean sah = tokenDiterima.equals(tokenRahasia);
// AMAN: MessageDigest.isEqual membandingkan seluruh byte dengan lama konstan.
boolean cocokKonstan = MessageDigest.isEqual(
tokenRahasia.getBytes(StandardCharsets.UTF_8),
tokenDiterima.getBytes(StandardCharsets.UTF_8));HMAC (Hash-based Message Authentication Code) menutup kedua celah itu. Server merahasiakan satu kunci; tanda tangan dihitung sebagai HmacSHA256(kunci, muatan) — 32 byte yang ditulis 64 karakter heksadesimal. Satu bit muatan berubah membuat tanda tangan berubah total, dan tanpa kunci penyerang tidak bisa menghitung tanda tangan untuk muatan palsu. Dua class berikut menerjemahkannya: TandaTangan menghitung dan memverifikasi, TokenAkses membentuk token.
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* Tanda tangan HMAC-SHA256 untuk token akses: menghitung dan memverifikasi
* tanda tangan atas muatan token dengan kunci rahasia yang hanya dipegang
* penerbit. Pembandingan memakai MessageDigest.isEqual sehingga lama
* verifikasi tidak bergantung pada isi tanda tangan yang diperiksa.
*/
public final class TandaTangan {
private TandaTangan() {
// Kelas utilitas: seluruh anggota static, tidak perlu instance.
}
private static final char[] HEX = "0123456789abcdef".toCharArray();
/**
* Menghitung HMAC-SHA256 atas data dengan kunci rahasia.
* Hasilnya teks heksadesimal kecil sepanjang 64 karakter (32 byte).
*/
public static String hmacHex(String kunciRahasia, String data) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
kunciRahasia.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return keHex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
} catch (GeneralSecurityException e) {
// HmacSHA256 wajib tersedia pada setiap implementasi JDK.
throw new IllegalStateException("mesin HMAC tidak tersedia", e);
}
}
/**
* Memverifikasi tanda tangan atas muatan. Pembandingan memakai
* MessageDigest.isEqual yang membandingkan seluruh byte tanpa berhenti
* di byte pertama yang berbeda, sehingga waktu pembandingan tidak
* membocorkan isi tanda tangan.
*/
public static boolean cocok(String kunciRahasia, String muatan, String tandaDiterima) {
String tandaDihitung = hmacHex(kunciRahasia, muatan);
return MessageDigest.isEqual(
tandaDihitung.getBytes(StandardCharsets.UTF_8),
tandaDiterima.getBytes(StandardCharsets.UTF_8));
}
/** Mengubah byte menjadi teks heksadesimal kecil, dua karakter per byte. */
private static String keHex(byte[] bytes) {
StringBuilder hasil = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
hasil.append(HEX[(b >> 4) & 0xf]).append(HEX[b & 0xf]);
}
return hasil.toString();
}
}/**
* Token akses bertanda tangan HMAC: muatan berisi nama pemegang dan saldo
* permintaan, keduanya tidak bisa diubah klien tanpa merusak tanda tangan.
* Bentuknya menyerupai JWT yang disederhanakan — muatan plus tanda tangan,
* tanpa bagian header dan tanpa pengodean base64url — sehingga strukturnya
* terbaca langsung.
*/
public record TokenAkses(String nama, int saldo) {
public TokenAkses {
if (nama == null || nama.strip().isEmpty()) {
throw new IllegalArgumentException("nama token tidak boleh kosong");
}
if (nama.contains(":")) {
throw new IllegalArgumentException("nama token tidak boleh mengandung ':'");
}
if (saldo < 0) {
throw new IllegalArgumentException("saldo token tidak boleh negatif");
}
nama = nama.strip();
}
/** Muatan yang ditandatangani: nama dan saldo awal dipisah titik dua. */
public String muatan() {
return nama + ":" + saldo;
}
/** Token lengkap siap dikirim klien: muatan plus tanda tangan HMAC. */
public String terbitkan(String kunciRahasia) {
return muatan() + ":" + TandaTangan.hmacHex(kunciRahasia, muatan());
}
}Bedah kedua class tersebut:
- Method
hmacHex:Mac.getInstancememinta mesin HMAC dari provider keamanan JDK,SecretKeySpecmembungkus kunci, dandoFinalmenghasilkan tanda tangan atas data UTF-8; exception checked yang mungkin terjadi ditangkap satucatch GeneralSecurityException. - Method
cocok: menghitung ulang tanda tangan atas muatan lalu membandingkannya lewatMessageDigest.isEqual— seluruh byte dibandingkan agar lama verifikasi tidak membocorkan isi tanda tangan. - Record
TokenAkses: compact constructor menolak nama kosong, nama yang memuat titik dua, dan saldo negatif; methodterbitkanmerangkai muatan dan tanda tangannya — hanya pemegang kunci bisa menerbitkan token sah.
Filter Autentikasi di Depan Router #
Autentikasi tidak menyentuh satu baris router. FilterAuth mengimplementasikan HttpHandler yang sama, membungkus RouterTugas, memeriksa setiap permintaan, lalu meneruskan hanya yang lolos lewat target.handle(exchange) — pola dekorator: pembungkus menambah perilaku tanpa mengubah yang dibungkus. Karena itu seluruh kode Bagian 3 hadir tanpa perubahan.
Pemeriksaan berlapis: header hilang, skema selain Bearer, token tanpa format, tanda tangan palsu, dan saldo habis — masing-masing dijawab 401 dengan pesan penyebabnya. Karena tanda tangan bebas titik dua, lastIndexOf(':') selalu menemukan pemisah antara muatan dan tanda tangan. Saldo dicatat per token: permintaan sah mengurangi satu, yang ditolak tidak — hanya klien terautentikasi yang membayar kuota.
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
/**
* Pintu autentikasi di depan router: memeriksa header Authorization sebelum
* permintaan diteruskan ke handler target. Token berformat
* "nama:saldo:tandaTangan" dengan tanda tangan HMAC-SHA256 atas muatan.
* Permintaan tanpa token, dengan token palsu, atau dari token yang saldonya
* habis dijawab 401 Unauthorized tanpa pernah menyentuh router.
*/
public class FilterAuth implements HttpHandler {
private final HttpHandler target;
private final String kunciRahasia;
/**
* Saldo tersisa per token. Handler bawaan HttpServer berjalan sekuensial
* pada satu thread dispatcher sehingga HashMap cukup; bila server
* dipasang executor, ganti ke ConcurrentHashMap.
*/
private final Map<String, Integer> sisaSaldo = new HashMap<>();
public FilterAuth(HttpHandler target, String kunciRahasia) {
this.target = target;
this.kunciRahasia = kunciRahasia;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
try {
verifikasi(exchange);
} catch (ApiException e) {
tolak(exchange, e.status(), e.getMessage());
return; // permintaan ditolak: router tidak dipanggil
}
target.handle(exchange); // router punya penanganan galat dan penutupan exchange sendiri
}
/**
* Memeriksa token akses pada header Authorization.
* Melempar ApiException 401 bila permintaan tidak sah.
*/
private void verifikasi(HttpExchange ex) {
String header = ex.getRequestHeaders().getFirst("Authorization");
if (header == null || header.isBlank()) {
throw new ApiException(401, "header Authorization wajib berisi token akses");
}
if (!header.startsWith("Bearer ")) {
throw new ApiException(401, "header Authorization harus berformat 'Bearer <token>',"
+ " diterima: '" + header + "'");
}
String token = header.substring("Bearer ".length()).strip();
// Tanda tangan heksadesimal tidak memuat ':', jadi titik dua terakhir
// pasti menjadi pemisah antara muatan dan tanda tangan.
int posisiTanda = token.lastIndexOf(':');
if (posisiTanda < 0) {
throw new ApiException(401, "token tidak berformat nama:saldo:tandaTangan");
}
String muatan = token.substring(0, posisiTanda);
String tanda = token.substring(posisiTanda + 1);
if (!TandaTangan.cocok(kunciRahasia, muatan, tanda)) {
throw new ApiException(401, "tanda tangan token tidak cocok");
}
String[] bagi = muatan.split(":", 2);
if (bagi.length != 2 || bagi[0].isBlank()) {
throw new ApiException(401, "muatan token tidak berformat nama:saldo");
}
int saldoAwal;
try {
saldoAwal = Integer.parseInt(bagi[1]);
} catch (NumberFormatException e) {
throw new ApiException(401, "saldo pada muatan token bukan angka");
}
// Hanya permintaan bertoken sah yang mengurangi saldo.
int sisa = sisaSaldo.getOrDefault(token, saldoAwal);
if (sisa <= 0) {
throw new ApiException(401, "saldo token '" + bagi[0] + "' habis");
}
sisaSaldo.put(token, sisa - 1);
}
/**
* Menulis respons penolakan: body JSON, header WWW-Authenticate yang
* diwajibkan RFC 7235 untuk setiap respons 401, lalu menutup exchange.
*/
private static void tolak(HttpExchange ex, int status, String pesan) throws IOException {
ex.getRequestBody().readAllBytes(); // buang body belum terbaca agar koneksi tetap sehat
byte[] isi = JsonMini.error(pesan).getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
ex.getResponseHeaders().set("WWW-Authenticate", "Bearer realm=\"api-tugas\"");
ex.sendResponseHeaders(status, isi.length);
try (OutputStream os = ex.getResponseBody()) {
os.write(isi);
} finally {
ex.close();
}
}
}Bedah FilterAuth:
- Method
handle: kegagalanverifikasidijawabtolaklalureturn— router tidak pernah melihat permintaan yang ditolak; permintaan sah diteruskan, dan router menutup exchange miliknya sendiri. - Method
verifikasi:getFirst("Authorization")membaca header pertama (nama header HTTP tidak peka huruf besar-kecil),substringmengupas skema, lalu token dipecah pada titik dua terakhir; saldo dicatat per token dalamHashMap— ganti keConcurrentHashMapbila executor dipasang. - Method
tolak: membuang body yang belum terbaca agar koneksi tetap sehat, lalu menulis body JSON plus headerWWW-Authenticateyang diwajibkan RFC 7235 pada respons 401.
Harness Pengujian End-to-End #
API yang diamankan harus diuji ulang dari luar. UjiApi menaikkan server sungguhan — FilterAuth membungkus RouterTugas di atas RepositoryBerkas — lalu memukul endpoint lewat HttpClient dalam proses yang sama, pola in-process Bagian 2. Dua puluh dua pemeriksaan terbagi empat babak: penolakan autentikasi, CRUD dan validasi, saldo token, serta persistensi lintas restart. Babak 1 mencetak body setiap respons 401 supaya amplop {"error":...} terlihat sebagai teks.
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;
/**
* Harness pengujian end-to-end seluruh API tugas: server dengan filter
* autentikasi dan klien HttpClient berbagi satu proses yang sama. Dua puluh
* dua pemeriksaan menutup autentikasi, CRUD, validasi, saldo token, dan
* persistensi berkas. Exit code 1 bila ada kegagalan.
*/
public class UjiApi {
/**
* Kunci penandatanganan untuk pengujian. Di produksi, kunci dibaca dari
* variabel lingkungan (misalnya System.getenv("API_KEY")) dan tidak
* pernah ditulis di dalam kode sumber.
*/
private static final String KUNCI = "kunci-uji-2026";
private static int lulus = 0;
private static int gagal = 0;
public static void main(String[] args) throws Exception {
Path berkas = Path.of("uji-tugas.txt");
Files.deleteIfExists(berkas); // mulai bersih agar uji dapat diulang
HttpClient klien = HttpClient.newHttpClient();
String tokenBudi = new TokenAkses("budi", 20).terbitkan(KUNCI);
String tokenSiti = new TokenAkses("siti", 20).terbitkan(KUNCI);
System.out.println("== Babak 1: permintaan tanpa token sah ==");
HttpServer server = buatServer(berkas);
try {
String dasar = dasar(server);
ujiTanpaToken(klien, dasar);
ujiSkemaSalah(klien, dasar);
ujiTokenPalsu(klien, dasar);
ujiMuatanDiganti(klien, dasar, tokenBudi);
ujiKunciLain(klien, dasar);
System.out.println();
System.out.println("== Babak 2: CRUD dan validasi dengan token sah ==");
ujiBuatBaca(klien, dasar, tokenBudi);
ujiUbahHapus(klien, dasar, tokenBudi);
ujiValidasi(klien, dasar, tokenBudi);
System.out.println();
System.out.println("== Babak 3: saldo token habis ==");
ujiSaldo(klien, dasar);
} finally {
server.stop(0);
}
System.out.println();
System.out.println("== Babak 4: server baru memuat ulang berkas ==");
HttpServer serverBaru = buatServer(berkas);
try {
ujiPersistensi(klien, dasar(serverBaru), tokenSiti);
} finally {
serverBaru.stop(0);
}
System.out.println();
System.out.println("Ringkasan: " + lulus + " lulus, " + gagal + " gagal");
if (gagal > 0) {
System.exit(1);
}
}
/** Server uji: router tugas dibungkus filter autentikasi, port dipilih sistem operasi. */
private static HttpServer buatServer(Path berkas) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", new FilterAuth(
new RouterTugas(new RepositoryBerkas(berkas)), KUNCI));
server.start();
return server;
}
/** Alamat dasar server; nomor port tidak dicetak agar keluaran deterministik. */
private static String dasar(HttpServer server) {
return "http://127.0.0.1:" + server.getAddress().getPort();
}
/** Satu pernyataan uji: catat PASS bila kondisi benar, FAIL bila salah. */
private static void cek(String nama, boolean kondisi) {
if (kondisi) {
lulus++;
System.out.println("PASS: " + nama);
} else {
gagal++;
System.out.println("FAIL: " + nama);
}
}
/**
* Mengirim satu permintaan; nilai authorization null berarti tanpa header
* Authorization sama sekali.
*/
private static HttpResponse<String> minta(HttpClient klien, String dasar, String authorization,
String metode, String path, String body) throws IOException, InterruptedException {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(dasar + path))
.timeout(Duration.ofSeconds(5));
if (authorization != null) {
b.header("Authorization", authorization);
}
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));
}
return klien.send(b.build(), HttpResponse.BodyHandlers.ofString());
}
/** Mencetak status dan body respons secara ringkas, tanpa nomor port. */
private static void lihat(String keterangan, HttpResponse<String> r) {
System.out.printf(Locale.US, " %-24s -> %d %s%n",
keterangan, r.statusCode(), r.body());
}
private static void ujiTanpaToken(HttpClient klien, String dasar) throws Exception {
HttpResponse<String> r = minta(klien, dasar, null, "GET", "/api/tugas", null);
lihat("tanpa Authorization", r);
cek("permintaan tanpa token dijawab 401",
r.statusCode() == 401 && r.body().contains("Authorization"));
cek("respons 401 menyertakan header WWW-Authenticate",
r.headers().firstValue("WWW-Authenticate").isPresent());
}
private static void ujiSkemaSalah(HttpClient klien, String dasar) throws Exception {
HttpResponse<String> r = minta(klien, dasar, "Basic teks-asli", "GET", "/api/tugas", null);
lihat("skema Basic", r);
cek("skema selain Bearer dijawab 401",
r.statusCode() == 401 && r.body().contains("Bearer"));
}
private static void ujiTokenPalsu(HttpClient klien, String dasar) throws Exception {
HttpResponse<String> r = minta(klien, dasar, "Bearer bukan-token", "GET", "/api/tugas", null);
lihat("token tanpa tanda tangan", r);
cek("token tanpa tanda tangan dijawab 401",
r.statusCode() == 401 && r.body().contains("format"));
}
private static void ujiMuatanDiganti(HttpClient klien, String dasar, String tokenSah)
throws Exception {
// Muatan saldo diganti diam-diam menjadi 99; tanda tangan asli dipertahankan.
String dipalsukan = "budi:99:" + tokenSah.substring(tokenSah.lastIndexOf(':') + 1);
HttpResponse<String> r = minta(klien, dasar, "Bearer " + dipalsukan, "GET", "/api/tugas", null);
lihat("muatan diganti", r);
cek("muatan yang diganti dijawab 401",
r.statusCode() == 401 && r.body().contains("tanda tangan"));
}
private static void ujiKunciLain(HttpClient klien, String dasar) throws Exception {
String tokenLain = new TokenAkses("budi", 20).terbitkan("kunci-yang-salah");
HttpResponse<String> r = minta(klien, dasar, "Bearer " + tokenLain, "GET", "/api/tugas", null);
lihat("kunci penandatangan lain", r);
cek("token tertanda kunci lain dijawab 401",
r.statusCode() == 401 && r.body().contains("tanda tangan"));
}
private static void ujiBuatBaca(HttpClient klien, String dasar, String token) throws Exception {
String otorisasi = "Bearer " + token;
HttpResponse<String> buat = minta(klien, dasar, otorisasi, "POST", "/api/tugas",
"{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\","
+ "\"status\":\"SELESAI\",\"tenggat\":\"2026-01-20\"}");
cek("POST dengan token sah menghasilkan 201 dan id 1",
buat.statusCode() == 201 && buat.body().contains("\"id\":1"));
cek("status SELESAI dari klien diabaikan, server menetapkan TODO",
buat.body().contains("\"status\":\"TODO\""));
HttpResponse<String> detail = minta(klien, dasar, otorisasi, "GET", "/api/tugas/1", null);
cek("GET detail mengembalikan tugas yang tersimpan",
detail.statusCode() == 200 && detail.body().contains("Belajar HttpServer"));
}
private static void ujiUbahHapus(HttpClient klien, String dasar, String token) throws Exception {
String otorisasi = "Bearer " + token;
HttpResponse<String> ubah = minta(klien, dasar, otorisasi, "PUT", "/api/tugas/1",
"{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\","
+ "\"status\":\"SELESAI\",\"tenggat\":\"2026-01-20\"}");
cek("PUT mengubah status menjadi SELESAI",
ubah.statusCode() == 200 && ubah.body().contains("\"status\":\"SELESAI\""));
HttpResponse<String> hapus = minta(klien, dasar, otorisasi, "DELETE", "/api/tugas/1", null);
cek("DELETE menghapus tugas dan menjawab 200",
hapus.statusCode() == 200 && hapus.body().contains("dihapus"));
HttpResponse<String> hilang = minta(klien, dasar, otorisasi, "GET", "/api/tugas/1", null);
cek("tugas yang terhapus dijawab 404",
hilang.statusCode() == 404 && hilang.body().contains("tidak ditemukan"));
// Dua tugas baru untuk babak persistensi; id tidak mengulang id yang dihapus.
minta(klien, dasar, otorisasi, "POST", "/api/tugas",
"{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\",\"tenggat\":\"2026-01-20\"}");
HttpResponse<String> siti = minta(klien, dasar, otorisasi, "POST", "/api/tugas",
"{\"judul\":\"Tulis dokumentasi API\",\"pemilik\":\"Siti\",\"tenggat\":\"2026-02-01\"}");
cek("id baru berlanjut tanpa mengulang id terhapus",
siti.statusCode() == 201 && siti.body().contains("\"id\":3"));
HttpResponse<String> daftar = minta(klien, dasar, otorisasi, "GET", "/api/tugas", null);
cek("daftar berisi dua tugas tersisa",
daftar.statusCode() == 200 && daftar.body().split("\"id\":", -1).length == 3);
}
private static void ujiValidasi(HttpClient klien, String dasar, String token) throws Exception {
String otorisasi = "Bearer " + token;
HttpResponse<String> tanpaPemilik = minta(klien, dasar, otorisasi, "POST", "/api/tugas",
"{\"judul\":\"Tugas tanpa pemilik\",\"tenggat\":\"2026-01-22\"}");
cek("payload tanpa pemilik dijawab 400",
tanpaPemilik.statusCode() == 400
&& tanpaPemilik.body().contains("pemilik wajib diisi"));
HttpResponse<String> rusak = minta(klien, dasar, otorisasi, "POST", "/api/tugas",
"{\"judul\":");
cek("JSON rusak dijawab 400",
rusak.statusCode() == 400 && rusak.body().contains("JSON tidak valid"));
HttpResponse<String> tambal = minta(klien, dasar, otorisasi, "PATCH", "/api/tugas/2", null);
cek("PATCH dijawab 405",
tambal.statusCode() == 405 && tambal.body().contains("PATCH"));
HttpResponse<String> asing = minta(klien, dasar, otorisasi, "GET", "/api/pengguna", null);
cek("endpoint tidak dikenal dijawab 404",
asing.statusCode() == 404 && asing.body().contains("endpoint"));
}
private static void ujiSaldo(HttpClient klien, String dasar) throws Exception {
String tokenKuota = new TokenAkses("kuota", 2).terbitkan(KUNCI);
HttpResponse<String> pertama = minta(klien, dasar, "Bearer " + tokenKuota,
"GET", "/api/tugas", null);
HttpResponse<String> kedua = minta(klien, dasar, "Bearer " + tokenKuota,
"GET", "/api/tugas/2", null);
cek("token bersaldo 2 melayani dua permintaan pertama",
pertama.statusCode() == 200 && kedua.statusCode() == 200);
HttpResponse<String> ketiga = minta(klien, dasar, "Bearer " + tokenKuota,
"GET", "/api/tugas", null);
lihat("permintaan ketiga", ketiga);
cek("permintaan ketiga dijawab 401 karena saldo habis",
ketiga.statusCode() == 401 && ketiga.body().contains("saldo"));
}
private static void ujiPersistensi(HttpClient klien, String dasar, String token) throws Exception {
String otorisasi = "Bearer " + token;
HttpResponse<String> daftar = minta(klien, dasar, otorisasi, "GET", "/api/tugas", null);
cek("tugas bertahan melewati restart server",
daftar.statusCode() == 200 && daftar.body().contains("Tulis dokumentasi API"));
HttpResponse<String> lanjutan = minta(klien, dasar, otorisasi, "POST", "/api/tugas",
"{\"judul\":\"Rapat tim mingguan\",\"pemilik\":\"Dewi\",\"tenggat\":\"2026-02-10\"}");
cek("id berlanjut dari isi berkas setelah restart",
lanjutan.statusCode() == 201 && lanjutan.body().contains("\"id\":4"));
}
}Output yang Dihasilkan:
== Babak 1: permintaan tanpa token sah ==
tanpa Authorization -> 401 {"error":"header Authorization wajib berisi token akses"}
PASS: permintaan tanpa token dijawab 401
PASS: respons 401 menyertakan header WWW-Authenticate
skema Basic -> 401 {"error":"header Authorization harus berformat 'Bearer <token>', diterima: 'Basic teks-asli'"}
PASS: skema selain Bearer dijawab 401
token tanpa tanda tangan -> 401 {"error":"token tidak berformat nama:saldo:tandaTangan"}
PASS: token tanpa tanda tangan dijawab 401
muatan diganti -> 401 {"error":"tanda tangan token tidak cocok"}
PASS: muatan yang diganti dijawab 401
kunci penandatangan lain -> 401 {"error":"tanda tangan token tidak cocok"}
PASS: token tertanda kunci lain dijawab 401
== Babak 2: CRUD dan validasi dengan token sah ==
PASS: POST dengan token sah menghasilkan 201 dan id 1
PASS: status SELESAI dari klien diabaikan, server menetapkan TODO
PASS: GET detail mengembalikan tugas yang tersimpan
PASS: PUT mengubah status menjadi SELESAI
PASS: DELETE menghapus tugas dan menjawab 200
PASS: tugas yang terhapus dijawab 404
PASS: id baru berlanjut tanpa mengulang id terhapus
PASS: daftar berisi dua tugas tersisa
PASS: payload tanpa pemilik dijawab 400
PASS: JSON rusak dijawab 400
PASS: PATCH dijawab 405
PASS: endpoint tidak dikenal dijawab 404
== Babak 3: saldo token habis ==
PASS: token bersaldo 2 melayani dua permintaan pertama
permintaan ketiga -> 401 {"error":"saldo token 'kuota' habis"}
PASS: permintaan ketiga dijawab 401 karena saldo habis
== Babak 4: server baru memuat ulang berkas ==
PASS: tugas bertahan melewati restart server
PASS: id berlanjut dari isi berkas setelah restart
Ringkasan: 22 lulus, 0 gagalKelompok pemeriksaan dan apa yang dibuktikannya:
- Method
buatServer: satu tempat perakitan; babak persistensi memanggil ulang method ini atas berkas yang sama setelah server pertama berhenti. - Method
minta:authorizationbernilainullberarti tanpa header — kasus uji tersendiri; sisanya meniru klien sungguhan. - Method
ujiMuatanDiganti: tanda tangan sah dipertahankan, muatan diganti menjadibudi:99— pemalsuan yang wajib ditolak HMAC. - Method
ujiSaldo: token bersaldo 2 melayani dua permintaan; permintaan ketiga ditolak 401 walau tandanya sah.
Padanan JUnit 5 #
Dua puluh dua cek di atas adalah apa yang ditulis JUnit sebagai method @Test berisi assertEquals dan assertTrue. JUnit bukan bagian JDK — ia ditambahkan sebagai dependensi build — sehingga blok berikut tampil sebagai rancangan tanpa klaim eksekusi; panduan resminya ada di daftar Referensi:
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 org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FilterAuthTest {
private HttpServer server;
private HttpClient klien;
private String dasar;
@BeforeEach
void naikkanServer() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", new FilterAuth(
new RouterTugas(new RepositoryMemori()), "kunci-uji-2026"));
server.start();
dasar = "http://127.0.0.1:" + server.getAddress().getPort();
klien = HttpClient.newHttpClient();
}
@AfterEach
void matikanServer() {
server.stop(0);
}
@Test
void permintaanTanpaTokenDijawab401() throws Exception {
HttpResponse<String> r = klien.send(
HttpRequest.newBuilder(URI.create(dasar + "/api/tugas")).build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(401, r.statusCode());
assertTrue(r.headers().firstValue("WWW-Authenticate").isPresent());
}
@Test
void muatanYangDigantiDitolak() throws Exception {
String sah = new TokenAkses("budi", 20).terbitkan("kunci-uji-2026");
String dipalsukan = "budi:99:" + sah.substring(sah.lastIndexOf(':') + 1);
HttpResponse<String> r = klien.send(
HttpRequest.newBuilder(URI.create(dasar + "/api/tugas"))
.header("Authorization", "Bearer " + dipalsukan).build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(401, r.statusCode());
assertTrue(r.body().contains("tanda tangan"));
}
}Terjemahannya hampir satu-ke-satu: @BeforeEach menggantikan perakitan server di main, satu cek menjadi satu assert*, dan pelari uji menggantikan ringkasan PASS/FAIL dengan laporan per method.
Proyek REST API manajemen tugas tuntas dari kontrak sampai pembuktian — desain API, router dan handler, penyimpanan serta validasi, hingga gerbang autentikasi plus harness — semuanya murni JDK. Setiap lapisan bisa diganti tanpa menyentuh lapisan lain; itulah bekal untuk proyek berikutnya. Di aplikasi-inventaris, pola yang sama tumbuh di domain baru: stok barang dan jurnal mutasi dengan aturan bisnis yang lebih tebal, transaksi JDBC, hingga ekspor laporan CSV.
Referensi #
- Oracle: Class Mac (Java SE 25 API)
- Oracle: Class MessageDigest (Java SE 25 API)
- JUnit 5 — Dokumentasi dan Panduan Pengguna
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;
}
}
FilterAuth.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.util.HashMap;
import java.util.Map;
/**
* Pintu autentikasi di depan router: memeriksa header Authorization sebelum
* permintaan diteruskan ke handler target. Token berformat
* "nama:saldo:tandaTangan" dengan tanda tangan HMAC-SHA256 atas muatan.
* Permintaan tanpa token, dengan token palsu, atau dari token yang saldonya
* habis dijawab 401 Unauthorized tanpa pernah menyentuh router.
*/
public class FilterAuth implements HttpHandler {
private final HttpHandler target;
private final String kunciRahasia;
/**
* Saldo tersisa per token. Handler bawaan HttpServer berjalan sekuensial
* pada satu thread dispatcher sehingga HashMap cukup; bila server
* dipasang executor, ganti ke ConcurrentHashMap.
*/
private final Map<String, Integer> sisaSaldo = new HashMap<>();
public FilterAuth(HttpHandler target, String kunciRahasia) {
this.target = target;
this.kunciRahasia = kunciRahasia;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
try {
verifikasi(exchange);
} catch (ApiException e) {
tolak(exchange, e.status(), e.getMessage());
return; // permintaan ditolak: router tidak dipanggil
}
target.handle(exchange); // router punya penanganan galat dan penutupan exchange sendiri
}
/**
* Memeriksa token akses pada header Authorization.
* Melempar ApiException 401 bila permintaan tidak sah.
*/
private void verifikasi(HttpExchange ex) {
String header = ex.getRequestHeaders().getFirst("Authorization");
if (header == null || header.isBlank()) {
throw new ApiException(401, "header Authorization wajib berisi token akses");
}
if (!header.startsWith("Bearer ")) {
throw new ApiException(401, "header Authorization harus berformat 'Bearer <token>',"
+ " diterima: '" + header + "'");
}
String token = header.substring("Bearer ".length()).strip();
// Tanda tangan heksadesimal tidak memuat ':', jadi titik dua terakhir
// pasti menjadi pemisah antara muatan dan tanda tangan.
int posisiTanda = token.lastIndexOf(':');
if (posisiTanda < 0) {
throw new ApiException(401, "token tidak berformat nama:saldo:tandaTangan");
}
String muatan = token.substring(0, posisiTanda);
String tanda = token.substring(posisiTanda + 1);
if (!TandaTangan.cocok(kunciRahasia, muatan, tanda)) {
throw new ApiException(401, "tanda tangan token tidak cocok");
}
String[] bagi = muatan.split(":", 2);
if (bagi.length != 2 || bagi[0].isBlank()) {
throw new ApiException(401, "muatan token tidak berformat nama:saldo");
}
int saldoAwal;
try {
saldoAwal = Integer.parseInt(bagi[1]);
} catch (NumberFormatException e) {
throw new ApiException(401, "saldo pada muatan token bukan angka");
}
// Hanya permintaan bertoken sah yang mengurangi saldo.
int sisa = sisaSaldo.getOrDefault(token, saldoAwal);
if (sisa <= 0) {
throw new ApiException(401, "saldo token '" + bagi[0] + "' habis");
}
sisaSaldo.put(token, sisa - 1);
}
/**
* Menulis respons penolakan: body JSON, header WWW-Authenticate yang
* diwajibkan RFC 7235 untuk setiap respons 401, lalu menutup exchange.
*/
private static void tolak(HttpExchange ex, int status, String pesan) throws IOException {
ex.getRequestBody().readAllBytes(); // buang body belum terbaca agar koneksi tetap sehat
byte[] isi = JsonMini.error(pesan).getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
ex.getResponseHeaders().set("WWW-Authenticate", "Bearer realm=\"api-tugas\"");
ex.sendResponseHeaders(status, isi.length);
try (OutputStream os = ex.getResponseBody()) {
os.write(isi);
} finally {
ex.close();
}
}
}
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);
}
}
}
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");
}
}
TandaTangan.java
Unduh berkasimport java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* Tanda tangan HMAC-SHA256 untuk token akses: menghitung dan memverifikasi
* tanda tangan atas muatan token dengan kunci rahasia yang hanya dipegang
* penerbit. Pembandingan memakai MessageDigest.isEqual sehingga lama
* verifikasi tidak bergantung pada isi tanda tangan yang diperiksa.
*/
public final class TandaTangan {
private TandaTangan() {
// Kelas utilitas: seluruh anggota static, tidak perlu instance.
}
private static final char[] HEX = "0123456789abcdef".toCharArray();
/**
* Menghitung HMAC-SHA256 atas data dengan kunci rahasia.
* Hasilnya teks heksadesimal kecil sepanjang 64 karakter (32 byte).
*/
public static String hmacHex(String kunciRahasia, String data) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
kunciRahasia.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return keHex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
} catch (GeneralSecurityException e) {
// HmacSHA256 wajib tersedia pada setiap implementasi JDK.
throw new IllegalStateException("mesin HMAC tidak tersedia", e);
}
}
/**
* Memverifikasi tanda tangan atas muatan. Pembandingan memakai
* MessageDigest.isEqual yang membandingkan seluruh byte tanpa berhenti
* di byte pertama yang berbeda, sehingga waktu pembandingan tidak
* membocorkan isi tanda tangan.
*/
public static boolean cocok(String kunciRahasia, String muatan, String tandaDiterima) {
String tandaDihitung = hmacHex(kunciRahasia, muatan);
return MessageDigest.isEqual(
tandaDihitung.getBytes(StandardCharsets.UTF_8),
tandaDiterima.getBytes(StandardCharsets.UTF_8));
}
/** Mengubah byte menjadi teks heksadesimal kecil, dua karakter per byte. */
private static String keHex(byte[] bytes) {
StringBuilder hasil = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
hasil.append(HEX[(b >> 4) & 0xf]).append(HEX[b & 0xf]);
}
return hasil.toString();
}
}
TokenAkses.java
Unduh berkas/**
* Token akses bertanda tangan HMAC: muatan berisi nama pemegang dan saldo
* permintaan, keduanya tidak bisa diubah klien tanpa merusak tanda tangan.
* Bentuknya menyerupai JWT yang disederhanakan — muatan plus tanda tangan,
* tanpa bagian header dan tanpa pengodean base64url — sehingga strukturnya
* terbaca langsung.
*/
public record TokenAkses(String nama, int saldo) {
public TokenAkses {
if (nama == null || nama.strip().isEmpty()) {
throw new IllegalArgumentException("nama token tidak boleh kosong");
}
if (nama.contains(":")) {
throw new IllegalArgumentException("nama token tidak boleh mengandung ':'");
}
if (saldo < 0) {
throw new IllegalArgumentException("saldo token tidak boleh negatif");
}
nama = nama.strip();
}
/** Muatan yang ditandatangani: nama dan saldo awal dipisah titik dua. */
public String muatan() {
return nama + ":" + saldo;
}
/** Token lengkap siap dikirim klien: muatan plus tanda tangan HMAC. */
public String terbitkan(String kunciRahasia) {
return muatan() + ":" + TandaTangan.hmacHex(kunciRahasia, muatan());
}
}
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");
}
}
UjiApi.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;
/**
* Harness pengujian end-to-end seluruh API tugas: server dengan filter
* autentikasi dan klien HttpClient berbagi satu proses yang sama. Dua puluh
* dua pemeriksaan menutup autentikasi, CRUD, validasi, saldo token, dan
* persistensi berkas. Exit code 1 bila ada kegagalan.
*/
public class UjiApi {
/**
* Kunci penandatanganan untuk pengujian. Di produksi, kunci dibaca dari
* variabel lingkungan (misalnya System.getenv("API_KEY")) dan tidak
* pernah ditulis di dalam kode sumber.
*/
private static final String KUNCI = "kunci-uji-2026";
private static int lulus = 0;
private static int gagal = 0;
public static void main(String[] args) throws Exception {
Path berkas = Path.of("uji-tugas.txt");
Files.deleteIfExists(berkas); // mulai bersih agar uji dapat diulang
HttpClient klien = HttpClient.newHttpClient();
String tokenBudi = new TokenAkses("budi", 20).terbitkan(KUNCI);
String tokenSiti = new TokenAkses("siti", 20).terbitkan(KUNCI);
System.out.println("== Babak 1: permintaan tanpa token sah ==");
HttpServer server = buatServer(berkas);
try {
String dasar = dasar(server);
ujiTanpaToken(klien, dasar);
ujiSkemaSalah(klien, dasar);
ujiTokenPalsu(klien, dasar);
ujiMuatanDiganti(klien, dasar, tokenBudi);
ujiKunciLain(klien, dasar);
System.out.println();
System.out.println("== Babak 2: CRUD dan validasi dengan token sah ==");
ujiBuatBaca(klien, dasar, tokenBudi);
ujiUbahHapus(klien, dasar, tokenBudi);
ujiValidasi(klien, dasar, tokenBudi);
System.out.println();
System.out.println("== Babak 3: saldo token habis ==");
ujiSaldo(klien, dasar);
} finally {
server.stop(0);
}
System.out.println();
System.out.println("== Babak 4: server baru memuat ulang berkas ==");
HttpServer serverBaru = buatServer(berkas);
try {
ujiPersistensi(klien, dasar(serverBaru), tokenSiti);
} finally {
serverBaru.stop(0);
}
System.out.println();
System.out.println("Ringkasan: " + lulus + " lulus, " + gagal + " gagal");
if (gagal > 0) {
System.exit(1);
}
}
/** Server uji: router tugas dibungkus filter autentikasi, port dipilih sistem operasi. */
private static HttpServer buatServer(Path berkas) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", new FilterAuth(
new RouterTugas(new RepositoryBerkas(berkas)), KUNCI));
server.start();
return server;
}
/** Alamat dasar server; nomor port tidak dicetak agar keluaran deterministik. */
private static String dasar(HttpServer server) {
return "http://127.0.0.1:" + server.getAddress().getPort();
}
/** Satu pernyataan uji: catat PASS bila kondisi benar, FAIL bila salah. */
private static void cek(String nama, boolean kondisi) {
if (kondisi) {
lulus++;
System.out.println("PASS: " + nama);
} else {
gagal++;
System.out.println("FAIL: " + nama);
}
}
/**
* Mengirim satu permintaan; nilai authorization null berarti tanpa header
* Authorization sama sekali.
*/
private static HttpResponse<String> minta(HttpClient klien, String dasar, String authorization,
String metode, String path, String body) throws IOException, InterruptedException {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(dasar + path))
.timeout(Duration.ofSeconds(5));
if (authorization != null) {
b.header("Authorization", authorization);
}
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));
}
return klien.send(b.build(), HttpResponse.BodyHandlers.ofString());
}
/** Mencetak status dan body respons secara ringkas, tanpa nomor port. */
private static void lihat(String keterangan, HttpResponse<String> r) {
System.out.printf(Locale.US, " %-24s -> %d %s%n",
keterangan, r.statusCode(), r.body());
}
private static void ujiTanpaToken(HttpClient klien, String dasar) throws Exception {
HttpResponse<String> r = minta(klien, dasar, null, "GET", "/api/tugas", null);
lihat("tanpa Authorization", r);
cek("permintaan tanpa token dijawab 401",
r.statusCode() == 401 && r.body().contains("Authorization"));
cek("respons 401 menyertakan header WWW-Authenticate",
r.headers().firstValue("WWW-Authenticate").isPresent());
}
private static void ujiSkemaSalah(HttpClient klien, String dasar) throws Exception {
HttpResponse<String> r = minta(klien, dasar, "Basic teks-asli", "GET", "/api/tugas", null);
lihat("skema Basic", r);
cek("skema selain Bearer dijawab 401",
r.statusCode() == 401 && r.body().contains("Bearer"));
}
private static void ujiTokenPalsu(HttpClient klien, String dasar) throws Exception {
HttpResponse<String> r = minta(klien, dasar, "Bearer bukan-token", "GET", "/api/tugas", null);
lihat("token tanpa tanda tangan", r);
cek("token tanpa tanda tangan dijawab 401",
r.statusCode() == 401 && r.body().contains("format"));
}
private static void ujiMuatanDiganti(HttpClient klien, String dasar, String tokenSah)
throws Exception {
// Muatan saldo diganti diam-diam menjadi 99; tanda tangan asli dipertahankan.
String dipalsukan = "budi:99:" + tokenSah.substring(tokenSah.lastIndexOf(':') + 1);
HttpResponse<String> r = minta(klien, dasar, "Bearer " + dipalsukan, "GET", "/api/tugas", null);
lihat("muatan diganti", r);
cek("muatan yang diganti dijawab 401",
r.statusCode() == 401 && r.body().contains("tanda tangan"));
}
private static void ujiKunciLain(HttpClient klien, String dasar) throws Exception {
String tokenLain = new TokenAkses("budi", 20).terbitkan("kunci-yang-salah");
HttpResponse<String> r = minta(klien, dasar, "Bearer " + tokenLain, "GET", "/api/tugas", null);
lihat("kunci penandatangan lain", r);
cek("token tertanda kunci lain dijawab 401",
r.statusCode() == 401 && r.body().contains("tanda tangan"));
}
private static void ujiBuatBaca(HttpClient klien, String dasar, String token) throws Exception {
String otorisasi = "Bearer " + token;
HttpResponse<String> buat = minta(klien, dasar, otorisasi, "POST", "/api/tugas",
"{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\","
+ "\"status\":\"SELESAI\",\"tenggat\":\"2026-01-20\"}");
cek("POST dengan token sah menghasilkan 201 dan id 1",
buat.statusCode() == 201 && buat.body().contains("\"id\":1"));
cek("status SELESAI dari klien diabaikan, server menetapkan TODO",
buat.body().contains("\"status\":\"TODO\""));
HttpResponse<String> detail = minta(klien, dasar, otorisasi, "GET", "/api/tugas/1", null);
cek("GET detail mengembalikan tugas yang tersimpan",
detail.statusCode() == 200 && detail.body().contains("Belajar HttpServer"));
}
private static void ujiUbahHapus(HttpClient klien, String dasar, String token) throws Exception {
String otorisasi = "Bearer " + token;
HttpResponse<String> ubah = minta(klien, dasar, otorisasi, "PUT", "/api/tugas/1",
"{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\","
+ "\"status\":\"SELESAI\",\"tenggat\":\"2026-01-20\"}");
cek("PUT mengubah status menjadi SELESAI",
ubah.statusCode() == 200 && ubah.body().contains("\"status\":\"SELESAI\""));
HttpResponse<String> hapus = minta(klien, dasar, otorisasi, "DELETE", "/api/tugas/1", null);
cek("DELETE menghapus tugas dan menjawab 200",
hapus.statusCode() == 200 && hapus.body().contains("dihapus"));
HttpResponse<String> hilang = minta(klien, dasar, otorisasi, "GET", "/api/tugas/1", null);
cek("tugas yang terhapus dijawab 404",
hilang.statusCode() == 404 && hilang.body().contains("tidak ditemukan"));
// Dua tugas baru untuk babak persistensi; id tidak mengulang id yang dihapus.
minta(klien, dasar, otorisasi, "POST", "/api/tugas",
"{\"judul\":\"Belajar HttpServer\",\"pemilik\":\"Budi\",\"tenggat\":\"2026-01-20\"}");
HttpResponse<String> siti = minta(klien, dasar, otorisasi, "POST", "/api/tugas",
"{\"judul\":\"Tulis dokumentasi API\",\"pemilik\":\"Siti\",\"tenggat\":\"2026-02-01\"}");
cek("id baru berlanjut tanpa mengulang id terhapus",
siti.statusCode() == 201 && siti.body().contains("\"id\":3"));
HttpResponse<String> daftar = minta(klien, dasar, otorisasi, "GET", "/api/tugas", null);
cek("daftar berisi dua tugas tersisa",
daftar.statusCode() == 200 && daftar.body().split("\"id\":", -1).length == 3);
}
private static void ujiValidasi(HttpClient klien, String dasar, String token) throws Exception {
String otorisasi = "Bearer " + token;
HttpResponse<String> tanpaPemilik = minta(klien, dasar, otorisasi, "POST", "/api/tugas",
"{\"judul\":\"Tugas tanpa pemilik\",\"tenggat\":\"2026-01-22\"}");
cek("payload tanpa pemilik dijawab 400",
tanpaPemilik.statusCode() == 400
&& tanpaPemilik.body().contains("pemilik wajib diisi"));
HttpResponse<String> rusak = minta(klien, dasar, otorisasi, "POST", "/api/tugas",
"{\"judul\":");
cek("JSON rusak dijawab 400",
rusak.statusCode() == 400 && rusak.body().contains("JSON tidak valid"));
HttpResponse<String> tambal = minta(klien, dasar, otorisasi, "PATCH", "/api/tugas/2", null);
cek("PATCH dijawab 405",
tambal.statusCode() == 405 && tambal.body().contains("PATCH"));
HttpResponse<String> asing = minta(klien, dasar, otorisasi, "GET", "/api/pengguna", null);
cek("endpoint tidak dikenal dijawab 404",
asing.statusCode() == 404 && asing.body().contains("endpoint"));
}
private static void ujiSaldo(HttpClient klien, String dasar) throws Exception {
String tokenKuota = new TokenAkses("kuota", 2).terbitkan(KUNCI);
HttpResponse<String> pertama = minta(klien, dasar, "Bearer " + tokenKuota,
"GET", "/api/tugas", null);
HttpResponse<String> kedua = minta(klien, dasar, "Bearer " + tokenKuota,
"GET", "/api/tugas/2", null);
cek("token bersaldo 2 melayani dua permintaan pertama",
pertama.statusCode() == 200 && kedua.statusCode() == 200);
HttpResponse<String> ketiga = minta(klien, dasar, "Bearer " + tokenKuota,
"GET", "/api/tugas", null);
lihat("permintaan ketiga", ketiga);
cek("permintaan ketiga dijawab 401 karena saldo habis",
ketiga.statusCode() == 401 && ketiga.body().contains("saldo"));
}
private static void ujiPersistensi(HttpClient klien, String dasar, String token) throws Exception {
String otorisasi = "Bearer " + token;
HttpResponse<String> daftar = minta(klien, dasar, otorisasi, "GET", "/api/tugas", null);
cek("tugas bertahan melewati restart server",
daftar.statusCode() == 200 && daftar.body().contains("Tulis dokumentasi API"));
HttpResponse<String> lanjutan = minta(klien, dasar, otorisasi, "POST", "/api/tugas",
"{\"judul\":\"Rapat tim mingguan\",\"pemilik\":\"Dewi\",\"tenggat\":\"2026-02-10\"}");
cek("id berlanjut dari isi berkas setelah restart",
lanjutan.statusCode() == 201 && lanjutan.body().contains("\"id\":4"));
}
}
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;
}
}