6c7595b041
Implement a declarative struct tag validation system for Go: - Add validator.go with sync.Map caching for reflection metadata - Add rules.go with 11 built-in validation rules (required, email, pattern, honeypot, timing, etc.) - Add errors.go with FieldError and ValidationErrors types - Update ContactFormRequest with validate tags - Add ValidateContactFormV2() using the new tag-based validator Rules implemented: - required/optional: field presence validation - trim/sanitize: automatic value transformations - min/max: UTF-8 aware length validation - email: RFC 5322 email format validation - pattern: predefined regex patterns (name, subject, company) - no_injection: email header injection prevention - honeypot: bot trap (must be empty) - timing: timestamp validation for bot detection Documentation: - docs/go-validation-system.md: complete validation guide - docs/go-template-system.md: template manager documentation - docs/go-routes-api.md: routes and API reference - docs/README.md: documentation index
225 lines
5.5 KiB
Go
225 lines
5.5 KiB
Go
package validation
|
|
|
|
import (
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Validator provides struct tag-based validation with caching
|
|
type Validator struct {
|
|
cache sync.Map // map[reflect.Type]*structMeta
|
|
}
|
|
|
|
// structMeta holds cached metadata about a struct
|
|
type structMeta struct {
|
|
fields []fieldMeta
|
|
}
|
|
|
|
// fieldMeta holds metadata about a single field
|
|
type fieldMeta struct {
|
|
index int // Field index in struct
|
|
name string // JSON field name (or struct field name if no json tag)
|
|
rules []ruleInfo // Validation rules to apply
|
|
}
|
|
|
|
// ruleInfo holds information about a single validation rule
|
|
type ruleInfo struct {
|
|
name string // Rule name (e.g., "required", "max")
|
|
param string // Optional parameter (e.g., "100" for "max=100")
|
|
}
|
|
|
|
// NewValidator creates a new validator instance
|
|
func NewValidator() *Validator {
|
|
return &Validator{}
|
|
}
|
|
|
|
// Validate validates a struct using its validate tags
|
|
// Returns ValidationErrors if validation fails, nil if successful
|
|
func (v *Validator) Validate(s interface{}) error {
|
|
val := reflect.ValueOf(s)
|
|
|
|
// Handle pointer to struct
|
|
if val.Kind() == reflect.Ptr {
|
|
val = val.Elem()
|
|
}
|
|
|
|
if val.Kind() != reflect.Struct {
|
|
return ValidationErrors{{
|
|
Field: "validation",
|
|
Tag: "struct",
|
|
Message: "validate requires a struct or pointer to struct",
|
|
}}
|
|
}
|
|
|
|
// Get or create cached metadata
|
|
meta := v.getStructMeta(val.Type())
|
|
|
|
// Validate all fields
|
|
var errors ValidationErrors
|
|
for _, field := range meta.fields {
|
|
fieldVal := val.Field(field.index)
|
|
|
|
// Convert field value to string for validation
|
|
var strValue string
|
|
switch fieldVal.Kind() {
|
|
case reflect.String:
|
|
strValue = fieldVal.String()
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
strValue = strconv.FormatInt(fieldVal.Int(), 10)
|
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
strValue = strconv.FormatUint(fieldVal.Uint(), 10)
|
|
default:
|
|
continue // Skip unsupported types
|
|
}
|
|
|
|
// Apply transformations first (trim, sanitize)
|
|
transformedValue := v.applyFieldTransforms(strValue, field.rules)
|
|
|
|
// Update field value if it was transformed and is a string
|
|
if transformedValue != strValue && fieldVal.Kind() == reflect.String && fieldVal.CanSet() {
|
|
fieldVal.SetString(transformedValue)
|
|
strValue = transformedValue
|
|
}
|
|
|
|
// Validate field with all rules
|
|
for _, rule := range field.rules {
|
|
// Skip transform-only rules
|
|
if rule.name == "trim" || rule.name == "sanitize" {
|
|
continue
|
|
}
|
|
|
|
// Skip optional marker
|
|
if rule.name == "optional" {
|
|
continue
|
|
}
|
|
|
|
// Get validation function
|
|
validateFunc, exists := validationRules[rule.name]
|
|
if !exists {
|
|
errors = append(errors, FieldError{
|
|
Field: field.name,
|
|
Tag: rule.name,
|
|
Message: "unknown validation rule: " + rule.name,
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Execute validation
|
|
if err := validateFunc(field.name, strValue, rule.param); err != nil {
|
|
errors = append(errors, *err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(errors) > 0 {
|
|
return errors
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getStructMeta retrieves or creates cached metadata for a struct type
|
|
func (v *Validator) getStructMeta(t reflect.Type) *structMeta {
|
|
// Try to load from cache
|
|
if cached, ok := v.cache.Load(t); ok {
|
|
return cached.(*structMeta)
|
|
}
|
|
|
|
// Parse struct and cache metadata
|
|
meta := v.parseStruct(t)
|
|
v.cache.Store(t, meta)
|
|
return meta
|
|
}
|
|
|
|
// parseStruct parses a struct type and extracts validation metadata
|
|
func (v *Validator) parseStruct(t reflect.Type) *structMeta {
|
|
meta := &structMeta{
|
|
fields: make([]fieldMeta, 0, t.NumField()),
|
|
}
|
|
|
|
for i := 0; i < t.NumField(); i++ {
|
|
field := t.Field(i)
|
|
|
|
// Skip unexported fields
|
|
if !field.IsExported() {
|
|
continue
|
|
}
|
|
|
|
// Get field name from json tag, fallback to struct field name
|
|
fieldName := field.Name
|
|
if jsonTag := field.Tag.Get("json"); jsonTag != "" {
|
|
parts := strings.Split(jsonTag, ",")
|
|
if parts[0] != "" && parts[0] != "-" {
|
|
fieldName = parts[0]
|
|
}
|
|
}
|
|
|
|
// Parse validate tag
|
|
validateTag := field.Tag.Get("validate")
|
|
if validateTag == "" {
|
|
continue // No validation rules
|
|
}
|
|
|
|
rules := v.parseValidateTag(validateTag)
|
|
if len(rules) > 0 {
|
|
meta.fields = append(meta.fields, fieldMeta{
|
|
index: i,
|
|
name: fieldName,
|
|
rules: rules,
|
|
})
|
|
}
|
|
}
|
|
|
|
return meta
|
|
}
|
|
|
|
// parseValidateTag parses a validate tag string into rule infos
|
|
// Format: "rule1,rule2=param,rule3"
|
|
func (v *Validator) parseValidateTag(tag string) []ruleInfo {
|
|
parts := strings.Split(tag, ",")
|
|
rules := make([]ruleInfo, 0, len(parts))
|
|
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
|
|
// Split rule name and parameter (e.g., "max=100")
|
|
ruleParts := strings.SplitN(part, "=", 2)
|
|
rule := ruleInfo{
|
|
name: ruleParts[0],
|
|
}
|
|
if len(ruleParts) > 1 {
|
|
rule.param = ruleParts[1]
|
|
}
|
|
|
|
rules = append(rules, rule)
|
|
}
|
|
|
|
return rules
|
|
}
|
|
|
|
// applyFieldTransforms applies transformation rules to a field value
|
|
func (v *Validator) applyFieldTransforms(value string, rules []ruleInfo) string {
|
|
for _, rule := range rules {
|
|
switch rule.name {
|
|
case "trim":
|
|
value = strings.TrimSpace(value)
|
|
case "sanitize":
|
|
value = strings.TrimSpace(value)
|
|
// Remove newlines from header fields
|
|
value = strings.ReplaceAll(value, "\r", "")
|
|
value = strings.ReplaceAll(value, "\n", "")
|
|
}
|
|
}
|
|
return value
|
|
}
|
|
|
|
// ValidateStruct is a convenience function that creates a validator and validates
|
|
func ValidateStruct(s interface{}) error {
|
|
v := NewValidator()
|
|
return v.Validate(s)
|
|
}
|