// Package httpvalidation provides consistent request validation across services. // // This package wraps go-playground/validator/v10 with a simpler API // and human-readable error messages suitable for API responses. // // Usage: // // type CreateUserRequest struct { // Email string `json:"email" validate:"required,email"` // Phone string `json:"phone" validate:"omitempty,e164"` // } // // func CreateUser(w http.ResponseWriter, r *http.Request) { // var req CreateUserRequest // if err := httpresponse.DecodeJSON(r, &req); err != nil { // httpresponse.BadRequest(w, r, "invalid JSON") // return // } // if details := httpvalidation.ValidateStruct(req); len(details) > 0 { // httpresponse.ValidationError(w, r, "validation failed", details) // return // } // // ... proceed with valid request // } package httpvalidation import ( "errors" "fmt" "reflect" "regexp" "strings" "sync" "github.com/go-playground/validator/v10" "github.com/google/uuid" ) var ( // Singleton validator instance once sync.Once validate *validator.Validate // Regex patterns for custom validations // E.164 allows 1-15 digits total, with country code starting with 1-9 phoneRegex = regexp.MustCompile(`^\+?[1-9]\d{4,14}$`) ) // ValidationDetail represents a single field validation error. // This structure is designed for API responses, providing clear // field-level error information to clients. type ValidationDetail struct { // Field is the JSON field name that failed validation. Field string `json:"field"` // Message is a human-readable description of the validation failure. Message string `json:"message"` } // Validator returns the singleton validator instance with all custom validators registered. // Thread-safe and initialized only once. func Validator() *validator.Validate { once.Do(func() { validate = validator.New() // Use JSON tag names in error messages validate.RegisterTagNameFunc(func(fld reflect.StructField) string { name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] if name == "-" || name == "" { return fld.Name } return name }) // Register custom validators _ = validate.RegisterValidation("uuid", validateUUID) _ = validate.RegisterValidation("uuid_or_empty", validateUUIDOrEmpty) _ = validate.RegisterValidation("phone", validatePhone) _ = validate.RegisterValidation("slug", validateSlug) _ = validate.RegisterValidation("hex_color", validateHexColor) }) return validate } // ValidateStruct validates a struct and returns a slice of ValidationDetail for any validation errors. // Returns nil if validation passes. // // Example: // // type CreateFanRequest struct { // Email string `json:"email" validate:"required,email"` // Phone string `json:"phone" validate:"omitempty,phone"` // } // // if details := httpvalidation.ValidateStruct(req); len(details) > 0 { // httpresponse.ValidationError(w, r, "validation failed", details) // return // } func ValidateStruct(s any) []ValidationDetail { v := Validator() err := v.Struct(s) if err == nil { return nil } var details []ValidationDetail // Use errors.As to handle wrapped errors var validationErrs validator.ValidationErrors if !errors.As(err, &validationErrs) { // If not validation errors, return generic error details = append(details, ValidationDetail{ Field: "unknown", Message: err.Error(), }) return details } // Convert validator errors to ValidationDetails for _, e := range validationErrs { details = append(details, ValidationDetail{ Field: fieldName(e), Message: fieldError(e), }) } return details } // ValidateVar validates a single variable against validation tags. // Returns nil if validation passes, or a ValidationDetail slice with the error. // // Example: // // if err := httpvalidation.ValidateVar(email, "required,email"); err != nil { // // handle error // } func ValidateVar(field any, tag string) []ValidationDetail { v := Validator() err := v.Var(field, tag) if err == nil { return nil } var validationErrs validator.ValidationErrors if !errors.As(err, &validationErrs) { return []ValidationDetail{{Field: "value", Message: err.Error()}} } if len(validationErrs) > 0 { return []ValidationDetail{{Field: "value", Message: fieldError(validationErrs[0])}} } return nil } // fieldName extracts the JSON field name from a validation error. // Falls back to the struct field name if JSON tag is not present. func fieldName(e validator.FieldError) string { field := e.Field() // Remove any struct prefix parts := strings.Split(field, ".") if len(parts) > 0 { field = parts[len(parts)-1] } // Convert to camelCase for API consistency if len(field) > 0 { return strings.ToLower(field[:1]) + field[1:] } return field } // fieldError generates a human-readable error message for a validation error. func fieldError(e validator.FieldError) string { field := e.Field() tag := e.Tag() param := e.Param() switch tag { case "required": return fmt.Sprintf("%s is required", field) case "email": return fmt.Sprintf("%s must be a valid email address", field) case "min": if e.Kind() == reflect.String { return fmt.Sprintf("%s must be at least %s characters", field, param) } return fmt.Sprintf("%s must be at least %s", field, param) case "max": if e.Kind() == reflect.String { return fmt.Sprintf("%s must be at most %s characters", field, param) } return fmt.Sprintf("%s must be at most %s", field, param) case "len": if e.Kind() == reflect.String { return fmt.Sprintf("%s must be exactly %s characters", field, param) } return fmt.Sprintf("%s must have exactly %s items", field, param) case "uuid": return fmt.Sprintf("%s must be a valid UUID", field) case "uuid_or_empty": return fmt.Sprintf("%s must be a valid UUID or empty", field) case "phone", "e164": return fmt.Sprintf("%s must be a valid phone number in E.164 format", field) case "url": return fmt.Sprintf("%s must be a valid URL", field) case "oneof": return fmt.Sprintf("%s must be one of: %s", field, param) case "gt": return fmt.Sprintf("%s must be greater than %s", field, param) case "gte": return fmt.Sprintf("%s must be greater than or equal to %s", field, param) case "lt": return fmt.Sprintf("%s must be less than %s", field, param) case "lte": return fmt.Sprintf("%s must be less than or equal to %s", field, param) case "slug": return fmt.Sprintf("%s must be a valid slug (lowercase letters, numbers, hyphens)", field) case "hex_color": return fmt.Sprintf("%s must be a valid hex color code", field) case "alphanum": return fmt.Sprintf("%s must contain only alphanumeric characters", field) case "alpha": return fmt.Sprintf("%s must contain only alphabetic characters", field) case "numeric": return fmt.Sprintf("%s must be numeric", field) case "datetime": return fmt.Sprintf("%s must be a valid datetime in format %s", field, param) case "eqfield": return fmt.Sprintf("%s must equal %s", field, param) case "nefield": return fmt.Sprintf("%s must not equal %s", field, param) default: return fmt.Sprintf("%s failed validation (%s)", field, tag) } } // ----------------------------------------------------------------------------- // Custom Validators // ----------------------------------------------------------------------------- // validateUUID checks if a field is a valid UUID. func validateUUID(fl validator.FieldLevel) bool { field := fl.Field().String() if field == "" { return false } _, err := uuid.Parse(field) return err == nil } // validateUUIDOrEmpty checks if a field is either empty or a valid UUID. func validateUUIDOrEmpty(fl validator.FieldLevel) bool { field := fl.Field().String() if field == "" { return true } _, err := uuid.Parse(field) return err == nil } // validatePhone checks if a field is a valid phone number in E.164 format. // E.164 format: +[country code][number] (e.g., +14155552671) func validatePhone(fl validator.FieldLevel) bool { phone := fl.Field().String() if phone == "" { return false } return phoneRegex.MatchString(phone) } // validateSlug checks if a field is a valid URL slug. // Valid slugs contain only lowercase letters, numbers, and hyphens. func validateSlug(fl validator.FieldLevel) bool { slug := fl.Field().String() if slug == "" { return false } // Must start with letter or number, can contain hyphens, must end with letter or number match, _ := regexp.MatchString(`^[a-z0-9]+(-[a-z0-9]+)*$`, slug) return match } // validateHexColor checks if a field is a valid hex color code. // Accepts #RGB, #RRGGBB, #RRGGBBAA formats. func validateHexColor(fl validator.FieldLevel) bool { color := fl.Field().String() if color == "" { return false } match, _ := regexp.MatchString(`^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$`, color) return match } // RegisterValidation registers a custom validation function. // Returns an error if registration fails. func RegisterValidation(tag string, fn validator.Func) error { return Validator().RegisterValidation(tag, fn) } // MustRegisterValidation registers a custom validation function and panics on error. // Use this during initialization when registration failure should be fatal. func MustRegisterValidation(tag string, fn validator.Func) { if err := RegisterValidation(tag, fn); err != nil { panic(fmt.Sprintf("failed to register validation %q: %v", tag, err)) } }