Пагинация
Часть раздела REST API путешествия по Node.js на Coddy. Урок 17 из 25.
Список из десяти тысяч задач слишком длинный для одного ответа. Отправляйте его страницами: клиент запрашивает ?page=2&limit=20 и получает эту часть списка, а также данные, необходимые для отображения «Страница 2 из 5»:
{ "items": [ ...books 21 to 40... ], "page": 2, "total": 95, "pages": 5 }const start = (page - 1) * limit; // страница 1 начинается с 0
const items = list.slice(start, start + limit);
const pages = Math.ceil(list.length / limit);Подсчитывайте total после применения фильтров, поскольку это количество элементов в списке, который запросил клиент. Если в строке запроса нет page, используйте значение 1, а для limit — 10, прежде чем преобразовывать значения: Number(undefined) — это NaN. Затем отклоняйте запросы с любыми значениями, которые не являются целыми числами в допустимом диапазоне, и возвращайте код 400. Максимальное значение limit не позволит клиенту запросить всё сразу.
Задание
ЛегкоСписок из десяти тысяч задач слишком длинный для одного ответа. Отправляй список страницами, чтобы клиент мог показывать «Страница 1 из 2».
- В
routes/tasks.jsизмени возвращаемое списковым маршрутом значение: вместо массива возвращай объект. ДляGET /api/tasks?limit=2при исходных задачах он возвращает:В{ "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находятся задачи этой страницы,page— номер страницы,total— количество задач, соответствующих фильтрам, аpages— общее количество страниц. - Считай
pageиlimitиз строки запроса:page— целое число, начиная с 1. Если параметр отсутствует, используй 1.limit— целое число от 1 до 50. Если параметр отсутствует, используй 10.
Сначала используй значение по умолчанию, а затем преобразуй его, потому что
Number(undefined)— этоNaN. Для любого другого значения (page=0,page=abc,page=1.5,limit=0,limit=51) возвращай статус 400; если неверно задан limit, сообщение должно бытьlimit must be a whole number from 1 to 50:{ "error": { "code": "invalid_query", "message": "page must be a whole number from 1" } } - Выполняй пагинацию последней: сначала фильтруй и сортируй, затем выбирай нужную страницу. В
totalподсчитываются задачи, оставшиеся после фильтрации, поэтому?done=false&limit=1возвращает"total": 2и"pages": 2.
Это меняет структуру ответа, поэтому клиент, который ожидал массив, тоже нужно изменить. В уроке «Версионирование» показано, как это сделать.
Попробуй: GET /api/tasks?limit=2 возвращает задачи 1 и 2 с "page": 1, "total": 3 и "pages": 2. GET /api/tasks?page=2&limit=2 возвращает только задачу 3. Обычный GET /api/tasks возвращает все 3 задачи с "page": 1 и "pages": 1. GET /api/tasks?page=0 возвращает статус 400 с кодом invalid_query.
Попробуйте сами
import { Router } from 'express';
import { z } from 'zod';
import { sendError, sendInvalid } from '../errors.js';
import { nextId, tasks } from '../store.js';
const router = Router();
// Задача целиком, в том виде, в каком её отправляет PUT. .strict() отклоняет поля, которые в нём не перечислены.
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, если у другой задачи уже есть такой заголовок, независимо от регистра.
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;
В этом уроке есть небольшой тест. Начните урок, чтобы ответить на вопросы и сохранить прогресс.
Все уроки раздела REST API
1Ресурсы и глаголы
Ресурсы и URLСоздание с помощью POST4Браузеры, версии и конфигурация
Что такое originРазрешение другого originCORSСтраница, вызывающая APIПотренируйтесь самостоятельно: Песочница Express