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

  1. Jaringan. Di docker-compose.yml, Mongo hanya ada di jaringan store. Jaringan itu hanya diikuti bnpb-aggregator. bnpb-api tidak bisa menjangkau Mongo, sekalipun mau.
  2. Kode. bnpb-api tidak 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:

  • api bisa diuji dengan Reader palsu tanpa menyalakan MongoDB.
  • api tidak 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:

  1. parseFilter(r) membaca query parameter menjadi store.Filter. Kalau ada parameter yang dikenal tetapi nilainya tidak sah, kembali pesan masalah, dan API menjawab 400 (kesalahan dari sisi pemanggil).
  2. reader.List(...) mengambil data. r.Context() membawa correlation ID request ini sampai ke store, sehingga log-nya tetap satu rangkaian.
  3. Kalau List gagal (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.
  4. Kalau berhasil, jawabannya dibungkus envelope {"data": [...]}, kesepakatan tim yang sama dengan kedua mock. bnpb-api menyaring isi data lalu menyalin kunci lain apa adanya.

Route 2, GET /hazards/{id}:

  1. r.PathValue("id") mengambil {id} dari alamat.
  2. reader.Get(...) mengembalikan pointer. nil berarti tidak ada, dan API menjawab 404.
  3. Kalau ada, event dikembalikan langsung sebagai satu objek (bukan dalam data). bnpb-api mengenali objek tunggal lewat adanya kunci hazard_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:

  • since harus UTC berakhiran Z. Format dengan offset seperti +07:00 sengaja ditolak: waktu dengan zona berbeda-beda antar service adalah sumber bug yang sulit dilacak.
  • source, hazard_type, severity harus salah satu nilai sah dari package domain. 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

api adalah satu-satunya jalan bagi service lain untuk membaca Canonical Store. Ia menyediakan GET /hazards dengan filter dan GET /hazards/{id}, persis path yang dipanggil bnpb-api. Mongo sendiri tidak terjangkau dari luar aggregator karena berada di jaringan Docker terpisah. Penyaringan field menurut hak akses bukan di sini, tetapi di bnpb-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-api mengubah 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 di bnpb-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

  1. Kenapa Register menerima Reader dan bukan *store.Store?
  2. Apa yang dilihat client kalau Mongo mati: 500 atau 502? Dari mana angkanya? (Tergantung lewat mana: langsung ke aggregator 500; lewat bnpb-api menjadi 502.)
  3. Kenapa since dengan +07:00 ditolak?