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.
Sfida
FacileUn 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".
- In
routes/tasks.js, modifica ciò che restituisce la route dell’elenco: un oggetto invece di un array. PerGET /api/tasks?limit=2con 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 }itemscontiene le attività di questa pagina,pageè il numero della pagina,totalè il numero di attività corrispondenti ai filtri epagesè il numero totale di pagine. - Leggi
pageelimitdalla 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 messaggiolimit must be a whole number from 1 to 50se il valore di limit non è valido:{ "error": { "code": "invalid_query", "message": "page must be a whole number from 1" } } - Esegui la paginazione per ultima: prima filtra e ordina, poi estrai la pagina.
totalconta le attività rimaste dopo i filtri, quindi?done=false&limit=1restituisce"total": 2e"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;
Questa lezione include un breve quiz. Inizia la lezione per rispondere e tenere traccia dei tuoi progressi.
Tutte le lezioni di API REST
1Risorse e verbi
Risorse e URLCreare con POST4Browser, versioni e configurazione
Che cos'è un'origineConsentire un'altra origineCORSUna pagina che chiama l'APIEsercitati da solo: Playground Express