JavaRush /Курси /Go SELF /Хелпер parseID: контракт, помилки та ValidationError.Fiel...

Хелпер parseID: контракт, помилки та ValidationError.Fields

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

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 зафіксуймо простий контракт:

Вхід (рядок) Що це означає Результат
"123"
коректний id
id = 123, err = nil
""
id відсутній / не витягнули
id = 0, err = ValidationError
"abc"
не число
id = 0, err = ValidationError
"-5" або "0" число, але не відповідає правилам
id = 0, err = ValidationError

Ключові правила валідності сьогодні мінімальні й практичні:

  1. id має бути цілим числом (через strconv.Atoi)
  2. 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 для помилок валідації. Зверніть увагу: сьогодні нам не обов’язково будувати ідеальну універсальну систему. Нам важливо побачити принцип «Fieldsfields».

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.
Це зручно для відладки, але ризиковано для продакшена. У тексті помилки можуть опинитися внутрішні шляхи, деталі інфраструктури або технічні повідомлення, які не можна розкривати назовні. Публічне повідомлення має бути стабільним і безпечним, а подробиці — залишатися в логах сервера.

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