05. api: jalur baca untuk service lain
Bagian kamu (P4, tafsiran dari label doc.go, belum dikonfirmasi tim).
File asli: internal/api/handler.go.
Syntax: belajar_go/09_http_dasar.go (handler dan router), 05_interface.go (interface).
Kenapa ada
Problem 4, kriteria 3: “Canonical Store hanya dapat diakses langsung oleh Aggregator, dan komponen lain memperoleh data melalui API Aggregator.”
bnpb-api (yang melayani Media, Tim Lapangan, dan Ops) butuh data HazardEvent. Tetapi ia
tidak boleh menyambung ke MongoDB. Package api ini adalah pintu resminya: bnpb-api
bertanya lewat HTTP, aggregator menjawab dari store.
client -> bnpb-api -> [ GET /hazards ] -> aggregator -> store -> MongoDB
^ file ini
Kenapa dibutuhkan
Tanpa pintu ini, bnpb-api terpaksa membaca Mongo langsung. Itu melanggar aturan satu pemilik
per storage dan memperluas permukaan serangan: lapisan yang menghadap client punya kunci ke
database.
Kontraknya ditentukan oleh bnpb-api, bukan kita
bnpb-api (milik Grace) meneruskan path dan query yang ia terima ke aggregator, apa adanya
(kecuali parameter fields). Jadi path di aggregator harus sama: GET /hazards dan
GET /hazards/{id}. Draf pertama kita memakai /internal/hazard-events. Itu diganti agar
cocok dengan bnpb-api yang sudah ada, karena prinsipnya: punya Grace diutamakan, kita
menyesuaikan.
Dua lapis yang mencegah bnpb-api menyentuh Mongo
- Jaringan. Di
docker-compose.yml, Mongo hanya ada di jaringanstore. Jaringan itu hanya diikutibnpb-aggregator.bnpb-apitidak bisa menjangkau Mongo, sekalipun mau. - Kode.
bnpb-apitidak punya driver Mongo, dan tidak boleh meng-import package ini (modul Go-nya terpisah). Ia hanya memanggil URL.
Bagian 1: Reader, apa yang dibutuhkan API dari store
Dikutip dari internal/api/handler.go, baris 16 sampai 19
type Reader interface {
List(ctx context.Context, f store.Filter) ([]domain.HazardEvent, error)
Get(ctx context.Context, hazardID string) (*domain.HazardEvent, error)
}Reader adalah interface: daftar kemampuan yang harus dimiliki sesuatu supaya bisa dipakai
API. Siapa pun yang punya method List dan Get dengan bentuk itu otomatis cocok, tanpa kata
“implements”. *store.Store punya keduanya, jadi main.go cukup menulis api.Register(mux, logger, st).
Kenapa tidak langsung memakai *store.Store? Dengan interface:
apibisa diuji denganReaderpalsu tanpa menyalakan MongoDB.apitidak bergantung pada detail MongoDB. (Pelajaran 5.)
Bagian 2: Register, dua route
Dikutip dari internal/api/handler.go, baris 29 sampai 58
func Register(mux *http.ServeMux, logger *slog.Logger, reader Reader) {
mux.HandleFunc("GET /hazards", func(w http.ResponseWriter, r *http.Request) {
filter, problem := parseFilter(r)
if problem != "" {
writeError(w, http.StatusBadRequest, problem)
return
}
events, err := reader.List(r.Context(), filter)
if err != nil {
logger.ErrorContext(r.Context(), "list hazard events failed", "error", err.Error())
writeError(w, http.StatusInternalServerError, "failed to read hazard events")
return
}
core.WriteJSON(w, http.StatusOK, map[string]any{"data": events})
})
mux.HandleFunc("GET /hazards/{id}", func(w http.ResponseWriter, r *http.Request) {
event, err := reader.Get(r.Context(), r.PathValue("id"))
if err != nil {
logger.ErrorContext(r.Context(), "get hazard event failed", "error", err.Error())
writeError(w, http.StatusInternalServerError, "failed to read hazard event")
return
}
if event == nil {
writeError(w, http.StatusNotFound, "hazard event not found")
return
}
core.WriteJSON(w, http.StatusOK, event)
})
}Route 1, GET /hazards:
parseFilter(r)membaca query parameter menjadistore.Filter. Kalau ada parameter yang dikenal tetapi nilainya tidak sah, kembali pesan masalah, dan API menjawab 400 (kesalahan dari sisi pemanggil).reader.List(...)mengambil data.r.Context()membawa correlation ID request ini sampai ke store, sehingga log-nya tetap satu rangkaian.- Kalau
Listgagal (misalnya Mongo mati), detail error dicatat ke log, tetapi pemanggil hanya menerima pesan umum dengan status 500. Alamat Mongo dan detail internal tidak bocor keluar. - Kalau berhasil, jawabannya dibungkus envelope
{"data": [...]}, kesepakatan tim yang sama dengan kedua mock.bnpb-apimenyaring isidatalalu menyalin kunci lain apa adanya.
Route 2, GET /hazards/{id}:
r.PathValue("id")mengambil{id}dari alamat.reader.Get(...)mengembalikan pointer.nilberarti tidak ada, dan API menjawab 404.- Kalau ada, event dikembalikan langsung sebagai satu objek (bukan dalam
data).bnpb-apimengenali objek tunggal lewat adanya kuncihazard_id.
Perhatikan return setelah setiap writeError. Tanpanya kode di bawahnya tetap jalan dan bisa
menulis jawaban kedua. (Pelajaran 9.)
Bagian 3: parseFilter, membaca dan memvalidasi query
Dikutip dari internal/api/handler.go, baris 60 sampai 93
func parseFilter(r *http.Request) (store.Filter, string) {
var f store.Filter
q := r.URL.Query()
if raw := q.Get("since"); raw != "" {
// Only UTC timestamps ending in "Z", the team convention.
t, err := time.Parse(time.RFC3339Nano, raw)
if !strings.HasSuffix(raw, "Z") || err != nil {
return f, "invalid 'since', expected ISO 8601 UTC e.g. 2026-09-25T10:00:00Z"
}
f.Since = &t
}
if raw := q.Get("source"); raw != "" {
f.Source = domain.Source(raw)
if f.Source != domain.SourceBMKG && f.Source != domain.SourcePVMBG {
return f, "invalid 'source', expected BMKG or PVMBG"
}
}
if raw := q.Get("hazard_type"); raw != "" {
f.HazardType = domain.HazardType(raw)
if f.HazardType != domain.HazardTypeSeismic && f.HazardType != domain.HazardTypeVolcanic {
return f, "invalid 'hazard_type', expected SEISMIC or VOLCANIC"
}
}
if raw := q.Get("severity"); raw != "" {
f.Severity = domain.Severity(raw)
switch f.Severity {
case domain.SeverityNormal, domain.SeverityWaspada, domain.SeveritySiaga, domain.SeverityAwas:
default:
return f, "invalid 'severity', expected NORMAL, WASPADA, SIAGA or AWAS"
}
}
return f, ""
}Aturannya:
sinceharus UTC berakhiranZ. Format dengan offset seperti+07:00sengaja ditolak: waktu dengan zona berbeda-beda antar service adalah sumber bug yang sulit dilacak.source,hazard_type,severityharus salah satu nilai sah dari packagedomain. Kalau tidak divalidasi, nilai sembarang akan menghasilkan daftar kosong yang menyesatkan (kelihatan “tidak ada data”, padahal sebenarnya filternya salah ketik).- Parameter yang tidak dikenal diabaikan, tidak ditolak.
Fungsi mengembalikan dua nilai: (store.Filter, string). String kosong berarti “tidak ada
masalah”. (Pelajaran 2 dan 1.)
Cara menjelaskan api dalam 30 detik
apiadalah satu-satunya jalan bagi service lain untuk membaca Canonical Store. Ia menyediakanGET /hazardsdengan filter danGET /hazards/{id}, persis path yang dipanggilbnpb-api. Mongo sendiri tidak terjangkau dari luar aggregator karena berada di jaringan Docker terpisah. Penyaringan field menurut hak akses bukan di sini, tetapi dibnpb-api.
Yang sudah dibuktikan
Dengan token sungguhan dari bnpb-auth (bukti_p4/08_jalur_penuh_client_ke_mongo.txt):
Media hanya menerima 7 field Ringkasan dan permintaan field mentah oleh Media ditolak 403;
Tim Lapangan dan Ops menerima 11 field; filter source dan severity bekerja; tanpa token 401;
GET /hazards/{id} bekerja. Mongo mati: GET /hazards menjawab 500 terkendali.
Batasan dan temuan
- Temuan untuk Grace: id yang tidak ada dijawab 404 oleh aggregator, tetapi
bnpb-apimengubah setiap status non-2xx dari aggregator menjadi 502. Client mendapat 502 hanya karena sebuah id tidak ada. - Endpoint ini mengembalikan semua field, termasuk koordinat presisi dan
attributes. Karena itu port aggregator tidak dipublikasikan ke host. Penyaringan ada dibnpb-api. - Tanpa pagination (semua yang cocok dikirim).
- Tanpa autentikasi antar service: perlindungannya isolasi jaringan.
- Parameter tak dikenal diabaikan, jadi salah ketik nama filter (mis.
sev=AWAS) tidak memfilter dan tidak memberi peringatan.
Latihan
- Kenapa
RegistermenerimaReaderdan bukan*store.Store? - Apa yang dilihat client kalau Mongo mati: 500 atau 502? Dari mana angkanya?
(Tergantung lewat mana: langsung ke aggregator 500; lewat
bnpb-apimenjadi 502.) - Kenapa
sincedengan+07:00ditolak?