309 lines
9.3 KiB
Go
309 lines
9.3 KiB
Go
// 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))
|
|
}
|
|
}
|