15357fd3c0
yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarbacreate and moove into new directories for BegushiyBashkir and yalarba
399 lines
10 KiB
Go
399 lines
10 KiB
Go
// pkg/utils/validation.go
|
|
package utils
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// ValidationError представляет ошибку валидации
|
|
type ValidationError struct {
|
|
Field string `json:"field"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func (e ValidationError) Error() string {
|
|
return fmt.Sprintf("%s: %s", e.Field, e.Message)
|
|
}
|
|
|
|
// ValidationResult содержит результат валидации
|
|
type ValidationResult struct {
|
|
IsValid bool
|
|
Errors []ValidationError
|
|
}
|
|
|
|
// TagOptions содержит опции из тега validate
|
|
type TagOptions struct {
|
|
Required bool
|
|
Min *float64
|
|
Max *float64
|
|
MinInt *int64
|
|
MaxInt *int64
|
|
OneOf []string
|
|
Email bool
|
|
MaxLength *int
|
|
MinLength *int
|
|
Custom string
|
|
}
|
|
|
|
// ValidateStruct валидирует структуру на основе тегов validate
|
|
func ValidateStruct(s interface{}) error {
|
|
val := reflect.ValueOf(s)
|
|
if val.Kind() == reflect.Ptr {
|
|
val = val.Elem()
|
|
}
|
|
|
|
if val.Kind() != reflect.Struct {
|
|
return fmt.Errorf("ValidateStruct expects a struct, got %T", s)
|
|
}
|
|
|
|
var errors []ValidationError
|
|
typ := val.Type()
|
|
|
|
for i := 0; i < val.NumField(); i++ {
|
|
field := val.Field(i)
|
|
fieldType := typ.Field(i)
|
|
|
|
// Пропускаем неэкспортируемые поля
|
|
if !field.CanInterface() {
|
|
continue
|
|
}
|
|
|
|
tag := fieldType.Tag.Get("validate")
|
|
if tag == "" {
|
|
continue
|
|
}
|
|
|
|
options := parseTagOptions(tag)
|
|
fieldName := getFieldName(fieldType)
|
|
|
|
// Валидация поля
|
|
if err := validateField(field, fieldName, options); err != nil {
|
|
errors = append(errors, err...)
|
|
}
|
|
}
|
|
|
|
if len(errors) > 0 {
|
|
return &ValidationResult{
|
|
IsValid: false,
|
|
Errors: errors,
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// parseTagOptions парсит тег validate и возвращает опции
|
|
func parseTagOptions(tag string) TagOptions {
|
|
options := TagOptions{}
|
|
parts := strings.Split(tag, ",")
|
|
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
|
|
switch {
|
|
case part == "required":
|
|
options.Required = true
|
|
case part == "email":
|
|
options.Email = true
|
|
case strings.HasPrefix(part, "min="):
|
|
if val, err := strconv.ParseFloat(part[4:], 64); err == nil {
|
|
options.Min = &val
|
|
}
|
|
case strings.HasPrefix(part, "max="):
|
|
if val, err := strconv.ParseFloat(part[4:], 64); err == nil {
|
|
options.Max = &val
|
|
}
|
|
case strings.HasPrefix(part, "minint="):
|
|
if val, err := strconv.ParseInt(part[7:], 10, 64); err == nil {
|
|
options.MinInt = &val
|
|
}
|
|
case strings.HasPrefix(part, "maxint="):
|
|
if val, err := strconv.ParseInt(part[7:], 10, 64); err == nil {
|
|
options.MaxInt = &val
|
|
}
|
|
case strings.HasPrefix(part, "oneof="):
|
|
options.OneOf = strings.Split(part[6:], " ")
|
|
case strings.HasPrefix(part, "maxlen="):
|
|
if val, err := strconv.Atoi(part[7:]); err == nil {
|
|
options.MaxLength = &val
|
|
}
|
|
case strings.HasPrefix(part, "minlen="):
|
|
if val, err := strconv.Atoi(part[7:]); err == nil {
|
|
options.MinLength = &val
|
|
}
|
|
case strings.HasPrefix(part, "custom="):
|
|
options.Custom = part[7:]
|
|
}
|
|
}
|
|
|
|
return options
|
|
}
|
|
|
|
// getFieldName возвращает имя поля для сообщений об ошибках
|
|
func getFieldName(field reflect.StructField) string {
|
|
jsonTag := field.Tag.Get("json")
|
|
if jsonTag != "" {
|
|
parts := strings.Split(jsonTag, ",")
|
|
if parts[0] != "" {
|
|
return parts[0]
|
|
}
|
|
}
|
|
return field.Name
|
|
}
|
|
|
|
// validateField валидирует отдельное поле
|
|
func validateField(field reflect.Value, fieldName string, options TagOptions) []ValidationError {
|
|
var errors []ValidationError
|
|
|
|
// Проверка required
|
|
if options.Required {
|
|
if isEmptyValue(field) {
|
|
errors = append(errors, ValidationError{
|
|
Field: fieldName,
|
|
Message: "field is required",
|
|
})
|
|
return errors // Если поле обязательно и пустое, дальше не проверяем
|
|
}
|
|
}
|
|
|
|
// Если поле пустое и не обязательное, дальше не проверяем
|
|
if isEmptyValue(field) {
|
|
return errors
|
|
}
|
|
|
|
// Валидация в зависимости от типа поля
|
|
switch field.Kind() {
|
|
case reflect.String:
|
|
errors = append(errors, validateString(field.String(), fieldName, options)...)
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
errors = append(errors, validateInt(field.Int(), fieldName, options)...)
|
|
case reflect.Float32, reflect.Float64:
|
|
errors = append(errors, validateFloat(field.Float(), fieldName, options)...)
|
|
case reflect.Struct:
|
|
// Для time.Time и других структур
|
|
if field.Type().String() == "time.Time" {
|
|
errors = append(errors, validateTime(field.Interface().(time.Time), fieldName, options)...)
|
|
}
|
|
}
|
|
|
|
return errors
|
|
}
|
|
|
|
// validateString валидирует строковые поля
|
|
func validateString(value, fieldName string, options TagOptions) []ValidationError {
|
|
var errors []ValidationError
|
|
|
|
// Проверка email
|
|
if options.Email {
|
|
if !isValidEmail(value) {
|
|
errors = append(errors, ValidationError{
|
|
Field: fieldName,
|
|
Message: "invalid email format",
|
|
})
|
|
}
|
|
}
|
|
|
|
// Проверка длины строки
|
|
if options.MinLength != nil && len(value) < *options.MinLength {
|
|
errors = append(errors, ValidationError{
|
|
Field: fieldName,
|
|
Message: fmt.Sprintf("minimum length is %d characters", *options.MinLength),
|
|
})
|
|
}
|
|
|
|
if options.MaxLength != nil && len(value) > *options.MaxLength {
|
|
errors = append(errors, ValidationError{
|
|
Field: fieldName,
|
|
Message: fmt.Sprintf("maximum length is %d characters", *options.MaxLength),
|
|
})
|
|
}
|
|
|
|
// Проверка oneof
|
|
if len(options.OneOf) > 0 {
|
|
valid := false
|
|
for _, allowed := range options.OneOf {
|
|
if value == allowed {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
if !valid {
|
|
errors = append(errors, ValidationError{
|
|
Field: fieldName,
|
|
Message: fmt.Sprintf("must be one of: %s", strings.Join(options.OneOf, ", ")),
|
|
})
|
|
}
|
|
}
|
|
|
|
return errors
|
|
}
|
|
|
|
// validateInt валидирует целочисленные поля
|
|
func validateInt(value int64, fieldName string, options TagOptions) []ValidationError {
|
|
var errors []ValidationError
|
|
|
|
if options.MinInt != nil && value < *options.MinInt {
|
|
errors = append(errors, ValidationError{
|
|
Field: fieldName,
|
|
Message: fmt.Sprintf("minimum value is %d", *options.MinInt),
|
|
})
|
|
}
|
|
|
|
if options.MaxInt != nil && value > *options.MaxInt {
|
|
errors = append(errors, ValidationError{
|
|
Field: fieldName,
|
|
Message: fmt.Sprintf("maximum value is %d", *options.MaxInt),
|
|
})
|
|
}
|
|
|
|
return errors
|
|
}
|
|
|
|
// validateFloat валидирует поля с плавающей точкой
|
|
func validateFloat(value float64, fieldName string, options TagOptions) []ValidationError {
|
|
var errors []ValidationError
|
|
|
|
if options.Min != nil && value < *options.Min {
|
|
errors = append(errors, ValidationError{
|
|
Field: fieldName,
|
|
Message: fmt.Sprintf("minimum value is %.2f", *options.Min),
|
|
})
|
|
}
|
|
|
|
if options.Max != nil && value > *options.Max {
|
|
errors = append(errors, ValidationError{
|
|
Field: fieldName,
|
|
Message: fmt.Sprintf("maximum value is %.2f", *options.Max),
|
|
})
|
|
}
|
|
|
|
return errors
|
|
}
|
|
|
|
// validateTime валидирует временные поля
|
|
func validateTime(value time.Time, fieldName string, options TagOptions) []ValidationError {
|
|
var errors []ValidationError
|
|
|
|
// Проверка, что дата не нулевая
|
|
if value.IsZero() && options.Required {
|
|
errors = append(errors, ValidationError{
|
|
Field: fieldName,
|
|
Message: "date is required",
|
|
})
|
|
}
|
|
|
|
// Проверка, что дата не в будущем (пример кастомной валидации)
|
|
if options.Custom == "not_future" && value.After(time.Now()) {
|
|
errors = append(errors, ValidationError{
|
|
Field: fieldName,
|
|
Message: "date cannot be in the future",
|
|
})
|
|
}
|
|
|
|
return errors
|
|
}
|
|
|
|
// isEmptyValue проверяет, является ли значение пустым
|
|
func isEmptyValue(v reflect.Value) bool {
|
|
switch v.Kind() {
|
|
case reflect.String:
|
|
return v.String() == ""
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
return v.Int() == 0
|
|
case reflect.Float32, reflect.Float64:
|
|
return v.Float() == 0
|
|
case reflect.Bool:
|
|
return !v.Bool()
|
|
case reflect.Struct:
|
|
if v.Type().String() == "time.Time" {
|
|
return v.Interface().(time.Time).IsZero()
|
|
}
|
|
case reflect.Ptr, reflect.Interface:
|
|
return v.IsNil()
|
|
case reflect.Slice, reflect.Map, reflect.Array:
|
|
return v.Len() == 0
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isValidEmail проверяет валидность email
|
|
func isValidEmail(email string) bool {
|
|
emailRegex := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
|
|
matched, _ := regexp.MatchString(emailRegex, email)
|
|
return matched
|
|
}
|
|
|
|
// Error возвращает строковое представление ошибок валидации
|
|
func (vr *ValidationResult) Error() string {
|
|
var errorMessages []string
|
|
for _, err := range vr.Errors {
|
|
errorMessages = append(errorMessages, err.Error())
|
|
}
|
|
return strings.Join(errorMessages, "; ")
|
|
}
|
|
|
|
// GetValidationErrors возвращает ошибки валидации в структурированном виде
|
|
func GetValidationErrors(err error) []ValidationError {
|
|
if vr, ok := err.(*ValidationResult); ok {
|
|
return vr.Errors
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LogValidationErrors логирует ошибки валидации
|
|
func LogValidationErrors(logger *zap.Logger, err error, context string) {
|
|
if vr, ok := err.(*ValidationResult); ok {
|
|
for _, validationErr := range vr.Errors {
|
|
logger.Warn("validation error",
|
|
zap.String("context", context),
|
|
zap.String("field", validationErr.Field),
|
|
zap.String("error", validationErr.Message),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ParseUintFromQuery парсит uint из query параметра
|
|
func ParseUintFromQuery(queryParam string, defaultValue uint) (uint, error) {
|
|
if queryParam == "" {
|
|
return defaultValue, nil
|
|
}
|
|
|
|
value, err := strconv.ParseUint(queryParam, 10, 32)
|
|
if err != nil {
|
|
return defaultValue, err
|
|
}
|
|
|
|
return uint(value), nil
|
|
}
|
|
|
|
// ParseIntFromQuery парсит int из query параметра
|
|
func ParseIntFromQuery(queryParam string, defaultValue int) (int, error) {
|
|
if queryParam == "" {
|
|
return defaultValue, nil
|
|
}
|
|
|
|
value, err := strconv.Atoi(queryParam)
|
|
if err != nil {
|
|
return defaultValue, err
|
|
}
|
|
|
|
return value, nil
|
|
}
|
|
|
|
// ParseBoolFromQuery парсит bool из query параметра
|
|
func ParseBoolFromQuery(queryParam string, defaultValue bool) bool {
|
|
if queryParam == "" {
|
|
return defaultValue
|
|
}
|
|
|
|
return strings.ToLower(queryParam) == "true" || queryParam == "1"
|
|
}
|