Урок 36: Валидация данных — go-playground/validator, кастомные правила

Урок 36. Валидация данных — go-playground/validator, кастомные правила

🔄 Node.js → Go (ключевые аналоги):

📋 Что изучаем

📦 Инициализация проекта

mkdir go-validation && cd go-validation
go mod init go-validation

# Устанавливаем validator
go get github.com/go-playground/validator/v10
go get github.com/go-playground/locales/ru
go get github.com/go-playground/universal-translator
go mod tidy

# Структура
mkdir -p internal/validation
mkdir -p internal/middleware
mkdir -p cmd/demo

💻 Файл: internal/validation/validator.go

package validation

import (
    "fmt"
    "reflect"
    "strings"

    "github.com/go-playground/locales/ru"
    ut "github.com/go-playground/universal-translator"
    "github.com/go-playground/validator/v10"
    ruTranslations "github.com/go-playground/validator/v10/translations/ru"
)

// ╔══════════════════════════════════════════════════════════╗
// ║  ГЛОБАЛЬНЫЙ ВАЛИДАТОР                                  ║
// ╚══════════════════════════════════════════════════════════╝

var (
    validate   *validator.Validate
    translator ut.Translator
)

func init() {
    // Создаём валидатор
    validate = validator.New()

    // Настраиваем переводчик (русский язык)
    ruLocale := ru.New()
    uni := ut.New(ruLocale, ruLocale)
    translator, _ = uni.GetTranslator("ru")

    // Регистрируем русские переводы
    ruTranslations.RegisterDefaultTranslations(validate, translator)

    // Регистрируем кастомные валидаторы
    registerCustomValidators()
}

// ╔══════════════════════════════════════════════════════════╗
// ║  КАСТОМНЫЕ ВАЛИДАТОРЫ                                  ║
// ╚══════════════════════════════════════════════════════════╝

func registerCustomValidators() {
    // Кастомный валидатор: phone (простая проверка)
    validate.RegisterValidation("phone", func(fl validator.FieldLevel) bool {
        phone := fl.Field().String()
        // Простая проверка: начинается с + и содержит минимум 10 цифр
        if !strings.HasPrefix(phone, "+") {
            return false
        }
        digits := strings.Map(func(r rune) rune {
            if r >= '0' && r <= '9' {
                return r
            }
            return -1
        }, phone)
        return len(digits) >= 10
    })

    // Регистрируем перевод для phone
    validate.RegisterTranslation("phone", translator,
        func(ut ut.Translator) error {
            return ut.Add("phone", "должен быть в формате +XXXXXXXXXXX", true)
        },
        func(ut ut.Translator, fe validator.FieldError) string {
            t, _ := ut.T("phone", fe.Field())
            return t
        },
    )

    // Кастомный валидатор: slug (только латиница, цифры, дефисы)
    validate.RegisterValidation("slug", func(fl validator.FieldLevel) bool {
        slug := fl.Field().String()
        for _, r := range slug {
            if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-') {
                return false
            }
        }
        return len(slug) > 0
    })

    // Кастомный валидатор: not_past (дата не в прошлом)
    validate.RegisterValidation("not_past", func(fl validator.FieldLevel) bool {
        dateStr := fl.Field().String()
        date, err := time.Parse("2006-01-02", dateStr)
        if err != nil {
            return false
        }
        return !date.Before(time.Now().Truncate(24 * time.Hour))
    })

    // Кастомный валидатор: strong_password
    validate.RegisterValidation("strong_password", func(fl validator.FieldLevel) bool {
        password := fl.Field().String()
        var hasUpper, hasLower, hasDigit, hasSpecial bool
        for _, ch := range password {
            switch {
            case unicode.IsUpper(ch): hasUpper = true
            case unicode.IsLower(ch): hasLower = true
            case unicode.IsDigit(ch): hasDigit = true
            case unicode.IsPunct(ch) || unicode.IsSymbol(ch): hasSpecial = true
            }
        }
        return hasUpper && hasLower && hasDigit && hasSpecial
    })
}

// Validate — валидирует структуру и возвращает читаемые ошибки
func Validate(s interface{}) error {
    err := validate.Struct(s)
    if err == nil {
        return nil
    }

    validationErrors, ok := err.(validator.ValidationErrors)
    if !ok {
        return err
    }

    // Формируем читаемое сообщение
    var messages []string
    for _, e := range validationErrors {
        messages = append(messages, e.Translate(translator))
    }

    return fmt.Errorf("validation failed: %s", strings.Join(messages, "; "))
}

// ValidateVar — валидирует одну переменную
func ValidateVar(field interface{}, tag string) error {
    return validate.Var(field, tag)
}

// GetValidationErrors — возвращает слайс ошибок для кастомной обработки
func GetValidationErrors(s interface{}) []FieldError {
    err := validate.Struct(s)
    if err == nil {
        return nil
    }

    validationErrors, ok := err.(validator.ValidationErrors)
    if !ok {
        return []FieldError{{Field: "unknown", Message: err.Error()}}
    }

    var errors []FieldError
    for _, e := range validationErrors {
        errors = append(errors, FieldError{
            Field:   e.Field(),
            Tag:     e.Tag(),
            Value:   fmt.Sprintf("%v", e.Value()),
            Message: e.Translate(translator),
        })
    }
    return errors
}

