---
title: "Валидация запросов"
description: "Урок валидация fiber: StructValidator в fiber.Config, адаптер под Validate(out any), теги validate, ValidationErrors и выбор кода 400 или 422."
course: "Fiber: экспресс-подобный веб-фреймворк на Fasthttp"
url: "https://golangmaster.ru/courses/fiber/lessons/10-validaciya/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Валидация запросов

*Курс «Fiber: экспресс-подобный веб-фреймворк на Fasthttp», урок 10 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/fiber/lessons/10-validaciya/*

Тема «валидация fiber» закрывает обещание девятого урока: там bind.go дважды показывал вызов validateStruct и признавался, что config.StructValidator по умолчанию nil и проверка молча пропускается. Сегодня в этот слот встаёт адаптер над go-playground/validator: разбираем точный интерфейс StructValidator из исходников тега v3.5.0, механику validateStruct на каждый bind-метод, три семейства ошибок на одном return err и выбор кода 400 или 422. Базовые теги — required, min, oneof — остались в девятом уроке курса gin; здесь всё fiber-специфичное: срезы верхнего уровня, которые валидатор не видит, json-имена в FieldError и свой тег рядом с адаптером.

### валидация 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 — провода были под напряжением, но вилку никто не воткнул.

### Адаптер: обёртка из одного метода

```go
package 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"}. Что произойдёт?

- ✅ 201 — задача создана: проверки молча пропущены, validateStruct увидел nil
- 400 и список ошибок по полям title и priority
- 422 — правила на структуре сработали сами
- 500 — nil-валидатор уронил хендлер

nil — документированный дефолт: validateStruct на втором шаге видит пустой config.StructValidator и тихо возвращает nil — без логов, без паники, без статуса ошибки. Разбор прошёл, хендлер ответил 201, и в хранилище легла задача с двухсимвольным title и приоритетом ultra. Предупреждение девятого урока в действии: молчаливый пропуск — это не отказ, а отсутствие настроек. Отсюда правило: любое новое API начинается с проверки, воткнут ли адаптер в конфиг.

### 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()?

- ✅ Priority — имя поля Go, движок по умолчанию не знает про json
- priority — имя из тега json
- oneof — имя несработавшего правила
- пустую строку — имя недоступно без конфигурации

Без RegisterTagNameFunc движок берёт имя поля reflect-а: Priority. Имя из тега json движок не видит — он не знает, что у структуры вообще есть json-теги: словари независимы, и это следствие того же факта, что Fiber не переключает словари SetTagName-ом. Ответ API с Priority будет пахнуть Go-кодом и расходиться с ключами ответа; RegisterTagNameFunc из блока про Task чинит это одной функцией: Field() начнёт отдавать title, priority, estimate.

### Три семейства ошибок на одном 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 как есть

```go
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": "пв"}
```

**Что выведет программа?**

- ✅ 500 и тело Key: 'Task.Title' Error:Field validation for 'Title' failed on the 'min' tag в text/plain
- 400 и {"errors":[{"field":"title","rule":"min"}]}
- 422 Unprocessable Entity — правило min не прошло
- 201 — валидация при разборе не вызывается

Разбор прошёл: синтаксис и типы в порядке, validateStruct добрался до адаптера, Struct вернул ValidationErrors — и это не *Error, поэтому DefaultErrorHandler берёт дефолтный код 500. Тело — err.Error() в text/plain: строки Key: 'Task.Title' Error:Field validation... Документация предупреждает об этом ровно там, где обещает автоматизацию: Returned as-is, a validation error surfaces through Fiber's error handling — поверхность есть, форма у неё сырая. Наивный адаптер плюс ленивый хендлер — и клиент винит сервер в своей опечатке.

### 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 и так содержит ВСЕ несработавшие поля всех элементов — перечисляйте их все, а не первый.

### Форма без заголовка: валидна или нет?

```go
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
```

**Что выведет программа?**

- ✅ 201 — curl -d шлёт форму, form-биндер разберёт title, проверки пройдены
- 422 — Bind не знает Content-Type формы и не найдёт парсер
- 400 и {"errors":[{"field":"title"}]} — правило min=3 не прошло
- 500 — адаптер получил не-структуру и упал

Три эффекта разом. curl -d без заголовка молча ставит Content-Type: application/x-www-form-urlencoded — ловушка девятого урока; Bind().Body уйдёт в form-биндер, и поле title разберётся как форма, а не как JSON. Дальше validateStruct — тот же для всех парсеров: адаптер проверит Task, title длиннее трёх символов, правила пройдены — 201. Валидация стоит после КАЖДОГО bind-источника, поэтому контракт полей един для JSON, форм, query и заголовков. Адаптер упал бы только на не-структуре, а Task — структура; 422 осталась занятой чужим сценарием — отсутствием парсера.

### Проверьте себя: авторежим и валидация

**Вопрос:** Хендлер начал с bind := c.Bind().WithAutoHandling(), разобрал err := bind.Body(&in) и вернул err как есть. JSON синтаксически верен, но title короче трёх символов. Что увидит клиент?

- ✅ 500 и Key: 'Task.Title' Error:... в text/plain — авторежим не трогает валидацию
- 400 и Bad request: ... — WithAutoHandling обработает всё
- 400 и {"errors":[{"field":"title"}]} — статус ставит validateStruct
- 201 — ошибка правил молча игнорируется

WithAutoHandling переключает обработку только для ошибок РАЗБОРА: returnErr и returnBindErr в bind.go заворачивают их в *Error со статусом 400 и сообщением Bad request: .... Путь валидации другой: Body возвращает результат validateStruct сырым, без returnErr (иначе было бы в девятом уроке), — ValidationErrors не *Error, и DefaultErrorHandler даёт 500 с text/plain. Исключение — All(): там validateStruct проходит через returnErr, и авторежим вернёт *Error 400. Правило: авторежим экономит код на биндинге, но ветку ValidationErrors вы пишете сами.

### 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

```go
package 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" |

### Допишите адаптер и ветку ошибок

Вставьте пропуски: имя метода интерфейса, вызов движка, тип ошибок проверки и код ответа из рецепта документации.

```go
// адаптер: единственный метод интерфейса fiber.StructValidator
func (v *validatorAdapter) ___(out any) error {
        // движок проверит структуру и вернёт ошибки правил
        return v.validate.___(out)
}

// в хендлере: ветка проваленной проверки
var ve validator.___
if errors.As(err, &ve) {
        // клиенту положен код из рецепта документации
        return c.Status(fiber.___).JSON(
                fiber.Map{"errors": "поля заполнены неверно"},
        )
}
```

Подсказка: Интерфейс требует Validate(out any) error; движок проверяет методом Struct; список ошибок — validator.ValidationErrors; рецепт документации отвечает 400 — StatusBadRequest.

Правильные ответы в пропусках: `Validate`, `Struct`, `ValidationErrors`, `StatusBadRequest`

### Соберите путь невалидного запроса

Расставьте шаги POST-запроса с коротким title по порядку: от команды в терминале до JSON-ответа со списком ошибок.

Правильный порядок строк:

1. `curl -X POST localhost:8080/api/tasks -H "Content-Type: application/json" -H "X-API-Key: s3cret" -d '{"title": "пв"}'`
2. `helmet ставит защитные заголовки и пропускает запрос дальше`
3. `cors добавляет Access-Control-Allow-Origin и тоже пропускает`
4. `страж requireAPIKey сверяет X-API-Key и пишет locals`
5. `JSON-декодер разворачивает тело в Task: синтаксис и типы в порядке`
6. `validateStruct находит StructValidator и вызывает Validate(out) адаптера`
7. `v.Struct возвращает ValidationErrors: поле title не набрало min=3`
8. `errors.As переводит ошибку в 400 и JSON {"errors":[{"field":"title","rule":"min"}]}`

> **Одна проверка на все Content-Type.** validateStruct стоит после каждого парсера, поэтому правила полей едины для JSON, форм, query и заголовков: клиент не найдёт Content-Type, которым можно протащить пустой title. Это же мост к следующей теме: одиннадцатый урок — формы и загрузка файлов, Bind().Form и multipart пройдут тот же validateStruct, и проверки формы достанутся бесплатно. Файлы, правда, валидатор размером и типом не измерит — там понадобятся свои проверки, но контекст для них уже готов.

### Словарь урока

- **StructValidator** — Интерфейс из bind.go v3: один метод Validate(out any) error. Поле fiber.Config; Default: nil — без валидатора проверка молча пропускается. Для карт, срезов и прочих не-структур не вызывается вовсе.
- **адаптер валидатора** — Ваша обёртка над *validator.Validate: Validate → v.Struct(out). Регистрируется один раз в fiber.Config; здесь же живёт конвенция кодов — можно возвращать fiber.NewError(422, ...), и ErrorHandler поставит код сам.
- **validateStruct** — Шаг Bind после каждого парсера: nil-check конфига, разворот указателей *T и **T, Kind() != Struct — пропуск, затем Validate(out). SkipValidation(true) выключает на цепочку; All() валидирует один раз слитый результат через returnErr.
- **ValidationErrors** — []FieldError, реализует error: Key: 'Task.Title' Error:Field validation for 'Title' failed on the 'min' tag. Возвращается сырой — Source и Field от BindError не заполняются. Вход — errors.As(err, &ve); поля несёт FieldError: Field, Tag, Param, Namespace.
- **omitempty у validate** — Второй словарь того же слова: пустое значение (0, "", nil, false) пропускает ПРАВИЛА поля, а не сериализацию. ID с omitempty,min=1: без ID — ок, с отрицательным — ошибка. Словари json и validate независимы и не мешают друг другу.
- **dive и срезы** — Срез верхнего уровня не валидируется: Kind() != Struct. Ручной прогон — engine.Var(in, "omitempty,dive"). Срез внутри структуры проверяется автоматически: dive спускает правила в элементы; на не-срезе dive — паника.

### Запуск, проверка 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 и формы ошибок — из [девятого урока](/courses/fiber/lessons/09-json/), слои и страж — из [восьмого](/courses/fiber/lessons/08-cors/), каталог тегов, dive и RegisterValidation — из [девятого урока курса gin](/courses/gin/lessons/09-validaciya/); полный план — на странице [курса Fiber](/courses/fiber/). В браузерной песочнице сайта сетевые приложения не запускаются — runner-ов в уроках Fiber нет, проверяйте примеры в терминале с локальным Go.

Дальше — одиннадцатый урок: формы и загрузка файлов. Bind().Form и multipart пройдут тот же validateStruct, так что проверки формы достанутся бесплатно; файлам понадобятся свои проверки размера и типа, но место для них — та же цепочка после разбора.

## Коротко: что запомнить

- 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 не нашёлся», и смешивать два смысла одной цифры не стоит.
