Learnly
Проектирование API/Глава 13/Урок 1

Форматы данных: запрос, ответ, пагинация, фильтрация

Форматы данных: запрос, ответ, пагинация, фильтрация

Структура данных в API, это контракт. Хаотичные поля, непоследовательные имена и непредсказуемые форматы дат превращаются в технический долг, который потом дорого исправлять, клиенты уже завязались на твои форматы.


Именование полей

Используй snake_case, стандарт де-факто для JSON в большинстве API:

{
  "user_id": 42,
  "first_name": "Иван",
  "last_name": "Петров",
  "is_active": true,
  "created_at": "2024-03-15T10:23:00Z"
}

Некоторые API (особенно в JS-экосистеме) используют camelCase. Главное, консистентность внутри одного API. Никогда не мешай user_id и firstName в одном ответе.


Даты и время, всегда ISO 8601 UTC

{
  "created_at": "2024-03-15T10:23:00Z",
  "expires_at": "2024-04-15T00:00:00Z"
}

Никогда не используй:

{
  "created_at": "15.03.2024",      // неоднозначно, 15 марта или 3 апреля?
  "created_at": 1710494580,        // unix timestamp нечитаем человеком
  "created_at": "March 15, 2024"  // зависит от локали
}

Почему ISO 8601 и UTC? Потому что это единственный формат который однозначен для любого клиента в любом часовом поясе. Клиент сам переведёт в нужную локаль.


Структура ответа: envelope pattern

Вопрос: оборачивать ли данные в объект-обёртку?

Без обёртки, ресурс напрямую:

GET /users/42

{
  "id": 42,
  "name": "Иван Петров",
  "email": "ivan@example.com"
}

С обёрткой, данные внутри data:

{
  "data": {
    "id": 42,
    "name": "Иван Петров"
  },
  "meta": {
    "request_id": "req_abc123",
    "timestamp": "2024-03-15T10:23:00Z"
  }
}

Когда обёртка полезна:

  • Нужно добавить метаданные (request_id, version, timing) без изменения структуры данных
  • Консистентность между ответами с коллекциями и без

Когда обёртка лишняя:

  • Простое CRUD API где метаданные не нужны
  • Публичное API, клиенты будут раздражены лишним уровнем вложенности

Практическое правило: используй обёртку для коллекций (там нужна пагинация), не используй для единичных ресурсов.


Пагинация

Когда записей тысячи, нельзя отдать всё одним запросом. Есть три подхода, и выбор между ними критически важен для производительности.

1. Offset-based (классика)

GET /api/v1/products?offset=40&limit=20
{
  "data": [...],
  "pagination": {
    "total": 1247,
    "limit": 20,
    "offset": 40,
    "has_next": true,
    "has_prev": true
  }
}

Как работает на бэкенде:

SELECT * FROM products
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;

Плюсы:

  • Можно прыгнуть на любую страницу, удобно для UI с номерами страниц
  • Просто реализовать
  • Интуитивно понятно для разработчиков

Минусы и почему это важно:

  • OFFSET 10000 заставляет базу прочитать 10 000 строк только чтобы их выбросить. На таблице в 1 млн записей это катастрофа для производительности
  • Race condition: пока пользователь листает страницы, кто-то добавил новые записи. Страница 3 покажет запись которая была на странице 2, или пропустит запись, дубли и пропуски

2. Page-based (синтаксический сахар)

GET /api/v1/products?page=3&per_page=20
{
  "data": [...],
  "pagination": {
    "current_page": 3,
    "per_page": 20,
    "total_pages": 63,
    "total": 1247
  }
}

Под капотом это тот же OFFSET = (page - 1) * per_page. Те же плюсы и минусы. Удобнее для UI с кнопками «1, 2, 3...».

3. Cursor-based (правильный подход для лент)

Вместо числового смещения, курсор: непрозрачная строка которая кодирует позицию в выборке.

GET /api/v1/posts?limit=20
{
  "data": [...20 постов...],
  "pagination": {
    "next_cursor": "eyJpZCI6MTIwLCJjcmVhdGVkX2F0IjoiMjAyNC0wMy0xNVQxMDoyMzowMFoifQ==",
    "has_next": true
  }
}

Следующая страница:

GET /api/v1/posts?cursor=eyJpZCI6MTIwLCJjcmVhdGVkX2F0IjoiMjAyNC0wMy0xNVQxMDoyMzowMFoifQ==&limit=20

