Форматы и JSON-конвенции, конверты ответа
Урок о том, в каком формате и по каким конвенциям возвращать данные клиенту.
Конверт ответа (response envelope) — единая обёртка вокруг полезных данных, обычно с полями
data,metaиerrors, которая делает форму всех ответов API предсказуемой.
Когда два сервиса обмениваются данными через HTTP, им мало договориться об URL и методах — нужно ещё согласовать форму передаваемых данных. В каком формате тело? Как называются поля? Как выглядит дата? Завёрнут ли объект в обёртку или лежит «голым»? Эти решения кажутся мелочью на первом эндпоинте, но к двадцатому несогласованность превращается в боль: клиент вынужден помнить, что у пользователя поле created_at, а у заказа — createdAt, и что ошибки иногда приходят строкой, а иногда массивом. Конвенции — это дисциплина, которая экономит часы отладки на стороне каждого потребителя API.
Content-Type и Accept: кто о чём договаривается
HTTP позволяет одному ресурсу иметь несколько представлений (JSON, XML, CSV). Согласование формата называется content negotiation. Клиент в заголовке Accept сообщает, что готов принять, а сервер в Content-Type — что реально прислал. Симметрично: когда клиент отправляет тело (POST/PUT), он сам выставляет Content-Type своего запроса.
curl -X POST https://api.example.com/v1/orders \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"productId": 42, "quantity": 2}'
Сервер обязан отвечать тем заголовком, который соответствует реальному телу. Распространённая ошибка — отдавать JSON с Content-Type: text/html: формально клиент получит данные, но строгие парсеры и прокси могут повести себя непредсказуемо. Если клиент просит формат, который сервер не умеет отдавать, корректный ответ — 406 Not Acceptable.
JSON как формат по умолчанию
Для современного REST API JSON — разумный дефолт: его понимают все языки, он компактнее XML и читается человеком. XML оставляют для legacy-интеграций и некоторых корпоративных стандартов; CSV удобен для выгрузок отчётов. Если ваш API обслуживает веб- и мобильные приложения — отдавайте JSON и не усложняйте.
Договоритесь о базовых правилах: тело всегда валидный JSON (даже ошибки — это JSON-объект, а не голый текст), кодировка UTF-8, числа без кавычек, булевы значения true/false, а не строки "true".
Именование полей: camelCase или snake_case
Главное правило — выбрать одно и держаться его во всём API. Смешение стилей в одном ответе выглядит как баг. Два популярных варианта:
| Стиль | Пример | Где привычен |
| camelCase | createdAt, firstName | JavaScript/TypeScript, фронтенд |
| snake_case | created_at, first_name | Python, Ruby, многие БД |
В этом курсе мы выбираем snake_case и используем его во всех примерах: он естественно ложится на имена столбцов БД и широко распространён в публичных API. Объективно «лучшего» варианта нет — важна только последовательность. Зафиксируйте выбор в гайдлайнах команды.
{
"id": 42,
"first_name": "Анна",
"created_at": "2026-06-22T14:30:00Z",
"is_active": true
}
Даты в ISO 8601
Самая частая беда с датами — отдавать их в локальном или человекочитаемом формате вроде 22.06.2026 17:30. Парсить такое — мука, а часовой пояс теряется. Стандарт для API — ISO 8601 в UTC с суффиксом Z: 2026-06-22T14:30:00Z. Этот формат однозначен, сортируется как обычная строка (лексикографический порядок совпадает с хронологическим) и понимается всеми библиотеками дат.
Если нужно сохранить пояс отправителя — используйте смещение: 2026-06-22T17:30:00+03:00. Никогда не отдавайте Unix-timestamp без явной договорённости: число 1781793000 ничего не говорит человеку и провоцирует ошибки в секундах/миллисекундах.
Конверт ответа против голого объекта
Есть два подхода к форме ответа. Голый объект — данные лежат прямо в корне тела:
{
"id": 42,
"first_name": "Анна"
}
Конверт — данные завёрнуты в обёртку, рядом с ними живут метаданные и ошибки:
{
"data": {
"id": 42,
"first_name": "Анна"
},
"meta": {
"request_id": "req_8h2k"
},
"errors": []
}
Для коллекций конверт особенно уместен — в meta естественно положить пагинацию:
{
"data": [
{"id": 1, "title": "Первый"},
{"id": 2, "title": "Второй"}
],
"meta": {
"total": 128,
"page": 1,
"per_page": 2
}
}
Плюсы и минусы конверта
- Плюс: единообразие — клиент всегда читает
data, что бы ни вернулось. - Плюс: есть куда положить метаданные (пагинацию, версию,
request_id) без замусоривания самих данных. - Плюс: единая структура ошибок в одном поле
errors. - Минус: лишний уровень вложенности — клиент пишет
response.data.idвместоresponse.id. - Минус: часть метаданных дублирует HTTP — статус и без конверта живёт в строке ответа, а пагинацию можно отдать заголовками.
Практичная середина: конверт для коллекций (там нужны meta с пагинацией) и допустимо отдавать одиночный ресурс голым, если вы это последовательно документировали. Но самый предсказуемый для клиента вариант — конверт везде.
Как работает под капотом
Когда вы выставляете Content-Type: application/json; charset=utf-8, вы инструктируете весь стек на пути ответа. HTTP-клиент по этому заголовку выбирает JSON-парсер; промежуточные прокси и CDN решают, можно ли кешировать и сжимать тело; браузер — стоит ли показывать его как файл или как данные. Сам JSON сериализуется из объекта в памяти сервера в байтовую строку UTF-8 — и здесь рождается граница: внутри кода у вас Python-словарь или JS-объект, а наружу уходит текст. Конвенции именования и формат дат применяются именно в момент сериализации: слой, превращающий объект в JSON, переименовывает createdAt в created_at и форматирует datetime в строку ISO 8601. Поэтому правило «выбери стиль и держись» технически означает «настрой сериализатор один раз централизованно», а не «расставляй имена руками в каждом обработчике».
Частые ошибки
- Смешанные стили имён. В одном ответе
firstNameиcreated_at. Клиент не угадает, потеряет час на отладке. - Неверный
Content-Type. Отдавать JSON под видомtext/html— провоцировать сбои у строгих клиентов и прокси. - Человекочитаемые даты.
22/06/2026непарсим и неоднозначен (это июнь или 22-й месяц?). Только ISO 8601. - Числа в кавычках.
"quantity": "2"вынуждает клиента приводить тип. Число — это число. - Путаница
nullи отсутствия поля.{"phone": null}означает «телефон известно что пуст», а отсутствие ключаphone— «поле не возвращается / неизвестно». Это разная семантика; выберите политику (например, всегда присылать ключ сnullдля известных, но пустых полей) и документируйте её.
Итоги
- JSON в UTF-8 — формат по умолчанию; согласуйте его через
Content-TypeиAccept. - Выберите один стиль имён (мы берём
snake_case) и держитесь его во всём API. - Даты — только ISO 8601 в UTC (
...Z); они однозначны и сортируются как строки. - Конверт
data/meta/errorsдаёт единообразие и место под метаданные ценой лишней вложенности. null≠ отсутствие поля: это разные сообщения, определитесь с политикой и зафиксируйте её.