JavaRush /Курсы /Go SELF /JSON API: заголовки, DTO и единый error envelope

JSON API: заголовки, DTO и единый error envelope

Go SELF
56 уровень , 1 лекция
Открыта

1. Зачем фиксировать JSON‑контракт

Когда люди слышат «JSON‑API», они часто думают: «Ну это же просто: отдали JSON, приняли JSON». Но «просто JSON» — это как «просто договориться встретиться»: без времени, адреса и формулировки «в каком формате вы меня ждёте» встреча превращается в сериал. Контракт JSON‑API — это не только «какие поля», но и «какие заголовки», «что делать при ошибке», «как клиент распознаёт проблему, не парся текст по словам».

Если вы не фиксируете контракт, вы получаете классический баг: фронтенд ждёт поле done, бэкенд переименовал в isDone, тестов нет, а пользователю «почему-то» всё время показывается «не выполнено». И это ещё лучший сценарий.

Чтобы контракт был читабельным и стабильным, мы сегодня закрепим три вещи:

  1. как и когда использовать Content-Type и Accept,
  2. как проектировать JSON тела запросов и ответов (через DTO),
  3. как возвращать ошибки единообразно через 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 — «что я хочу от вас». Это две разные роли, как «передать документы» и «получить расписку».

Мини-таблица для памяти

Чтобы мозг не пытался держать это в виде «магии заголовков», удобно видеть короткую табличку:

Заголовок Кто ставит Про что Когда нужен
Content-Type
отправитель тела формат тела (например JSON) когда есть тело
Accept
клиент формат ожидаемого ответа почти всегда, если клиенту важен формат

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) превращаете её в один из публичных классов:

  • validation400 + envelope с fields,
  • not_found404 + envelope без fields,
  • internal500 + 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 и создаёте себе обязательства, которые не планировали.

1
Задача
Go SELF, 56 уровень, 1 лекция
Недоступна
JSON запрос
JSON запрос
1
Задача
Go SELF, 56 уровень, 1 лекция
Недоступна
Ошибка not_found
Ошибка not_found
1
Задача
Go SELF, 56 уровень, 1 лекция
Недоступна
Конструктор ошибок
Конструктор ошибок
1
Задача
Go SELF, 56 уровень, 1 лекция
Недоступна
Контракт создания
Контракт создания
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