JavaRush /Курси /Go SELF /Помилки декодування та валідації JSON

Помилки декодування та валідації JSON

Go SELF
Рівень 59 , Лекція 3
Відкрита

1. Помилки вводу — частина контракту API

Коли ви пишете сервер «для себе», дуже хочеться думати так: «Якщо клієнт накоїв — нехай сам і страждає». Але щойно в API з’являється хоча б один реальний споживач — фронтенд, скрипт, інтеграція чи мобільний застосунок — з’ясовується неприємна річ: помилки — це теж інтерфейс, так само як і успішні відповіді.

Клієнтський код не читає ваших думок. Він читає HTTP-статус, заголовки та JSON. Якщо сьогодні на пошкоджений JSON ви віддаєте рядок, завтра — HTML, а післязавтра — JSON з іншим полем, то ламаєте клієнта так само ефективно, якби перейменували поле title на taskTitle. У Go загалом дуже цінують зворотну сумісність і не люблять ламати споживачів API без крайньої потреби.

Тому домовмося про таке: для помилок декодування та валідації клієнт завжди отримує один і той самий error envelope, і йому не потрібно гадати, у якому вигляді сьогодні прилетить помилка.

2. Чому http.Error ламає єдиний JSON-формат API

http.Error виглядає як суперзручна кнопка «зроби мені помилку». І вона справді зручна — доки ви не будуєте JSON API. Історично в Go є багато прикладів, де handler робить щось на кшталт http.Error(w, err.Error(), 500), і здається: «Ну нормально ж».

Проблема в тому, що http.Error — особливо http.Error(w, err.Error(), 500) — майже гарантовано призводить до трьох неприємностей одночасно.

Перша неприємність — формат. http.Error пише звичайний текст (часто як text/plain; charset=utf-8, а ще додає переведення рядка). Якщо ваш API в інших випадках повертає JSON, то в клієнта починається «вгадай мелодію»: на успіх — JSON, на помилку — рядок.

Друга неприємність — витік деталей. Якщо ви віддаєте err.Error() назовні, ви часто показуєте нутрощі: назви полів структур, деталі парсингу, інколи шматки конфігурації та інші «секрети Полішинеля». Це не завжди прямо небезпечно, але майже завжди неохайно.

Третя неприємність — копіпаста. Щойно обробників стає більше двох, http.Error розмножується швидше за кроликів: у кожному місці трохи інший текст, трохи інша логіка, трохи інші статуси. Класична мотивація до централізації обробки помилок якраз і з’являється через таку повторюваність.

Від цього моменту ми домовляємося: http.Error у нашому JSON API не використовуємо. Узагалі. Навіть «на хвилиночку».

3. Єдиний error envelope: формат помилок

Щоб далі говорити однією мовою, зафіксуємо формат, який клієнт очікує завжди, коли щось пішло не так:

{
  "error": {
    "code": "validation",
    "message": "некоректний запит",
    "fields": {
      "title": "не має бути порожнім"
    }
  }
}

Де code — короткий машинний код (зручно для автоматичної обробки), message — людське загальне повідомлення, а fields — об’єкт із помилками за конкретними полями (використовуємо лише для помилок валідації, інакше поле відсутнє).

У Go це зазвичай виглядає так:

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"`
}

Зверніть увагу на omitempty: якщо Fields == nil, то fields у JSON не з’явиться. Це робить відповідь охайнішою та зрозумілішою: немає помилок за полями — немає й поля fields.

4. Декодування та валідація: дві різні проблеми

Дуже поширена помилка новачка — зводити все до одного: «Якщо щось не так — 400». Формально це часто працює. Але коли ви налагоджуєте клієнт або пишете документацію, різниця стає принциповою.

У нашому сервері є два окремі етапи перевірки вводу.

Перший етап — декодування: ми читаємо r.Body і намагаємося зрозуміти, чи це валідний JSON потрібної структури. Тут помилки зазвичай технічні: пошкоджений синтаксис, передчасне завершення, зайві поля, неправильний тип — наприклад, рядок замість числа.

