Содержание урока · 15 разделов
- валидация fiber: провода из девятого урока
- Интерфейс из bind.go: один метод Validate(out any)
- go get и цена зависимости
- validateStruct: пять шагов на каждый bind
- Два словаря одного слова: omitempty
- Три семейства ошибок на одном return err
- 400 против 422: конвенция важнее цифры
- Карта ошибок POST /api/tasks
- Срез верхнего уровня: валидации не будет
- RegisterValidation: свой тег рядом с адаптером
- FieldError в JSON-ответ: четыре метода и путь
- Запуск, проверка curl-ом и что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
валидация fiber: провода из девятого урока#
Каждый POST-хендлер девятого урока заканчивался одной короткой проверкой руками: if in.Title == "". Один раз — не страшно; пять эндпоинтов — уже рутина; а когда к задачам добавятся пользователи и проекты, самодельные if-ы расползутся и начнут противоречить друг другу. Тема «валидация fiber» — о том, как вынести проверку из тела хендлера в декларацию на структуре и заставить Bind исполнять её сам.
Провода мы уже видели: девятый урок показал вызов validateStruct после разбора и честно предупредил, что config.StructValidator по умолчанию nil и шаг проверки молча пропускается. Сегодня в эту розетку втыкается штепсель. Документация Fiber формулирует распределение ролей без обиняков: Fiber does not bundle a validation library — библиотеки валидации в коробке нет, ядро остаётся без зависимостей, а фреймворк предлагает только слот fiber.Config.StructValidator и один вызов после каждого bind-метода. Кого ставить в слот — ваше решение; наш выбор традиционен для экосистемы: go-playground/validator десятого поколения — тот же движок, что крутится внутри gin, и поисковый запрос validator golang приводит в один и тот же репозиторий, откуда бы вы ни стартовали.
Интерфейс из bind.go: один метод Validate(out any)#
Сверимся с исходниками тега v3.5.0. В bind.go интерфейс объявлен четвёркой строк: док-комментарий StructValidator is an interface to register custom struct validator for binding — и единственный метод Validate(out any) error. Сравните с gin из параллельного курса: там интерфейс StructValidator живёт в binding/binding.go и требует два метода — ValidateStruct(any) error и Engine() any, а словарь тегов переключается SetTagName("binding"). Fiber просит меньше — один метод — и словарь не трогает вовсе: движок читает родные validate-теги, переключать нечего.
Второй источник — app.go, поле конфига. Док-комментарий там такой: If you want to validate header/form/query... automatically when to bind, you can define struct validator. Fiber doesn't have default validator, so it'll skip validator step if you don't use any validator — и ниже Default: nil. Из комментария следуют два факта, которые легко пропустить. Первый: параметр метода — any, то есть формально адаптер может получить что угодно; фильтрация «структура или нет» — забота не библиотеки, а самого bind.go, до неё дойдём. Второй: nil — не баг, а документированный дефолт: без валидатора шаг проверки пропускается молча, без предупреждений в логах. Именно поэтому в девятом уроке POST с пустым title спокойно возвращал 201 — провода были под напряжением, но вилку никто не воткнул.
Адаптер: обёртка из одного метода
GOpackage main
import (
"log"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v3"
)
// validatorAdapter — обёртка, реализующая интерфейс fiber.StructValidator
type validatorAdapter struct {
validate *validator.Validate
}
// Validate — единственный метод интерфейса: bind.go зовёт его сам
func (v *validatorAdapter) Validate(out any) error {
// движок проверит структуру и вернёт ValidationErrors при провале
return v.validate.Struct(out)
}
func main() {
app := fiber.New(fiber.Config{
// одна настройка — валидация на каждом bind-методе
StructValidator: &validatorAdapter{validate: validator.New()},
})
app.Get("/healthz", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"ok": true})
})
log.Fatal(app.Listen(":8080"))
}Документация описывает этот шаг ровно одной фразой: StructValidator expects a single Validate(out any) error method, so wrap the library in a tiny adapter and register it once — «один метод, крошечный адаптер, регистрация один раз». validator.New() создаёт движок со всеми встроенными правилами; Struct(out) возвращает nil на валидной структуре, InvalidValidationError на невалидном входе (nil или не-структура — через fiber почти недостижимо, bind.go фильтрует до вызова) и ValidationErrors со списком несработавших правил. Адаптер — единственное место, где имена fiber и validator встречаются в коде; дальше хендлеры знают только Bind.
go get и цена зависимости#
Зависимость ставится одной командой: go get github.com/go-playground/validator/v10. Обратите внимание на суффикс /v10 — это отдельный модуль ветки десятого поколения; в исходниках gin документирована v10.6.1, в вашем go.mod может быть новее — интерфейс стабилен годами. Важно понимать, куда легла зависимость: пакет fiber ничего не импортирует из go-playground — документация подчёркивает, что ядро остаётся dependency-free; импорт живёт в вашем main.go, и удалить валидатор из проекта — значит вычеркнуть одну строку из конфига и одну из импортов.
Зато теперь проверка — настройка один раз, а не бойлерплейт в каждом хендлере: документация называет это a one-time setup instead of per-handler boilerplate. И это не ловушка вендора: интерфейс — один метод, так что самодельный валидатор на if-ах, другая библиотека или цепочка проверок — всё проходит через один и тот же слот. Дешёвая страховка на будущее: держите адаптер в отдельном файле вместе с регистрацией правил — одно место, куда смотрят при споре «как мы валидируем».
validateStruct: пять шагов на каждый bind#
Что происходит на каждый bind-вызов. В bind.go вызов validateStruct стоит после каждого парсера семейства — Body, JSON, XML, Form, MsgPack, CBOR, Query, URI, Header, Cookie, Custom; документация так и говорит: every bind method (Body, Query, Form, and the rest) now triggers validation and returns its errors alongside binding errors. Внутри метода пять шагов, и почти каждый — потенциальный выход без проверки. Первый: shouldSkipValidation — флаг цепочки, которым управляет chainable-метод SkipValidation(skip bool) Bind: c.Bind().SkipValidation(true).Body(&in) разберёт тело, но проверку пропустит. Второй: validator := config.StructValidator; если nil — тихий выход, документированный дефолт из app.go. Третий: reflect.TypeOf(out) и разворот указателей — комментарий исходников описывает операцию дословно: unwrap pointers (e.g. T, **T) to inspect the underlying destination type. Четвёртый: если под указателем не структура — Kind() != reflect.Struct — выход: примечание официального гайда предупреждает, что StructValidator runs only for struct destinations (or pointers to structs), а биндинг в карты и прочие не-структуры проверку пропускает; fiber.Map и срезы адаптер не увидят. Пятый: validator.Validate(out) — вызов вашего адаптера с исходным указателем на заполненную структуру.
Отдельный сюрприз спрятан в Bind().All: на время слияния источников он глушит shouldSkipValidation, а validateStruct зовёт один раз в конце — уже на итоговой, собранной из URI, тела, query, заголовков и cookie структуре, причём через returnErr. Значит, в режиме WithAutoHandling именно All вернёт проваленную проверку как *Error со статусом 400 — отличие, о котором мы ещё вспомним.
Проверьте себя: дефолт без валидатора
fiber.New() создан без StructValidator — конфиг не трогали. Хендлер разобрал c.Bind().Body(&in) и вернул 201. Клиент прислал {"title": "пв", "priority": "ultra"}. Что произойдёт?
Task на новой сетке: теги json плюс validate
GO// единый движок: адаптеру, tagNameFunc, своему тегу и ручным проверкам
var engine = validator.New()
var appValidator = &validatorAdapter{validate: engine}
// Field() начнёт отдавать имена из тега json: title, priority, estimate
func init() {
engine.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
// json:"-" выводит поле из валидации, как и из сериализации
if name == "-" {
return ""
}
return name
})
}
// Task — форма данных API: тег json отвечает байтам, validate правилам
type Task struct {
ID int `json:"id" validate:"omitempty,min=1"`
Title string `json:"title" validate:"required,min=3,max=100"`
Done bool `json:"done"`
Priority string `json:"priority,omitempty" validate:"omitempty,oneof=low medium high"`
Estimate int `json:"estimate,omitempty" validate:"omitempty,workhours=40"`
CreatedAt string `json:"created_at,omitempty"`
}Правила повторяют каталог из девятого урока gin — required, min, max, oneof — пересказывать их не будем; отметим только новое место. Estimate подписан workhours=40 — это свой тег, его регистрация появится ближе к концу урока. Done остался вовсе без validate-тега: false — легитимное значение по определению, отсутствие правил тоже решение. Теги двух словарей не конфликтуют: encoding/json читает json и собирает ответ девятого урока, движок читает validate и не лезет в байты. RegisterTagNameFunc — официальный рецепт: Field() и Namespace() начнут отдавать title вместо Title, и ошибки заговорят языком API, а не языка Go.
Два словаря одного слова: omitempty#
Слово omitempty встречается в двух словарях, и это разные команды. В словаре encoding/json из девятого урока omitempty говорит «не сериализуй пустое поле». В словаре validator omitempty говорит «если значение пустое — пропусти все правила этого поля»: ноль, пустая строка, nil, false. Поле Priority помечено и там, и там, и команды не мешают друг другу: json-движок решает, печатать ли ключ в ответе, валидатор — проверять ли значение во входе. Связка omitempty,oneof=low medium high читается как «пусто — значит без приоритета; любое другое значение — только из трёх».
На ID та же связка решает типовую задачу: клиент, создающий задачу, ID не присылает — omitempty пропускает min=1 для нуля; но приславший отрицательный ID получит FieldError. Каталог остального — из gin-курса: email, url, gte, lte и десятки других лежат в том же словаре и работают одинаково, потому что движок общий. Из технических мелочей одна: min и max на строках считают руны, не байты, поэтому кириллица считается честно — «пв» не пройдёт min=3, сколько бы байтов ни занимал.
Проверьте себя: чьё имя вернёт Field()
RegisterTagNameFunc НЕ зарегистрирован. Правило priority oneof=low medium high не прошло на значении "ultra". Что вернёт e.Field()?
Три семейства ошибок на одном return err#
На return err из одного Body сходятся теперь три семейства, и различать их — обязательный навык. Первое — из девятого урока: BindError с полями Err, Source и Field — тело не разобралось: битый JSON, не тот тип поля. Второе — новое: validator.ValidationErrors; это срез FieldError, а у среза есть метод Error(), то есть тип реализует error и летит по обычному каналу ошибок. Сообщение каноничное: Key: 'Task.Title' Error:Field validation for 'Title' failed on the 'min' tag — а для списка ошибок такие строки склеиваются переводами строк. Третье — Error самого Fiber: так выглядит ошибка в режиме WithAutoHandling или созданная fiber.NewError.
Ключевой факт из bind.go: validateStruct возвращает ошибку валидации СЫРОЙ. Обёртка returnBindErr, которая заворачивает ошибки разбора в BindError с Source и Field, на путь валидации не действует — Source и Field у валидационной ошибки не заполняются, информацию о полях несёт сам ValidationErrors. Следствие для авторежима: WithAutoHandling снимает флаг shouldSkipErrHandling, но статус 400 от этого получает только ошибка РАЗБОРА; проваленная проверка из Body() вернётся сырой — и, возвращённая как есть, уедет в DefaultErrorHandler на 500. Исключение мы уже знаем: All() прогоняет validateStruct через returnErr, и там авторежим даёт Error 400. Порядок проверки в хендлере: errors.As на validator.ValidationErrors, затем на fiber.BindError — семейства не пересекаются, но обе ветки должны существовать.
POST: две ветки errors.As и свой каталог
GO// POST /api/tasks: два семейства ошибок — две ветки errors.As
api.Post("/tasks", func(c fiber.Ctx) error {
var in Task
// разбор и проверка — один вызов, ошибки приедут в одном err
err := c.Bind().Body(&in)
if err != nil {
var ve validator.ValidationErrors
// ветка №1: правила validate не прошли
if errors.As(err, &ve) {
out := make([]fiber.Map, 0, len(ve))
for _, e := range ve {
out = append(out, fiber.Map{
"field": e.Field(), // title — json-имя
"rule": e.Tag(), // min
"param": e.Param(), // 3
})
}
return c.Status(fiber.StatusBadRequest).JSON(
fiber.Map{"errors": out},
)
}
var be *fiber.BindError
// ветка №2: тело не разобралось — схема девятого урока
if errors.As(err, &be) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "запрос не разобран",
"source": be.Source,
"field": be.Field,
})
}
// остальное возвращаем как есть — ErrorHandler покажет 500
return err
}
nextID++
in.ID = nextID
in.CreatedAt = time.Now().Format(time.RFC3339)
tasks = append(tasks, in)
return c.Status(fiber.StatusCreated).JSON(in)
})Ветка №1 повторяет официальный рецепт документации: unwrap validator.ValidationErrors и собрать структурный ответ вместо текста — Field, Tag и Param каждого FieldError ложатся в {field, rule, param}. Форма {errors: [...]} дополняет форму девятого урока {error, source, field}: клиент читает оба, не гадая, какие поля чинить. Ветка №2 — та самая, что вы уже писали в девятом уроке: Source различит body и query, Field приедет из UnmarshalTypeError. Всё, что не опознано, возвращается как есть — честный 500 лучше проглоченной ошибки, а заменит его собственный ErrorHandler.
Наивный адаптер: return err как есть
Прочитайте код и выберите, что он выведет:
type validatorAdapter struct {
validate *validator.Validate
}
// Validate — единственный метод интерфейса fiber.StructValidator
func (v *validatorAdapter) Validate(out any) error {
return v.validate.Struct(out)
}
app := fiber.New(fiber.Config{
StructValidator: &validatorAdapter{validate: validator.New()},
})
api.Post("/tasks", func(c fiber.Ctx) error {
var in Task
if err := c.Bind().Body(&in); err != nil {
return err // ValidationErrors уедет как есть
}
return c.Status(fiber.StatusCreated).JSON(in)
})
// Запрос:
// POST /api/tasks Content-Type: application/json
// {"title": "пв"}400 против 422: конвенция важнее цифры#
Каким кодом отвечать на проваленную проверку? Официальный рецепт Fiber выбирает 400 Bad Request: Step 4 документации собирает ответ c.Status(fiber.StatusBadRequest).JSON(...) со списком ошибок. Против 400 есть аргумент из свежего словаря RFC 9110: 422 там называется Unprocessable Content — «содержимое синтаксически корректно, но семантика не прошла», а пустой title — ровно такой случай. За 400 — практика: gin в девятом уроке курса отвечал 400, и клиенты чаще всего ожидают именно его. Нюанс, который нельзя игнорировать: 422 в нашем API уже занята — девятый урок отдал её ErrUnprocessableEntity, ситуации «парсер под Content-Type не нашёлся».
Если спор цифр хочется закрыть один раз и навсегда, спрячьте его в адаптер: пусть Validate при errors.As(err, &ve) возвращает fiber.NewError(fiber.StatusUnprocessableEntity, "поля заполнены неверно"). Это готовая *Error — DefaultErrorHandler сам поставит 422 и запишет message в text/plain, а хендлеры продолжат просто возвращать err. Правило одно: выберите цифру один раз и держите единой весь API — каталог ошибок и клиентский код завязаны на неё сильнее, чем на любую философию RFC.
Карта ошибок POST /api/tasks#
| Событие | Что прилетит в err | Код и тело |
|---|---|---|
| битый JSON или тип поля не тот | *BindError с Source body и Field — ветка девятого урока | 400 с вашими JSON-ошибками; в сыром виде — 500 и text/plain |
| Content-Type вне списка Bind | ErrUnprocessableEntity — готовая *Error | 422 Unprocessable Entity от ErrorHandler |
| поле пусто или короче min | validator.ValidationErrors — список FieldError | 400 по рецепту документации; 422 — по конвенции проекта |
| приоритет вне oneof или число вне диапазона | FieldError с Tag и Param | та же ветка 400: поле и правило в ответе |
| валидатор не задан в конфиге | ничего: validateStruct вернул nil на пустом слоте | 201 — задача создана без проверки |
| срез верхнего уровня с плохими элементами | тоже ничего: срез — не структура | 201 — ручной v.Var с dive обязателен |
Срез верхнего уровня: валидации не будет#
Срез — развилка, где Fiber расходится с gin радикально. В девятом уроке курса gin batch-запрос []TodoInput проверялся сам: когда ShouldBindJSON получал &batch, binding gin-а видел Slice, ходил по элементам в цикле и собирал ошибки в собственный тип binding.SliceValidationError — отдельное семейство с индексами в сообщениях. В Fiber v3 validateStruct смотрит на назначение: []Task под указателем — Kind() == Slice, а не Struct — и выход без проверки, ни единого правила на элементы. Документация предупреждает одной строкой (StructValidator runs only for struct destinations), но цена промаха высока: батч-импорт из девятого урока молча примет задачи с пустыми title и вернёт 201.
Лечение — ручной прогон движка: engine.Var(in, "omitempty,dive"). Var проверяет одиночное значение по строке тегов, а dive спускает правила в элементы среза — механика dive разобрана в gin-курсе, напомним одной строкой: dive на не-срезе и не-карте — паника. Ошибка прилетит той же ValidationErrors, и хендлер различит её тем же errors.As. Срезы ВНУТРИ структуры ведут себя наоборот: поле Members []Member с validate:"dive" проверяется автоматически — validateStruct вызван на внешней структуре, а движок сам спускается по dive в элементы. Два маршрута запомните одним правилом: сверху Fiber не спускается, внутри структуры движок спускается по тегу.
Батч-импорт: ручной dive через v.Var
GO// POST /api/tasks:import — батч из девятого урока: срез проверяем руками
api.Post("/tasks:import", func(c fiber.Ctx) error {
var in []Task
// validateStruct пропустит срез: Kind() != reflect.Struct
if err := c.Bind().Body(&in); err != nil {
return err
}
// ручной прогон: dive спускает правила в каждый элемент
if err := engine.Var(in, "omitempty,dive"); err != nil {
var ve validator.ValidationErrors
if errors.As(err, &ve) {
out := make([]fiber.Map, 0, len(ve))
for _, e := range ve {
// Namespace покажет индекс: Task[1].title
out = append(out, fiber.Map{
"path": e.Namespace(),
"rule": e.Tag(),
})
}
return c.Status(fiber.StatusBadRequest).JSON(
fiber.Map{"errors": out},
)
}
return err
}
for _, t := range in {
nextID++
t.ID = nextID
t.CreatedAt = time.Now().Format(time.RFC3339)
tasks = append(tasks, t)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"items": tasks})
})Три ступени, которые легко пропустить. Первая: Bind().Body разберёт массив — декодеру всё равно, срез или структура. Вторая: валидация НЕ вызовется — назначение не структура, validateStruct вернул nil; без ручного прогона битый элемент лег бы в хранилище. Третья: engine.Var(in, "omitempty,dive") делает то, что для структур делает адаптер, — только по явной строке тегов. В ответе уместен Namespace(), а не Field(): индекс элемента вшит в путь — Task[1].title скажет, какой именно элемент чинить, тогда как Field() вернул бы безымянное title. ValidationErrors и так содержит ВСЕ несработавшие поля всех элементов — перечисляйте их все, а не первый.
Форма без заголовка: валидна или нет?
Прочитайте код и выберите, что он выведет:
api.Post("/tasks", func(c fiber.Ctx) error {
var in Task
// адаптер из урока подключён в fiber.Config
if err := c.Bind().Body(&in); err != nil {
return err
}
return c.Status(fiber.StatusCreated).JSON(in)
})
// Запрос без заголовка Content-Type:
// curl -d "title=позвонить клиенту" -H "X-API-Key: s3cret" \
// localhost:8080/api/tasksПроверьте себя: авторежим и валидация
Хендлер начал с bind := c.Bind().WithAutoHandling(), разобрал err := bind.Body(&in) и вернул err как есть. JSON синтаксически верен, но title короче трёх символов. Что увидит клиент?
RegisterValidation: свой тег рядом с адаптером#
Свой тег — короткий финальный штрих, и он почти без изменений переезжает из девятого урока gin: RegisterValidation(tag, func(fl FieldLevel) bool) регистрирует правило под именем тега, функция возвращает булево. Отличия — в месте и моменте. У gin движок спрятан за binding.Validator и доставался Engine() из недр фреймворка; у Fiber движок ваш с первого дня: вы сами вызвали validator.New(), сами держите ссылку, и регистрация живёт в init рядом с RegisterTagNameFunc — до первого запроса. Опоздаете — первый же запрос с неизвестным тегом упадёт в панику Undefined validation function, та же самая, что в gin-уроке.
Наш тег workhours параметризованный: в теге estimate написано workhours=40, и функция читает «40» через fl.Param(). Контракт такой: fl.Field() — reflect.Value проверяемого поля, fl.Param() — строка-параметр из тега, возврат false даёт FieldError с Tag() = workhours и Param() = 40. То же самое покрыли бы встроенные gte=1,lte=40 — наш пример нужен ради контракта, а каталог кастомных правил держите в одном init-файле рядом с адаптером: это место, где бизнес-логика встречается с формой данных.
Каркас: адаптер, теги, две ветки ошибок в main.go
GOpackage main
import (
"errors"
"log"
"reflect"
"strconv"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/cors"
"github.com/gofiber/fiber/v3/middleware/helmet"
"github.com/gofiber/fiber/v3/middleware/logger"
)
// Task — форма данных API: тег json отвечает байтам, validate правилам
type Task struct {
ID int `json:"id" validate:"omitempty,min=1"`
Title string `json:"title" validate:"required,min=3,max=100"`
Done bool `json:"done"`
Priority string `json:"priority,omitempty" validate:"omitempty,oneof=low medium high"`
Estimate int `json:"estimate,omitempty" validate:"omitempty,workhours=40"`
CreatedAt string `json:"created_at,omitempty"`
}
// validatorAdapter — обёртка под интерфейс fiber.StructValidator
type validatorAdapter struct {
validate *validator.Validate
}
// Validate — единственный метод интерфейса: bind.go зовёт его сам
func (v *validatorAdapter) Validate(out any) error {
return v.validate.Struct(out)
}
// единый движок: адаптеру, tagNameFunc, своему тегу и ручным проверкам
var engine = validator.New()
var appValidator = &validatorAdapter{validate: engine}
func init() {
// Field() начнёт отдавать json-имена: title, priority, estimate
engine.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
// свой тег: оценка от 1 до param часов, param читается из тега
engine.RegisterValidation("workhours", func(fl validator.FieldLevel) bool {
max, _ := strconv.Atoi(fl.Param())
v := fl.Field().Int()
return v > 0 && v <= int64(max)
})
}
// страж из седьмого урока: без X-API-Key группа закрыта
func requireAPIKey(key string) fiber.Handler {
return func(c fiber.Ctx) error {
if c.Get("X-API-Key") != key {
return c.Status(fiber.StatusUnauthorized).JSON(
fiber.Map{"error": "неверный API-ключ"},
)
}
c.Locals("user", "spa-client")
return c.Next()
}
}
func main() {
app := fiber.New(fiber.Config{
// одна настройка — валидация на каждом bind-методе
StructValidator: appValidator,
})
// слои восьмого урока: порядок прежний
app.Use(helmet.New())
app.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:3000"},
AllowHeaders: []string{"Content-Type", "X-API-Key"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowCredentials: true,
MaxAge: 3600,
}))
app.Use(logger.New())
api := app.Group("/api", requireAPIKey("s3cret"))
tasks := make([]Task, 0)
nextID := 0
api.Get("/tasks", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"items": tasks})
})
api.Post("/tasks", func(c fiber.Ctx) error {
var in Task
// разбор и проверка — один вызов
err := c.Bind().Body(&in)
if err != nil {
var ve validator.ValidationErrors
// ветка №1: правила validate не прошли
if errors.As(err, &ve) {
out := make([]fiber.Map, 0, len(ve))
for _, e := range ve {
out = append(out, fiber.Map{
"field": e.Field(),
"rule": e.Tag(),
"param": e.Param(),
})
}
return c.Status(fiber.StatusBadRequest).JSON(
fiber.Map{"errors": out},
)
}
var be *fiber.BindError
// ветка №2: тело не разобралось (девятый урок)
if errors.As(err, &be) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "запрос не разобран",
"source": be.Source,
"field": be.Field,
})
}
return err
}
nextID++
in.ID = nextID
in.CreatedAt = time.Now().Format(time.RFC3339)
tasks = append(tasks, in)
return c.Status(fiber.StatusCreated).JSON(in)
})
api.Delete("/tasks", func(c fiber.Ctx) error {
tasks = nil
return c.SendStatus(fiber.StatusNoContent)
})
// health-check вне группы и без ключа
app.Get("/healthz", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"ok": true})
})
log.Fatal(app.Listen(":8080"))
}Каркас собирает урок в целое. Конфиг получил ровно одну новую строку — StructValidator: appValidator; слои восьмого урока не тронуты: шлем, CORS для SPA на http://localhost:3000, лог, страж седьмого урока, /healthz без ключа. Движок один — engine: его держит адаптер, его настраивают RegisterTagNameFunc и RegisterValidation, его вызывает ручной Var в батч-эндпоинте. POST хендлер — те же коды девятого урока (200 на список, 201 на создание, 204 через SendStatus) плюс две ветки errors.As: ValidationErrors раньше BindError. Разница с прошлым уроком незаметна снаружи и радикальна внутри: ручной if in.Title == "" исчез — правила живут на структуре и исполняются до первой строки хендлера.
FieldError в JSON-ответ: четыре метода и путь#
| Метод FieldError | Что отдаёт | Пример для priority=ultra | Ключ в ответе |
|---|---|---|---|
| e.Field() | имя поля; с RegisterTagNameFunc — из тега json | priority | "field" |
| e.Tag() | несработавшее правило | oneof | "rule" |
| e.Param() | параметр правила из тега | low medium high | "param" |
| e.Value() | недопустимое значение | ultra | "value" |
| e.Namespace() | полный путь с индексами вложенности | Task.priority | "path" |
Допишите адаптер и ветку ошибок
Вставьте пропуски: имя метода интерфейса, вызов движка, тип ошибок проверки и код ответа из рецепта документации.
Соберите путь невалидного запроса
Расставьте шаги POST-запроса с коротким title по порядку: от команды в терминале до JSON-ответа со списком ошибок.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
validateStruct стоит после каждого парсера, поэтому правила полей едины для JSON, форм, query и заголовков: клиент не найдёт Content-Type, которым можно протащить пустой title. Это же мост к следующей теме: одиннадцатый урок — формы и загрузка файлов, Bind().Form и multipart пройдут тот же validateStruct, и проверки формы достанутся бесплатно. Файлы, правда, валидатор размером и типом не измерит — там понадобятся свои проверки, но контекст для них уже готов.
Словарь урока
Запуск, проверка curl-ом и что дальше#
Соберите каркас и прогоните пять сценариев. Первый — светлая дорожка: curl -i -X POST localhost:8080/api/tasks -H "Content-Type: application/json" -H "X-API-Key: s3cret" -d '{"title":"позвонить клиенту","priority":"high"}' — 201 и созданная задача с id и created_at. Второй — короткий title: -d '{"title":"пв"}' — 400 и {"errors":[{"field":"title","rule":"min","param":"3"}]}: json-имя Field() взял из RegisterTagNameFunc. Третий — внеполосный приоритет: "priority":"ultra" — 400 и param "low medium high". Четвёртый — батч: POST /api/tasks:import с [{"title":"ок"},{"note":"нет title"}] — 400, и в ответе path Task[1].title укажет на второй элемент. Пятый — ловушка наивного адаптера: уберите обе ветки errors.As и верните err как есть — 500 и Key: 'Task.Title' Error:Field validation... в text/plain.
Материал держится на трёх столбах: провода bind.go и формы ошибок — из девятого урока, слои и страж — из восьмого, каталог тегов, dive и RegisterValidation — из девятого урока курса gin; полный план — на странице курса Fiber. В браузерной песочнице сайта сетевые приложения не запускаются — runner-ов в уроках Fiber нет, проверяйте примеры в терминале с локальным Go.
Дальше — одиннадцатый урок: формы и загрузка файлов. Bind().Form и multipart пройдут тот же validateStruct, так что проверки формы достанутся бесплатно; файлам понадобятся свои проверки размера и типа, но место для них — та же цепочка после разбора.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- fiber.Config{StructValidator: ...} — единственный слот валидации: интерфейс из bind.go — один метод Validate(out any) error; Default: nil — без валидатора шаг проверки пропускается молча.
- Адаптер — ваша обёртка: Validate → v.Struct(out); ставится go get github.com/go-playground/validator/v10. Ядро Fiber dependency-free, словарь тегов не переключается — validate, а не binding как в gin.
- validateStruct стоит после каждого bind-метода: nil-check, разворот *T/**T, Kind() != Struct → пропуск; SkipValidation(true) выключает на цепочку; All() глушит промежуточные проверки и валидирует слитый результат один раз — в авторежиме через returnErr даёт *Error 400.
- Три семейства ошибок: *BindError (разбор, Source/Field), validator.ValidationErrors ([]FieldError с сообщениями Key: 'Task.Title' Error:...), *Error. Валидация возвращается сырой: WithAutoHandling ловит только разбор — ветку ValidationErrors пишет хендлер.
- Код ответа — вопрос конвенции: рецепт документации — 400 со списком {field, rule, param}; 422 (Unprocessable Content по RFC 9110) защитима адаптером через fiber.NewError — но 422 у Bind уже занята отсутствием парсера.
- Срезы верхнего уровня не валидируются вовсе — для батч-импорта ручной engine.Var(in, "omitempty,dive") и Namespace() с индексом; срезы внутри структуры проверяются по dive автоматически.
- RegisterTagNameFunc переводит Field() на json-имена (title вместо Title); RegisterValidation("workhours", ...) регистрирует свой тег в вашем движке в init — до первого запроса, иначе паника Undefined validation function.
Домашнее задание
Разведите конвенции кодов: добавьте в каркас второй вариант адаптера, возвращающий fiber.NewError(fiber.StatusUnprocessableEntity, "поля заполнены неверно") при errors.As(err, &ve), и сравните ответы обоих на {"title":"пв"} — код, Content-Type и тело. Вложите в Task поле Meta struct с тегом validate:"required,oneof=ui api" на строке Source, добавьте dive на поле и найдите путь ошибки в Namespace(). Перепишите батч-хендлер так, чтобы ответ перечислял все плохие элементы, и проверьте, что ValidationErrors уже содержит все FieldError. Включите c.Bind().SkipValidation(true) на импортном эндпоинте и объясните в комментарии, почему это ложная экономия. Наконец, сделайте параметр тега workhours настраиваемым через переменную окружения и проверьте fl.Param().
Личные заметкиПусто
Вопросы и ответы по уроку
Поставил теги validate на структуру, а проверки как не бывало — почему?
Слот StructValidator в fiber.Config пуст: дефолт nil, и validateStruct молча пропускает шаг — док-комментарий app.go говорит прямо: Fiber doesn't have default validator. Подключите адаптер: fiber.New(fiber.Config{StructValidator: &validatorAdapter{validate: validator.New()}}) — и теги оживут на каждом bind-методе. Проверяется это мгновенно: запрос с заведомо пустым обязательным полем должен вернуть 400, а не 201.
Чем ошибка валидации отличается от BindError из девятого урока?
Типом и местом рождения. BindError — ошибка РАЗБОРА: битый JSON, несовпадение типов; она несёт Source и Field. ValidationErrors — результат несработавших ПРАВИЛ validate; bind.go возвращает её сырой, без Source и Field, поэтому ветки различают errors.As-ом: сначала на validator.ValidationErrors, затем на *fiber.BindError. А WithAutoHandling ставит 400 только на разбор — валидацию обрабатываете сами, иначе она уедет в 500.
Какой код выбрать на проваленную валидацию — 400 или 422?
Официальный рецепт Fiber — 400 Bad Request с JSON-списком ошибок. 422 (Unprocessable Content по RFC 9110) защитима для случая «синтаксис верен, семантика нет»: верните из адаптера fiber.NewError(fiber.StatusUnprocessableEntity, ...) — ErrorHandler поставит код сам. Главное — одна конвенция на весь API: 422 у Bind уже занята ситуацией «парсер под Content-Type не нашёлся», и смешивать два смысла одной цифры не стоит.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Gin: самый популярный веб-фреймворк Go
Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.
- Уверенно владеть gin.Engine: маршруты, группы, версии
- Привязывать запросы к структурам (ShouldBindJSON и др.)
- Писать middleware и использовать встроенные
- Строить аутентификацию на JWT