// FieldError — структура ошибки валидации
type FieldError struct {
    Field   string `json:"field"`
    Tag     string `json:"tag"`
    Value   string `json:"value"`
    Message string `json:"message"`
}

import (
    "time"
    "unicode"
)

💻 Файл: internal/validation/models.go

package validation

// ╔══════════════════════════════════════════════════════════╗
// ║  ПРИМЕРЫ СТРУКТУР С ВАЛИДАЦИЕЙ                         ║
// ╚══════════════════════════════════════════════════════════╝

// CreateUserRequest — запрос на создание пользователя
type CreateUserRequest struct {
    Name     string `json:"name" validate:"required,min=2,max=50"`
    Email    string `json:"email" validate:"required,email"`
    Age      int    `json:"age" validate:"gte=0,lte=150"`
    Phone    string `json:"phone" validate:"omitempty,phone"` // omitempty — пропустить если пусто
    Password string `json:"password" validate:"required,min=8,max=100,strong_password"`
    Role     string `json:"role" validate:"required,oneof=admin user moderator"`
    Slug     string `json:"slug" validate:"required,slug"`
    Website  string `json:"website" validate:"omitempty,url"`
}

// UpdateUserRequest — запрос на обновление (все поля опциональны)
type UpdateUserRequest struct {
    Name  *string `json:"name" validate:"omitempty,min=2,max=50"`
    Email *string `json:"email" validate:"omitempty,email"`
    Age   *int    `json:"age" validate:"omitempty,gte=0,lte=150"`
    Phone *string `json:"phone" validate:"omitempty,phone"`
}

// OrderRequest — пример с вложенными структурами
type OrderRequest struct {
    CustomerID string        `json:"customer_id" validate:"required,uuid"`
    Items      []OrderItem   `json:"items" validate:"required,min=1,max=100,dive"`
    // dive — "ныряет" внутрь слайса и валидирует каждый элемент
    PromoCode  string        `json:"promo_code" validate:"omitempty,len=8"`
    TotalPrice float64       `json:"total_price" validate:"required,gt=0"`
}

// OrderItem — элемент заказа
type OrderItem struct {
    ProductID string `json:"product_id" validate:"required,uuid"`
    Quantity  int    `json:"quantity" validate:"required,gt=0,lte=1000"`
    Price     float64 `json:"price" validate:"required,gt=0"`
}

// SearchRequest — пример условной валидации
type SearchRequest struct {
    Query     string `json:"query" validate:"required_if=SearchType text"` // Только если SearchType=text
    SearchType string `json:"search_type" validate:"required,oneof=text voice image"`
    Limit     int    `json:"limit" validate:"required_with=Offset,omitempty,min=1,max=100"`
    // required_with — если Offset задан, то и Limit обязателен
    Offset    int    `json:"offset" validate:"omitempty,min=0"`
}

// DateRangeRequest — валидация дат
type DateRangeRequest struct {
    StartDate string `json:"start_date" validate:"required,datetime=2006-01-02"`
    EndDate   string `json:"end_date" validate:"required,datetime=2006-01-02,gtfield=StartDate"`
    // gtfield — EndDate должен быть больше StartDate
}

💻 Файл: internal/middleware/validation.go

package middleware

import (
    "encoding/json"
    "net/http"

    "go-validation/internal/validation"
)

// ValidateBody — middleware для валидации JSON-тела
// Принимает функцию-фабрику для создания нужной структуры
func ValidateBody[T any]() func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            var req T

            // Декодируем JSON
            if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusBadRequest)
                json.NewEncoder(w).Encode(map[string]string{
                    "error": "invalid JSON format",
                })
                return
            }

            // Валидируем
            if errs := validation.GetValidationErrors(req); len(errs) > 0 {
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusUnprocessableEntity)
                json.NewEncoder(w).Encode(map[string]interface{}{
                    "error":  "validation failed",
                    "fields": errs,
                })
                return
            }

            // В реальности — положить req в контекст
            next.ServeHTTP(w, r)
        })
    }
}

💻 Файл: cmd/demo/main.go

package main

import (
    "encoding/json"
    "fmt"
    "log"

    "go-validation/internal/validation"
)

