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:
{
"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.
| Pendekatan | Kelebihan | Kelemahan |
|---|---|---|
halaman + batas (offset) | Sederhana, bisa lompat ke halaman mana pun | Item 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 akhir | Tidak 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:
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:
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:
- Baris 14-15 (whitelist): Nilai status dan kolom urut yang diizinkan dideklarasikan di atas — sumber tunggal kebenaran yang sama seperti pola
STATUS_VALIDpada Chapter Validasi Input. - Baris 28-66 (
validasiKueri): Fungsi ini memeriksa keempat parameter dan mengembalikan nilai siap pakai sekaligus daftar galat. Perhatikan polaquery.has()pada URLSearchParams : parameter yang tidak dikirim memakai nilai default, parameter yang dikirim divalidasi. KonversiNumber()menghasilkanNaNuntuk teks acak dan2.5untuk pecahan — keduanya ditolakNumber.isInteger, sementara string kosong menjadi0dan tertangkap pemeriksaan< 1. - 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. - Baris 77-85 (filter lalu sort): Koleksi disalin dulu (
[...tugas.values()]) agarfilterdansorttidak mengubahMapasli. Penyaringan dilakukan sebelum pengurutan, dan comparator mengembalikan-1,0, atau1yang dibalik tanda bila urutan menurun. - Baris 87-95 (perhitungan halaman):
totalHalamanmemakaiMath.ceilagar sisa item membentuk halaman terakhir, danMath.max(1, ...)menjaga koleksi kosong tetap dilaporkan satu halaman. Pemotonganslice(mulai, mulai + batas)aman untukmulaimelebihi panjang array — hasilnya array kosong, sesuai keputusan desain kita. - 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 tugasbelum, diurutkan judul menurun, dipotong dua item. Permintaan ketiga menunjukkan halaman terlampaui menghasilkandatakosong denganmetatetap 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.