Filter & Sort — API

API yang baik memungkinkan client memfilter dan mengurutkan data via query parameters. Filtering: GET…

Daftar buku Toko Buku sudah diambil dari database. Sekarang client mau memilih: buku Andrea Hirata saja, yang termahal dulu, halaman 2. Pilihan itu dikirim lewat query string, bagian URL sesudah tanda ?: GET /books?author_id=1&sort=price&order=desc. Filter menyaring baris mana yang ikut, sort mengatur urutannya. Bayangin minta tolong ke pelayan toko: "yang Andrea Hirata saja, urutkan dari yang paling mahal". Pelayan cuma melayani pilihan yang ada di daftarnya. Mengurutkan menurut judul, harga, atau stok boleh. Menurut harga modal gak boleh, itu rahasia toko.

🧪 Tebakan cepat: handler mengurutkan dengan ORDER BY ? dan mengirim nilai sort lewat placeholder, sama seperti nilai filter. Request ?sort=price hasilnya terurut menurut harga, tetap urutan asal, atau error? Tebak dulu, jawabannya ada di bagian "Sort: nama kolom gak bisa lewat placeholder".

Membaca query string

Express membaca query string ke req.query. Isinya selalu teks, dan parameter yang gak dikirim bernilai undefined:

Request req.query
GET /books {}
GET /books?author_id=1 { author_id: "1" }
GET /books?author_id=1&sort=price&order=desc { author_id: "1", sort: "price", order: "desc" }

Di Laravel sama: GET /api/siswa?kelas=XII dibaca di controller dengan $request->query('kelas'), hasilnya "XII".

Filter: syarat ditambah satu per satu

Tiap filter yang dikirim menambah satu syarat ke WHERE. Filter yang gak dikirim gak menambah apa-apa.

const where = [];
const params = [];
if (req.query.author_id) {
  where.push("author_id = ?");
  params.push(Number(req.query.author_id));
}
if (req.query.in_stock === "1") {
  where.push("stock > 0");
}
const whereSql = where.length ? " WHERE " + where.join(" AND ") : "";
Query string WHERE yang jadi params Buku
(kosong) (gak ada) [] 8
?author_id=1 WHERE author_id = ? [1] 3
?in_stock=1 WHERE stock > 0 [] 6
?author_id=2&in_stock=1 WHERE author_id = ? AND stock > 0 [2] 1

Sort: nama kolom gak bisa lewat placeholder

Placeholder cuma mengisi nilai. ORDER BY ? dengan nilai "price" artinya "urutkan menurut teks 'price'", dan teks itu sama persis di semua baris. SQLite gak error, tapi gak mengurutkan apa-apa: hasilnya tetap urutan asal, Laskar Pelangi, Bumi Manusia, Cantik Itu Luka, dan seterusnya. Itu jawaban tebakannya.

Jadi nama kolom harus ditulis ke teks SQL. Satu-satunya cara aman: allowlist, daftar nama yang diizinkan, ditulis server sendiri.

const SORTABLE = ["id", "title", "price", "stock"];
const { sort = "id", order = "asc" } = req.query;
if (!SORTABLE.includes(sort)) {
  return res.status(400).json({ error: "sort must be one of: id, title, price, stock" });
}
if (order !== "asc" && order !== "desc") {
  return res.status(400).json({ error: "order must be asc or desc" });
}
const orderSql = ` ORDER BY ${sort} ${order.toUpperCase()}`;

Kenapa harus begini? Tanpa allowlist, ?sort=cost_price mengurutkan menurut harga modal: Edensor (modal 52.000) di depan, lalu Sang Pemimpi (55.000). Padahal menurut harga jual, Sang Pemimpi (79.000) yang termurah. API gak pernah mengirim cost_price, tapi urutannya membocorkannya. Dan karena sort masuk ke teks SQL, isi lain bisa mengubah perintahnya: SQL injection.

Halaman: page dan limit

LIMIT dan OFFSET adalah nilai, jadi boleh lewat placeholder. limit dijaga antara 1 dan 50, supaya client gak bisa meminta sejuta baris sekaligus. Satu query lagi menghitung total dengan WHERE yang sama, untuk meta seperti di pelajaran Pagination. Handler lengkapnya:

const SORTABLE = ["id", "title", "price", "stock"];

app.get("/books", (req, res) => {
  const { author_id, in_stock, sort = "id", order = "asc" } = req.query;
  if (!SORTABLE.includes(sort)) {
    return res.status(400).json({ error: "sort must be one of: id, title, price, stock" });
  }
  if (order !== "asc" && order !== "desc") {
    return res.status(400).json({ error: "order must be asc or desc" });
  }
  const page = Math.max(parseInt(req.query.page, 10) || 1, 1);
  const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 3, 1), 50);

  const where = [];
  const params = [];
  if (author_id) {
    where.push("author_id = ?");
    params.push(Number(author_id));
  }
  if (in_stock === "1") where.push("stock > 0");
  const whereSql = where.length ? " WHERE " + where.join(" AND ") : "";

  const { total } = db.prepare("SELECT COUNT(*) AS total FROM books" + whereSql).get(...params);
  const data = db
    .prepare(`SELECT id, title, author_id, price, stock FROM books${whereSql} ORDER BY ${sort} ${order.toUpperCase()} LIMIT ? OFFSET ?`)
    .all(...params, limit, (page - 1) * limit);

  res.json({ data, meta: { page, limit, total, totalPages: Math.ceil(total / limit) } });
});

GET /books?author_id=1&sort=price&order=desc dijawab tiga buku Andrea Hirata, termahal dulu: Laskar Pelangi (89.000), Edensor (85.000), Sang Pemimpi (79.000), plus "meta": { "page": 1, "limit": 3, "total": 3, "totalPages": 1 }.

GET /books?in_stock=1&sort=title&page=2: 6 buku lolos filter, limit 3, jadi totalPages 2. Halaman 2 berisi Laskar Pelangi, Ronggeng Dukuh Paruk, dan Sang Pemimpi.

📌 Di Laravel

$kelas = $request->query('kelas');
$sort = $request->query('sort', 'id');
abort_unless(in_array($sort, ['id', 'nama', 'nis'], true), 400, 'sort must be one of: id, nama, nis');
$order = $request->query('order') === 'desc' ? 'desc' : 'asc';

$siswa = Siswa::when($kelas, fn ($q) => $q->where('kelas', $kelas))
    ->orderBy($sort, $order)
    ->paginate(15);

⚠️ Jebakan yang sering ketemu

TL;DR: Filter dan sort dikirim lewat query string, ?author_id=1&sort=price&order=desc, dan dibaca dari req.query (di Laravel $request->query('kelas')); semua nilainya teks. Tiap filter menambah satu syarat ke WHERE, dengan nilai lewat placeholder. Nama kolom gak bisa lewat placeholder: ORDER BY ? gak mengurutkan apa-apa. Jadi sort dicek dengan allowlist (["id", "title", "price", "stock"]) dan order harus asc atau desc; selain itu dijawab 400. Tanpa allowlist, sort=cost_price membocorkan harga modal lewat urutan, dan orderBy($column) dari client membuka SQL injection atau akses kolom sensitif. LIMIT ? OFFSET ? plus meta untuk halaman. Di Laravel, when($kelas, ...) menambah filter hanya kalau parameternya ada.

Yang akan kamu pelajari