GET /tugas pada Chapter-Chapter sebelumnya selalu mengembalikan seluruh koleksi. Dengan tujuh tugas hal itu masih wajar; dengan sepuluh ribu tugas, satu response membawa megabyte data, client menunggu lama, dan server sibuk menyusun array raksasa untuk setiap permintaan. Solusi standar di dunia REST ada dua: pagination membatasi jumlah item per permintaan, dan filtering menyempitkan koleksi sesuai kriteria. Keduanya dikendalikan lewat query parameter — bagian URL setelah tanda tanya — yang sudah kita kenal sejak blueprint pada Chapter Merancang Endpoint REST. Pada Chapter ini kita menghidupkan parameter halaman, batas, status, dan urut lengkap dengan validasinya.

Envelope data dan meta #

Selama ini koleksi dikirim sebagai array polos. Begitu pagination masuk, kita perlu tempat untuk memberi tahu client berapa total item dan ada berapa halaman — informasi yang tidak muat di dalam array. Karena itu bentuk response berubah menjadi envelope dua field:

json Bentuk response terpaginasi
{
  "data": [
    { "id": 3, "title": "Merancang skema database", "status": "belum" }
  ],
  "meta": {
    "halaman": 2,
    "batas": 2,
    "total": 7,
    "totalHalaman": 4
  }
}

Field data memuat halaman koleksi, sedangkan meta memuat fakta tentang keseluruhan hasil. Client memakai meta untuk merender navigasi halaman atau menghentikan pengguliran data. Kode status tetap 200 — permintaan berhasil, hanya hasilnya kosong atau terpotong.

Cara Kerja Pagination Berbasis halaman #

Pagination paling sederhana memakai dua parameter: halaman (nomor halaman, mulai dari 1) dan batas (jumlah item per halaman). Dengan keduanya, offset dihitung dari rumus mulai = (halaman - 1) × batas, lalu koleksi dipotong dari mulai sebanyak batas item. Nilai default menjaga client sederhana — tanpa parameter apa pun, server mengembalikan halaman pertama dengan batas wajar.

Ada dua keputusan desain yang perlu disepakati di sini. Pertama, halaman yang melebihi jangkauan (halaman=9 padahal hanya ada 3 halaman) dijawab 200 dengan data kosong, bukan 404 — koleksinya ada, hanya halamannya tidak berisi. Kedua, nilai batas dibatasi maksimal 100: tanpa batas, client bisa meminta batas=1000000 dan mengembalikan kita ke masalah awal.

PendekatanKelebihanKelemahan
halaman + batas (offset)Sederhana, bisa lompat ke halaman mana punItem bergeser saat ada data baru disisipkan; halaman dalam lambat di database besar
cursor (ambil setelah id tertentu)Stabil saat data berubah, cocok untuk umpan tanpa akhirTidak bisa lompat ke halaman tertentu; client harus menyimpan cursor

Pendekatan cursor — “berikan 10 item setelah id 42” — menghindari pergeseran item karena posisi ditentukan oleh nilai data, bukan urutan hitungan. Untuk API tugas kita, pagination berbasis halaman sudah memadai; tabel di atas menjadi bekal ketika kita merancang API umpan yang terus bertambah.

Filtering dan Sorting #

Penyaringan memakai parameter status dengan pencocokan nilai penuh: ?status=selesai hanya mengembalikan tugas yang sudah selesai. Pengurutan memakai parameter urut dengan format nama kolom, opsional diawali - untuk menurun: ?urut=-title mengurutkan judul dari Z ke A. Nama kolom yang diterima dibatasi whitelist — id, title, status — lewat sebuah Set. Tanpa whitelist, client bisa mencoba ?urut= nama field internal apa pun dan menjadikannya celah introspeksi; kebijakan yang sama juga menjaga sorting tetap konsisten dengan kolom yang benar-benar dijamin ada. Berikut implementasi lengkapnya:

js
import http from 'node:http';

