Форматы данных: запрос, ответ, пагинация, фильтрация
Структура данных в 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для выбора полей, особенно важно для мобильных клиентов