Dua kebutuhan produksi menyusul: data yang bertahan lintas restart dan pintu yang tidak terbuka bagi sembarang pemanggil. Bagian 3 menukar RepositoryTugasWebMemori dengan RepositoryTugasWebBerkas — cukup mengganti satu baris perakitan karena layanan hanya mengenal interface — lalu membungkus router dengan FilterTokenWeb, dekorator yang memeriksa kredensial sebelum permintaan menyentuh aplikasi. Seluruh berkas Bagian 2 hadir tanpa perubahan; keduanya masuk sebagai berkas baru, persis cara Spring Data JPA dan Spring Security menempel tanpa menyentuh kode controller.
Repository Berkas sebagai Pengganti Memori #
Inilah saat pembayaran arsitektur Bagian 1 lunas: mengganti penyimpanan tidak menyentuh LayananTugasWeb, RouterWeb, maupun ValidasiWeb. Formatnya sederhana — satu tugas satu baris id|judul|pemilik|status|tenggat, ditulis ulang utuh pada setiap mutasi. Konstruktor memuat berkas yang sudah ada dan melanjutkan penomoran id dari id tertinggi, sehingga restart tidak mengulang id yang pernah terpakai:
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 untuk lapisan repository: satu tugas satu baris dengan
* format "id|judul|pemilik|status|tenggat" (karakter | karenanya dilarang
* ValidasiWeb). Berkas dibaca saat konstruksi bila sudah ada; setiap mutasi
* menulis ulang seluruh isi agar isi berkas selalu konsisten. Id berlanjut
* dari id tertinggi yang termuat, sehingga restart tidak mengulang id.
* Ke gagalan I/O dibungkus UncheckedIOException agar kontrak tetap rapi.
*/
public class RepositoryTugasWebBerkas implements RepositoryTugasWeb {
private final Path berkas;
private final Map<Integer, TugasWeb> data = new LinkedHashMap<>();
private int urutan = 0;
public RepositoryTugasWebBerkas(Path berkas) {
this.berkas = berkas;
if (Files.exists(berkas)) {
muat();
}
}
/** Jumlah tugas yang termuat saat konstruksi; nol bila berkas baru. */
public int jumlahTermuat() {
return data.size();
}
@Override
public TugasWeb simpanBaru(TugasWeb tugas) {
urutan++;
TugasWeb tersimpan = tugas.denganId(urutan);
data.put(urutan, tersimpan);
tulisUlang();
return tersimpan;
}
@Override
public Optional<TugasWeb> cari(int id) {
return Optional.ofNullable(data.get(id));
}
@Override
public List<TugasWeb> semua() {
return new ArrayList<>(data.values());
}
@Override
public Optional<TugasWeb> perbarui(TugasWeb tugas) {
if (!data.containsKey(tugas.id())) {
return Optional.empty();
}
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;
}
/** Membaca seluruh baris berkas; baris kosong diabaikan, baris rusak
* menghentikan proses dengan nomor baris yang jelas. */
private void muat() {
List<String> baris;
try {
baris = Files.readAllLines(berkas, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException("gagal membaca " + berkas.getFileName(), e);
}
int nomor = 0;
for (String isi : baris) {
nomor++;
if (isi.isBlank()) {
continue;
}
TugasWeb tugas = parseBaris(nomor, isi);
data.put(tugas.id(), tugas);
}
urutan = data.keySet().stream()
.mapToInt(Integer::intValue)
.max().orElse(0);
}
/** Mengurai satu baris "id|judul|pemilik|status|tenggat". */
private static TugasWeb parseBaris(int nomor, String isi) {
String[] bagian = isi.split("\\|", 5);
if (bagian.length != 5) {
throw new IllegalStateException("baris " + nomor + " rusak: \"" + isi + "\"");
}
try {
int id = Integer.parseInt(bagian[0].strip());
return new TugasWeb(id, bagian[1], bagian[2],
StatusTugasWeb.valueOf(bagian[3].strip()),
LocalDate.parse(bagian[4].strip()));
} catch (RuntimeException e) {
throw new IllegalStateException("baris " + nomor + " rusak: \"" + isi + "\"", e);
}
}
/** Menulis ulang seluruh isi ke berkas, satu tugas satu baris. */
private void tulisUlang() {
List<String> baris = new ArrayList<>();
for (TugasWeb t : data.values()) {
baris.add(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.getFileName(), e);
}
}
}Tiga keputusan di dalamnya:
- Method
muat:Files.readAllLinesmembaca seluruh isi UTF-8; baris kosong diabaikan; baris rusak menghentikan proses dengan nomor barisnya — kegagalan terjadi dini, bukan diam-diam. - Method
tulisUlang: seluruh isi ditulis ulang tiap mutasi. Untuk puluhan tugas ini murah dan selalu konsisten; untuk jutaan baris, pola ini digantikan database dengan transaksi. UncheckedIOException: kegagalan I/O dibungkus unchecked agar kontrak interface tetap bersih — padananDataAccessExceptionSpring.
| Aspek | RepositoryTugasWebMemori | RepositoryTugasWebBerkas | JPA produksi |
|---|---|---|---|
| Data bertahan | tidak | ya, satu berkas | ya, database relasional |
| Id lintas restart | mulai 1 | lanjut dari tertinggi | GeneratedValue / identity |
| Beban mutasi | sepele | tulis ulang utuh | transaksi SQL |
Gerbang Keamanan di Depan Router #
Spring Security bekerja sebagai filter chain: deretan pemeriksa di luar seluruh aplikasi, dan request yang ditolak tidak pernah sampai ke controller. FilterTokenWeb adalah miniatur satu-filter dari arsitektur itu — dekorator HttpHandler yang membungkus router, sehingga tidak ada satu baris kode Bagian 2 yang berubah. Autentikasi memakai skema Basic: kredensial nama:sandi dienkode base64 pada header Authorization. Di atasnya berdiri otorisasi: DELETE hanya untuk peran ADMIN, dan penolakan peran dijawab 403 — berbeda dari 401 yang berarti identitas belum terbukti:
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/**
* Satu pengguna aplikasi: nama, sandi, dan peran. Pembandingan sandi
* memakai MessageDigest.isEqual yang membandingkan seluruh byte tanpa
* berhenti di byte pertama yang berbeda, sehingga lama pembandingan tidak
* membocorkan isi sandi (kelanjutan pola proyek REST API manajemen tugas).
* Pada Spring Security, entitas ini berpadanan dengan UserDetails dan
* sandi seharusnya tersimpan sebagai hash.
*/
public record PenggunaWeb(String nama, String sandi, Peran peran) {
/** Peran pengguna: ANGGOTA boleh mengelola tugas, ADMIN boleh menghapus. */
public enum Peran {
ANGGOTA,
ADMIN
}
public PenggunaWeb {
if (nama == null || nama.isBlank()) {
throw new IllegalArgumentException("nama pengguna wajib diisi");
}
if (sandi == null || sandi.isEmpty()) {
throw new IllegalArgumentException("sandi pengguna wajib diisi");
}
if (peran == null) {
throw new IllegalArgumentException("peran pengguna wajib diisi");
}
nama = nama.strip();
}
/** Membandingkan sandi kandidat dengan sandi tersimpan secara konstan. */
public boolean cocokSandi(String kandidat) {
String pembanding = kandidat == null ? "" : kandidat;
return MessageDigest.isEqual(
sandi.getBytes(StandardCharsets.UTF_8),
pembanding.getBytes(StandardCharsets.UTF_8));
}
}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.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Gerbang keamanan di depan router: dekorator HttpHandler yang memeriksa
* header Authorization skema Basic sebelum permintaan diteruskan. Struktur
* ini adalah miniatur filter chain Spring Security: satu pembungkus di luar
* seluruh aplikasi, kode aplikasi tidak menyentuh satu baris pun.
*
* Autentikasi: kredensial "nama:sandi" dienkode base64 pada header; nama
* dicari di daftar pengguna terdaftar dan sandi dibandingkan konstan-waktu.
* Otorisasi: DELETE hanya untuk peran ADMIN, ditolak 403 bila peran kurang.
* Seluruh penolakan autentikasi dijawab 401 plus header WWW-Authenticate
* yang diwajibkan RFC 7235 (dan RFC 7617 untuk parameter charset).
*/
public class FilterTokenWeb implements HttpHandler {
private final HttpHandler target;
private final Map<String, PenggunaWeb> pengguna = new LinkedHashMap<>();
public FilterTokenWeb(HttpHandler target, PenggunaWeb... penggunaTerdaftar) {
this.target = target;
for (PenggunaWeb p : penggunaTerdaftar) {
pengguna.put(p.nama(), p);
}
}
@Override
public void handle(HttpExchange exchange) throws IOException {
try {
PenggunaWeb terverifikasi = autentikasi(exchange);
otorisasi(exchange, terverifikasi);
} catch (ApiException e) {
tolak(exchange, e.status(), e.getMessage());
return; // permintaan ditolak: router tidak pernah dipanggil
}
target.handle(exchange); // router punya penanganan galat dan penutupan exchange sendiri
}
/**
* Memeriksa kredensial Basic pada header Authorization.
* Melempar ApiException 401 bila kredensial tidak sah.
*/
private PenggunaWeb autentikasi(HttpExchange ex) {
String header = ex.getRequestHeaders().getFirst("Authorization");
if (header == null || !header.startsWith("Basic ")) {
throw new ApiException(401,
"header Authorization wajib berformat 'Basic <kredensial>'");
}
byte[] oktet;
try {
oktet = Base64.getDecoder().decode(header.substring("Basic ".length()).strip());
} catch (IllegalArgumentException e) {
throw new ApiException(401, "kredensial Basic bukan base64 yang sah");
}
String teks = new String(oktet, StandardCharsets.UTF_8);
// Pemisah pertama: sandi boleh memuat karakter ':' sedangkan nama tidak.
int posisi = teks.indexOf(':');
if (posisi < 1) {
throw new ApiException(401, "kredensial harus berformat 'nama:sandi'");
}
String nama = teks.substring(0, posisi);
String sandi = teks.substring(posisi + 1);
PenggunaWeb terdaftar = pengguna.get(nama);
if (terdaftar == null || !terdaftar.cocokSandi(sandi)) {
// Pesan seragam untuk nama tak dikenal dan sandi salah,
// supaya tidak membocorkan nama mana yang terdaftar.
throw new ApiException(401, "nama atau sandi salah");
}
return terdaftar;
}
/**
* Memeriksa izin per permintaan: menghapus tugas hanya untuk ADMIN.
* Melempar ApiException 403 bila peran tidak mencukupi.
*/
private void otorisasi(HttpExchange ex, PenggunaWeb terverifikasi) {
if ("DELETE".equals(ex.getRequestMethod())
&& terverifikasi.peran() != PenggunaWeb.Peran.ADMIN) {
throw new ApiException(403, "menghapus tugas memerlukan peran ADMIN"
+ " (terautentikasi sebagai " + terverifikasi.nama() + ")");
}
}
/** Menulis respons penolakan: body JSON, header WWW-Authenticate untuk 401. */
private static void tolak(HttpExchange ex, int status, String pesan) throws IOException {
ex.getRequestBody().readAllBytes(); // buang body belum terbaca agar koneksi sehat
byte[] isi = JsonWeb.galat(status, pesan).getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
if (status == 401) {
ex.getResponseHeaders().set("WWW-Authenticate",
"Basic realm=\"tugas-web\", charset=\"UTF-8\"");
}
ex.sendResponseHeaders(status, isi.length);
try (OutputStream os = ex.getResponseBody()) {
os.write(isi);
} finally {
ex.close();
}
}
}Bedah gerbangnya:
- Method
handle: kegagalanautentikasi/otorisasidijawabtolaklalureturn— router tidak pernah melihat permintaan yang ditolak; permintaan sah diteruskan utuh ketarget.handle. - Method
autentikasi:Base64.getDecoder().decodemengurai kredensial (galat pengodean dijawab 401),indexOf(':')memisah nama dari sandi pada titik dua pertama karena sandi boleh memuat:sedangkan nama tidak, dan pesan penolakan dibuat seragam agar tidak membocorkan nama yang terdaftar. - Method
otorisasi: memeriksa pasangan metode + peran — keputusanhasRolepadaauthorizeHttpRequestsSpring Security dipadankan satu ke satu. - Method
tolak: membuang body permintaan agar koneksi sehat, lalu menulis body JSON dan headerWWW-Authenticateyang diwajibkan RFC 7235 untuk setiap respons 401.
Dua Sesi Server: Data Bertahan #
AplikasiWeb sekarang merakit FilterTokenWeb(new RouterWeb(layanan)) di atas repository berkas, lalu menjalankan dua sesi server atas berkas yang sama — sesi kedua membuktikan persistensi:
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.time.LocalDate;
import java.util.Base64;
import java.util.Locale;
/**
* Titik masuk inti telanjang Bagian 3: repository berkas + gerbang keamanan.
* Dua sesi server menunjukkan persistensi lintas restart: sesi pertama
* membuat data pada berkas tugas-web.txt, sesi kedua memuatnya kembali.
* Berkas dihapus lebih dulu bila ada agar demo selalu mulai bersih dan
* keluaran identik di setiap eksekusi.
*/
public class AplikasiWeb {
private static final Path BERKAS = Path.of("tugas-web.txt");
public static void main(String[] args) throws Exception {
Files.deleteIfExists(BERKAS);
System.out.println("=== AplikasiWeb Bagian 3: persistensi + keamanan ===");
System.out.println("[Bootstrap] tugas-web.txt disiapkan bersih");
PenggunaWeb budi = new PenggunaWeb("budi", "sandi-budi", PenggunaWeb.Peran.ANGGOTA);
PenggunaWeb siti = new PenggunaWeb("siti", "sandi-siti", PenggunaWeb.Peran.ADMIN);
System.out.println("[Bootstrap] pengguna terdaftar: budi (ANGGOTA), siti (ADMIN)");
HttpClient klien = HttpClient.newHttpClient();
System.out.println();
System.out.println("== Sesi 1: server pertama ==");
HttpServer server = mulai();
try {
String dasar = dasar(server);
kirim(klien, dasar, null, "GET", "/api/tugas", null);
kirim(klien, dasar, "Bearer bukan-basic", "GET", "/api/tugas", null);
kirim(klien, dasar, otorisasi("budi", "salah-total"), "GET", "/api/tugas", null);
kirim(klien, dasar, otorisasi(budi), "POST", "/api/tugas",
"{\"judul\":\"Belajar arsitektur berlapis\",\"pemilik\":\"Budi\","
+ "\"tenggat\":\"2026-02-01\"}");
kirim(klien, dasar, otorisasi(budi), "POST", "/api/tugas",
"{\"judul\":\"Riset keamanan aplikasi web\",\"pemilik\":\"Dewi\","
+ "\"tenggat\":\"2026-01-25\"}");
kirim(klien, dasar, otorisasi(budi), "DELETE", "/api/tugas/1", null);
kirim(klien, dasar, otorisasi(siti), "DELETE", "/api/tugas/1", null);
kirim(klien, dasar, otorisasi(budi), "GET", "/api/tugas", null);
} finally {
server.stop(0);
}
System.out.println();
System.out.println("== Sesi 2: server baru memuat berkas yang sama ==");
HttpServer serverBaru = mulai();
try {
String dasar = dasar(serverBaru);
kirim(klien, dasar, otorisasi(siti), "GET", "/api/tugas", null);
kirim(klien, dasar, otorisasi(budi), "POST", "/api/tugas",
"{\"judul\":\"Menulis dokumentasi API\",\"pemilik\":\"Siti\","
+ "\"tenggat\":\"2026-01-20\"}");
} finally {
serverBaru.stop(0);
}
System.out.println();
System.out.println("[Bootstrap] kedua server berhenti; data bertahan di tugas-web.txt");
}
/** Server uji: filter keamanan membungkus router di atas repository berkas. */
private static HttpServer mulai() throws IOException {
RepositoryTugasWebBerkas repository = new RepositoryTugasWebBerkas(BERKAS);
System.out.println("[Bootstrap] repository berkas siap: "
+ repository.jumlahTermuat() + " tugas termuat");
LayananTugasWeb layanan = new LayananTugasWeb(repository, LocalDate.of(2026, 1, 15));
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", new FilterTokenWeb(new RouterWeb(layanan),
new PenggunaWeb("budi", "sandi-budi", PenggunaWeb.Peran.ANGGOTA),
new PenggunaWeb("siti", "sandi-siti", PenggunaWeb.Peran.ADMIN)));
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();
}
/** Header Authorization Basic dari pasangan nama dan sandi. */
private static String otorisasi(String nama, String sandi) {
byte[] oktet = (nama + ":" + sandi).getBytes(StandardCharsets.UTF_8);
return "Basic " + Base64.getEncoder().encodeToString(oktet);
}
private static String otorisasi(PenggunaWeb pengguna) {
return otorisasi(pengguna.nama(), pengguna.sandi());
}
/** Mengirim satu permintaan lalu mencetak status dan body respons. */
private static void kirim(HttpClient klien, String dasar, String otorisasi,
String metode, String path, String body)
throws IOException, InterruptedException {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(dasar + path))
.timeout(Duration.ofSeconds(5));
if (otorisasi != null) {
b.header("Authorization", otorisasi);
}
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> r = klien.send(b.build(),
HttpResponse.BodyHandlers.ofString());
String www = r.headers().firstValue("WWW-Authenticate")
.map(w -> " WWW-Authenticate=" + w).orElse("");
String isi = r.body().isEmpty() ? "(tanpa body)" : r.body();
System.out.printf(Locale.US, "%-7s %-15s -> %d%s %s%n",
metode, path, r.statusCode(), www, isi);
}
}Output yang Dihasilkan:
=== AplikasiWeb Bagian 3: persistensi + keamanan ===
[Bootstrap] tugas-web.txt disiapkan bersih
[Bootstrap] pengguna terdaftar: budi (ANGGOTA), siti (ADMIN)
== Sesi 1: server pertama ==
[Bootstrap] repository berkas siap: 0 tugas termuat
GET /api/tugas -> 401 WWW-Authenticate=Basic realm="tugas-web", charset="UTF-8" {"status":401,"pesan":"header Authorization wajib berformat 'Basic <kredensial>'"}
GET /api/tugas -> 401 WWW-Authenticate=Basic realm="tugas-web", charset="UTF-8" {"status":401,"pesan":"header Authorization wajib berformat 'Basic <kredensial>'"}
GET /api/tugas -> 401 WWW-Authenticate=Basic realm="tugas-web", charset="UTF-8" {"status":401,"pesan":"nama atau sandi salah"}
POST /api/tugas -> 201 {"id":1,"judul":"Belajar arsitektur berlapis","pemilik":"Budi","status":"TODO","tenggat":"2026-02-01"}
POST /api/tugas -> 201 {"id":2,"judul":"Riset keamanan aplikasi web","pemilik":"Dewi","status":"TODO","tenggat":"2026-01-25"}
DELETE /api/tugas/1 -> 403 {"status":403,"pesan":"menghapus tugas memerlukan peran ADMIN (terautentikasi sebagai budi)"}
DELETE /api/tugas/1 -> 204 (tanpa body)
GET /api/tugas -> 200 [{"id":2,"judul":"Riset keamanan aplikasi web","pemilik":"Dewi","status":"TODO","tenggat":"2026-01-25"}]
== Sesi 2: server baru memuat berkas yang sama ==
[Bootstrap] repository berkas siap: 1 tugas termuat
GET /api/tugas -> 200 [{"id":2,"judul":"Riset keamanan aplikasi web","pemilik":"Dewi","status":"TODO","tenggat":"2026-01-25"}]
POST /api/tugas -> 201 {"id":3,"judul":"Menulis dokumentasi API","pemilik":"Siti","status":"TODO","tenggat":"2026-01-20"}
[Bootstrap] kedua server berhenti; data bertahan di tugas-web.txtBacaan hasilnya:
- Tiga penolakan pertama: tanpa header, skema selain Basic, dan sandi salah — semuanya 401 dengan
WWW-Authenticateyang memberi tahu klien cara mencoba ulang. - DELETE oleh budi lalu siti: 403 untuk ANGGOTA, 204 untuk ADMIN — autentikasi lolos pada keduanya, perbedaan ada pada otorisasi.
- Sesi 2: server baru melaporkan
1 tugas termuat, daftar berisi tugas yang dibuat sesi 1, dan POST berikutnya mendapat id 3 — penomoran berlanjut, bukan mulai ulang.
Padanan Spring Data JPA dan Spring Security #
Pada Spring Boot, repository berkas digantikan entitas JPA dan interface JpaRepository yang menghasilkan implementasinya sendiri — ditampilkan sebagai rancangan, tidak dieksekusi di lingkungan ini:
@Entity
@Table(name = "tugas")
public class Tugas {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(min = 3, max = 100)
private String judul;
@NotBlank
@Size(max = 60)
private String pemilik;
@Enumerated(EnumType.STRING)
private StatusTugas status;
private LocalDate tenggat;
// getter, setter, dan konstruktor default JPA
}public interface RepositoryTugasJpa extends JpaRepository<Tugas, Long> {
List<Tugas> findByPemilikAndStatus(String pemilik, StatusTugas status);
}spring.datasource.url=jdbc:postgresql://localhost:5432/tugas
spring.datasource.username=budi
spring.datasource.password=YOUR_DATABASE_PASSWORD
spring.jpa.hibernate.ddl-auto=validate
app.hari-operasional=2026-01-15Gerbang keamanannya menjadi bean SecurityFilterChain; padanan FilterTokenWeb adalah blok httpBasic dan padanan otorisasi adalah requestMatchers(...).hasRole(...):
@Configuration
@EnableWebSecurity
public class KonfigurasiKeamanan {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(otorisasi -> otorisasi
.requestMatchers(HttpMethod.DELETE, "/api/tugas/**").hasRole("ADMIN")
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(Customizer.withDefaults());
return http.build();
}
@Bean
public UserDetailsService pengguna() {
return new InMemoryUserDetailsManager(
User.withUsername("budi")
.password("{bcrypt}$2a$10$CONTOH_HASH_SANDEVI")
.roles("ANGGOTA")
.build(),
User.withUsername("siti")
.password("{bcrypt}$2a$10$CONTOH_HASH_SANDEVI")
.roles("ADMIN")
.build());
}
}Padanannya kembali satu-ke-satu: tabel tugas menggantikan berkas pipa; method turunan findByPemilikAndStatus menggantikan stream filter di validasi; spring.datasource.* menggantikan Path.of("tugas-web.txt"); dan requestMatchers(HttpMethod.DELETE, ...).hasRole("ADMIN") adalah kalimat yang sama dengan pemeriksaan metode+peran di otorisasi.
Aplikasi kini utuh sebagai sistem: berlapis, tervalidasi, bertahan, terkunci. Bagian 4 membuktikannya secara bertingkat — pengujian unit layanan tanpa jaringan, pengujian integrasi HTTP in-process — lalu mengemas inti telanjang menjadi JAR eksekusi dan menyandingkannya dengan @SpringBootTest, MockMvc, serta Dockerfile sebagai rancangan menuju produksi.
Referensi #
- Spring Data JPA Reference
- Spring Security Reference: Authorize HttpServletRequests
- Spring Security Reference: Password Storage
- Oracle: Class Base64 (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
* router, lalu diterjemahkan menjadi respons JSON berstatus sesuai. Peran ini
* dipegang @ExceptionHandler pada aplikasi Spring Boot sungguhan.
*/
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;
}
}
AplikasiWeb.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.time.LocalDate;
import java.util.Base64;
import java.util.Locale;
/**
* Titik masuk inti telanjang Bagian 3: repository berkas + gerbang keamanan.
* Dua sesi server menunjukkan persistensi lintas restart: sesi pertama
* membuat data pada berkas tugas-web.txt, sesi kedua memuatnya kembali.
* Berkas dihapus lebih dulu bila ada agar demo selalu mulai bersih dan
* keluaran identik di setiap eksekusi.
*/
public class AplikasiWeb {
private static final Path BERKAS = Path.of("tugas-web.txt");
public static void main(String[] args) throws Exception {
Files.deleteIfExists(BERKAS);
System.out.println("=== AplikasiWeb Bagian 3: persistensi + keamanan ===");
System.out.println("[Bootstrap] tugas-web.txt disiapkan bersih");
PenggunaWeb budi = new PenggunaWeb("budi", "sandi-budi", PenggunaWeb.Peran.ANGGOTA);
PenggunaWeb siti = new PenggunaWeb("siti", "sandi-siti", PenggunaWeb.Peran.ADMIN);
System.out.println("[Bootstrap] pengguna terdaftar: budi (ANGGOTA), siti (ADMIN)");
HttpClient klien = HttpClient.newHttpClient();
System.out.println();
System.out.println("== Sesi 1: server pertama ==");
HttpServer server = mulai();
try {
String dasar = dasar(server);
kirim(klien, dasar, null, "GET", "/api/tugas", null);
kirim(klien, dasar, "Bearer bukan-basic", "GET", "/api/tugas", null);
kirim(klien, dasar, otorisasi("budi", "salah-total"), "GET", "/api/tugas", null);
kirim(klien, dasar, otorisasi(budi), "POST", "/api/tugas",
"{\"judul\":\"Belajar arsitektur berlapis\",\"pemilik\":\"Budi\","
+ "\"tenggat\":\"2026-02-01\"}");
kirim(klien, dasar, otorisasi(budi), "POST", "/api/tugas",
"{\"judul\":\"Riset keamanan aplikasi web\",\"pemilik\":\"Dewi\","
+ "\"tenggat\":\"2026-01-25\"}");
kirim(klien, dasar, otorisasi(budi), "DELETE", "/api/tugas/1", null);
kirim(klien, dasar, otorisasi(siti), "DELETE", "/api/tugas/1", null);
kirim(klien, dasar, otorisasi(budi), "GET", "/api/tugas", null);
} finally {
server.stop(0);
}
System.out.println();
System.out.println("== Sesi 2: server baru memuat berkas yang sama ==");
HttpServer serverBaru = mulai();
try {
String dasar = dasar(serverBaru);
kirim(klien, dasar, otorisasi(siti), "GET", "/api/tugas", null);
kirim(klien, dasar, otorisasi(budi), "POST", "/api/tugas",
"{\"judul\":\"Menulis dokumentasi API\",\"pemilik\":\"Siti\","
+ "\"tenggat\":\"2026-01-20\"}");
} finally {
serverBaru.stop(0);
}
System.out.println();
System.out.println("[Bootstrap] kedua server berhenti; data bertahan di tugas-web.txt");
}
/** Server uji: filter keamanan membungkus router di atas repository berkas. */
private static HttpServer mulai() throws IOException {
RepositoryTugasWebBerkas repository = new RepositoryTugasWebBerkas(BERKAS);
System.out.println("[Bootstrap] repository berkas siap: "
+ repository.jumlahTermuat() + " tugas termuat");
LayananTugasWeb layanan = new LayananTugasWeb(repository, LocalDate.of(2026, 1, 15));
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", new FilterTokenWeb(new RouterWeb(layanan),
new PenggunaWeb("budi", "sandi-budi", PenggunaWeb.Peran.ANGGOTA),
new PenggunaWeb("siti", "sandi-siti", PenggunaWeb.Peran.ADMIN)));
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();
}
/** Header Authorization Basic dari pasangan nama dan sandi. */
private static String otorisasi(String nama, String sandi) {
byte[] oktet = (nama + ":" + sandi).getBytes(StandardCharsets.UTF_8);
return "Basic " + Base64.getEncoder().encodeToString(oktet);
}
private static String otorisasi(PenggunaWeb pengguna) {
return otorisasi(pengguna.nama(), pengguna.sandi());
}
/** Mengirim satu permintaan lalu mencetak status dan body respons. */
private static void kirim(HttpClient klien, String dasar, String otorisasi,
String metode, String path, String body)
throws IOException, InterruptedException {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(dasar + path))
.timeout(Duration.ofSeconds(5));
if (otorisasi != null) {
b.header("Authorization", otorisasi);
}
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> r = klien.send(b.build(),
HttpResponse.BodyHandlers.ofString());
String www = r.headers().firstValue("WWW-Authenticate")
.map(w -> " WWW-Authenticate=" + w).orElse("");
String isi = r.body().isEmpty() ? "(tanpa body)" : r.body();
System.out.printf(Locale.US, "%-7s %-15s -> %d%s %s%n",
metode, path, r.statusCode(), www, isi);
}
}
AturanBisnisException.java
Unduh berkas/**
* Operasi menabrak aturan bisnis yang bergantung pada kondisi terkini:
* transisi status yang sudah lampau atau batas tugas aktif per pemilik.
* Diterjemahkan router menjadi respons 409 Conflict.
*/
public class AturanBisnisException extends ApiException {
public AturanBisnisException(String pesan) {
super(409, pesan);
}
}
FilterTokenWeb.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.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Gerbang keamanan di depan router: dekorator HttpHandler yang memeriksa
* header Authorization skema Basic sebelum permintaan diteruskan. Struktur
* ini adalah miniatur filter chain Spring Security: satu pembungkus di luar
* seluruh aplikasi, kode aplikasi tidak menyentuh satu baris pun.
*
* Autentikasi: kredensial "nama:sandi" dienkode base64 pada header; nama
* dicari di daftar pengguna terdaftar dan sandi dibandingkan konstan-waktu.
* Otorisasi: DELETE hanya untuk peran ADMIN, ditolak 403 bila peran kurang.
* Seluruh penolakan autentikasi dijawab 401 plus header WWW-Authenticate
* yang diwajibkan RFC 7235 (dan RFC 7617 untuk parameter charset).
*/
public class FilterTokenWeb implements HttpHandler {
private final HttpHandler target;
private final Map<String, PenggunaWeb> pengguna = new LinkedHashMap<>();
public FilterTokenWeb(HttpHandler target, PenggunaWeb... penggunaTerdaftar) {
this.target = target;
for (PenggunaWeb p : penggunaTerdaftar) {
pengguna.put(p.nama(), p);
}
}
@Override
public void handle(HttpExchange exchange) throws IOException {
try {
PenggunaWeb terverifikasi = autentikasi(exchange);
otorisasi(exchange, terverifikasi);
} catch (ApiException e) {
tolak(exchange, e.status(), e.getMessage());
return; // permintaan ditolak: router tidak pernah dipanggil
}
target.handle(exchange); // router punya penanganan galat dan penutupan exchange sendiri
}
/**
* Memeriksa kredensial Basic pada header Authorization.
* Melempar ApiException 401 bila kredensial tidak sah.
*/
private PenggunaWeb autentikasi(HttpExchange ex) {
String header = ex.getRequestHeaders().getFirst("Authorization");
if (header == null || !header.startsWith("Basic ")) {
throw new ApiException(401,
"header Authorization wajib berformat 'Basic <kredensial>'");
}
byte[] oktet;
try {
oktet = Base64.getDecoder().decode(header.substring("Basic ".length()).strip());
} catch (IllegalArgumentException e) {
throw new ApiException(401, "kredensial Basic bukan base64 yang sah");
}
String teks = new String(oktet, StandardCharsets.UTF_8);
// Pemisah pertama: sandi boleh memuat karakter ':' sedangkan nama tidak.
int posisi = teks.indexOf(':');
if (posisi < 1) {
throw new ApiException(401, "kredensial harus berformat 'nama:sandi'");
}
String nama = teks.substring(0, posisi);
String sandi = teks.substring(posisi + 1);
PenggunaWeb terdaftar = pengguna.get(nama);
if (terdaftar == null || !terdaftar.cocokSandi(sandi)) {
// Pesan seragam untuk nama tak dikenal dan sandi salah,
// supaya tidak membocorkan nama mana yang terdaftar.
throw new ApiException(401, "nama atau sandi salah");
}
return terdaftar;
}
/**
* Memeriksa izin per permintaan: menghapus tugas hanya untuk ADMIN.
* Melempar ApiException 403 bila peran tidak mencukupi.
*/
private void otorisasi(HttpExchange ex, PenggunaWeb terverifikasi) {
if ("DELETE".equals(ex.getRequestMethod())
&& terverifikasi.peran() != PenggunaWeb.Peran.ADMIN) {
throw new ApiException(403, "menghapus tugas memerlukan peran ADMIN"
+ " (terautentikasi sebagai " + terverifikasi.nama() + ")");
}
}
/** Menulis respons penolakan: body JSON, header WWW-Authenticate untuk 401. */
private static void tolak(HttpExchange ex, int status, String pesan) throws IOException {
ex.getRequestBody().readAllBytes(); // buang body belum terbaca agar koneksi sehat
byte[] isi = JsonWeb.galat(status, pesan).getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
if (status == 401) {
ex.getResponseHeaders().set("WWW-Authenticate",
"Basic realm=\"tugas-web\", charset=\"UTF-8\"");
}
ex.sendResponseHeaders(status, isi.length);
try (OutputStream os = ex.getResponseBody()) {
os.write(isi);
} finally {
ex.close();
}
}
}
JsonWeb.java
Unduh berkasimport java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Serializer dan parser JSON mini untuk inti telanjang aplikasi web:
* objek datar dengan nilai teks, angka, boolean, atau null. Objek dan array
* bersarang sengaja tidak didukung agar kelas ini tetap ringkas; pustaka
* seperti Jackson menangani seluruh kasus pada aplikasi Spring Boot asli.
*/
public final class JsonWeb {
private JsonWeb() {
// Kelas utilitas: seluruh anggota static.
}
/** Meloloskan karakter yang wajib di-escape 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 sah (kutip plus escape). */
public static String teks(String nilai) {
return "\"" + escape(nilai) + "\"";
}
/** Satu tugas menjadi objek JSON dengan urutan field tetap. */
public static String tugas(TugasWeb t) {
return "{\"id\":" + t.id()
+ ",\"judul\":" + teks(t.judul())
+ ",\"pemilik\":" + teks(t.pemilik())
+ ",\"status\":\"" + t.status() + "\""
+ ",\"tenggat\":\"" + t.tenggat() + "\"}";
}
/** Daftar tugas menjadi array JSON. */
public static String daftar(List<TugasWeb> semua) {
StringBuilder hasil = new StringBuilder("[");
for (TugasWeb t : semua) {
if (hasil.length() > 1) {
hasil.append(",");
}
hasil.append(tugas(t));
}
return hasil.append("]").toString();
}
/** Amplop galat tunggal. */
public static String galat(int status, String pesan) {
return "{\"status\":" + status + ",\"pesan\":" + teks(pesan) + "}";
}
/** Amplop galat validasi dengan rincian per field. */
public static String galat(int status, String pesan, List<String> rincian) {
if (rincian == null || rincian.isEmpty()) {
return galat(status, pesan);
}
StringBuilder hasil = new StringBuilder("{\"status\":").append(status)
.append(",\"pesan\":").append(teks(pesan)).append(",\"galat\":[");
boolean pertama = true;
for (String g : rincian) {
if (!pertama) {
hasil.append(",");
}
pertama = false;
hasil.append(teks(g));
}
return hasil.append("]}").toString();
}
/**
* Mem-parsing objek JSON datar menjadi Map dengan urutan field terjaga.
* Nilai berupa String, Long, Double, Boolean, atau null; nilai bersarang
* ditolak. Melempar IllegalArgumentException bila teks 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 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 datar. */
Object nilai() {
char c = lihat();
if (c == '"') {
return string();
}
if (c == '{' || c == '[') {
throw galat("objek/array bersarang tidak didukung parser mini");
}
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 + ")");
}
}
}
LayananTugasWeb.java
Unduh berkasimport java.time.LocalDate;
import java.util.List;
/**
* Lapisan layanan: pemilik aturan bisnis aplikasi web manajemen tugas.
* Seluruh operasi lewat sini; router tidak pernah menyentuh repository
* secara langsung, dan repository tidak pernah tahu bahwa HTTP ada.
* Hari operasional disuntik lewat konstruktor agar aturan "tenggat lampau"
* bisa diuji dengan tanggal beku, bukan LocalDate.now().
*/
public class LayananTugasWeb {
/** Batas tugas berstatus TODO per pemilik: aturan bisnis lapisan layanan. */
public static final int BATAS_TUGAS_AKTIF = 5;
private final RepositoryTugasWeb repository;
private final LocalDate hariIni;
public LayananTugasWeb(RepositoryTugasWeb repository, LocalDate hariIni) {
this.repository = repository;
this.hariIni = hariIni;
}
/** Hari operasional aplikasi, disuntik lewat konstruktor. */
public LocalDate hariIni() {
return hariIni;
}
/**
* Menyusun tugas baru setelah validasi bentuk field dan aturan kontekstual:
* status selalu TODO, tenggat wajib belum lampau, dan pemilik belum
* menyentuh batas tugas aktif.
*/
public TugasWeb buat(PermintaanTugas permintaan) {
LocalDate tenggat = validasi(permintaan);
TugasWeb baru = new TugasWeb(0, permintaan.judul(), permintaan.pemilik(),
StatusTugasWeb.TODO, tenggat);
return repository.simpanBaru(baru);
}
/** Seluruh tugas terurut id. */
public List<TugasWeb> daftar() {
return repository.semua();
}
/** Satu tugas berdasarkan id; 404 bila tidak ada. */
public TugasWeb cari(int id) {
return repository.cari(id)
.orElseThrow(() -> new TugasTidakDitemukanException(id));
}
/**
* Memperbarui judul, pemilik, dan tenggat; status dipertahankan apa adanya
* karena transisi status hanya boleh lewat tandaiSelesai.
*/
public TugasWeb ubah(int id, PermintaanTugas permintaan) {
TugasWeb tugas = cari(id);
LocalDate tenggat = validasi(permintaan);
TugasWeb baru = new TugasWeb(id, permintaan.judul(), permintaan.pemilik(),
tugas.status(), tenggat);
return repository.perbarui(baru)
.orElseThrow(() -> new TugasTidakDitemukanException(id));
}
/** Menandai tugas selesai; 409 bila tugas memang sudah SELESAI. */
public TugasWeb tandaiSelesai(int id) {
TugasWeb tugas = cari(id);
if (tugas.status() == StatusTugasWeb.SELESAI) {
throw new AturanBisnisException("tugas #" + id
+ " sudah SELESAI, tidak bisa ditandai selesai ulang");
}
TugasWeb selesai = tugas.denganStatus(StatusTugasWeb.SELESAI);
return repository.perbarui(selesai)
.orElseThrow(() -> new TugasTidakDitemukanException(id));
}
/** Menghapus tugas; 404 bila id tidak ada. */
public void hapus(int id) {
cari(id);
repository.hapus(id);
}
/**
* Validasi menyeluruh satu muatan: bentuk field lewat ValidasiWeb
* (galat dikumpulkan), lalu dua aturan kontekstual — tenggat wajib
* belum lampau dan pemilik belum menyentuh batas tugas aktif.
* Mengembalikan tenggat terurai untuk dipakai pemanggil.
*/
private LocalDate validasi(PermintaanTugas permintaan) {
List<String> galat = ValidasiWeb.periksa(permintaan);
if (!galat.isEmpty()) {
throw new ValidasiGagalException(galat);
}
LocalDate tenggat = LocalDate.parse(permintaan.tenggat().strip());
if (tenggat.isBefore(hariIni)) {
throw new ValidasiGagalException("tenggat " + tenggat
+ " sudah lampau, hari operasional " + hariIni);
}
long aktif = repository.semua().stream()
.filter(t -> t.pemilik().equals(permintaan.pemilik().strip()))
.filter(t -> t.status() == StatusTugasWeb.TODO)
.count();
if (aktif >= BATAS_TUGAS_AKTIF) {
throw new AturanBisnisException("pemilik " + permintaan.pemilik()
+ " sudah memiliki " + BATAS_TUGAS_AKTIF + " tugas TODO");
}
return tenggat;
}
}
PenggunaWeb.java
Unduh berkasimport java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/**
* Satu pengguna aplikasi: nama, sandi, dan peran. Pembandingan sandi
* memakai MessageDigest.isEqual yang membandingkan seluruh byte tanpa
* berhenti di byte pertama yang berbeda, sehingga lama pembandingan tidak
* membocorkan isi sandi (kelanjutan pola proyek REST API manajemen tugas).
* Pada Spring Security, entitas ini berpadanan dengan UserDetails dan
* sandi seharusnya tersimpan sebagai hash.
*/
public record PenggunaWeb(String nama, String sandi, Peran peran) {
/** Peran pengguna: ANGGOTA boleh mengelola tugas, ADMIN boleh menghapus. */
public enum Peran {
ANGGOTA,
ADMIN
}
public PenggunaWeb {
if (nama == null || nama.isBlank()) {
throw new IllegalArgumentException("nama pengguna wajib diisi");
}
if (sandi == null || sandi.isEmpty()) {
throw new IllegalArgumentException("sandi pengguna wajib diisi");
}
if (peran == null) {
throw new IllegalArgumentException("peran pengguna wajib diisi");
}
nama = nama.strip();
}
/** Membandingkan sandi kandidat dengan sandi tersimpan secara konstan. */
public boolean cocokSandi(String kandidat) {
String pembanding = kandidat == null ? "" : kandidat;
return MessageDigest.isEqual(
sandi.getBytes(StandardCharsets.UTF_8),
pembanding.getBytes(StandardCharsets.UTF_8));
}
}
PermintaanTugas.java
Unduh berkas/**
* Muatan permintaan pembuatan dan pembaruan tugas: teks mentah dari klien
* sebelum divalidasi. Record ini berperan seperti DTO pada controller
* Spring; pemeriksaan isinya dilakukan lapisan layanan, bukan record ini.
*/
public record PermintaanTugas(String judul, String pemilik, String tenggat) {
}
RepositoryTugasWeb.java
Unduh berkasimport java.util.List;
import java.util.Optional;
/**
* Kontrak lapisan penyimpanan untuk resource tugas. Layanan hanya mengenal
* interface ini sehingga implementasi bebas ditukar: memori untuk pengujian,
* berkas untuk persistensi Bagian 3, atau JDBC/JPA pada aplikasi nyata.
*/
public interface RepositoryTugasWeb {
/** Menyimpan tugas baru; id ditetapkan otomatis oleh penyimpanan. */
TugasWeb simpanBaru(TugasWeb tugas);
/** Mencari satu tugas berdasarkan id. */
Optional<TugasWeb> cari(int id);
/** Seluruh tugas terurut menaik berdasarkan id. */
List<TugasWeb> semua();
/** Menyimpan perubahan tugas yang sudah ada; kosong bila id tidak ada. */
Optional<TugasWeb> perbarui(TugasWeb tugas);
/** Menghapus tugas; false bila id tidak ada. */
boolean hapus(int id);
}
RepositoryTugasWebBerkas.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 untuk lapisan repository: satu tugas satu baris dengan
* format "id|judul|pemilik|status|tenggat" (karakter | karenanya dilarang
* ValidasiWeb). Berkas dibaca saat konstruksi bila sudah ada; setiap mutasi
* menulis ulang seluruh isi agar isi berkas selalu konsisten. Id berlanjut
* dari id tertinggi yang termuat, sehingga restart tidak mengulang id.
* Ke gagalan I/O dibungkus UncheckedIOException agar kontrak tetap rapi.
*/
public class RepositoryTugasWebBerkas implements RepositoryTugasWeb {
private final Path berkas;
private final Map<Integer, TugasWeb> data = new LinkedHashMap<>();
private int urutan = 0;
public RepositoryTugasWebBerkas(Path berkas) {
this.berkas = berkas;
if (Files.exists(berkas)) {
muat();
}
}
/** Jumlah tugas yang termuat saat konstruksi; nol bila berkas baru. */
public int jumlahTermuat() {
return data.size();
}
@Override
public TugasWeb simpanBaru(TugasWeb tugas) {
urutan++;
TugasWeb tersimpan = tugas.denganId(urutan);
data.put(urutan, tersimpan);
tulisUlang();
return tersimpan;
}
@Override
public Optional<TugasWeb> cari(int id) {
return Optional.ofNullable(data.get(id));
}
@Override
public List<TugasWeb> semua() {
return new ArrayList<>(data.values());
}
@Override
public Optional<TugasWeb> perbarui(TugasWeb tugas) {
if (!data.containsKey(tugas.id())) {
return Optional.empty();
}
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;
}
/** Membaca seluruh baris berkas; baris kosong diabaikan, baris rusak
* menghentikan proses dengan nomor baris yang jelas. */
private void muat() {
List<String> baris;
try {
baris = Files.readAllLines(berkas, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException("gagal membaca " + berkas.getFileName(), e);
}
int nomor = 0;
for (String isi : baris) {
nomor++;
if (isi.isBlank()) {
continue;
}
TugasWeb tugas = parseBaris(nomor, isi);
data.put(tugas.id(), tugas);
}
urutan = data.keySet().stream()
.mapToInt(Integer::intValue)
.max().orElse(0);
}
/** Mengurai satu baris "id|judul|pemilik|status|tenggat". */
private static TugasWeb parseBaris(int nomor, String isi) {
String[] bagian = isi.split("\\|", 5);
if (bagian.length != 5) {
throw new IllegalStateException("baris " + nomor + " rusak: \"" + isi + "\"");
}
try {
int id = Integer.parseInt(bagian[0].strip());
return new TugasWeb(id, bagian[1], bagian[2],
StatusTugasWeb.valueOf(bagian[3].strip()),
LocalDate.parse(bagian[4].strip()));
} catch (RuntimeException e) {
throw new IllegalStateException("baris " + nomor + " rusak: \"" + isi + "\"", e);
}
}
/** Menulis ulang seluruh isi ke berkas, satu tugas satu baris. */
private void tulisUlang() {
List<String> baris = new ArrayList<>();
for (TugasWeb t : data.values()) {
baris.add(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.getFileName(), e);
}
}
}
RepositoryTugasWebMemori.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, penghitung sederhana menetapkan id berurutan mulai dari 1.
*/
public class RepositoryTugasWebMemori implements RepositoryTugasWeb {
private final Map<Integer, TugasWeb> data = new LinkedHashMap<>();
private int urutan = 0;
@Override
public TugasWeb simpanBaru(TugasWeb tugas) {
urutan++;
TugasWeb tersimpan = tugas.denganId(urutan);
data.put(urutan, tersimpan);
return tersimpan;
}
@Override
public Optional<TugasWeb> cari(int id) {
return Optional.ofNullable(data.get(id));
}
@Override
public List<TugasWeb> semua() {
return new ArrayList<>(data.values());
}
@Override
public Optional<TugasWeb> perbarui(TugasWeb 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;
}
}
RouterWeb.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.LinkedHashMap;
import java.util.Map;
/**
* Lapisan web inti telanjang: satu HttpHandler yang menyimpan peta endpoint
* "METODE /pola" -> kode, lengkap dengan pencocokan pola {id}. Struktur ini
* adalah miniatur DispatcherServlet Spring MVC: peta handler menggantikan
* anotasi @GetMapping/@PostMapping, dan satu blok catch di batas menggantikan
* @ExceptionHandler.
*/
public class RouterWeb implements HttpHandler {
/** Satu endpoint terdaftar: pola "METODE /api/tugas/{id}". */
@FunctionalInterface
public interface Endpoint {
/** Memproses satu permintaan; parameter berisi nilai {id} hasil cocok pola. */
ResponsWeb tangani(Map<String, String> parameter, String body);
}
/** Respons siap kirim: status HTTP, body JSON, dan Location untuk 201. */
public record ResponsWeb(int status, String json, String lokasi) {
static ResponsWeb ok(String json) {
return new ResponsWeb(200, json, null);
}
static ResponsWeb dibuat(TugasWeb tugas) {
return new ResponsWeb(201, JsonWeb.tugas(tugas), "/api/tugas/" + tugas.id());
}
static ResponsWeb tanpaIsi() {
return new ResponsWeb(204, null, null);
}
}
private final LayananTugasWeb layanan;
private final Map<String, Endpoint> rute = new LinkedHashMap<>();
public RouterWeb(LayananTugasWeb layanan) {
this.layanan = layanan;
rute.put("GET /api/tugas", (p, b) -> ResponsWeb.ok(JsonWeb.daftar(layanan.daftar())));
rute.put("POST /api/tugas", (p, b) -> ResponsWeb.dibuat(layanan.buat(bacaPermintaan(b))));
rute.put("GET /api/tugas/{id}", (p, b) ->
ResponsWeb.ok(JsonWeb.tugas(layanan.cari(id(p)))));
rute.put("PUT /api/tugas/{id}", (p, b) ->
ResponsWeb.ok(JsonWeb.tugas(layanan.ubah(id(p), bacaPermintaan(b)))));
rute.put("POST /api/tugas/{id}/selesai", (p, b) ->
ResponsWeb.ok(JsonWeb.tugas(layanan.tandaiSelesai(id(p)))));
rute.put("DELETE /api/tugas/{id}", (p, b) -> {
layanan.hapus(id(p));
return ResponsWeb.tanpaIsi();
});
}
@Override
public void handle(HttpExchange exchange) throws IOException {
// Body dibaca selalu agar koneksi sehat walau permintaan ditolak.
String body = new String(exchange.getRequestBody().readAllBytes(),
StandardCharsets.UTF_8);
String metode = exchange.getRequestMethod();
String path = exchange.getRequestURI().getPath();
try {
kirim(exchange, cocokkan(metode, path, body));
} catch (ValidasiGagalException e) {
kirim(exchange, new ResponsWeb(400,
JsonWeb.galat(400, "validasi gagal", e.galat()), null));
} catch (ApiException e) {
kirim(exchange, new ResponsWeb(e.status(),
JsonWeb.galat(e.status(), e.getMessage()), null));
} catch (IllegalArgumentException e) {
kirim(exchange, new ResponsWeb(400,
JsonWeb.galat(400, e.getMessage()), null));
} catch (RuntimeException e) {
kirim(exchange, new ResponsWeb(500,
JsonWeb.galat(500, "kegagalan tak terduga: " + e), null));
}
}
/** Mencocokkan metode dan path dengan peta rute; 405 bila path dikenal
* dengan metode lain, 404 bila path memang tidak terdaftar. */
private ResponsWeb cocokkan(String metode, String path, String body) {
for (Map.Entry<String, Endpoint> rute : this.rute.entrySet()) {
String[] kunci = rute.getKey().split(" ", 2);
Map<String, String> parameter = cocokkanPola(kunci[1], path);
if (parameter != null && kunci[0].equals(metode)) {
return rute.getValue().tangani(parameter, body);
}
}
if (pathTerdaftar(path)) {
throw new ApiException(405, "metode " + metode + " tidak didukung untuk " + path);
}
throw new ApiException(404, "endpoint tidak dikenal: " + metode + " " + path);
}
/** Mencocokkan pola bersegmen dengan path; {id} menyerap satu segmen angka. */
private static Map<String, String> cocokkanPola(String pola, String path) {
String[] segPola = pola.split("/");
String[] segPath = path.split("/");
if (segPola.length != segPath.length) {
return null;
}
Map<String, String> parameter = new LinkedHashMap<>();
for (int i = 0; i < segPola.length; i++) {
String p = segPola[i];
if (p.startsWith("{") && p.endsWith("}")) {
if (!segPath[i].matches("\\d+")) {
return null;
}
parameter.put(p.substring(1, p.length() - 1), segPath[i]);
} else if (!p.equals(segPath[i])) {
return null;
}
}
return parameter;
}
/** Apakah path cocok dengan pola rute mana pun (metode apa pun). */
private boolean pathTerdaftar(String path) {
for (String kunci : rute.keySet()) {
if (cocokkanPola(kunci.split(" ", 2)[1], path) != null) {
return true;
}
}
return false;
}
/** Id numerik dari parameter hasil cocok pola. */
private static int id(Map<String, String> parameter) {
return Integer.parseInt(parameter.get("id"));
}
/** Membaca body JSON menjadi PermintaanTugas. */
private static PermintaanTugas bacaPermintaan(String body) {
if (body == null || body.isBlank()) {
throw new ValidasiGagalException("body permintaan wajib berisi JSON objek");
}
Map<String, Object> data = JsonWeb.parseObjek(body);
return new PermintaanTugas(teks(data, "judul"), teks(data, "pemilik"),
teks(data, "tenggat"));
}
/** Nilai field teks; null bila field hilang, 400 bila bukan teks. */
private static String teks(Map<String, Object> data, String field) {
Object nilai = data.get(field);
if (nilai == null) {
return null;
}
if (nilai instanceof String s) {
return s;
}
throw new ApiException(400, "field " + field + " harus berupa teks");
}
/** Menulis respons: body null berarti 204 tanpa isi. */
private static void kirim(HttpExchange exchange, ResponsWeb respons) throws IOException {
if (respons.json() == null) {
exchange.sendResponseHeaders(respons.status(), -1);
exchange.close();
return;
}
byte[] isi = respons.json().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
if (respons.lokasi() != null) {
exchange.getResponseHeaders().set("Location", respons.lokasi());
}
exchange.sendResponseHeaders(respons.status(), isi.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(isi);
} finally {
exchange.close();
}
}
}
StatusTugasWeb.java
Unduh berkas/**
* Status daur hidup tugas pada aplikasi web manajemen tugas.
* TODO berarti tugas masih terbuka, SELESAI berarti tugas tuntas.
*/
public enum StatusTugasWeb {
TODO,
SELESAI
}
TugasTidakDitemukanException.java
Unduh berkas/**
* Id tugas yang diminta tidak ada di penyimpanan.
* Diterjemahkan router menjadi respons 404 Not Found.
*/
public class TugasTidakDitemukanException extends ApiException {
public TugasTidakDitemukanException(int id) {
super(404, "tugas dengan id " + id + " tidak ditemukan");
}
}
TugasWeb.java
Unduh berkasimport java.time.LocalDate;
/**
* Satu tugas sebagai entitas domain: judul, pemilik, status, dan tenggat.
* Record dipilih karena tugas adalah nilai yang tidak berubah di tempat;
* perubahan dilakukan lewat method salinan di bawah, persis pola wither.
*/
public record TugasWeb(int id, String judul, String pemilik, StatusTugasWeb status,
LocalDate tenggat) {
public TugasWeb {
if (judul == null || judul.isBlank()) {
throw new IllegalArgumentException("judul tugas wajib diisi");
}
if (pemilik == null || pemilik.isBlank()) {
throw new IllegalArgumentException("pemilik tugas wajib diisi");
}
if (status == null) {
throw new IllegalArgumentException("status tugas wajib diisi");
}
if (tenggat == null) {
throw new IllegalArgumentException("tenggat tugas wajib diisi");
}
judul = judul.strip();
pemilik = pemilik.strip();
}
/** Salinan tugas dengan id berbeda; dipakai repository saat menetapkan id. */
public TugasWeb denganId(int idBaru) {
return new TugasWeb(idBaru, judul, pemilik, status, tenggat);
}
/** Salinan tugas dengan status berbeda; dipakai layanan saat transisi status. */
public TugasWeb denganStatus(StatusTugasWeb statusBaru) {
return new TugasWeb(id, judul, pemilik, statusBaru, tenggat);
}
/** Ringkasan satu baris untuk pencatatan di terminal. */
public String formatRingkas() {
return "#" + id + " [" + status + "] " + judul
+ " (pemilik " + pemilik + ", tenggat " + tenggat + ")";
}
}
ValidasiGagalException.java
Unduh berkasimport java.util.List;
/**
* Muatan permintaan tidak lolos pemeriksaan. Membawa daftar galat per field
* agar router bisa menyusun respons 400 dengan rincian terstruktur — padanan
* MethodArgumentNotValidException pada Spring MVC.
*/
public class ValidasiGagalException extends ApiException {
private final List<String> galat;
public ValidasiGagalException(String pesan) {
this(List.of(pesan));
}
public ValidasiGagalException(List<String> galat) {
super(400, "validasi gagal: " + String.join("; ", galat));
this.galat = List.copyOf(galat);
}
/** Rincian galat per field, siap ditampilkan sebagai array JSON. */
public List<String> galat() {
return galat;
}
}
ValidasiWeb.java
Unduh berkasimport java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
/**
* Validasi bentuk field muatan tugas: panjang judul, isi pemilik, format
* tanggal, dan karakter yang dilarang format penyimpanan Bagian 3. Seluruh
* galat dikumpulkan sekaligus agar klien tahu semua kesalahan dalam satu
* respons, bukan satu per satu. Aturan yang butuh konteks (tenggat lampau,
* batas tugas aktif) tetap di LayananTugasWeb.
*/
public final class ValidasiWeb {
private ValidasiWeb() {
// Kelas utilitas: seluruh anggota static.
}
/**
* Memeriksa muatan permintaan dan mengembalikan daftar galat.
* Daftar kosong berarti seluruh field lolos pemeriksaan bentuk.
*/
public static List<String> periksa(PermintaanTugas permintaan) {
List<String> galat = new ArrayList<>();
String judul = permintaan.judul() == null ? "" : permintaan.judul().strip();
if (judul.length() < 3 || judul.length() > 100) {
galat.add("judul wajib berisi 3-100 karakter");
}
if (judul.contains("|")) {
galat.add("judul tidak boleh memuat karakter '|' (pemisah format penyimpanan)");
}
String pemilik = permintaan.pemilik() == null ? "" : permintaan.pemilik().strip();
if (pemilik.isEmpty() || pemilik.length() > 60) {
galat.add("pemilik wajib diisi, maksimum 60 karakter");
}
if (pemilik.contains("|")) {
galat.add("pemilik tidak boleh memuat karakter '|'");
}
if (permintaan.tenggat() == null) {
galat.add("tenggat wajib berformat ISO yyyy-MM-dd");
} else {
try {
LocalDate.parse(permintaan.tenggat().strip());
} catch (DateTimeParseException e) {
galat.add("tenggat wajib berformat ISO yyyy-MM-dd");
}
}
return galat;
}
}