Другий етап — валідація: JSON коректний, структура збіглася, але дані не відповідають правилам предметної області. Наприклад, title порожній або складається з пробілів. Це вже не «битий JSON», а «погані дані».

Зручно уявляти це як конвеєр:

flowchart TD
    A[HTTP-запит] --> B[Декодування JSON]
    B -->|декодування успішне| C[Валідація полів]
    B -->|помилка декодування| E[400 + error envelope: body/...]
    C -->|валідація успішна| D[Бізнес-логіка]
    C -->|помилка валідації| F[400 + error envelope: fields]

Тут важлива ідея: і помилки декодування, і помилки валідації — це 400, але fields і сенс повідомлень відрізняються.

5. Приклад: POST /tasks і поганий ввід

Щоб не обговорювати абстракції у вакуумі, продовжимо наше навчальне API для задач. Сьогодні нас цікавить створення задачі через JSON.

Запит виглядатиме так:

{ "title": "Buy milk" }

Для нього заведемо DTO — структуру запиту:

package main

type createTaskRequest struct {
	Title string `json:"title"`
}

І ось які ситуації ми маємо вміти відрізняти, не втрачаючи самовладання:

  • тіло порожнє (клієнт не надіслав нічого);
  • JSON пошкоджений ({ "title": "Buy milk");
  • JSON коректний, але поле має неправильний тип ({ "title": 123 });
  • JSON містить зайві поля ({ "title": "Buy milk", "lol": true });
  • JSON коректний, але title порожній або складається з пробілів ({ "title": " " });
  • тіло надто велике (клієнт вирішив надіслати «Війну і мир» як назву задачі).

Якщо ви навчитеся стабільно обробляти це в одній кінцевій точці, інші кінцеві точки будуватимуться за тим самим шаблоном. А якщо ні — ваші клієнти почнуть «вчитися» методом спроб і помилок… і зазвичай вчаться вони на вашому проді.

6. Як класифікувати помилки json.Decoder

encoding/json — доволі чесний пакет: він не приховує, що саме пішло не так. Ба більше, багато помилок там є типізованими — наприклад, *json.SyntaxError. І Go прямо заохочує такі речі: помилки — це значення, вони можуть мати типи, і це нормально використовувати.

Але є нюанс: щоб охайно відрізняти види помилок, нам потрібна мінімальна «таблиця відповідностей» з внутрішніх помилок декодера у наш зовнішній контракт — error envelope.

Ми перевірятимемо помилки через errors.As/errors.Is. Це важливо, тому що помилки можуть бути обгорнуті: ви додали контекст через fmt.Errorf("decode: %w", err), а початковий тип помилки все одно хочеться дізнатися. Саме для цього в Go існують errors.Is і errors.As.

Мінітаблиця: що сталося і що повертаємо

Стисло зведімо основну ідею в таблицю. Це не «єдино правильні формулювання», але добрий стабільний початок.

Ситуація (всередині) Як розпізнати в Go Що відповідаємо клієнту
Порожнє тіло err == io.EOF при першому Decode 400 + fields.body = "не має бути порожнім"
Пошкоджений JSON (синтаксис) var e *json.SyntaxError; errors.As(err, &e) 400 + fields.body = "некоректний JSON"
Неправильний тип поля var e *json.UnmarshalTypeError; errors.As(err, &e) 400 + fields.<field> = "неправильний тип"
Зайві поля DisallowUnknownFields() + перевірка тексту "json: unknown field ..." 400 + fields.body = "невідоме поле" (або конкретніше)
Кілька JSON-об’єктів підряд другий Decode не повернув io.EOF 400 + fields.body = "має містити лише один JSON-об’єкт"
Надто велике тіло var e *http.MaxBytesError; errors.As(err, &e) 400 + fields.body = "занадто велике"

Сенс у тому, що клієнт завжди отримує один і той самий зовнішній формат, але «начинка» (fields) дає йому зрозуміти, що саме виправляти.

7. decodeJSON: безпечно читаємо тіло запиту і повертаємо помилку клієнту

Зараз ми зберемо функцію, яка робить одразу три речі: обмежує розмір тіла запиту, строго декодує JSON і перевіряє правило «рівно один JSON-об’єкт». Важливо: ця функція поки що сама не формує відповідь. Вона повертає помилку, яку обробник зможе перетворити на error envelope.

Спочатку заведемо свою маленьку помилку для «проблем вводу»:

package main

type inputError struct {
	Fields map[string]string
}

func (e inputError) Error() string {
	return "некоректний запит"
}

Ця помилка не намагається бути «розумною»: її призначення — зберігати Fields. А рядок Error() нам потрібен лише тому, що інакше це не буде error.

Тепер сама функція декодування:

package main

import (
	"encoding/json"
	"net/http"
)

func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
	const maxBody = 1 << 20 // 1 MiB
	r.Body = http.MaxBytesReader(w, r.Body, maxBody)
	defer r.Body.Close()

	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	if err := decodeOne(dec, dst); err != nil {
		return classifyDecodeError(err)
	}
	return nil
}

