Menu
CoddyTech

Пагинация

Часть раздела 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 не позволит клиенту запросить всё сразу.

challenge icon

Задание

Легко

Список из десяти тысяч задач слишком длинный для одного ответа. Отправляй список страницами, чтобы клиент мог показывать «Страница 1 из 2».

  1. В 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 — общее количество страниц.
  2. Считай 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" } }
  3. Выполняй пагинацию последней: сначала фильтруй и сортируй, затем выбирай нужную страницу. В 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;
quiz iconПроверьте себя

В этом уроке есть небольшой тест. Начните урок, чтобы ответить на вопросы и сохранить прогресс.

Все уроки раздела REST API

Потренируйтесь самостоятельно: Песочница Express