Форматы и 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. Смешение стилей в одном ответе выглядит как баг. Два популярных варианта:

СтильПримерГде привычен
camelCasecreatedAt, firstNameJavaScript/TypeScript, фронтенд
snake_casecreated_at, first_namePython, 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 ≠ отсутствие поля: это разные сообщения, определитесь с политикой и зафиксируйте её.
Проверьте себя
1. Чем отличается ответ {"phone": null} от ответа, где ключа phone нет вовсе?
AНичем, это эквивалентные записи
Bnull означает известное пустое значение, а отсутствие ключа — что поле не возвращается или неизвестно
Cnull допустим только в массивах, а отсутствие ключа — только в объектах
DОтсутствие ключа всегда ошибка сериализации
2. Какой формат даты в JSON-ответе предпочтителен для REST API?
AЛокальный человекочитаемый, например 22.06.2026 17:30
BISO 8601 в UTC, например 2026-06-22T14:30:00Z
CПроизвольный, лишь бы клиент знал часовой пояс из документации
DТолько Unix-timestamp в секундах без дополнительных полей
3. Что делает заголовок Accept в запросе клиента?
AСообщает серверу, какой формат ответа клиент готов принять
BУказывает Content-Type тела самого запроса
CВключает кеширование ответа на стороне прокси
DАвторизует клиента вместо заголовка Authorization