Декодуємо та перевіряємо «хвіст»:

package main

import (
	"encoding/json"
	"errors"
	"io"
)

func decodeOne(dec *json.Decoder, dst any) error {
	if err := dec.Decode(dst); err != nil {
		return err
	}
	if err := dec.Decode(&struct{}{}); err != io.EOF {
		return errors.New("зайві дані після JSON")
	}
	return nil
}

Так, ми свідомо розбили на дві функції: щоб шматки коду були маленькими й читалися без лупи.

Тепер «перекладач» помилок декодування в inputError:

package main

import (
	"encoding/json"
	"errors"
	"io"
	"net/http"
)

func classifyDecodeError(err error) error {
	var maxErr *http.MaxBytesError
	if errors.As(err, &maxErr) {
		return inputError{Fields: map[string]string{"body": "занадто велике"}}
	}
	if errors.Is(err, io.EOF) {
		return inputError{Fields: map[string]string{"body": "не має бути порожнім"}}
	}
	var typeErr *json.UnmarshalTypeError
	if errors.As(err, &typeErr) && typeErr.Field != "" {
		return inputError{Fields: map[string]string{typeErr.Field: "неправильний тип"}}
	}
	return inputError{Fields: map[string]string{"body": "некоректний JSON"}}
}

Тут є два «навчальні компроміси», і це нормально.

  • По-перше, ми не намагаємося ідеально розпарсити «unknown field X». Можна зробити точніше, але на першому наближенні достатньо загального повідомлення "некоректний JSON" або "невідоме поле".
  • По-друге, ми не видаємо назовні err.Error() — і це принципово. Навіть якщо дуже хочеться «допомогти клієнту». Справжня допомога має бути в стабільному контракті, а не у випадковому рядку з внутрішньої бібліотеки.

8. Валідація полів після декодування

Валідація — це момент, коли ви перестаєте бути «парсером JSON» і стаєте «охоронцем на вході в предметну область». Предметній області зазвичай байдуже, чи був JSON гарним. Їй важливо, щоб значення мали сенс.

Тому валідовуємо окремо й явно. Для createTaskRequest правило просте: title не має бути порожнім або складатися з пробілів.

package main

import "strings"

func validateCreateTask(req createTaskRequest) error {
	title := strings.TrimSpace(req.Title)
	if title == "" {
		return inputError{Fields: map[string]string{"title": "не має бути порожнім"}}
	}
	return nil
}

Чому ми знову повертаємо inputError? Тому що для обробника не важливо, де саме зламався ввід — на декодуванні чи на валідації. Якщо це «помилка клієнта», обробник повертає 400 з error envelope.

9. Обробник POST /tasks: завжди відповідаємо через envelope

Тепер зберемо обробник POST /tasks, який використовує наші функції. Для простоти: якщо все добре, повернемо 201 Created і маленький JSON { "ok": true }. У реальному проєкті ми б повернули DTO створеної задачі, але зараз наша мета — обробка помилок вводу.

Спочатку — мінімальна функція для запису error envelope. Так, це невеликий helper: ми не будуємо «універсальний комбайн», а просто не хочемо тричі повторювати однакову структуру JSON:

package main

import (
	"encoding/json"
	"net/http"
)

