Menu
CoddyTech

Paginasi

Termasuk bagian REST API dalam perjalanan Node.js di Coddy. Pelajaran 17 dari 25.

Daftar berisi sepuluh ribu tugas terlalu panjang untuk satu jawaban. Kirim dalam beberapa halaman: client meminta ?page=2&limit=20 dan menerima potongan itu, beserta informasi yang diperlukan untuk menampilkan "Halaman 2 dari 5":

{ "items": [ ...books 21 to 40... ], "page": 2, "total": 95, "pages": 5 }
const start = (page - 1) * limit;    // halaman 1 dimulai dari 0
const items = list.slice(start, start + limit);
const pages = Math.ceil(list.length / limit);

Hitung total setelah filter diterapkan, karena nilai itu menjelaskan daftar yang diminta client. Jika string kueri tidak memiliki page, gunakan 1, dan 10 untuk limit, sebelum mengonversinya: Number(undefined) adalah NaN. Lalu, tolak nilai apa pun yang bukan bilangan bulat dalam rentang yang diizinkan dengan status 400. Batas maksimum untuk limit mencegah client meminta semuanya sekaligus.

challenge icon

Tantangan

Mudah

Daftar sepuluh ribu tugas terlalu panjang untuk satu respons. Kirim daftar dalam beberapa halaman, agar client tahu apa yang harus ditampilkan sebagai "Page 1 of 2".

  1. Di routes/tasks.js, ubah nilai yang dikembalikan rute daftar: objek, bukan array. Untuk GET /api/tasks?limit=2 dengan tugas awal, hasilnya adalah:
    {
      "items": [
        { "id": 1, "title": "Write the project brief", "done": true, "createdAt": "2026-03-02T09:00:00.000Z" },
        { "id": 2, "title": "Book the meeting room", "done": false, "createdAt": "2026-03-03T10:30:00.000Z" }
      ],
      "page": 1,
      "total": 3,
      "pages": 2
    }
    items berisi tugas di halaman ini, page adalah nomor halaman, total adalah jumlah tugas yang cocok dengan filter, dan pages adalah jumlah halamannya.
  2. Baca page dan limit dari query string:
    • page adalah bilangan bulat mulai dari 1. Jika tidak ada, gunakan 1.
    • limit adalah bilangan bulat dari 1 hingga 50. Jika tidak ada, gunakan 10.

    Gunakan nilai default sebelum mengonversinya, karena Number(undefined) adalah NaN. Nilai lainnya (page=0, page=abc, page=1.5, limit=0, limit=51) menghasilkan status 400, dengan pesan limit must be a whole number from 1 to 50 untuk nilai limit yang tidak valid:

    { "error": { "code": "invalid_query", "message": "page must be a whole number from 1" } }
  3. Lakukan paginasi terakhir: filter dan urutkan terlebih dahulu, lalu ambil halaman yang diminta. total menghitung jumlah tugas yang tersisa setelah difilter, jadi ?done=false&limit=1 menghasilkan "total": 2 dan "pages": 2.

Ini mengubah bentuk respons, jadi client yang mengharapkan array juga harus berubah. Pelajaran Versioning menunjukkan cara menanganinya.

Coba: GET /api/tasks?limit=2 mengembalikan tugas 1 dan 2 dengan "page": 1, "total": 3, dan "pages": 2. GET /api/tasks?page=2&limit=2 hanya mengembalikan tugas 3. GET /api/tasks biasa mengembalikan semua 3 tugas dengan "page": 1 dan "pages": 1. GET /api/tasks?page=0 mengembalikan 400 dengan kode invalid_query.

Coba sendiri

import { Router } from 'express';
import { z } from 'zod';
import { sendError, sendInvalid } from '../errors.js';
import { nextId, tasks } from '../store.js';

const router = Router();

// Task lengkap, seperti yang dikirim oleh PUT. .strict() menolak field yang tidak tercantum.
const Task = z
    .object({
        title: z.string().trim().min(1).max(100),
        done: z.boolean(),
    })
    .strict();
const NewTask = Task.partial({ done: true });
const TaskChanges = Task.partial();
const SORT_FIELDS = ['title', 'createdAt'];

// True jika task lain sudah memiliki judul ini, tanpa membedakan huruf besar dan kecil.
const titleTaken = (title, exceptId) =>
    tasks.some((t) => t.id !== exceptId && t.title.toLowerCase() === title.toLowerCase());

router.get('/', (req, res) => {
    const { done, q, sort } = req.query;
    let list = tasks;
    if (done !== undefined) {
        if (done !== 'true' && done !== 'false') {
            return sendError(res, 400, 'invalid_query', 'done must be true or false');
        }
        list = list.filter((t) => t.done === (done === 'true'));
    }
    if (q !== undefined) {
        const words = String(q).toLowerCase();
        list = list.filter((t) => t.title.toLowerCase().includes(words));
    }
    if (sort !== undefined) {
        const field = String(sort).startsWith('-') ? String(sort).slice(1) : String(sort);
        if (!SORT_FIELDS.includes(field)) {
            return sendError(
                res,
                400,
                'invalid_query',
                'sort must be title or createdAt, with - in front to reverse',
            );
        }
        const direction = String(sort).startsWith('-') ? -1 : 1;
        list = list.toSorted((a, b) => a[field].localeCompare(b[field]) * direction);
    }
    res.json(list);
});

router.get('/:id', (req, res) => {
    const task = tasks.find((t) => t.id === Number(req.params.id));
    if (!task) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    res.json(task);
});

router.post('/', (req, res) => {
    const result = NewTask.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    const { title, done = false } = result.data;
    if (titleTaken(title)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    const task = { id: nextId(), title, done, createdAt: new Date().toISOString() };
    tasks.push(task);
    res.status(201).location(`/api/tasks/${task.id}`).json(task);
});

router.patch('/:id', (req, res) => {
    const task = tasks.find((t) => t.id === Number(req.params.id));
    if (!task) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    const result = TaskChanges.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    if (result.data.title !== undefined && titleTaken(result.data.title, task.id)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    Object.assign(task, result.data);
    res.json(task);
});

router.put('/:id', (req, res) => {
    const index = tasks.findIndex((t) => t.id === Number(req.params.id));
    if (index === -1) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    const result = Task.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    const { id, createdAt } = tasks[index];
    if (titleTaken(result.data.title, id)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    tasks[index] = { id, ...result.data, createdAt };
    res.json(tasks[index]);
});

router.delete('/:id', (req, res) => {
    const index = tasks.findIndex((t) => t.id === Number(req.params.id));
    if (index === -1) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    tasks.splice(index, 1);
    res.status(204).end();
});

export default router;
quiz iconUji dirimu

Pelajaran ini berisi kuis singkat. Mulai pelajarannya untuk menjawab kuis dan melacak progresmu.

Semua pelajaran di REST API

Latihan sendiri: Playground Express