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:
2026-04-13 02:01:43 +02:00
parent eca52b1329
commit c3b007a4e7
13 changed files with 676 additions and 0 deletions
+48
View File
@@ -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
}
+49
View File
@@ -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.
+67
View File
@@ -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"])
}
})
}
+8
View File
@@ -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")
}
+16
View File
@@ -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
}
+45
View File
@@ -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).
+53
View File
@@ -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)
}
})
}
+191
View File
@@ -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
}
}
+53
View File
@@ -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.
+98
View File
@@ -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))
}
})
}
+7
View File
@@ -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
}
+21
View File
@@ -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.
+20
View File
@@ -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.