merge: issue/0018-config-env — Config & env management

# Conflicts:
#	registry.db
This commit is contained in:
2026-04-13 02:05:47 +02:00
34 changed files with 2211 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")
}
+133
View File
@@ -0,0 +1,133 @@
package infra
import (
"fmt"
"os"
"reflect"
"strconv"
"strings"
)
// ConfigFromEnv populates a configuration struct from environment variables
// using struct field tags. The target must be a non-nil pointer to a struct.
//
// Supported tags:
// - env:"VAR_NAME" — environment variable to read (required tag)
// - default:"value" — value used when env var is unset/empty
// - required:"true" — error if env var is unset/empty and no default
// - secret:"true" — documented only; no runtime effect on loading
//
// Supported field types: string, int, int64, bool, float64, []string.
// []string fields are parsed from comma-separated values.
//
// Example struct:
//
// type Config struct {
// Port int `env:"PORT" default:"8080" required:"true"`
// APIKey string `env:"API_KEY" required:"true" secret:"true"`
// Tags []string `env:"TAGS" default:"a,b"`
// }
func ConfigFromEnv(target any) error {
v := reflect.ValueOf(target)
if v.Kind() != reflect.Ptr || v.IsNil() {
return fmt.Errorf("config_from_env: target must be a non-nil pointer to a struct")
}
v = v.Elem()
if v.Kind() != reflect.Struct {
return fmt.Errorf("config_from_env: target must point to a struct, got %s", v.Kind())
}
t := v.Type()
var errs []string
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if !field.IsExported() {
continue
}
envKey := field.Tag.Get("env")
if envKey == "" {
continue // no env tag → skip
}
defaultVal := field.Tag.Get("default")
required := field.Tag.Get("required") == "true"
raw := os.Getenv(envKey)
if raw == "" {
if defaultVal != "" {
raw = defaultVal
} else if required {
errs = append(errs, fmt.Sprintf("field %s: env var %q is required but not set", field.Name, envKey))
continue
} else {
continue // optional and unset — leave field at zero value
}
}
fv := v.Field(i)
if err := setField(fv, field.Name, raw); err != nil {
errs = append(errs, err.Error())
}
}
if len(errs) > 0 {
return fmt.Errorf("config_from_env: %s", strings.Join(errs, "; "))
}
return nil
}
// setField converts the string raw to the appropriate type and sets fv.
func setField(fv reflect.Value, fieldName, raw string) error {
switch fv.Kind() {
case reflect.String:
fv.SetString(raw)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("field %s: cannot parse %q as int: %w", fieldName, raw, err)
}
fv.SetInt(n)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
n, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return fmt.Errorf("field %s: cannot parse %q as uint: %w", fieldName, raw, err)
}
fv.SetUint(n)
case reflect.Float32, reflect.Float64:
f, err := strconv.ParseFloat(raw, 64)
if err != nil {
return fmt.Errorf("field %s: cannot parse %q as float: %w", fieldName, raw, err)
}
fv.SetFloat(f)
case reflect.Bool:
b, err := strconv.ParseBool(raw)
if err != nil {
return fmt.Errorf("field %s: cannot parse %q as bool (use true/false/1/0): %w", fieldName, raw, err)
}
fv.SetBool(b)
case reflect.Slice:
if fv.Type().Elem().Kind() != reflect.String {
return fmt.Errorf("field %s: slice of non-string is not supported, use []string", fieldName)
}
parts := strings.Split(raw, ",")
trimmed := make([]string, 0, len(parts))
for _, p := range parts {
t := strings.TrimSpace(p)
if t != "" {
trimmed = append(trimmed, t)
}
}
fv.Set(reflect.ValueOf(trimmed))
default:
return fmt.Errorf("field %s: unsupported type %s", fieldName, fv.Kind())
}
return nil
}
+56
View File
@@ -0,0 +1,56 @@
---
name: config_from_env
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func ConfigFromEnv(target any) error"
description: "Puebla una struct de configuracion desde variables de entorno usando reflection y struct tags (env, default, required, secret). Soporta string, int, int64, bool, float64 y []string (comma-separated). Acumula todos los errores antes de retornar."
tags: [config, env, reflect, infra, tags]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os, reflect, strconv, strings]
params:
- name: target
desc: "puntero no-nil a struct con tags env, default, required y/o secret en sus campos exportados"
output: "nil si la struct se poblo correctamente, error si falta una variable required o hay un tipo incompatible"
tested: true
tests:
- "populate string field desde env"
- "populate int field con conversion"
- "populate bool field con conversion"
- "populate float64 field"
- "populate slice string comma-separated"
- "valor default usado cuando env no seteada"
- "required sin valor retorna error"
- "int invalido retorna error descriptivo"
- "puntero nil retorna error"
test_file_path: "functions/infra/config_from_env_test.go"
file_path: "functions/infra/config_from_env.go"
---
## Ejemplo
```go
type AppConfig struct {
Host string `env:"HOST" default:"localhost"`
Port int `env:"PORT" default:"8080" required:"true"`
APIKey string `env:"API_KEY" required:"true" secret:"true"`
Tags []string `env:"TAGS" default:"prod,stable"`
Debug bool `env:"DEBUG" default:"false"`
Timeout float64 `env:"TIMEOUT" default:"30.0"`
}
var cfg AppConfig
if err := ConfigFromEnv(&cfg); err != nil {
log.Fatal(err)
}
```
## Notas
Sin dependencias externas — solo stdlib. Tipos soportados: string, int/int64, uint/uint64, bool, float32/float64, []string. Los []string se parsean splitteando por coma y trimeando espacios. El tag secret:"true" no tiene efecto en runtime (solo documentacion — ver ConfigDump para ocultar al loggear).
+121
View File
@@ -0,0 +1,121 @@
package infra
import (
"testing"
)
func TestConfigFromEnv(t *testing.T) {
t.Run("populate string field desde env", func(t *testing.T) {
type Cfg struct {
Host string `env:"CFE_HOST"`
}
t.Setenv("CFE_HOST", "myhost")
var c Cfg
if err := ConfigFromEnv(&c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c.Host != "myhost" {
t.Errorf("expected myhost, got %q", c.Host)
}
})
t.Run("populate int field con conversion", func(t *testing.T) {
type Cfg struct {
Port int `env:"CFE_PORT"`
}
t.Setenv("CFE_PORT", "9090")
var c Cfg
if err := ConfigFromEnv(&c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c.Port != 9090 {
t.Errorf("expected 9090, got %d", c.Port)
}
})
t.Run("populate bool field con conversion", func(t *testing.T) {
type Cfg struct {
Debug bool `env:"CFE_DEBUG"`
}
t.Setenv("CFE_DEBUG", "true")
var c Cfg
if err := ConfigFromEnv(&c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !c.Debug {
t.Error("expected Debug=true")
}
})
t.Run("populate float64 field", func(t *testing.T) {
type Cfg struct {
Ratio float64 `env:"CFE_RATIO"`
}
t.Setenv("CFE_RATIO", "3.14")
var c Cfg
if err := ConfigFromEnv(&c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c.Ratio < 3.13 || c.Ratio > 3.15 {
t.Errorf("expected ~3.14, got %f", c.Ratio)
}
})
t.Run("populate slice string comma-separated", func(t *testing.T) {
type Cfg struct {
Tags []string `env:"CFE_TAGS"`
}
t.Setenv("CFE_TAGS", "a,b,c")
var c Cfg
if err := ConfigFromEnv(&c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(c.Tags) != 3 || c.Tags[0] != "a" || c.Tags[2] != "c" {
t.Errorf("unexpected tags: %v", c.Tags)
}
})
t.Run("valor default usado cuando env no seteada", func(t *testing.T) {
type Cfg struct {
Port int `env:"CFE_PORT_DEFAULT" default:"8080"`
}
var c Cfg
if err := ConfigFromEnv(&c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c.Port != 8080 {
t.Errorf("expected default 8080, got %d", c.Port)
}
})
t.Run("required sin valor retorna error", func(t *testing.T) {
type Cfg struct {
APIKey string `env:"CFE_API_KEY_REQUIRED" required:"true"`
}
var c Cfg
err := ConfigFromEnv(&c)
if err == nil {
t.Error("expected error for missing required var")
}
})
t.Run("int invalido retorna error descriptivo", func(t *testing.T) {
type Cfg struct {
Port int `env:"CFE_PORT_BAD"`
}
t.Setenv("CFE_PORT_BAD", "not-a-number")
var c Cfg
err := ConfigFromEnv(&c)
if err == nil {
t.Error("expected error for invalid int")
}
})
t.Run("puntero nil retorna error", func(t *testing.T) {
var p *struct{ X string }
err := ConfigFromEnv(p)
if err == nil {
t.Error("expected error for nil pointer")
}
})
}
+40
View File
@@ -0,0 +1,40 @@
package infra
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// ConfigFromFile loads configuration from a file into target.
// The target must be a non-nil pointer to a struct (same as json.Unmarshal).
//
// Supported formats:
// - JSON (.json) — full support via encoding/json
// - YAML (.yaml, .yml) — stub: returns "not implemented"
//
// The format is determined by the file extension (case-insensitive).
func ConfigFromFile(path string, target any) error {
lower := strings.ToLower(path)
switch {
case strings.HasSuffix(lower, ".json"):
return loadJSON(path, target)
case strings.HasSuffix(lower, ".yaml"), strings.HasSuffix(lower, ".yml"):
return fmt.Errorf("config_from_file: YAML support not implemented; use a JSON file or add gopkg.in/yaml.v3")
default:
return fmt.Errorf("config_from_file: unsupported file extension for %q (supported: .json, .yaml, .yml)", path)
}
}
func loadJSON(path string, target any) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("config_from_file: read %q: %w", path, err)
}
if err := json.Unmarshal(data, target); err != nil {
return fmt.Errorf("config_from_file: parse JSON %q: %w", path, err)
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
---
name: config_from_file
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func ConfigFromFile(path string, target any) error"
description: "Carga configuracion desde un archivo en target. Soporta JSON (encoding/json stdlib). YAML es stub: retorna 'not implemented'. Extension determina el formato."
tags: [config, file, json, yaml, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [encoding/json, fmt, os, strings]
params:
- name: path
desc: "ruta al archivo de configuracion (.json, .yaml, .yml)"
- name: target
desc: "puntero a struct o mapa donde se va a deserializar la configuracion"
output: "nil si el archivo se cargo y parseo correctamente, error si el archivo no existe, tiene formato invalido, o la extension no esta soportada"
tested: true
tests:
- "carga JSON valido en struct"
- "JSON invalido retorna error"
- "archivo inexistente retorna error"
- "extension YAML retorna not implemented"
- "extension desconocida retorna error"
test_file_path: "functions/infra/config_from_file_test.go"
file_path: "functions/infra/config_from_file.go"
---
## Ejemplo
```go
type DBConfig struct {
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
}
var cfg DBConfig
if err := ConfigFromFile("config/database.json", &cfg); err != nil {
log.Fatal(err)
}
```
## Notas
JSON: implementacion completa via encoding/json de stdlib. YAML: stub que retorna error "not implemented" — para YAML real agregar gopkg.in/yaml.v3. El formato se determina por la extension del archivo (case-insensitive). Se puede combinar con ConfigFromEnv: cargar defaults del archivo y luego sobreescribir con env vars.
+66
View File
@@ -0,0 +1,66 @@
package infra
import (
"os"
"path/filepath"
"testing"
)
func TestConfigFromFile(t *testing.T) {
t.Run("carga JSON valido en struct", func(t *testing.T) {
type Cfg struct {
Host string `json:"host"`
Port int `json:"port"`
}
content := `{"host":"testhost","port":5432}`
dir := t.TempDir()
p := filepath.Join(dir, "config.json")
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
var c Cfg
if err := ConfigFromFile(p, &c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c.Host != "testhost" || c.Port != 5432 {
t.Errorf("unexpected: %+v", c)
}
})
t.Run("JSON invalido retorna error", func(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "bad.json")
os.WriteFile(p, []byte("{not valid json}"), 0o600)
var target map[string]any
err := ConfigFromFile(p, &target)
if err == nil {
t.Error("expected error for invalid JSON")
}
})
t.Run("archivo inexistente retorna error", func(t *testing.T) {
var target map[string]any
err := ConfigFromFile("/nonexistent/config.json", &target)
if err == nil {
t.Error("expected error for missing file")
}
})
t.Run("extension YAML retorna not implemented", func(t *testing.T) {
var target map[string]any
err := ConfigFromFile("config.yaml", &target)
if err == nil {
t.Error("expected not-implemented error for YAML")
}
})
t.Run("extension desconocida retorna error", func(t *testing.T) {
var target map[string]any
err := ConfigFromFile("config.toml", &target)
if err == nil {
t.Error("expected error for unsupported extension")
}
})
}
+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
}
+73
View File
@@ -0,0 +1,73 @@
package infra
import (
"bufio"
"fmt"
"os"
"strings"
)
// DotenvLoad parses a .env file and sets environment variables via os.Setenv.
// It does NOT overwrite variables that are already set in the environment.
//
// Parsing rules:
// - Lines starting with # (after trimming) are ignored as comments.
// - Blank lines are ignored.
// - Lines must follow the KEY=VALUE format.
// - Values may be optionally quoted with single or double quotes; quotes are stripped.
// - Inline comments (# after value) are NOT stripped — the raw value after = is used.
//
// Returns an error if the file cannot be opened or if a line has no '=' separator.
func DotenvLoad(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("dotenv_load: open %q: %w", path, err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
// skip blank lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.IndexByte(line, '=')
if idx < 0 {
return fmt.Errorf("dotenv_load: %q line %d: missing '=' in %q", path, lineNum, line)
}
key := strings.TrimSpace(line[:idx])
val := line[idx+1:]
// strip surrounding quotes from value
val = stripQuotes(val)
// only set if not already present
if os.Getenv(key) == "" {
if err := os.Setenv(key, val); err != nil {
return fmt.Errorf("dotenv_load: setenv %q: %w", key, err)
}
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("dotenv_load: scan %q: %w", path, err)
}
return nil
}
// stripQuotes removes surrounding single or double quotes from a string.
func stripQuotes(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') ||
(s[0] == '\'' && s[len(s)-1] == '\'') {
return s[1 : len(s)-1]
}
}
return s
}
+46
View File
@@ -0,0 +1,46 @@
---
name: dotenv_load
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func DotenvLoad(path string) error"
description: "Parsea un archivo .env (KEY=VALUE), ignora comentarios # y lineas vacias, y llama os.Setenv para cada par. No sobreescribe variables ya presentes en el entorno. Soporta valores con comillas simples o dobles."
tags: [dotenv, env, config, os, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [bufio, fmt, os, strings]
params:
- name: path
desc: "ruta al archivo .env a parsear (ej: \".env\", \"config/.env.production\")"
output: "nil si el archivo se parseo y aplico correctamente, error si el archivo no existe o tiene formato invalido"
tested: true
tests:
- "parsea KEY=VALUE y setea variable"
- "ignora lineas comentario y vacias"
- "no sobreescribe variables ya seteadas"
- "valores con comillas dobles se stripean"
- "valores con comillas simples se stripean"
- "archivo inexistente retorna error"
- "linea sin signo igual retorna error"
test_file_path: "functions/infra/dotenv_load_test.go"
file_path: "functions/infra/dotenv_load.go"
---
## Ejemplo
```go
// Al inicio de la app, cargar .env sin sobrescribir vars de CI/CD
if err := DotenvLoad(".env"); err != nil {
log.Printf("warning: %v (ok if running in CI)", err)
}
// Ahora os.Getenv("DATABASE_URL") funciona
```
## Notas
Solo stdlib. Semantica de no-sobreescritura permite que variables inyectadas por CI/CD o Docker tengan precedencia sobre el .env local. Formato soportado: KEY=VALUE, KEY="valor con espacios", KEY='valor'. Los comentarios inline (#) no se stripean — el valor raw tras = se usa completo (sin comillas externas).
+97
View File
@@ -0,0 +1,97 @@
package infra
import (
"os"
"path/filepath"
"testing"
)
func TestDotenvLoad(t *testing.T) {
// helper to write a temp .env file
writeEnv := func(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, ".env")
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return p
}
t.Run("parsea KEY=VALUE y setea variable", func(t *testing.T) {
p := writeEnv(t, "DOTENV_TEST_FOO=hello\n")
t.Setenv("DOTENV_TEST_FOO", "") // ensure unset
os.Unsetenv("DOTENV_TEST_FOO")
if err := DotenvLoad(p); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := os.Getenv("DOTENV_TEST_FOO"); got != "hello" {
t.Errorf("expected DOTENV_TEST_FOO=hello, got %q", got)
}
})
t.Run("ignora lineas comentario y vacias", func(t *testing.T) {
content := "# this is a comment\n\nDOTENV_TEST_BAR=world\n"
p := writeEnv(t, content)
os.Unsetenv("DOTENV_TEST_BAR")
if err := DotenvLoad(p); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := os.Getenv("DOTENV_TEST_BAR"); got != "world" {
t.Errorf("expected world, got %q", got)
}
})
t.Run("no sobreescribe variables ya seteadas", func(t *testing.T) {
p := writeEnv(t, "DOTENV_TEST_EXISTING=new\n")
t.Setenv("DOTENV_TEST_EXISTING", "original")
if err := DotenvLoad(p); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := os.Getenv("DOTENV_TEST_EXISTING"); got != "original" {
t.Errorf("expected original (not overwritten), got %q", got)
}
})
t.Run("valores con comillas dobles se stripean", func(t *testing.T) {
p := writeEnv(t, `DOTENV_TEST_QUOTED="quoted value"`)
os.Unsetenv("DOTENV_TEST_QUOTED")
if err := DotenvLoad(p); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := os.Getenv("DOTENV_TEST_QUOTED"); got != "quoted value" {
t.Errorf("expected 'quoted value', got %q", got)
}
})
t.Run("valores con comillas simples se stripean", func(t *testing.T) {
p := writeEnv(t, "DOTENV_TEST_SINGLE='single quoted'")
os.Unsetenv("DOTENV_TEST_SINGLE")
if err := DotenvLoad(p); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := os.Getenv("DOTENV_TEST_SINGLE"); got != "single quoted" {
t.Errorf("expected 'single quoted', got %q", got)
}
})
t.Run("archivo inexistente retorna error", func(t *testing.T) {
err := DotenvLoad("/nonexistent/.env.does.not.exist")
if err == nil {
t.Error("expected error for missing file")
}
})
t.Run("linea sin signo igual retorna error", func(t *testing.T) {
p := writeEnv(t, "INVALID_LINE\n")
err := DotenvLoad(p)
if err == nil {
t.Error("expected error for line without '='")
}
})
}
+16
View File
@@ -0,0 +1,16 @@
package infra
import (
"fmt"
"os"
)
// EnvRequire returns the value of the environment variable named key.
// If the variable is unset or empty it returns an error with a descriptive message.
func EnvRequire(key string) (string, error) {
val := os.Getenv(key)
if val == "" {
return "", fmt.Errorf("env_require: environment variable %q is not set or empty", key)
}
return val, nil
}
+41
View File
@@ -0,0 +1,41 @@
---
name: env_require
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func EnvRequire(key string) (string, error)"
description: "Lee una variable de entorno con os.Getenv y retorna error descriptivo si esta vacia o no seteada. Fail-fast para variables obligatorias al arrancar la app."
tags: [env, config, os, infra, required]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os]
params:
- name: key
desc: "nombre de la variable de entorno requerida (ej: DATABASE_URL, API_KEY)"
output: "valor de la variable si esta presente y no vacia, error descriptivo si falta"
tested: true
tests:
- "retorna valor cuando variable esta seteada"
- "retorna error con mensaje descriptivo si variable no existe"
- "variable vacia se trata como no seteada"
test_file_path: "functions/infra/env_require_test.go"
file_path: "functions/infra/env_require.go"
---
## Ejemplo
```go
dbURL, err := EnvRequire("DATABASE_URL")
if err != nil {
log.Fatal(err) // "env_require: environment variable \"DATABASE_URL\" is not set or empty"
}
```
## Notas
Wrapper minimo sobre os.Getenv. Trata vacio y no-seteado igual (ambos son error). Para multiples variables, usar EnvRequireAll que acumula todos los errores en una sola llamada.
+31
View File
@@ -0,0 +1,31 @@
package infra
import (
"fmt"
"os"
"strings"
)
// EnvRequireAll returns a map of all requested environment variables.
// All keys are checked even if some are missing. If any variable is unset or
// empty, a single error listing all missing variables is returned.
// On success, the returned map contains every key with its current value.
func EnvRequireAll(keys []string) (map[string]string, error) {
result := make(map[string]string, len(keys))
var missing []string
for _, key := range keys {
val := os.Getenv(key)
if val == "" {
missing = append(missing, key)
} else {
result[key] = val
}
}
if len(missing) > 0 {
return nil, fmt.Errorf("env_require_all: missing environment variables: %s", strings.Join(missing, ", "))
}
return result, nil
}
+43
View File
@@ -0,0 +1,43 @@
---
name: env_require_all
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func EnvRequireAll(keys []string) (map[string]string, error)"
description: "Verifica y retorna multiples variables de entorno. Todas las claves se comprueban aunque algunas fallen, acumulando los nombres de las faltantes en un unico error. Util para validacion exhaustiva al arranque."
tags: [env, config, os, infra, required, batch]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os, strings]
params:
- name: keys
desc: "lista de nombres de variables de entorno que deben estar presentes y no vacias"
output: "mapa key→value con todos los valores si todos existen, error con lista de las faltantes si alguna falla"
tested: true
tests:
- "retorna mapa con todos los valores cuando estan seteados"
- "acumula todos los errores de variables faltantes"
- "lista vacia retorna mapa vacio sin error"
test_file_path: "functions/infra/env_require_test.go"
file_path: "functions/infra/env_require_all.go"
---
## Ejemplo
```go
vars, err := EnvRequireAll([]string{"DATABASE_URL", "API_KEY", "JWT_SECRET"})
if err != nil {
log.Fatal(err)
// "env_require_all: missing environment variables: API_KEY, JWT_SECRET"
}
dbURL := vars["DATABASE_URL"]
```
## Notas
Diferencia con EnvRequire: comprueba todas las claves de golpe y lista todas las faltantes en el error. Mejor UX en apps con muchas variables requeridas — el usuario ve todo lo que le falta en un solo error.
+74
View File
@@ -0,0 +1,74 @@
package infra
import (
"testing"
)
func TestEnvRequire(t *testing.T) {
t.Run("retorna valor cuando variable esta seteada", func(t *testing.T) {
t.Setenv("ENV_REQUIRE_TEST_KEY", "myvalue")
got, err := EnvRequire("ENV_REQUIRE_TEST_KEY")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "myvalue" {
t.Errorf("expected myvalue, got %q", got)
}
})
t.Run("retorna error con mensaje descriptivo si variable no existe", func(t *testing.T) {
got, err := EnvRequire("ENV_REQUIRE_DEFINITELY_NOT_SET_XYZ")
if err == nil {
t.Error("expected error for missing variable")
}
if got != "" {
t.Errorf("expected empty string on error, got %q", got)
}
if err != nil && len(err.Error()) == 0 {
t.Error("error message should not be empty")
}
})
t.Run("variable vacia se trata como no seteada", func(t *testing.T) {
t.Setenv("ENV_REQUIRE_EMPTY_KEY", "")
_, err := EnvRequire("ENV_REQUIRE_EMPTY_KEY")
if err == nil {
t.Error("expected error for empty variable")
}
})
}
func TestEnvRequireAll(t *testing.T) {
t.Run("retorna mapa con todos los valores cuando estan seteados", func(t *testing.T) {
t.Setenv("REQUIRE_ALL_A", "alpha")
t.Setenv("REQUIRE_ALL_B", "beta")
got, err := EnvRequireAll([]string{"REQUIRE_ALL_A", "REQUIRE_ALL_B"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got["REQUIRE_ALL_A"] != "alpha" || got["REQUIRE_ALL_B"] != "beta" {
t.Errorf("unexpected map: %v", got)
}
})
t.Run("acumula todos los errores de variables faltantes", func(t *testing.T) {
_, err := EnvRequireAll([]string{"REQUIRE_ALL_MISSING_1", "REQUIRE_ALL_MISSING_2"})
if err == nil {
t.Error("expected error")
}
msg := err.Error()
if len(msg) == 0 {
t.Error("expected non-empty error message")
}
})
t.Run("lista vacia retorna mapa vacio sin error", func(t *testing.T) {
got, err := EnvRequireAll([]string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 0 {
t.Errorf("expected empty map, got %v", got)
}
})
}