1. parseID: навіщо він потрібен і який у нього контракт
Коли ви вперше пишете обробник на кшталт GET /api/v1/tasks/{id}, усе виглядає невинно: взяли id зі шляху, розпарсили, пішли далі. Але варто додати другий обробник (DELETE /api/v1/tasks/{id}), потім третій (POST /api/v1/tasks/{id}/done) — і ви раптом виявляєте, що в кожному повторюється одна й та сама логіка. А потім ще й «майже така сама», тільки з іншими текстами помилок, — і починається маленьке локальне пекло.
Чому копіпаста «Atoi + перевірки» — пастка
Уявімо типовий «перший варіант», який трапляється в новачків (і майже в усіх нас у перший день):
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
if id <= 0 {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
Це працює, але проблема в тому, що ви змішали три різні відповідальності в одному місці: витягування входу, правила валідності та формування HTTP‑відповіді. У результаті будь-яке «косметичне» оновлення перетворюється на масову правку: змінили правило (id тепер може бути 0? не може?), змінили текст помилки, захотіли повертати fields у JSON — і ви правите 5–10 обробників.
Саме тому ми вводимо маленький, але дуже корисний хелпер: parseID. Він робить одну річ: отримує рядок і повертає (int, error) за єдиним контрактом. А обробник вирішує, як саме перетворити помилку на HTTP‑відповідь.
Контракт parseID: що функція обіцяє коду, який її викликає
Коли ми говоримо «контракт функції», це не юридичний документ — хоча інколи хочеться покликати юриста на code review. Це чітка домовленість: які входи очікуємо, які виходи даємо та як інтерпретувати помилку. Якщо контракт розмитий, кожен обробник починає сам «додумувати» правила — і у вас знову копіпаста, тільки вже більш творча.
Для parseID зафіксуймо простий контракт:
| Вхід (рядок) | Що це означає | Результат |
|---|---|---|
|
коректний id | |
|
id відсутній / не витягнули | |
|
не число | |
| "-5" або "0" | число, але не відповідає правилам | |
Ключові правила валідності сьогодні мінімальні й практичні:
- id має бути цілим числом (через strconv.Atoi)
- id має бути додатним (id > 0)
Чому саме id > 0? Тому що в багатьох API id — це ідентифікатор запису в сховищі, а 0 зазвичай означає «не задано». Можна придумати іншу домовленість, але важливіше інше: вона має бути одна й всюди однакова.
Підкреслю окремо, бо це важливо: parseID не пише HTTP‑відповідь. Він не знає, який у вас формат помилок — JSON‑envelope чи просто текст, — і не має знати. Він просто повертає помилку як значення, у дусі Go.
ValidationError: одна помилка, але з деталями за полями
Якщо ви робите API трохи акуратніше, ніж «повернемо рядок і далі якось живіть», вам швидко знадобиться повертати деталі валідації: що саме не так. У JSON‑API це зазвичай виглядає як «код помилки + повідомлення + помилки в полях». Ми якраз хочемо, щоб клієнт отримав щось на кшталт:
{
"error": {
"code": "validation",
"message": "invalid request",
"fields": {
"id": "must be integer"
}
}
}
Щоб не ліпити це вручну в кожному обробнику, ми вводимо типізовану помилку:
package main
type ValidationError struct {
Fields map[string]string
}
func (e *ValidationError) Error() string {
return "validation error"
}
Тут є дві важливі причини.
Перша: Fields — це машиночитна структура. Вона потрібна не лише людині, а й клієнту. Наприклад, UI може підсвітити поле "id" червоним і показати текст.
Друга: Error() повертає короткий загальний текст. Він не зобов’язаний містити всі деталі, бо вони вже лежать у Fields. Так і зручніше, і чистіше.
Так, можна було б повернути просто fmt.Errorf("id must be integer"). Але тоді ваш загальний обробник помилок не зможе структуровано заповнити fields в error envelope. У підсумку ви скотитеся назад до «розбираємо рядки помилок», а це заняття приблизно як парсити HTML регулярками: іноді працює, але соромно.
Реалізація parseID: коротко й з єдиними повідомленнями
Сама реалізація parseID має бути максимально нудною. Це комплімент: чим нудніший такий код, тим менше він ламається. Ми просто послідовно перевіряємо вхід і повертаємо ValidationError в одному форматі.
package main
import (
"strconv"
"strings"
)
func parseID(s string) (int, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, &ValidationError{Fields: map[string]string{"id": "is required"}}
}
id, err := strconv.Atoi(s)
if err != nil {
return 0, &ValidationError{Fields: map[string]string{"id": "must be integer"}}
}
if id <= 0 {
return 0, &ValidationError{Fields: map[string]string{"id": "must be positive"}}
}
return id, nil
}
Пара практичних зауважень про цей код.
Ми робимо strings.TrimSpace, бо значення з URL зазвичай і так чисте, але життя любить сюрпризи. Нехай "/tasks/ 12 " не стане вашим персональним фільмом жахів.
Ми повертаємо 0 разом із помилкою. Це нормально: за контрактом id валідний лише якщо err == nil. 0 тут просто «заглушка».
І найважливіше: усі обробники тепер отримуватимуть однакову помилку валідації й зможуть перетворювати її на однакову відповідь API.
2. Обробник і error envelope: як передати Fields у відповідь
Обробники в Go легко перетворюються на кашу, якщо в них одночасно змішати парсинг, валідацію, бізнес-логіку та формування відповіді клієнту. Ми хочемо, щоб обробник читався як лінійний сценарій: «прочитали вхід → перевірили → виконали дію → відповіли».
Як parseID використовується в обробнику
Ось приклад обробника, який дістає {id}, валідує його через parseID і далі міг би звертатися до сховища — сьогодні сховище нас не цікавить, нам важливий вхідний контракт.
package main
import (
"errors"
"net/http"
)
func handleGetTask(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
var ve *ValidationError
if errors.As(err, &ve) {
writeValidationError(w, ve.Fields)
return
}
writeInternalError(w)
return
}
_ = id // тут пізніше буде читання задачі за id
w.WriteHeader(http.StatusOK)
}
Зверніть увагу на errors.As. Це стандартний спосіб дістати помилку конкретного типу зі значення error. У Go 1.13 з’явилися стандартні механізми errors.Is/errors.As, щоб такі перевірки були уніфікованими.
Сенс у тому, що обробник не зобов’язаний знати, як саме влаштована помилка всередині — рядок там чи struct. Він робить просту річ: якщо це ValidationError — віддаємо 400 із fields. Інакше — 500, але без витоку деталей назовні.
Як ValidationError.Fields потрапляє в error envelope
Єдиний JSON-формат помилок зазвичай називають error envelope — тобто ми завжди відповідаємо об’єктом вигляду { "error": { ... } }, навіть якщо всередині помилка різна. Клієнту так простіше: він знає, куди дивитися, і не мусить писати 15 гілок для розбору відповідей.
Для початку заведемо структури — мінімально необхідні:
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 не зʼявиться. Це корисно, бо помилки валідації справді відрізняються від решти: лише їм потрібні помилки в полях.
Тепер зробімо маленький writer для помилок валідації. Зверніть увагу: сьогодні нам не обов’язково будувати ідеальну універсальну систему. Нам важливо побачити принцип «Fields → fields».
package main
import (
"encoding/json"
"net/http"
)
func writeValidationError(w http.ResponseWriter, fields map[string]string) {
env := ErrorEnvelope{Error: APIError{
Code: "validation", Message: "invalid request", Fields: fields,
}}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(env)
}
І для внутрішньої помилки — окрема безпечна відповідь. Головне правило: для 5xx не віддаємо клієнту err.Error(), тому що там може бути що завгодно: шлях до файлу, деталі БД, внутрішні імена таблиць — і просто сороміцькі повідомлення, які ви писали о третій ночі.
package main
import (
"encoding/json"
"net/http"
)
func writeInternalError(w http.ResponseWriter) {
env := ErrorEnvelope{Error: APIError{Code: "internal", Message: "internal error"}}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(env)
}
Якщо ви зараз думаєте: «А чому б не зробити appHandler, який повертає помилку, і один загальний ServeHTTP, щоб не повторювати обробку?» — ви мислите в правильному напрямку. У Go це популярний прийом, щоб зменшувати повторення обробки помилок у HTTP‑коді. Але сьогодні наша мета вже досягнута: ми навчилися однаково повертати помилки валідації й складати деталі в fields.
Щоб закріпити, ось маленька схема потоку:
flowchart TD
A["HTTP-запит до /api/v1/tasks/{id}"] --> B[обробник]
B --> C["r.PathValue('id') -> string"]
C --> D[parseID]
D -->|id, nil| E[успішна логіка]
D -->|ValidationError| F[writeValidationError -> 400 + fields]
D -->|інша помилка| G[writeInternalError -> 500]
Міні-збірка: як виглядає застосунок із parseID
Щоб у вас було відчуття цілісної картини, зберімо мінімальний main, який реєструє маршрут із {id} і використовує обробник вище. Код спеціально простий: без збереження задач, без повернення задачі у JSON — лише перевірка id і статуси.
package main
import "net/http"
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/tasks/{id}", handleGetTask)
_ = http.ListenAndServe(":8080", mux)
}
Тепер подумки (або реально через curl) перевірте:
Якщо запит GET /api/v1/tasks/abc, то parseID поверне ValidationError з {"id":"must be integer"}, а API відповість 400 і покладе це в error.error.fields.id.
Якщо запит GET /api/v1/tasks/0, то буде {"id":"must be positive"}.
Якщо запит GET /api/v1/tasks/10, то все гаразд і буде 200 OK — нехай поки без тіла.
3. Типові помилки під час проєктування parseID і ValidationError
Помилка № 1: змішувати рівні відповідальності.
Якщо parseID в одному місці повертає ValidationError, в іншому — fmt.Errorf, а в третьому сам пише HTTP‑відповідь, контракт розмивається. Обробник перестає розуміти, що саме він отримує і як це обробляти. У підсумку логіка знову розповзається по коду. parseID має або повертати строго визначений тип помилки, або значення й помилку — але не займатися HTTP і не «стрибати» між форматами.
Помилка № 2: ігнорувати помилку від strconv.Atoi.
Atoi не намагається вгадати, що ви мали на увазі. Якщо рядок не є числом, він повертає помилку — і її потрібно перевіряти. Якщо цього не зробити, далі піде некоректний id (наприклад, 0), а проблема проявиться пізніше і в іншому місці. Обробка помилки має бути негайною й явною.
Помилка № 3: не враховувати порожній id.
Навіть якщо маршрут /tasks/{id} передбачає наявність сегмента, порожній рядок може з’явитися через описку в імені параметра (наприклад, {taskID}, а читаєте PathValue("id")). Тому parseID має явно перевіряти порожнє значення й повертати передбачувану помилку "is required", щоб збій проявився одразу й коректно повернув 400.
Помилка № 4: віддавати клієнту err.Error() у відповіді 500.
Це зручно для відладки, але ризиковано для продакшена. У тексті помилки можуть опинитися внутрішні шляхи, деталі інфраструктури або технічні повідомлення, які не можна розкривати назовні. Публічне повідомлення має бути стабільним і безпечним, а подробиці — залишатися в логах сервера.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