feat(infra): funciones impuras Go para carga de env/config (dotenv, env_require, config_from_env, config_from_file)
- dotenv_load: parser .env con no-sobreescritura y soporte de comillas - env_require: os.Getenv con error descriptivo fail-fast - env_require_all: verifica multiples vars y lista todas las faltantes - config_from_env: reflection sobre struct tags env/default/required/secret, 5 tipos soportados - config_from_file: JSON via stdlib, YAML stub con not-implemented - 25 tests unitarios, todos PASS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user