Pagination
Fait partie de la section API REST du Journey Node.js de Coddy. Leçon 17 sur 25.
Une liste de dix mille tâches est trop longue pour une seule réponse. Envoyez-la par pages : le client demande ?page=2&limit=20 et reçoit cette tranche, ainsi que les informations nécessaires pour afficher « Page 2 sur 5 » :
{ "items": [ ...books 21 to 40... ], "page": 2, "total": 95, "pages": 5 }const start = (page - 1) * limit; // la page 1 commence à 0
const items = list.slice(start, start + limit);
const pages = Math.ceil(list.length / limit);Calculez total après les filtres, car il décrit la liste demandée par le client. Si la chaîne de requête ne contient pas page, utilisez 1, et 10 pour limit, avant de convertir : Number(undefined) vaut NaN. Ensuite, refusez toute valeur qui n’est pas un entier dans la plage autorisée, avec une erreur 400. Une valeur maximale pour limit empêche un client de tout demander en une seule fois.
Défi
FacileUne liste de dix mille tâches est trop longue pour une seule réponse. Envoyez la liste sous forme de pages, avec les informations dont un client a besoin pour afficher "Page 1 of 2".
- Dans
routes/tasks.js, modifiez ce que renvoie la route de liste : un objet au lieu d’un tableau. PourGET /api/tasks?limit=2avec les tâches de départ, elle renvoie :{ "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 }itemscontient les tâches de cette page,pageest le numéro de page,totalindique combien de tâches correspondent aux filtres, etpagesindique le nombre de pages. - Lisez
pageetlimitdans la chaîne de requête :pageest un nombre entier à partir de 1. En son absence, utilisez 1.limitest un nombre entier de 1 à 50. En son absence, utilisez 10.
Utilisez la valeur par défaut avant la conversion, car
Number(undefined)vautNaN. Toute autre valeur (page=0,page=abc,page=1.5,limit=0,limit=51) renvoie le statut 400, avec le messagelimit must be a whole number from 1 to 50si la limite est incorrecte :{ "error": { "code": "invalid_query", "message": "page must be a whole number from 1" } } - Effectuez la pagination en dernier : filtrez et triez d’abord, puis récupérez la page.
totalcompte les tâches restantes après l’application des filtres ; ainsi,?done=false&limit=1donne"total": 2et"pages": 2.
La structure de la réponse change ; un client qui s’attendait à recevoir un tableau doit donc également être modifié. La leçon sur le versionnement montre comment procéder.
Essayez : GET /api/tasks?limit=2 renvoie les tâches 1 et 2 avec "page": 1, "total": 3 et "pages": 2. GET /api/tasks?page=2&limit=2 renvoie uniquement la tâche 3. GET /api/tasks sans paramètres renvoie les 3 tâches avec "page": 1 et "pages": 1. GET /api/tasks?page=0 renvoie le statut 400 avec le code invalid_query.
Essayez vous-même
import { Router } from 'express';
import { z } from 'zod';
import { sendError, sendInvalid } from '../errors.js';
import { nextId, tasks } from '../store.js';
const router = Router();
// Une tâche complète, telle que PUT l’envoie. .strict() refuse les champs qu’elle ne répertorie pas.
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'];
// Vrai si une autre tâche possède déjà ce titre, quelle que soit la casse.
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;
Cette leçon comprend un petit quiz. Commencez la leçon pour y répondre et suivre votre progression.
Toutes les leçons de API REST
1Ressources et verbes
Ressources et URLCréer avec POST4Navigateurs, versions et configuration
Qu’est-ce qu’une origineAutoriser une autre origineCORSUne page qui appelle l’APIEntraînez-vous par vous-même : Playground Express