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:
2026-04-13 02:01:50 +02:00
parent c3b007a4e7
commit ebf246beb0
14 changed files with 888 additions and 0 deletions
+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")
}
})
}
+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)
}
})
}