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: "купити молоко"}

	b, _ := json.MarshalIndent(req, "", "  ")
	fmt.Println(string(b))
	// {
	//   "title": "купити молоко"
	// }
}

Це простий, але дуже корисний прийом: ви буквально бачите, що обіцяєте клієнту.

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: "некоректний запит",
			Fields:  map[string]string{"title": "поле не має бути порожнім"},
		},
	}

	b, _ := json.MarshalIndent(env, "", "  ")
	fmt.Println(string(b))
	// {
	//   "error": {
	//     "code": "validation",
	//     "message": "некоректний запит",
	//     "fields": {
	//       "title": "поле не має бути порожнім"
	//     }
	//   }
	// }
}

Зверніть увагу на дві речі.

По‑перше, fields — словник, а не масив рядків. Це зручно клієнту: можна підсвітити конкретне поле форми.

По‑друге, message тут загальне — «некоректний запит», а деталі — у fields. Це допомагає не перетворювати message на роман у дванадцяти томах.

Not found: без fields

Коли ресурс не знайдено, наприклад задачі з таким id немає, fields зазвичай не потрібен: це не «помилка конкретного поля введення», а відсутність сутності.

package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	env := ErrorEnvelope{
		Error: APIError{
			Code:    "not_found",
			Message: "ресурс не знайдено",
		},
	}

	b, _ := json.MarshalIndent(env, "", "  ")
	fmt.Println(string(b))
	// {
	//   "error": {
	//     "code": "not_found",
	//     "message": "ресурс не знайдено"
	//   }
	// }
}

Ось тут omitempty і проявляє себе: fields не потрапляє в JSON, і клієнт не думає: «О, fields є, значить це validation».

Internal: безпечне повідомлення

Найчастіша помилка новачка в API — «просто віддати err.Error() назовні». Це іноді навіть здається зручним («ну там же правда написано, що сталося»), але в реальному світі так ви:

  • розкриваєте внутрішні деталі — шляхи файлів, SQL, конфіги,
  • випадково робите контракт залежним від тексту помилки,
  • а інколи ще й «світите» секрети.

Ми робимо стабільне повідомлення, наприклад "внутрішня помилка". Деталі — у логах, а не в контракті. І це рівно та межа: що вважати частиною публічного 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 "некоректний запит"
	case "not_found":
		return "ресурс не знайдено"
	default:
		return "внутрішня помилка"
	}
}

Так, це схоже на «міні‑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: "купити молоко", Done: false}

	b, _ := json.MarshalIndent(task, "", "  ")
	fmt.Println(string(b))
	// {
	//   "id": 1,
	//   "title": "купити молоко",
	//   "done": false
	// }
}

Помилка: невалідний запит

package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	env := newErrorEnvelope("validation", map[string]string{
		"title": "поле не має бути порожнім",
	})

	b, _ := json.MarshalIndent(env, "", "  ")
	fmt.Println(string(b))
	// {
	//   "error": {
	//     "code": "validation",
	//     "message": "некоректний запит",
	//     "fields": {
	//       "title": "поле не має бути порожнім"
	//     }
	//   }
	// }
}

Форма помилок не залежить від endpoint-а. Це і є головна цінність: клієнт може написати один обробник помилок і використовувати його повторно всюди.

Де живе контракт JSON‑API: схема

Іноді корисно побачити картинку, щоб мозок перестав «перемелювати» це в абстракції.

flowchart LR
    C[Клієнт] -->|HTTP-запит + Accept + JSON-тіло| S[Сервер]
    S -->|2xx + JSON DTO| C
    S -->|4xx/5xx + JSON error envelope| 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 й створюєте собі зобов’язання, яких не планували.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