Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 10 из 25 ~20 мин +50 XP

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

Тема «валидация 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 и свой тег рядом с адаптером.

Содержание урока · 15 разделов

валидация 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"}. Что произойдёт?

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 вне списка BindErrUnprocessableEntity — готовая *Error422 Unprocessable Entity от ErrorHandler
поле пусто или короче minvalidator.ValidationErrors — список FieldError400 по рецепту документации; 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

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 — из тега jsonpriority"field"
e.Tag()несработавшее правилоoneof"rule"
e.Param()параметр правила из тегаlow medium high"param"
e.Value()недопустимое значениеultra"value"
e.Namespace()полный путь с индексами вложенностиTask.priority"path"

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

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

// адаптер: единственный метод интерфейса 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": "поля заполнены неверно"}, ) }

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

StructValidator

нажмите, чтобы перевернуть
Определение

Интерфейс из bind.go v3: один метод Validate(out any) error. Поле fiber.Config; Default: nil — без валидатора проверка молча пропускается. Для карт, срезов и прочих не-структур не вызывается вовсе.

1 / 6

Запуск, проверка 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().

Личные заметкиПусто
Хранятся только в вашем браузере (Fiber · Валидация запросов)

Вопросы и ответы по уроку

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Gin25 уроков~13 чпрактика25 уроков уже доступны

Gin: самый популярный веб-фреймворк Go

Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.

  • Уверенно владеть gin.Engine: маршруты, группы, версии
  • Привязывать запросы к структурам (ShouldBindJSON и др.)
  • Писать middleware и использовать встроенные
  • Строить аутентификацию на JWT