func main() {
    fmt.Println("╔══════════════════════════════════════════╗")
    fmt.Println("║   ВАЛИДАЦИЯ: GO-PLAYGROUND/VALIDATOR    ║")
    fmt.Println("╚══════════════════════════════════════════╝")

    // ==========================================
    // 1. ПРАВИЛЬНЫЙ ЗАПРОС
    // ==========================================
    fmt.Println("\n── 1. ПРАВИЛЬНЫЙ ЗАПРОС ──")
    validReq := validation.CreateUserRequest{
        Name:     "Alice",
        Email:    "alice@example.com",
        Age:      30,
        Phone:    "+79001234567",
        Password: "StrongPass123!",
        Role:     "admin",
        Slug:     "alice-admin",
        Website:  "https://alice.example.com",
    }

    if err := validation.Validate(validReq); err != nil {
        fmt.Printf("  ❌ Ошибка: %v\n", err)
    } else {
        fmt.Println("  ✅ Валидация пройдена")
    }

    // ==========================================
    // 2. МНОЖЕСТВО ОШИБОК
    // ==========================================
    fmt.Println("\n── 2. МНОЖЕСТВО ОШИБОК ──")
    invalidReq := validation.CreateUserRequest{
        Name:     "",               // required, min=2
        Email:    "invalid-email",  // email
        Age:      200,              // lte=150
        Password: "weak",           // min=8, strong_password
        Role:     "superadmin",     // oneof
        Slug:     "Invalid Slug!",  // slug
    }

    errors := validation.GetValidationErrors(invalidReq)
    fmt.Printf("  Количество ошибок: %d\n", len(errors))
    for _, e := range errors {
        fmt.Printf("%s: %s (тег: %s, значение: %s)\n",
            e.Field, e.Message, e.Tag, e.Value)
    }

    // ==========================================
    // 3. ВЛОЖЕННЫЕ СТРУКТУРЫ (dive)
    // ==========================================
    fmt.Println("\n── 3. ВЛОЖЕННЫЕ СТРУКТУРЫ (dive) ──")
    orderReq := validation.OrderRequest{
        CustomerID: "550e8400-e29b-41d4-a716-446655440000",
        Items: []validation.OrderItem{
            {ProductID: "550e8400-e29b-41d4-a716-446655440001", Quantity: 2, Price: 99.99},
            {ProductID: "", Quantity: 0, Price: -10}, // Ошибки здесь
        },
        TotalPrice: 199.98,
    }

    orderErrors := validation.GetValidationErrors(orderReq)
    if len(orderErrors) > 0 {
        fmt.Println("  Ошибки в заказе:")
        for _, e := range orderErrors {
            fmt.Printf("%s: %s\n", e.Field, e.Message)
        }
    }

    // ==========================================
    // 4. УСЛОВНАЯ ВАЛИДАЦИЯ
    // ==========================================
    fmt.Println("\n── 4. УСЛОВНАЯ ВАЛИДАЦИЯ ──")
    searchReq := validation.SearchRequest{
        SearchType: "text",
        Query:      "", // required_if=SearchType text → ОБЯЗАТЕЛЕН
    }

    if err := validation.Validate(searchReq); err != nil {
        fmt.Printf("%v\n", err)
    }

    // ==========================================
    // 5. GT_FIELD (сравнение полей)
    // ==========================================
    fmt.Println("\n── 5. СРАВНЕНИЕ ПОЛЕЙ (gtfield) ──")
    dateReq := validation.DateRangeRequest{
        StartDate: "2024-12-31",
        EndDate:   "2024-01-01", // Меньше StartDate
    }

    if err := validation.Validate(dateReq); err != nil {
        fmt.Printf("%v\n", err)
    }

    // ==========================================
    // 6. ВАЛИДАЦИЯ ОДНОГО ПОЛЯ
    // ==========================================
    fmt.Println("\n── 6. ВАЛИДАЦИЯ ОДНОГО ПОЛЯ ──")
    tests := []struct {
        value string
        tag   string
    }{
        {"user@example.com", "required,email"},
        {"not-an-email", "required,email"},
        {"", "required,email"},
    }

    for _, t := range tests {
        err := validation.ValidateVar(t.value, t.tag)
        if err != nil {
            fmt.Printf("%q не прошло %q: %v\n", t.value, t.tag, err)
        } else {
            fmt.Printf("%q прошло %q\n", t.value, t.tag)
        }
    }

    // ==========================================
    // 7. КРАСИВЫЙ ВЫВОД ОШИБОК (JSON)
    // ==========================================
    fmt.Println("\n── 7. ОШИБКИ В JSON ──")
    jsonErrors, _ := json.MarshalIndent(validation.GetValidationErrors(invalidReq), "", "  ")
    fmt.Println(string(jsonErrors))

    fmt.Println("\n✅ Демонстрация завершена!")
}

🚀 Запуск

go run ./cmd/demo/main.go

📊 Популярные теги валидации

КатегорияТегиПример
Обязательностьrequired, omitemptyvalidate:"required"
Строкиmin, max, len, email, urlvalidate:"min=2,max=50"
Числаgte, lte, gt, ltvalidate:"gte=0,lte=150"
Ограниченияoneof, eqfield, gtfieldvalidate:"oneof=admin user"
Слайсы/Мапыmin, max, dive, lenvalidate:"min=1,dive"
Условныеrequired_if, required_withvalidate:"required_if=Type text"
Форматыuuid, datetime, jsonvalidate:"uuid"
⚠️ Типичные ошибки:
💡 Практический совет:

💡 Best practices от сеньоров:

🔑 Ключевые концепции

Что нужно запомнить из этого урока:

💡 Для Node.js разработчика:

← Предыдущий урок Следующий урок →