Menu
CoddyTech

Paginazione

Fa parte della sezione API REST del percorso Node.js di Coddy. Lezione 17 di 25.

Un elenco di diecimila attività è troppo lungo per una sola risposta. Invialo in pagine: il client richiede ?page=2&limit=20 e riceve quella porzione, oltre a ciò che gli serve per visualizzare «Pagina 2 di 5»:

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

Conta total dopo i filtri, perché descrive l'elenco richiesto dal client. Se la stringa di query non contiene page, usa 1 e 10 per limit, prima di effettuare la conversione: Number(undefined) è NaN. Poi rifiuta con 400 qualsiasi valore che non sia un numero intero nell'intervallo previsto. Un valore massimo per limit impedisce al client di richiedere tutto in una volta.

challenge icon

Sfida

Facile

Un elenco di diecimila attività è troppo lungo per una sola risposta. Invia l’elenco in pagine, indicando ciò che serve a un client per mostrare "Page 1 of 2".

  1. In routes/tasks.js, modifica ciò che restituisce la route dell’elenco: un oggetto invece di un array. Per GET /api/tasks?limit=2 con le attività iniziali, restituisce:
    {
      "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 contiene le attività di questa pagina, page è il numero della pagina, total è il numero di attività corrispondenti ai filtri e pages è il numero totale di pagine.
  2. Leggi page e limit dalla stringa di query:
    • page è un numero intero a partire da 1. Se non è specificato, usa 1.
    • limit è un numero intero da 1 a 50. Se non è specificato, usa 10.

    Usa il valore predefinito prima della conversione, perché Number(undefined) è NaN. Qualsiasi altro valore (page=0, page=abc, page=1.5, limit=0, limit=51) restituisce lo stato 400, con il messaggio limit must be a whole number from 1 to 50 se il valore di limit non è valido:

    { "error": { "code": "invalid_query", "message": "page must be a whole number from 1" } }
  3. Esegui la paginazione per ultima: prima filtra e ordina, poi estrai la pagina. total conta le attività rimaste dopo i filtri, quindi ?done=false&limit=1 restituisce "total": 2 e "pages": 2.

Questo modifica la struttura della risposta, quindi anche un client che si aspettava un array deve essere modificato. La lezione sul versionamento mostra come gestire questo cambiamento.

Prova: GET /api/tasks?limit=2 restituisce le attività 1 e 2 con "page": 1, "total": 3 e "pages": 2. GET /api/tasks?page=2&limit=2 restituisce solo l’attività 3. GET /api/tasks senza parametri restituisce tutte e 3 le attività con "page": 1 e "pages": 1. GET /api/tasks?page=0 restituisce 400 con il codice invalid_query.

Provalo tu

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

const router = Router();

// Un task completo, come lo invia PUT. .strict() rifiuta i campi che non elenca.
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 se un altro task ha già questo titolo, indipendentemente da maiuscole e minuscole.
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 iconMettiti alla prova

Questa lezione include un breve quiz. Inizia la lezione per rispondere e tenere traccia dei tuoi progressi.

Tutte le lezioni di API REST

Esercitati da solo: Playground Express