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: "купити молоко"}
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, перетворюєте її на один із публічних класів:
- validation → 400 + envelope з fields,
- not_found → 404 + envelope без fields,
- internal → 500 + 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 й створюєте собі зобов’язання, яких не планували.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