Skip to content

Satu Query Param, Tiga Lapis: Filter ?module= yang Aman

Adityo Guni Waluyo

Nambah filter ?module= bukan cuma urusan WHERE: validasi 400 di handler, subquery parameterized, dan cache key per kombinasi filter.

Ringkasan

Cachenya awalnya cuma mappoints:all jadi kalau filter module cuma ditempel di SQL, data bisa ketuker parah. Makanya dibikin tiga lapis pertahanan biar aman. Validasi dulu kalau modulnya ngaco langsung 400, query-nya pakai parameterized, dan cache key-nya dipisah sesuai filter.

Saya lagi ngelihat log cache di endpoint map points. Kuncinya waktu itu cuma satu: mappoints:all. Bayangin kalau saya naif nambahin filter ?module=PARIWISATA cuma di level query SQL tanpa ngubah logika cache sama sekali. Request pertama yang minta data pariwisata bakal nyimpen hasilnya di kunci mappoints:all itu, dan request berikutnya yang nggak pakai filter bakal langsung disuguhi data pariwisata. Atau sebaliknya: data tanpa filter kebawa ke request yang spesifik.

Waktu pertama lihat kebutuhan ini, kepala saya langsung bilang: tinggal tambah satu kondisi WHERE, kelar. Tapi pola akses pengguna nggak linier; ada yang buka halaman tanpa filter, ada yang langsung milih kategori spesifik. Kalau cache key-nya statis, data yang keluar bakal ketukar-katukir. HTTP juga stateless: tiap request divalidasi sendiri-sendiri tanpa ngandelin request sebelumnya [5]. Kalau cuma mengandelin database, CPU dan I/O udah kebuang dulu buat query yang seharusnya ditolak dari awal.

Jebakan cache yang nggak kelihatan

Masalah utamanya tabrakan data. Request berfilter yang nyimpen hasilnya di kunci netral itu ibarat ngeracuni cache yang seharusnya dinikmat semua pengguna. Solusinya nggak mungkin di satu tempat: satu query param butuh tiga lapis pertahanan biar nggak ada celah. Ditolak lebih awal, difilter di SQL, dan di cache pun dipisah.

Lapis satu dan dua: validasi 400 dan subquery parameterized

Lapis pertama di handler. Sebelum nyentuh database apa pun, nilai module dicek ke enum tetap: PARIWISATA, EKONOMI_KREATIF, OLAHRAGA, BUDAYA, INFORMASI, KEPEMUDAAN; total 6 nilai yang boleh [1]. Salah nilai? Langsung 400 INVALID_MODULE, lengkap sama daftar nilai valid di pesannya [1]. Artinya sesuai kontrak HTTP: 400 itu server menolak memproses karena kesalahannya di sisi client, dan ngulang request yang sama tanpa perubahan bakal gagal lagi [1].

Detail kecil yang penting: q.Get balikin string kosong kalau param-nya nggak ada [4]. Makanya kondisinya module != "" && !ValidModule(module); yang ditolak cuma nilai yang eksplisit salah, sedangkan kosong berarti tanpa filter dan itu sah. Validasinya satu predikat bersama, scope.ValidModule, dipakai di 3 endpoint: listEntities, getMapPoints, listCategories. Nggak ada versi validasi yang ketinggalan di salah satu pintu.

Lapis kedua di repository: subquery parameterized AND category_id IN (SELECT id FROM categories WHERE module = ?) dicangkok seragam di query list, map points, sampai map points viewport. Parameterized, bukan diselipin string, jadi nilainya perjalanan dari query param sampai ke database tetap data, bukan kode.

Lapis tiga: cache key ikut bawa filter

Lapis ketiga, dan ini yang bikin skenario racun di atas mustahil terjadi: cache key bercabang mengikuti kombinasi filter. Ada mappoints:module:m:cat:id, mappoints:module:m, mappoints:cat:id, atau mappoints:all, semua TTL 5 menit. Request berfilter nggak akan pernah baca ataupun ngeracuni cache tanpa filter [2]. Konsep ini sebenernya satu cermain dengan header Vary di HTTP caching: respons dipisah per bagian request yang mempengaruhi isinya [2]. Bedanya, di sini pemisahan dilakukan eksplisit di server, nggak ngandalin perilaku CDN yang beda-beda.

Sisanya dokumentasi: enum-nya dicantumin di openapi.yaml ketiga endpoint plus catatan bahwa nilai tak dikenal bakal kena 400 [3]. Client jadi punya kontrak tertulis, bukan coba-coba [3]. Bentuk akhirnya di handler kayak gini:

module := q.Get("module")
if module != "" && !scope.ValidModule(module) {
    response.Error(w, http.StatusBadRequest, "INVALID_MODULE",
        "unknown module; allowed: PARIWISATA, EKONOMI_KREATIF, OLAHRAGA, BUDAYA, INFORMASI, KEPEMUDAAN")
    return
}

cacheKey := "mappoints:all"
switch {
case module != "" && categoryID > 0:
    cacheKey = fmt.Sprintf("mappoints:module:%s:cat:%d", module, categoryID)
case module != "":
    cacheKey = "mappoints:module:" + module
case categoryID > 0:
    cacheKey = fmt.Sprintf("mappoints:cat:%d", categoryID)
}

Satu detail yang gampang kelewat: endpoint categories udah dulu punya ?module=. Commit ini ya nambahin validasi 400 ke pintu itu, sekalian nge-rollout filter ke list dan map points. Tes handler (107 baris) nutup kasus 400 dan kombinasi filter; tes repository khusus module (90 baris) nutup bentuk query-nya. Dengan fail fast di lapis pertama, dua lapis di bawah tinggal fokus satu kerja: nyaring, bukan menebak.

Sources

Artikel terkait

Filter ?module= Aman: Validasi 400 dan Cache Key · Adityo GW