const host = '127.0.0.1';
const tugas = new Map([
  [1, { id: 1, title: 'Belajar Node.js', status: 'selesai' }],
  [2, { id: 2, title: 'Menulis dokumentasi API', status: 'belum' }],
  [3, { id: 3, title: 'Merancang skema database', status: 'belum' }],
  [4, { id: 4, title: 'Membuat halaman utama', status: 'selesai' }],
  [5, { id: 5, title: 'Menyiapkan materi presentasi', status: 'belum' }],
  [6, { id: 6, title: 'Meninjau pull request', status: 'selesai' }],
  [7, { id: 7, title: 'Belajar validasi input', status: 'belum' }],
]);

const STATUS_VALID = ['belum', 'selesai'];
const KOLOM_URUT = new Set(['id', 'title', 'status']);

function kirimJson(res, kode, data) {
  res.writeHead(kode, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(data));
}

function kirimError(res, kode, kodeError, pesan, detail) {
  const error = { kode: kodeError, pesan };
  if (detail) error.detail = detail;
  kirimJson(res, kode, { error });
}

function validasiKueri(query) {
  const galat = [];

  let halaman = 1;
  if (query.has('halaman')) {
    halaman = Number(query.get('halaman'));
    if (!Number.isInteger(halaman) || halaman < 1) {
      galat.push('halaman harus bilangan bulat minimal 1');
    }
  }

  let batas = 3;
  if (query.has('batas')) {
    batas = Number(query.get('batas'));
    if (!Number.isInteger(batas) || batas < 1) {
      galat.push('batas harus bilangan bulat minimal 1');
    }
    if (batas > 100) galat.push('batas maksimal 100');
  }

  let status = null;
  if (query.has('status')) {
    status = query.get('status');
    if (!STATUS_VALID.includes(status)) {
      galat.push('status harus salah satu dari: belum, selesai');
    }
  }

  let urut = 'id';
  if (query.has('urut')) {
    urut = query.get('urut');
    const kolom = urut.startsWith('-') ? urut.slice(1) : urut;
    if (!KOLOM_URUT.has(kolom)) {
      galat.push('urut harus salah satu dari: id, title, status (awali dengan - untuk menurun)');
    }
  }

  return { galat, halaman, batas, status, urut };
}

const server = http.createServer((req, res) => {
  const url = new URL(req.url, `http://${host}`);

  if (req.method === 'GET' && url.pathname === '/tugas') {
    const { galat, halaman, batas, status, urut } = validasiKueri(url.searchParams);
    if (galat.length > 0) {
      return kirimError(res, 422, 'VALIDASI_GAGAL', 'Parameter kueri tidak valid', galat);
    }

    let hasil = [...tugas.values()];
    if (status) hasil = hasil.filter((t) => t.status === status);

    const menurun = urut.startsWith('-');
    const kolom = menurun ? urut.slice(1) : urut;
    hasil.sort((a, b) => {
      const banding = a[kolom] < b[kolom] ? -1 : a[kolom] > b[kolom] ? 1 : 0;
      return menurun ? -banding : banding;
    });

    const total = hasil.length;
    const totalHalaman = Math.max(1, Math.ceil(total / batas));
    const mulai = (halaman - 1) * batas;
    const data = hasil.slice(mulai, mulai + batas);

    return kirimJson(res, 200, {
      data,
      meta: { halaman, batas, total, totalHalaman },
    });
  }

  return kirimError(res, 404, 'TIDAK_ADA', 'Endpoint tidak ditemukan');
});

server.listen(0, host, async () => {
  const port = server.address().port;
  const base = `http://${host}:${port}`;

  const a = await fetch(`${base}/tugas?halaman=2&batas=2`);
  console.log(`GET /tugas?halaman=2&batas=2 -> ${a.status}`, JSON.stringify(await a.json()));

  const b = await fetch(`${base}/tugas?status=belum&urut=-title&batas=2`);
  console.log(`GET /tugas?status=belum&urut=-title&batas=2 -> ${b.status}`, JSON.stringify(await b.json()));

  const c = await fetch(`${base}/tugas?halaman=9`);
  console.log(`GET /tugas?halaman=9 -> ${c.status}`, JSON.stringify(await c.json()));

  const d = await fetch(`${base}/tugas?halaman=0`);
  console.log(`GET /tugas?halaman=0 -> ${d.status}`, JSON.stringify(await d.json()));

  server.close();
  server.closeAllConnections();
});