Как это работает на бэкенде:

Курсор, это base64 от {"id": 120, "created_at": "2024-03-15T10:23:00Z"}. Бэкенд декодирует его и делает запрос:

SELECT * FROM posts
WHERE created_at < '2024-03-15T10:23:00Z'
   OR (created_at = '2024-03-15T10:23:00Z' AND id < 120)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Этот запрос использует индекс и не читает лишних строк, он работает одинаково быстро и на записи 1, и на записи 1 000 000.

Плюсы:

  • Стабильно при добавлении новых данных, нет дублей и пропусков
  • Эффективен на больших таблицах, работает через индекс
  • Именно так работают Instagram, Twitter, Facebook (бесконечный скролл)

Минусы:

  • Нельзя прыгнуть на произвольную страницу, только «вперёд/назад»
  • Сложнее реализовать
  • Курсор непрозрачен, нельзя угадать или построить вручную

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

Сценарий Рекомендация
Таблица в админке с номерами страниц Offset / Page
Лента, бесконечный скролл Cursor
Таблица > 100k записей Cursor
Данные часто меняются Cursor
Нужен поиск по номеру страницы Offset / Page

Фильтрация

Базовые фильтры

Все фильтры передаются через query parameters. Названия совпадают с именами полей:

GET /api/v1/products?category_id=5&is_active=true
GET /api/v1/orders?status=pending&user_id=42

Диапазоны

GET /api/v1/products?min_price=1000&max_price=50000
GET /api/v1/orders?created_after=2024-01-01&created_before=2024-03-31

Несколько значений (OR-логика)

Два популярных подхода:

# Вариант 1, повторяющийся параметр
GET /api/v1/products?status=active&status=pending

# Вариант 2, через запятую
GET /api/v1/products?status=active,pending

Stripe использует первый вариант. GitHub, второй. Выбери один и придерживайся его.

Операторы сравнения (для сложных API)

Когда нужна гибкость, можно добавить операторы:

GET /api/v1/products?price[gte]=1000&price[lte]=50000
GET /api/v1/users?age[gt]=18
GET /api/v1/posts?title[contains]=API

Такой стиль использует Stripe. Он многословен, но очень выразителен.

Полнотекстовый поиск

GET /api/v1/products?search=ноутбук+dell
GET /api/v1/users?q=иван

Используй search или q, оба распространены, главное консистентность.


Сортировка

# Одно поле
GET /api/v1/products?sort=price&order=asc
GET /api/v1/products?sort=price&order=desc

# Стиль через минус (популярен в Django REST, DRF)
GET /api/v1/products?sort=-price          # desc
GET /api/v1/products?sort=price           # asc

# Несколько полей
GET /api/v1/products?sort=-created_at,name

Всегда документируй какие поля поддерживают сортировку, сортировка по неиндексированному полю убьёт производительность.


Sparse fieldsets, выбор полей

Позволяет клиенту запросить только нужные поля. Критично для мобильных клиентов где трафик дорог:

GET /api/v1/users?fields=id,name,email
{
  "data": [
    { "id": 1, "name": "Иван", "email": "ivan@example.com" },
    { "id": 2, "name": "Мария", "email": "maria@example.com" }
  ]
}

Вместо отдачи 30 полей, отдаём 3. GraphQL решает эту проблему системно, REST решает через fields.


Пример полного запроса

GET /api/v1/products
  ?category_id=5
  &min_price=5000
  &max_price=100000
  &is_active=true
  &sort=-created_at
  &fields=id,name,price,image_url
  &cursor=eyJpZCI6MTAwfQ==
  &limit=24

Читается как книга: товары из категории 5, цена 5000–100000, только активные, сортировка от новых к старым, только нужные поля, следующие 24 записи после курсора.


Итого

  • Поля в snake_case, даты в ISO 8601 UTC, никаких исключений
  • Envelope (обёртка data), для коллекций, не для единичных ресурсов
  • Offset-пагинация, для таблиц с навигацией по страницам и небольшими данными
  • Cursor-пагинация, для лент, больших данных и часто обновляемых данных
  • Фильтры через query params, операторы в стиле field[op]=value для сложной фильтрации
  • Поддерживай fields для выбора полей, особенно важно для мобильных клиентов
AI-тест
1 / 8

Какое соглашение именования полей JSON данных в API является наиболее рекомендованным?