func writeValidationError(w http.ResponseWriter, fields map[string]string) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(http.StatusBadRequest)

	_ = json.NewEncoder(w).Encode(ErrorEnvelope{
		Error: APIError{
			Code:    "validation",
			Message: "некоректний запит",
			Fields:  fields,
		},
	})
}

Тепер сам обробник:

package main

import (
	"encoding/json"
	"errors"
	"net/http"
)

func handleCreateTask(w http.ResponseWriter, r *http.Request) {
	var req createTaskRequest

	if err := decodeJSON(w, r, &req); err != nil {
		var in inputError
		if errors.As(err, &in) {
			writeValidationError(w, in.Fields)
			return
		}
		writeValidationError(w, map[string]string{"body": "некоректний JSON"})
		return
	}

	if err := validateCreateTask(req); err != nil {
		var in inputError
		if errors.As(err, &in) {
			writeValidationError(w, in.Fields)
			return
		}
		writeValidationError(w, map[string]string{"body": "некоректний запит"})
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(http.StatusCreated)
	_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}

Так, тут є невелика навчальна надмірність: двічі перевіряємо errors.As. Але зате логіка лінійна й зрозуміла: decode → validate → успіх.

І ще один важливий момент: зверніть увагу, що в разі проблем із вводом ми завжди повертаємо відповідь у форматі JSON. Клієнту не потрібно з’ясовувати, що саме ми використали всередині.

Що можна розкривати клієнту, а що краще залишити в логах

Коли ви обираєте, що писати в помилці, ви фактично обираєте, що стає частиною API. У Go це добре видно навіть на рівні звичайних функцій: якщо ви обгортаєте внутрішню помилку і даєте клієнту можливість її розпізнати, то ніби обіцяєте, що ця внутрішня деталь тепер стабільна. І це може стати проблемою, якщо ви захочете змінити реалізацію.

В HTTP API все так само. Якщо ви віддаєте назовні «сирі» тексти з encoding/json або «сирі» помилки бази даних, клієнт може почати на них покладатися — навіть якщо клянеться, що не буде. І за місяць ви виявите, що не можете змінити бібліотеку або покращити текст, бо в когось усе зламається.

Тому добре правило таке: назовні віддаємо стабільні коди та передбачувані поля, а деталі залишаємо собі — наприклад, у логах. Навіть якщо зараз логів немає, усе одно краще не робити «витоків майбутніх проблем».

10. Типові помилки

Помилка №1: відповідати на пошкоджений JSON через http.Error, а на валідацію — через JSON.
Так у клієнта з’являються два формати помилок. У найкращому разі він починає писати умовні гілки: «якщо JSON розпарсився — одне, інакше — інше». У найгіршому — падає прямо на парсингу відповіді. Для API значно краще, коли формат помилок один і завжди JSON.

Помилка №2: надсилати назовні err.Error() з encoding/json.
Навіть якщо здається, що це «корисно клієнту», на практиці ви віддаєте деталі реалізації та нестабільні формулювання. Клієнт їх запам’ятовує, тести на фронтенді їх фіксують, і ви самі себе заганяєте в кут.

Помилка №3: змішувати декодування та валідацію в одну купу без структури.
Якщо ви спочатку валідовуєте поля, а потім намагаєтеся декодувати JSON або робите це в різних місцях, легко отримати дивні баги: r.Body читається один раз, і вдруге там уже «порожньо». Правильніше тримати протокол «decode → validate» завжди в одному порядку та в одному місці.

Помилка №4: не перевіряти правило «рівно один JSON-об’єкт».
Якщо зробити лише один Decode, то запит із тілом виду {...}{...} може частково «проковтнутися», а сміття залишиться непоміченим. Це рідко трапляється в нормальних клієнтів, але часто трапляється в тестах, ручних запитах і в зловмисників — так, вони теж інколи існують.

Помилка №5: забути про ліміт розміру тіла запиту.
Без http.MaxBytesReader ви дозволяєте клієнту надіслати гігантське тіло, яке читатиметься й витрачатиме пам’ять і час. Навіть якщо ви не думаєте про атаки, думайте хоча б про випадковий баг клієнта: «ой, ми відправили не те поле, і воно виявилося мегабайтним».

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