Output yang Dihasilkan:

text
GET /tugas?halaman=2&batas=2 -> 200 {"data":[{"id":3,"title":"Merancang skema database","status":"belum"},{"id":4,"title":"Membuat halaman utama","status":"selesai"}],"meta":{"halaman":2,"batas":2,"total":7,"totalHalaman":4}}
GET /tugas?status=belum&urut=-title&batas=2 -> 200 {"data":[{"id":3,"title":"Merancang skema database","status":"belum"},{"id":5,"title":"Menyiapkan materi presentasi","status":"belum"}],"meta":{"halaman":1,"batas":2,"total":4,"totalHalaman":2}}
GET /tugas?halaman=9 -> 200 {"data":[],"meta":{"halaman":9,"batas":3,"total":7,"totalHalaman":3}}
GET /tugas?halaman=0 -> 422 {"error":{"kode":"VALIDASI_GAGAL","pesan":"Parameter kueri tidak valid","detail":["halaman harus bilangan bulat minimal 1"]}}

Empat permintaan di skenario uji menelusuri jalur-jalur penting. Mari kita bedah implementasinya:

  1. Baris 14-15 (whitelist): Nilai status dan kolom urut yang diizinkan dideklarasikan di atas — sumber tunggal kebenaran yang sama seperti pola STATUS_VALID pada Chapter Validasi Input.
  2. Baris 28-66 (validasiKueri): Fungsi ini memeriksa keempat parameter dan mengembalikan nilai siap pakai sekaligus daftar galat. Perhatikan pola query.has() pada URLSearchParams : parameter yang tidak dikirim memakai nilai default, parameter yang dikirim divalidasi. Konversi Number() menghasilkan NaN untuk teks acak dan 2.5 untuk pecahan — keduanya ditolak Number.isInteger, sementara string kosong menjadi 0 dan tertangkap pemeriksaan < 1.
  3. Baris 71-75 (gerbang validasi): Query parameter tunduk pada aturan yang sama dengan body — satu pun galat dikirim sebagai 422 dengan daftar detail, persis bentuk validasi Chapter sebelumnya. Client hanya perlu memahami satu konvensi error.
  4. Baris 77-85 (filter lalu sort): Koleksi disalin dulu ([...tugas.values()]) agar filter dan sort tidak mengubah Map asli. Penyaringan dilakukan sebelum pengurutan, dan comparator mengembalikan -1, 0, atau 1 yang dibalik tanda bila urutan menurun.
  5. Baris 87-95 (perhitungan halaman): totalHalaman memakai Math.ceil agar sisa item membentuk halaman terakhir, dan Math.max(1, ...) menjaga koleksi kosong tetap dilaporkan satu halaman. Pemotongan slice(mulai, mulai + batas) aman untuk mulai melebihi panjang array — hasilnya array kosong, sesuai keputusan desain kita.
  6. Baris 105-115 (skenario uji): Permintaan pertama membuktikan offset — item ke-3 dan ke-4 muncul di halaman 2 dengan batas=2. Permintaan kedua menggabungkan filter dan sort — hanya tugas belum, diurutkan judul menurun, dipotong dua item. Permintaan ketiga menunjukkan halaman terlampaui menghasilkan data kosong dengan meta tetap utuh. Permintaan keempat menegaskan parameter tidak valid ditolak 422, bukan diabaikan diam-diam.

API kita kini punya koleksi yang rapi: bisa dibatasi, disaring, dan diurutkan. Tetapi setiap perbaikan bentuk response — misalnya mengganti nama field title menjadi nama — berpotensi memutus semua client lama sekaligus. Chapter Versioning API menjawabnya dengan mengelola beberapa versi API berdampingan.