feat(infra): tipos ConfigError y ConfigValidation + funciones puras Go (validate, merge, dump)
- ConfigError y ConfigValidation como tipos producto con sus .md en types/infra/ - config_validate: validacion con tags required/format/min/max/oneof via reflection - config_merge: merge no-mutante de map[string]string con precedencia de override - config_dump: serializacion de structs a map con mascara *** para campos secret - 17 tests unitarios, todos PASS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// emailRE is a basic email format check.
|
||||
var emailRE = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
|
||||
|
||||
// urlRE checks for http:// or https:// prefix.
|
||||
var urlRE = regexp.MustCompile(`^https?://`)
|
||||
|
||||
// ConfigValidate validates a configuration struct populated with values.
|
||||
// It reads struct field tags to determine validation rules:
|
||||
// - required:"true" — field must be non-zero / non-empty
|
||||
// - format:"email" — field value must match email pattern
|
||||
// - format:"url" — field value must start with http:// or https://
|
||||
//
|
||||
// Only exported fields are inspected. Validation errors are accumulated; all
|
||||
// fields are checked even if earlier ones fail. Returns a ConfigValidation
|
||||
// with IsValid=true when no errors are found.
|
||||
func ConfigValidate(cfg any) ConfigValidation {
|
||||
var errs []ConfigError
|
||||
|
||||
v := reflect.ValueOf(cfg)
|
||||
if v.Kind() == reflect.Ptr {
|
||||
if v.IsNil() {
|
||||
return ConfigValidation{Errors: []ConfigError{{Field: "", Message: "cfg is nil", Tag: "required"}}, IsValid: false}
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
if v.Kind() != reflect.Struct {
|
||||
return ConfigValidation{Errors: []ConfigError{{Field: "", Message: "cfg must be a struct", Tag: "required"}}, IsValid: false}
|
||||
}
|
||||
|
||||
t := v.Type()
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
fv := v.Field(i)
|
||||
fieldName := field.Name
|
||||
|
||||
// required check
|
||||
required := field.Tag.Get("required")
|
||||
if required == "true" {
|
||||
if isZero(fv) {
|
||||
errs = append(errs, ConfigError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("field %s is required", fieldName),
|
||||
Tag: "required",
|
||||
})
|
||||
continue // skip format check when value is missing
|
||||
}
|
||||
}
|
||||
|
||||
// format check (only for string fields)
|
||||
format := field.Tag.Get("format")
|
||||
if format != "" && fv.Kind() == reflect.String {
|
||||
val := fv.String()
|
||||
switch format {
|
||||
case "email":
|
||||
if !emailRE.MatchString(val) {
|
||||
errs = append(errs, ConfigError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("field %s has invalid email format: %q", fieldName, val),
|
||||
Tag: "format",
|
||||
})
|
||||
}
|
||||
case "url":
|
||||
if !urlRE.MatchString(val) {
|
||||
errs = append(errs, ConfigError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("field %s has invalid URL format (must start with http:// or https://): %q", fieldName, val),
|
||||
Tag: "format",
|
||||
})
|
||||
}
|
||||
default:
|
||||
errs = append(errs, ConfigError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("field %s has unknown format tag: %q", fieldName, format),
|
||||
Tag: "format",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// min/max for numeric fields
|
||||
minTag := field.Tag.Get("min")
|
||||
maxTag := field.Tag.Get("max")
|
||||
if (minTag != "" || maxTag != "") && isNumeric(fv) {
|
||||
val := toFloat(fv)
|
||||
if minTag != "" {
|
||||
var minVal float64
|
||||
fmt.Sscanf(minTag, "%f", &minVal)
|
||||
if val < minVal {
|
||||
errs = append(errs, ConfigError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("field %s value %v is below minimum %v", fieldName, val, minVal),
|
||||
Tag: "min",
|
||||
})
|
||||
}
|
||||
}
|
||||
if maxTag != "" {
|
||||
var maxVal float64
|
||||
fmt.Sscanf(maxTag, "%f", &maxVal)
|
||||
if val > maxVal {
|
||||
errs = append(errs, ConfigError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("field %s value %v is above maximum %v", fieldName, val, maxVal),
|
||||
Tag: "max",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// oneof check for string fields
|
||||
oneof := field.Tag.Get("oneof")
|
||||
if oneof != "" && fv.Kind() == reflect.String {
|
||||
val := fv.String()
|
||||
allowed := strings.Split(oneof, " ")
|
||||
found := false
|
||||
for _, a := range allowed {
|
||||
if a == val {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
errs = append(errs, ConfigError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("field %s value %q is not one of: %s", fieldName, val, oneof),
|
||||
Tag: "oneof",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ConfigValidation{
|
||||
Errors: errs,
|
||||
IsValid: len(errs) == 0,
|
||||
}
|
||||
}
|
||||
|
||||
func isZero(v reflect.Value) bool {
|
||||
switch v.Kind() {
|
||||
case reflect.String:
|
||||
return v.Len() == 0
|
||||
case reflect.Slice, reflect.Map:
|
||||
return v.IsNil() || v.Len() == 0
|
||||
case reflect.Ptr, reflect.Interface:
|
||||
return v.IsNil()
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return v.Int() == 0
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return v.Uint() == 0
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return v.Float() == 0
|
||||
case reflect.Bool:
|
||||
return !v.Bool()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isNumeric(v reflect.Value) bool {
|
||||
switch v.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
|
||||
reflect.Float32, reflect.Float64:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func toFloat(v reflect.Value) float64 {
|
||||
switch v.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return float64(v.Int())
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return float64(v.Uint())
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return v.Float()
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user