1. Зачем фиксировать JSON‑контракт
Когда люди слышат «JSON‑API», они часто думают: «Ну это же просто: отдали JSON, приняли JSON». Но «просто JSON» — это как «просто договориться встретиться»: без времени, адреса и формулировки «в каком формате вы меня ждёте» встреча превращается в сериал. Контракт JSON‑API — это не только «какие поля», но и «какие заголовки», «что делать при ошибке», «как клиент распознаёт проблему, не парся текст по словам».
Если вы не фиксируете контракт, вы получаете классический баг: фронтенд ждёт поле done, бэкенд переименовал в isDone, тестов нет, а пользователю «почему-то» всё время показывается «не выполнено». И это ещё лучший сценарий.
Чтобы контракт был читабельным и стабильным, мы сегодня закрепим три вещи:
- как и когда использовать Content-Type и Accept,
- как проектировать JSON тела запросов и ответов (через DTO),
- как возвращать ошибки единообразно через error envelope: { "error": { "code": "...", "message": "...", "fields": { ... } } }.
2. Заголовки JSON‑API: Content-Type и Accept
Заголовки в HTTP многим кажутся «второстепенными», потому что «главное же тело». Но в реальности заголовки — это как наклейка на коробке при доставке. Можно, конечно, привезти посылку без подписи, но тогда её будут трясти, нюхать и угадывать: «а это точно кружка, а не кирпич?». Заголовки помогают не гадать.
Content-Type: что это и зачем
Content-Type — это заголовок про то, что вы отправляете в теле. Если вы отправляете JSON в запросе (например, создаёте задачу), ваш запрос обязан честно сказать: «внутри JSON».
Типичный пример:
POST /api/v1/tasks HTTP/1.1
Content-Type: application/json
{"title":"buy milk"}
Если Content-Type нет или там написано что-то другое, сервер имеет полное моральное право сказать: «Я не знаю, как это читать» — и вернуть ошибку. Чаще всего в HTTP для этого используют статус 415 Unsupported Media Type, но сейчас нам важна не нумерология, а сама идея контракта.
Важный нюанс: если у запроса нет тела (например, многие GET), то и Content-Type обычно не нужен. Не надо отправлять Content-Type: application/json «на всякий случай»: это как подписывать пустой конверт «внутри пирожок».
Accept: что это и зачем
Accept — это заголовок про то, что клиент хочет получить. В простом JSON‑API чаще всего клиент говорит: «Ожидаю application/json».
Пример:
GET /api/v1/tasks HTTP/1.1
Accept: application/json
Если клиент не укажет Accept, сервер может всё равно вернуть JSON (особенно если API строго JSON‑ный), но нормальный контракт любит явность.
Важный психологический момент: Content-Type — это «что я принёс вам», Accept — «что я хочу от вас». Это две разные роли, как «передать документы» и «получить расписку».
Мини-таблица для памяти
Чтобы мозг не пытался держать это в виде «магии заголовков», удобно видеть короткую табличку:
| Заголовок | Кто ставит | Про что | Когда нужен |
|---|---|---|---|
|
отправитель тела | формат тела (например JSON) | когда есть тело |
|
клиент | формат ожидаемого ответа | почти всегда, если клиенту важен формат |
3. Форматы request/response: зачем DTO
Когда вы проектируете API, очень хочется взять вашу доменную структуру Task и просто добавить теги json:"...", чтобы «всё само работало». Это выглядит как экономия времени, но обычно это кредит под 300% годовых: маленькая радость сейчас и большой ремонт потом.
Причина простая: доменная модель отвечает за смысл и правила предметной области, а JSON‑контракт отвечает за внешний формат общения с клиентом. Это разные ответственности. Если их склеить, вы начинаете бояться менять домен, потому что «сломаю API», и бояться менять API, потому что «сломаю домен». Получается архитектурный бутерброд из страха.
Поэтому мы вводим DTO (Data Transfer Object): структуры для JSON «снаружи». А доменные типы живут «внутри» и не обязаны знать, что такое JSON.
DTO для задач
Представим наше учебное приложение «таск‑трекер» (список задач). На уровне API мы хотим:
- создать задачу по заголовку,
- получить задачу,
- получить список задач.
Минимально понятные поля для JSON:
- id — число,
- title — строка,
- done — булево.
Создадим DTO:
package main
type TaskDTO struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
А для запроса создания задачи (обычно клиент не передаёт id — его выдаёт сервер):
package main
type CreateTaskRequest struct {
Title string `json:"title"`
}
Обратите внимание: мы специально не делаем title,omitempty — потому что «пустой title» для задачи чаще всего ошибка, и мы хотим, чтобы отсутствие/пустота явно всплывали на валидации, а не «исчезали».
Как выглядит JSON без сервера
Мы пока не поднимаем HTTP‑сервер (это будет отдельная история), но формат JSON мы можем отладить уже сейчас «в вакууме».
package main
import (
"encoding/json"
"fmt"
)
func main() {
req := CreateTaskRequest{Title: "buy milk"}
b, _ := json.MarshalIndent(req, "", " ")
fmt.Println(string(b))
// {
// "title": "buy milk"
// }
}
Это простой, но очень полезный приём: вы буквально видите, что обещаете клиенту.
4. Единый error envelope: контракт ошибок
Ошибки — это место, где API чаще всего превращается в «зоопарк». Один endpoint возвращает {"error":"bad"}, другой — {"message":"oops"}, третий вообще отдаёт текстом «panic: runtime error». Клиентская сторона начинает писать: «если есть поле error — это ошибка, если есть поле message — тоже ошибка, если пришёл HTML — ну… наверное тоже ошибка».
Чтобы этого не было, мы фиксируем единый формат ошибок, который одинаков для всего API. Это и есть error envelope.
Идея хорошо ложится на Go‑подход «ошибки — значения»: ошибки можно конструировать, оборачивать, классифицировать и стабильно отображать.
Канонический формат
Контракт нашего модуля:
{
"error": {
"code": "...",
"message": "...",
"fields": { "...": "..." }
}
}
Правила:
- error.code — машинный код (для логики клиента),
- error.message — короткий безопасный текст (для человека),
- error.fields — появляется только для validation‑ошибок, как «поле → проблема».
Это важно: fields не должен превращаться в мусорную корзину «всё подряд». Клиент будет ожидать, что fields означает «ошибки по конкретным входным полям».
Структуры для envelope в Go
Описываем envelope так, чтобы JSON стабильно соответствовал контракту.
package main
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]string `json:"fields,omitempty"`
}
type ErrorEnvelope struct {
Error APIError `json:"error"`
}
Почему Fields с omitempty? Потому что в большинстве ошибок fields не нужен, и мы не хотим всегда слать "fields": {}. Пустота должна быть пустотой.
Зачем нужны и code, и message
Иногда хочется оставить только message, потому что «человек же читает». Но компьютер — тоже читает (клиентское приложение, скрипты, интеграции). Если клиент хочет отличить not_found от validation, он не должен парсить строку и искать там «not found» как в квесте.
А иногда хочется оставить только code и убрать message, потому что «пусть фронтендер сам напишет тексты». Это тоже плохо: вы теряете единый UX, и разные клиенты начинают показывать разные сообщения. Для небольшого учебного API нам полезнее, чтобы сервер возвращал и машинный код, и короткое сообщение.
Тут уместно вспомнить идею из практик Go: как только вы делаете что-то распознаваемым и стабильным, вы превращаете это в часть API‑контракта, и менять это потом больно. Поэтому error envelope мы и фиксируем как «контракт».
5. Примеры ошибок: validation, not_found, internal
Ошибки полезно «увидеть глазами», а не только описать словами. Мы сделаем это снова через json.MarshalIndent, чтобы можно было проверить форму JSON без поднятия сервера.
Validation‑ошибка с fields
Типичный сценарий: клиент прислал пустой title.
package main
import (
"encoding/json"
"fmt"
)
func main() {
env := ErrorEnvelope{
Error: APIError{
Code: "validation",
Message: "invalid request",
Fields: map[string]string{"title": "must not be empty"},
},
}
b, _ := json.MarshalIndent(env, "", " ")
fmt.Println(string(b))
// {
// "error": {
// "code": "validation",
// "message": "invalid request",
// "fields": {
// "title": "must not be empty"
// }
// }
// }
}
Обратите внимание на две вещи.
Во-первых, fields — словарь, а не массив строк. Это удобно клиенту: можно подсветить конкретное поле формы.
Во-вторых, message тут общий («invalid request»), а детали — в fields. Это помогает не превращать message в роман на 12 томов.
Not found: без fields
Когда ресурс не найден (например, задачи с таким id нет), fields обычно не нужен: это не «ошибка конкретного поля ввода», а отсутствие сущности.
package main
import (
"encoding/json"
"fmt"
)
func main() {
env := ErrorEnvelope{
Error: APIError{
Code: "not_found",
Message: "resource not found",
},
}
b, _ := json.MarshalIndent(env, "", " ")
fmt.Println(string(b))
// {
// "error": {
// "code": "not_found",
// "message": "resource not found"
// }
// }
}
Вот здесь omitempty и проявляет себя: fields не попадает в JSON, и клиент не думает: «О, fields есть, значит это validation».
Internal: безопасное сообщение
Самая частая ошибка новичка в API — «просто отдать err.Error() наружу». Это иногда даже кажется удобным («ну там же правда написано, что случилось»), но в реальном мире так вы:
- раскрываете внутренние детали (пути файлов, SQL, конфиги),
- случайно делаете контракт зависимым от текста ошибки,
- а иногда ещё и светите секреты.
Мы делаем стабильное сообщение, например "internal error". Детали — в логах, а не в контракте. И это ровно та грань: что считать частью публичного API, а что — внутренней реализацией.
6. Как связать error envelope и Go‑ошибки
Сейчас важный момент: error envelope — это JSON‑форма на границе системы, а внутри у вас всё ещё нормальные Go‑ошибки (error), которые вы возвращаете из функций, оборачиваете и проверяете через errors.Is/errors.As (это вы уже умеете по предыдущим дням).
Идея простая: внутри приложения вы храните богатую ошибку (с контекстом), а на выходе (в HTTP) превращаете её в один из публичных классов:
- validation → 400 + envelope с fields,
- not_found → 404 + envelope без fields,
- internal → 500 + envelope без внутренних деталей.
Почему так? Потому что «ошибка как значение» позволяет хранить богатый контекст для программы, но публичный контракт должен быть стабильным и безопасным.
Мини‑словарь сообщений по коду
Чтобы не лепить тексты «как получится» в разных местах, удобно держать маленький словарь. Это не «идеальная архитектура», а просто дисциплина.
package main
func messageFor(code string) string {
switch code {
case "validation":
return "invalid request"
case "not_found":
return "resource not found"
default:
return "internal error"
}
}
Да, это похоже на «мини‑i18n», только без локализаций. Главное — стабильность.
Конструктор envelope
Чтобы не писать каждый раз ErrorEnvelope{Error: APIError{...}}, сделаем маленькую функцию:
package main
func newErrorEnvelope(code string, fields map[string]string) ErrorEnvelope {
return ErrorEnvelope{
Error: APIError{
Code: code,
Message: messageFor(code),
Fields: fields,
},
}
}
Смысл здесь не в «красоте», а в том, что контракт ошибки становится централизованным. Если у вас в одном месте формируются ошибки, шанс «случайно поменять формат» сильно ниже.
7. Успешный ответ и ошибка в одном стиле
Даже без сервера можно показать, как будет выглядеть типичный успешный ответ и типичный ошибочный. Это помогает держать в голове, что API — это две равноправные ветки: успех и ошибка. Не бывает API «без ошибок», бывает API «с неожиданными ошибками».
Успех: одна задача
package main
import (
"encoding/json"
"fmt"
)
func main() {
task := TaskDTO{ID: 1, Title: "buy milk", Done: false}
b, _ := json.MarshalIndent(task, "", " ")
fmt.Println(string(b))
// {
// "id": 1,
// "title": "buy milk",
// "done": false
// }
}
Ошибка: невалидный запрос
package main
import (
"encoding/json"
"fmt"
)
func main() {
env := newErrorEnvelope("validation", map[string]string{
"title": "must not be empty",
})
b, _ := json.MarshalIndent(env, "", " ")
fmt.Println(string(b))
// {
// "error": {
// "code": "validation",
// "message": "invalid request",
// "fields": {
// "title": "must not be empty"
// }
// }
// }
}
Форма ошибок не зависит от endpoint’а. Это и есть главная ценность: клиент может написать один обработчик ошибок и переиспользовать его везде.
Где живёт контракт JSON‑API: схема
Иногда полезно увидеть картинку, чтобы мозг перестал «перемалывать» это в абстракции.
flowchart LR
C[Client] -->|HTTP request + Accept + JSON body| S[Server]
S -->|2xx + JSON DTO| C
S -->|4xx/5xx + JSON ErrorEnvelope| C
Суть схемы: клиент всегда ожидает JSON (по Accept), сервер всегда отвечает либо DTO‑данными, либо error envelope. «Всегда одинаковая форма ошибки» — это не занудство, это то, что экономит недели жизни на интеграциях.
8. Типичные ошибки
Ошибка №1: путать Content-Type и Accept (или ставить оба «на всякий случай»).
Когда в запросе есть JSON‑тело, Content-Type: application/json обязателен, иначе серверу приходится угадывать формат. Accept — просьба клиента о формате ответа. Если ставить заголовки механически, легко получить ситуацию, где клиент «просит JSON», но отправляет тело как «непонятно что», и потом удивляется, что сервер не читает данные.
Ошибка №2: отдавать разные форматы ошибок на разных endpoint’ах.
Часто это начинается невинно: «здесь верну строку, там верну объект». Через месяц у клиента уже три обработчика ошибок и один нервный тик. Единый error envelope — это дисциплина, которая делает API предсказуемым. Как только вы его зафиксировали, относитесь к нему как к публичному контракту, а не как к «черновику, который можно улучшить».
Ошибка №3: превращать message в свалку деталей (или, наоборот, делать только code).
Если message содержит внутренние детали ("sql: no rows", пути файлов, куски stack trace), вы либо раскрываете лишнее, либо случайно делаете текст ошибки частью контракта. Если оставить один code, вы получите разнобой клиентских сообщений и потерю единого UX. Нормальная пара — code для логики и message для человека.
Ошибка №4: использовать fields не по назначению.
fields должен означать «ошибки валидации по конкретным входным полям». Если вы начнёте пихать туда что угодно (например, "storage": "down"), клиент перестанет доверять полю и будет вынужден писать костыли. В итоге вы теряете главное преимущество: возможность подсветить пользователю конкретные проблемы ввода.
Ошибка №5: утечка внутренней ошибки наружу через err.Error().
Внутреннюю ошибку полезно оборачивать, логировать и анализировать внутри сервера — в Go это естественно, потому что ошибки являются значениями и их можно структурировать. Но наружу, в HTTP‑контракт, надо выдавать стабильное и безопасное сообщение. Иначе вы «запекаете» реализацию в публичный API и создаёте себе обязательства, которые не планировали.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