Menu
CoddyTech

Paginação

Parte da seção APIs REST do Journey de Node.js da Coddy. Lição 17 de 25.

Uma lista de dez mil tarefas é longa demais para uma única resposta. Envie-a em páginas: o cliente solicita ?page=2&limit=20 e recebe esse trecho, além do que precisa para exibir “Página 2 de 5”:

{ "items": [ ...books 21 to 40... ], "page": 2, "total": 95, "pages": 5 }
const start = (page - 1) * limit;    // a página 1 começa em 0
const items = list.slice(start, start + limit);
const pages = Math.ceil(list.length / limit);

Calcule total depois dos filtros, pois ele descreve a lista solicitada pelo cliente. Quando a string de consulta não tiver page, use 1 e 10 para limit antes de fazer a conversão: Number(undefined) é NaN. Em seguida, rejeite com o código 400 qualquer valor que não seja um número inteiro dentro do intervalo. Um limite máximo para limit impede que um cliente solicite tudo de uma só vez.

challenge icon

Desafio

Fácil

Uma lista de dez mil tarefas é longa demais para uma única resposta. Envie a lista em páginas, com o que um cliente precisa para mostrar "Page 1 of 2".

  1. Em routes/tasks.js, altere o que a rota de lista retorna: um objeto em vez de um array. Para GET /api/tasks?limit=2 com as tarefas iniciais, ela retorna:
    {
      "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 contém as tarefas desta página, page é o número da página, total é o número de tarefas que correspondem aos filtros e pages é o número total de páginas.
  2. Leia page e limit da string de consulta:
    • page é um número inteiro a partir de 1. Se não for informado, use 1.
    • limit é um número inteiro de 1 a 50. Se não for informado, use 10.

    Use o valor padrão antes de converter, porque Number(undefined) é NaN. Qualquer outro valor (page=0, page=abc, page=1.5, limit=0, limit=51) retorna o status 400, com a mensagem limit must be a whole number from 1 to 50 para um limite inválido:

    { "error": { "code": "invalid_query", "message": "page must be a whole number from 1" } }
  3. Faça a paginação por último: filtre e ordene primeiro, depois extraia a página. total conta as tarefas restantes após os filtros, então ?done=false&limit=1 retorna "total": 2 e "pages": 2.

Isso altera o formato da resposta, então um cliente que esperava um array também precisa ser alterado. A lição sobre versionamento mostra como lidar com isso.

Experimente: GET /api/tasks?limit=2 retorna as tarefas 1 e 2, com "page": 1, "total": 3 e "pages": 2. GET /api/tasks?page=2&limit=2 retorna apenas a tarefa 3. GET /api/tasks sem parâmetros retorna as 3 tarefas, com "page": 1 e "pages": 1. GET /api/tasks?page=0 retorna 400 com o código invalid_query.

Experimente você mesmo

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

const router = Router();

// Uma tarefa completa, como PUT a envia. .strict() rejeita campos que não estão listados.
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'];

// Verdadeiro quando outra tarefa já tem esse título, independentemente de maiúsculas ou minúsculas.
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 iconTeste seus conhecimentos

Esta lição inclui um quiz rápido. Comece a lição para respondê-lo e acompanhar seu progresso.

Todas as lições de APIs REST

Pratique por conta própria: Playground de Express