diff --git a/functions/infra/config_dump.go b/functions/infra/config_dump.go new file mode 100644 index 00000000..21c78354 --- /dev/null +++ b/functions/infra/config_dump.go @@ -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 +} diff --git a/functions/infra/config_dump.md b/functions/infra/config_dump.md new file mode 100644 index 00000000..e12600f8 --- /dev/null +++ b/functions/infra/config_dump.md @@ -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. diff --git a/functions/infra/config_dump_test.go b/functions/infra/config_dump_test.go new file mode 100644 index 00000000..4d254166 --- /dev/null +++ b/functions/infra/config_dump_test.go @@ -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"]) + } + }) +} diff --git a/functions/infra/config_error.go b/functions/infra/config_error.go new file mode 100644 index 00000000..2944c51a --- /dev/null +++ b/functions/infra/config_error.go @@ -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") +} diff --git a/functions/infra/config_merge.go b/functions/infra/config_merge.go new file mode 100644 index 00000000..c72f6024 --- /dev/null +++ b/functions/infra/config_merge.go @@ -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 +} diff --git a/functions/infra/config_merge.md b/functions/infra/config_merge.md new file mode 100644 index 00000000..fddb6733 --- /dev/null +++ b/functions/infra/config_merge.md @@ -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). diff --git a/functions/infra/config_merge_test.go b/functions/infra/config_merge_test.go new file mode 100644 index 00000000..c8543a7a --- /dev/null +++ b/functions/infra/config_merge_test.go @@ -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) + } + }) +} diff --git a/functions/infra/config_validate.go b/functions/infra/config_validate.go new file mode 100644 index 00000000..65094d07 --- /dev/null +++ b/functions/infra/config_validate.go @@ -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 + } +} diff --git a/functions/infra/config_validate.md b/functions/infra/config_validate.md new file mode 100644 index 00000000..91cee35f --- /dev/null +++ b/functions/infra/config_validate.md @@ -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. diff --git a/functions/infra/config_validate_test.go b/functions/infra/config_validate_test.go new file mode 100644 index 00000000..ab2465d2 --- /dev/null +++ b/functions/infra/config_validate_test.go @@ -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)) + } + }) +} diff --git a/functions/infra/config_validation.go b/functions/infra/config_validation.go new file mode 100644 index 00000000..a3e5a803 --- /dev/null +++ b/functions/infra/config_validation.go @@ -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 +} diff --git a/types/infra/config_error.md b/types/infra/config_error.md new file mode 100644 index 00000000..7a7556bc --- /dev/null +++ b/types/infra/config_error.md @@ -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. diff --git a/types/infra/config_validation.md b/types/infra/config_validation.md new file mode 100644 index 00000000..37b554d0 --- /dev/null +++ b/types/infra/config_validation.md @@ -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.