sp4-final-1770497325/pkg/httpvalidation/validator.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))
}
}