// 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), ) } } }