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,48 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
const secretMask = "***"
|
||||||
|
|
||||||
|
// ConfigDump converts a configuration struct to a map[string]string.
|
||||||
|
// Exported fields are included. Fields tagged with secret:"true" have their
|
||||||
|
// value replaced with "***". Nested structs are not traversed — only top-level
|
||||||
|
// exported fields are included.
|
||||||
|
//
|
||||||
|
// The map key is the field name. Values are formatted with fmt.Sprintf("%v", ...).
|
||||||
|
func ConfigDump(cfg any) map[string]string {
|
||||||
|
result := make(map[string]string)
|
||||||
|
|
||||||
|
v := reflect.ValueOf(cfg)
|
||||||
|
if v.Kind() == reflect.Ptr {
|
||||||
|
if v.IsNil() {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
v = v.Elem()
|
||||||
|
}
|
||||||
|
if v.Kind() != reflect.Struct {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
t := v.Type()
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
field := t.Field(i)
|
||||||
|
if !field.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := field.Name
|
||||||
|
|
||||||
|
if field.Tag.Get("secret") == "true" {
|
||||||
|
result[key] = secretMask
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fv := v.Field(i)
|
||||||
|
result[key] = fmt.Sprintf("%v", fv.Interface())
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
name: config_dump
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "func ConfigDump(cfg any) map[string]string"
|
||||||
|
description: "Convierte una struct de configuracion a map[string]string para logging o inspeccion. Campos con tag secret:\"true\" aparecen como \"***\". Solo campos exportados de nivel superior."
|
||||||
|
tags: [config, dump, debug, logging, infra, reflect]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [fmt, reflect]
|
||||||
|
params:
|
||||||
|
- name: cfg
|
||||||
|
desc: "struct de configuracion (valor o puntero) cuyos campos exportados se van a serializar a strings"
|
||||||
|
output: "mapa string→string con cada campo exportado; valores secretos reemplazados con ***"
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "campos normales se incluyen como string"
|
||||||
|
- "campos secret aparecen como tres asteriscos"
|
||||||
|
- "campos no exportados no aparecen"
|
||||||
|
- "puntero a struct funciona"
|
||||||
|
- "struct con bool y float se formatean correctamente"
|
||||||
|
test_file_path: "functions/infra/config_dump_test.go"
|
||||||
|
file_path: "functions/infra/config_dump.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
type AppConfig struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
APIKey string `secret:"true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := AppConfig{Host: "localhost", Port: 8080, APIKey: "sk-abc123"}
|
||||||
|
dump := ConfigDump(cfg)
|
||||||
|
// dump = {"Host": "localhost", "Port": "8080", "APIKey": "***"}
|
||||||
|
log.Printf("startup config: %v", dump)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Funcion pura. Usa fmt.Sprintf("%v") para formatear todos los tipos. No desciende a structs anidados. Util para logging seguro de configuracion al inicio de una app.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigDump(t *testing.T) {
|
||||||
|
t.Run("campos normales se incluyen como string", func(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
}
|
||||||
|
got := ConfigDump(Cfg{Host: "localhost", Port: 8080})
|
||||||
|
if got["Host"] != "localhost" {
|
||||||
|
t.Errorf("expected Host=localhost, got %q", got["Host"])
|
||||||
|
}
|
||||||
|
if got["Port"] != "8080" {
|
||||||
|
t.Errorf("expected Port=8080, got %q", got["Port"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("campos secret aparecen como tres asteriscos", func(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
APIKey string `secret:"true"`
|
||||||
|
}
|
||||||
|
got := ConfigDump(Cfg{APIKey: "super-secret-key"})
|
||||||
|
if got["APIKey"] != "***" {
|
||||||
|
t.Errorf("expected ***, got %q", got["APIKey"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("campos no exportados no aparecen", func(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Public string
|
||||||
|
private string //nolint:structcheck
|
||||||
|
}
|
||||||
|
_ = Cfg{} // ensure it compiles
|
||||||
|
got := ConfigDump(Cfg{Public: "yes"})
|
||||||
|
if _, ok := got["private"]; ok {
|
||||||
|
t.Error("private field should not be in dump")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("puntero a struct funciona", func(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
got := ConfigDump(&Cfg{Name: "test"})
|
||||||
|
if got["Name"] != "test" {
|
||||||
|
t.Errorf("expected Name=test, got %q", got["Name"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("struct con bool y float se formatean correctamente", func(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Debug bool
|
||||||
|
Timeout float64
|
||||||
|
}
|
||||||
|
got := ConfigDump(Cfg{Debug: true, Timeout: 3.14})
|
||||||
|
if got["Debug"] != "true" {
|
||||||
|
t.Errorf("expected Debug=true, got %q", got["Debug"])
|
||||||
|
}
|
||||||
|
if got["Timeout"] != "3.14" {
|
||||||
|
t.Errorf("expected Timeout=3.14, got %q", got["Timeout"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
// ConfigError represents a single validation error for a configuration field.
|
||||||
|
type ConfigError struct {
|
||||||
|
Field string // struct field name or env var name
|
||||||
|
Message string // human-readable error description
|
||||||
|
Tag string // validation tag that triggered the error (e.g. "required", "format")
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
// ConfigMerge merges two string maps, with override taking precedence over base.
|
||||||
|
// Keys present in both maps take the value from override.
|
||||||
|
// Keys present only in base are kept. Keys present only in override are added.
|
||||||
|
// Neither input map is mutated — a new map is returned.
|
||||||
|
func ConfigMerge(base, override map[string]string) map[string]string {
|
||||||
|
result := make(map[string]string, len(base)+len(override))
|
||||||
|
for k, v := range base {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range override {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
name: config_merge
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "func ConfigMerge(base, override map[string]string) map[string]string"
|
||||||
|
description: "Merge de dos map[string]string. El map override tiene precedencia sobre base en claves comunes. Ninguno de los inputs es mutado — retorna un mapa nuevo."
|
||||||
|
tags: [config, merge, map, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: base
|
||||||
|
desc: "mapa de configuracion base, valores por defecto o de menor prioridad"
|
||||||
|
- name: override
|
||||||
|
desc: "mapa de configuracion con mayor prioridad, sobreescribe claves de base"
|
||||||
|
output: "nuevo mapa con todas las claves de base y override, donde override gana en conflictos"
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "override gana sobre base en claves comunes"
|
||||||
|
- "claves solo en base se mantienen"
|
||||||
|
- "claves solo en override se agregan"
|
||||||
|
- "no muta el map base"
|
||||||
|
- "merge con maps vacios retorna mapa vacio"
|
||||||
|
test_file_path: "functions/infra/config_merge_test.go"
|
||||||
|
file_path: "functions/infra/config_merge.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
defaults := map[string]string{"host": "localhost", "port": "5432", "debug": "false"}
|
||||||
|
env := map[string]string{"port": "9999", "debug": "true"}
|
||||||
|
merged := ConfigMerge(defaults, env)
|
||||||
|
// merged = {"host": "localhost", "port": "9999", "debug": "true"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Funcion pura. Util para combinar defaults hardcodeados con valores del archivo .env o de variables de entorno. Patron tipico: ConfigMerge(hardcoded_defaults, dotenv_values).
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigMerge(t *testing.T) {
|
||||||
|
t.Run("override gana sobre base en claves comunes", func(t *testing.T) {
|
||||||
|
base := map[string]string{"host": "localhost", "port": "5432"}
|
||||||
|
over := map[string]string{"port": "9999"}
|
||||||
|
got := ConfigMerge(base, over)
|
||||||
|
if got["port"] != "9999" {
|
||||||
|
t.Errorf("expected port=9999, got %q", got["port"])
|
||||||
|
}
|
||||||
|
if got["host"] != "localhost" {
|
||||||
|
t.Errorf("expected host=localhost, got %q", got["host"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("claves solo en base se mantienen", func(t *testing.T) {
|
||||||
|
base := map[string]string{"a": "1", "b": "2"}
|
||||||
|
over := map[string]string{}
|
||||||
|
got := ConfigMerge(base, over)
|
||||||
|
if got["a"] != "1" || got["b"] != "2" {
|
||||||
|
t.Errorf("unexpected result: %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("claves solo en override se agregan", func(t *testing.T) {
|
||||||
|
base := map[string]string{}
|
||||||
|
over := map[string]string{"x": "new"}
|
||||||
|
got := ConfigMerge(base, over)
|
||||||
|
if got["x"] != "new" {
|
||||||
|
t.Errorf("expected x=new, got %q", got["x"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no muta el map base", func(t *testing.T) {
|
||||||
|
base := map[string]string{"key": "original"}
|
||||||
|
over := map[string]string{"key": "changed"}
|
||||||
|
_ = ConfigMerge(base, over)
|
||||||
|
if base["key"] != "original" {
|
||||||
|
t.Errorf("base was mutated: got %q", base["key"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("merge con maps vacios retorna mapa vacio", func(t *testing.T) {
|
||||||
|
got := ConfigMerge(map[string]string{}, map[string]string{})
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("expected empty map, got %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
name: config_validate
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "func ConfigValidate(cfg any) ConfigValidation"
|
||||||
|
description: "Valida una struct de configuracion usando struct tags. Acumula todos los errores (required, format:email, format:url, min, max, oneof) sin detener en el primero. Retorna ConfigValidation con IsValid=true si no hay errores."
|
||||||
|
tags: [config, validation, env, infra, reflect]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: [config_validation_go_infra, config_error_go_infra]
|
||||||
|
returns: [config_validation_go_infra]
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [fmt, reflect, regexp, strings]
|
||||||
|
params:
|
||||||
|
- name: cfg
|
||||||
|
desc: "struct de configuracion (valor o puntero) con tags required, format, min, max, oneof en sus campos exportados"
|
||||||
|
output: "ConfigValidation con la lista de errores acumulados y el flag IsValid"
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "struct valido retorna IsValid true"
|
||||||
|
- "campo required vacio acumula error"
|
||||||
|
- "format email invalido produce error"
|
||||||
|
- "format email valido pasa"
|
||||||
|
- "format url invalido produce error"
|
||||||
|
- "puntero a struct funciona igual"
|
||||||
|
- "todos los errores acumulados aunque falle el primero"
|
||||||
|
test_file_path: "functions/infra/config_validate_test.go"
|
||||||
|
file_path: "functions/infra/config_validate.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ServerConfig struct {
|
||||||
|
Host string `required:"true"`
|
||||||
|
Port int `required:"true" min:"1" max:"65535"`
|
||||||
|
Email string `format:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
v := ConfigValidate(ServerConfig{Host: "localhost", Port: 8080})
|
||||||
|
if !v.IsValid {
|
||||||
|
for _, e := range v.Errors {
|
||||||
|
log.Printf("[%s] %s: %s", e.Tag, e.Field, e.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Funcion pura — usa solo reflect, sin I/O. Soporta tags: `required:"true"`, `format:"email"`, `format:"url"`, `min:"N"`, `max:"N"`, `oneof:"a b c"`. Solo inspecciona campos exportados. No es recursiva — no baja a structs anidados.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigValidate(t *testing.T) {
|
||||||
|
t.Run("struct valido retorna IsValid true", func(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Host string `required:"true"`
|
||||||
|
Port int `required:"true"`
|
||||||
|
}
|
||||||
|
got := ConfigValidate(Cfg{Host: "localhost", Port: 8080})
|
||||||
|
if !got.IsValid {
|
||||||
|
t.Errorf("expected IsValid=true, errors: %v", got.Errors)
|
||||||
|
}
|
||||||
|
if len(got.Errors) != 0 {
|
||||||
|
t.Errorf("expected no errors, got %v", got.Errors)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("campo required vacio acumula error", func(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Host string `required:"true"`
|
||||||
|
Key string `required:"true"`
|
||||||
|
}
|
||||||
|
got := ConfigValidate(Cfg{})
|
||||||
|
if got.IsValid {
|
||||||
|
t.Error("expected IsValid=false")
|
||||||
|
}
|
||||||
|
if len(got.Errors) != 2 {
|
||||||
|
t.Errorf("expected 2 errors, got %d: %v", len(got.Errors), got.Errors)
|
||||||
|
}
|
||||||
|
for _, e := range got.Errors {
|
||||||
|
if e.Tag != "required" {
|
||||||
|
t.Errorf("expected tag 'required', got %q", e.Tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("format email invalido produce error", func(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Email string `format:"email"`
|
||||||
|
}
|
||||||
|
got := ConfigValidate(Cfg{Email: "not-an-email"})
|
||||||
|
if got.IsValid {
|
||||||
|
t.Error("expected IsValid=false")
|
||||||
|
}
|
||||||
|
if len(got.Errors) == 0 || got.Errors[0].Tag != "format" {
|
||||||
|
t.Errorf("expected format error, got %v", got.Errors)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("format email valido pasa", func(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Email string `format:"email"`
|
||||||
|
}
|
||||||
|
got := ConfigValidate(Cfg{Email: "user@example.com"})
|
||||||
|
if !got.IsValid {
|
||||||
|
t.Errorf("expected IsValid=true, errors: %v", got.Errors)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("format url invalido produce error", func(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
BaseURL string `format:"url"`
|
||||||
|
}
|
||||||
|
got := ConfigValidate(Cfg{BaseURL: "ftp://bad.url"})
|
||||||
|
if got.IsValid {
|
||||||
|
t.Error("expected IsValid=false")
|
||||||
|
}
|
||||||
|
if len(got.Errors) == 0 || got.Errors[0].Tag != "format" {
|
||||||
|
t.Errorf("expected format error, got %v", got.Errors)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("puntero a struct funciona igual", func(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Name string `required:"true"`
|
||||||
|
}
|
||||||
|
got := ConfigValidate(&Cfg{Name: "test"})
|
||||||
|
if !got.IsValid {
|
||||||
|
t.Errorf("expected IsValid=true, errors: %v", got.Errors)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("todos los errores acumulados aunque falle el primero", func(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
A string `required:"true"`
|
||||||
|
B string `required:"true"`
|
||||||
|
C string `required:"true"`
|
||||||
|
}
|
||||||
|
got := ConfigValidate(Cfg{})
|
||||||
|
if len(got.Errors) != 3 {
|
||||||
|
t.Errorf("expected 3 errors, got %d", len(got.Errors))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
// ConfigValidation holds the result of validating a configuration struct.
|
||||||
|
type ConfigValidation struct {
|
||||||
|
Errors []ConfigError // all validation errors found; empty if valid
|
||||||
|
IsValid bool // true if Errors is empty
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: config_error
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
algebraic: product
|
||||||
|
definition: |
|
||||||
|
type ConfigError struct {
|
||||||
|
Field string
|
||||||
|
Message string
|
||||||
|
Tag string
|
||||||
|
}
|
||||||
|
description: "Error de validacion de un campo de configuracion. Field identifica el campo, Message describe el problema, Tag indica la regla que fallo (required, format, etc.)."
|
||||||
|
tags: [config, validation, error, infra]
|
||||||
|
uses_types: []
|
||||||
|
file_path: "functions/infra/config_error.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Tipo producto — todos los campos siempre presentes. Se acumula en ConfigValidation.Errors. Tag toma valores como "required" o "format" para que el consumidor pueda filtrar por tipo de error.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: config_validation
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
algebraic: product
|
||||||
|
definition: |
|
||||||
|
type ConfigValidation struct {
|
||||||
|
Errors []ConfigError
|
||||||
|
IsValid bool
|
||||||
|
}
|
||||||
|
description: "Resultado de validar una struct de configuracion. Contiene todos los errores acumulados y un bool de conveniencia. IsValid es true solo cuando Errors esta vacio."
|
||||||
|
tags: [config, validation, result, infra]
|
||||||
|
uses_types: [config_error_go_infra]
|
||||||
|
file_path: "functions/infra/config_validation.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Tipo producto. IsValid se computa derivadamente de len(Errors)==0. Permite al consumidor tanto iterar sobre todos los errores como hacer un check rapido con IsValid.
|
||||||
Reference in New Issue
Block a user