REST API Style Guide: Query-параметры и пагинация

Query-параметры REST API: camelCase, фильтрация, offset и cursor пагинация.

Статья внедрена в скилл AI-агента ucp-api-review / ucp-api-design REST API пагинация query параметры

REST API пагинация query параметры: 9. Query-параметры

9.1. camelCase

REST API пагинация query параметры — gET /api/v1/orders?customerId=123&dateFrom=2025-01-01    -- правильно
GET /api/v1/orders?customer_id=123&date_from=2025-01-01  -- неправильно
GET /api/v1/orders?CustomerID=123                        -- неправильно

9.2. Фильтрация -- имя поля

GET /api/v1/orders?status=CONFIRMED
GET /api/v1/orders?customerId=550e8400-e29b-41d4-a716-446655440000

9.3. Диапазоны -- суффикс From / To

GET /api/v1/orders?dateFrom=2025-01-01&dateTo=2025-12-31
GET /api/v1/orders?amountFrom=100&amountTo=500

9.4. Пагинация

Два подхода: offset-based и cursor-based. Выбор зависит от характера данных и требований.

Offset-based (страничная)

Классический подход: клиент указывает номер страницы и размер.

GET /api/v1/orders?page=1&size=20
GET /api/v1/orders?page=3&size=50
  • page -- номер страницы, 1-based (первая страница = 1)
  • size -- количество элементов на странице, значение по умолчанию задается сервером (например, 20)

Пример ответа:

{
  "content": [
    { "orderId": "...", "status": "CREATED" },
    { "orderId": "...", "status": "CONFIRMED" }
  ],
  "page": 1,
  "size": 20,
  "totalElements": 243,
  "totalPages": 13
}

Когда использовать:

  • Нужен произвольный переход на любую страницу (страница 1, потом сразу страница 10)
  • Нужно знать общее количество (totalElements, totalPages)
  • Данные относительно статичны (справочники, каталоги)
  • UI с номерами страниц

Ограничения:

  • При вставке/удалении записей между запросами элементы могут дублироваться или пропускаться
  • OFFSET в SQL замедляется на больших смещениях (БД сканирует и отбрасывает строки)

Cursor-based (курсорная)

Клиент получает непрозрачный курсор (токен) для запроса следующей/предыдущей порции данных.

GET /api/v1/orders?size=20                              -- первая страница
GET /api/v1/orders?size=20&cursor=eyJpZCI6MTAwfQ==      -- следующая страница
  • cursor -- непрозрачный токен, полученный из предыдущего ответа. Клиент не должен его парсить или конструировать
  • size -- количество элементов на странице

Пример ответа:

{
  "content": [
    { "orderId": "...", "status": "CREATED" },
    { "orderId": "...", "status": "CONFIRMED" }
  ],
  "size": 20,
  "nextCursor": "eyJpZCI6MTIwfQ==",
  "prevCursor": "eyJpZCI6MTAwfQ==",
  "hasNext": true,
  "hasPrev": true
}

Когда использовать:

  • Данные часто меняются (лента событий, сообщения, уведомления)
  • Большие объемы данных, где offset деградирует по производительности
  • Бесконечная прокрутка (infinite scroll)
  • Real-time потоки данных

Ограничения:

  • Нет произвольного перехода на страницу N
  • Нельзя узнать общее количество без отдельного запроса
  • Сложнее реализовать на бэкенде

Сравнение

  • Offset -- простая реализация, произвольный доступ к страницам, но деградация на больших данных и нестабильность при изменениях
  • Cursor -- стабильная выборка, высокая производительность на больших данных, но только последовательная навигация

9.5. Сортировка -- sort

GET /api/v1/orders?sort=createdAt,desc
GET /api/v1/orders?sort=totalAmount,asc&sort=createdAt,desc

Формат: имяПоля,направление. Рекомендуется сортировка по одному полю -- множественная сортировка может приводить к проблемам с производительностью (составные индексы). Сортировка по нескольким полям допустима через повторение параметра, но должна быть обоснована.

9.6. Полнотекстовый поиск -- q

Для полнотекстового поиска по коллекции используется параметр q:

GET /api/v1/orders?q=термин
GET /api/v1/products?q=клавиатура

9.7. Множественные значения -- повтор параметра

GET /api/v1/orders?status=CREATED&status=CONFIRMED     -- правильно
GET /api/v1/orders?status=CREATED,CONFIRMED            -- неправильно

Повтор параметра -- стандартный способ передачи массивов в query string. Поддерживается из коробки большинством фреймворков и корректно описывается в OpenAPI через style: form, explode: true.

9.8. Сложные поисковые запросы -- POST вместо GET

GET имеет практические ограничения: длина URL (~2000–8000 символов в зависимости от браузера/сервера), невозможность передать вложенные структуры в query-параметрах. Когда поисковый запрос не укладывается в GET -- используйте POST с суффиксом /search:

GET  /api/v1/orders?status=CONFIRMED&dateFrom=2025-01-01          -- простой поиск
POST /api/v1/orders/search                                         -- сложный поиск

Тело POST-запроса содержит критерии поиска:

POST /api/v1/orders/search
Content-Type: application/json

{
  "statuses": ["CONFIRMED", "PAID", "SHIPPED"],
  "dateRange": {
    "from": "2025-01-01",
    "to": "2025-12-31"
  },
  "customer": {
    "regionIds": [1, 5, 12],
    "segment": "VIP"
  },
  "totalAmount": {
    "from": 1000,
    "to": 50000
  },
  "sort": [
    { "field": "createdAt", "direction": "DESC" },
    { "field": "totalAmount", "direction": "ASC" }
  ],
  "page": 1,
  "size": 20
}

Когда переходить с GET на POST:

  • Фильтры -- плоские поля (status, date) -- GET достаточно, POST избыточен
  • Вложенные объекты в фильтрах -- GET не подходит, используйте POST
  • Массив из 10+ значений в одном фильтре -- GET неудобен, используйте POST
  • Фильтр содержит спецсимволы или свободный текст -- GET ломает читаемость из-за кодирования, используйте POST
  • Комбинация AND/OR условий -- GET не подходит, используйте POST
  • Запрос нужно сохранять/переиспользовать -- POST (тело = JSON, легко хранить)

Правила для POST-поиска:

  1. URL: /resources/search -- не /resources/query, не /resources/find, не /search/resources
  2. Метод: POST -- потому что GET с телом формально не запрещен RFC, но на практике тело игнорируется прокси, кешами и многими клиентами
  3. Код ответа: 200 OK -- ресурс не создается, поэтому не 201
  4. Ответ -- тот же формат, что и у GET /resources (пагинированный список)
  5. Идемпотентность: POST-поиск идемпотентен по факту (повторный вызов с тем же телом дает тот же результат), но HTTP этого не гарантирует -- при необходимости кеширования добавьте заголовок Idempotency-Key