Paginación
Parte de la sección API REST del Journey de Node.js de Coddy. Lección 17 de 25.
Una lista de diez mil tareas es demasiado larga para una sola respuesta. Envíala por páginas: el cliente solicita ?page=2&limit=20 y obtiene ese fragmento, además de lo que necesita para mostrar «Página 2 de 5»:
{ "items": [ ...books 21 to 40... ], "page": 2, "total": 95, "pages": 5 }const start = (page - 1) * limit; // la página 1 comienza en 0
const items = list.slice(start, start + limit);
const pages = Math.ceil(list.length / limit);Cuenta total después de aplicar los filtros, ya que describe la lista que solicitó el cliente. Si la cadena de consulta no incluye page, usa 1, y 10 para limit, antes de convertirlos: Number(undefined) es NaN. Después, rechaza con el código 400 cualquier valor que no sea un número entero dentro del rango. Un límite máximo para limit evita que un cliente solicite todo de una vez.
Desafío
FácilUna lista de diez mil tareas es demasiado larga para una sola respuesta. Envía la lista en páginas, con lo que un cliente necesita para mostrar «Página 1 de 2».
- En
routes/tasks.js, cambia lo que devuelve la ruta de la lista: un objeto en lugar de un array. ParaGET /api/tasks?limit=2con las tareas iniciales, devuelve:{ "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 las tareas de esta página,pagees el número de página,totales la cantidad de tareas que coinciden con los filtros ypageses la cantidad de páginas. - Lee
pageylimitde la cadena de consulta:pagees un número entero a partir de 1. Si no se especifica, usa 1.limites un número entero del 1 al 50. Si no se especifica, usa 10.
Usa el valor predeterminado antes de convertirlo, porque
Number(undefined)esNaN. Cualquier otro valor (page=0,page=abc,page=1.5,limit=0,limit=51) devuelve el estado 400, con el mensajelimit must be a whole number from 1 to 50cuando el límite no es válido:{ "error": { "code": "invalid_query", "message": "page must be a whole number from 1" } } - Página al final: primero filtra y ordena, luego extrae la página.
totalcuenta las tareas que quedan después de aplicar los filtros, así que?done=false&limit=1da"total": 2y"pages": 2.
Esto cambia la estructura de la respuesta, así que también tiene que cambiar cualquier cliente que esperara un array. La lección sobre versionado muestra cómo hacerlo.
Pruébalo: GET /api/tasks?limit=2 devuelve las tareas 1 y 2 con "page": 1, "total": 3 y "pages": 2. GET /api/tasks?page=2&limit=2 devuelve solo la tarea 3. Un GET /api/tasks simple devuelve las 3 tareas con "page": 1 y "pages": 1. GET /api/tasks?page=0 devuelve 400 con el código invalid_query.
Pruébalo tú mismo
import { Router } from 'express';
import { z } from 'zod';
import { sendError, sendInvalid } from '../errors.js';
import { nextId, tasks } from '../store.js';
const router = Router();
// Una tarea completa, tal como la envía PUT. .strict() rechaza los campos que no figuran en su lista.
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 si otra tarea ya tiene este título, sin distinguir entre mayúsculas y 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;
Esta lección incluye un breve cuestionario. Empieza la lección para responderlo y registrar tu progreso.
Todas las lecciones de API REST
1Recursos y verbos
Recursos y URLCrear con POST4Navegadores, versiones y configuración
Qué es un origenPermitir otro origenCORSUna página que llama a la APIPractica por tu cuenta: Playground de Express