diff --git a/functions/infra/config_from_env.go b/functions/infra/config_from_env.go new file mode 100644 index 00000000..2c8af480 --- /dev/null +++ b/functions/infra/config_from_env.go @@ -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 +} diff --git a/functions/infra/config_from_env.md b/functions/infra/config_from_env.md new file mode 100644 index 00000000..16dd1b8e --- /dev/null +++ b/functions/infra/config_from_env.md @@ -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). diff --git a/functions/infra/config_from_env_test.go b/functions/infra/config_from_env_test.go new file mode 100644 index 00000000..d505dad6 --- /dev/null +++ b/functions/infra/config_from_env_test.go @@ -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") + } + }) +} diff --git a/functions/infra/config_from_file.go b/functions/infra/config_from_file.go new file mode 100644 index 00000000..bdb4324f --- /dev/null +++ b/functions/infra/config_from_file.go @@ -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 +} diff --git a/functions/infra/config_from_file.md b/functions/infra/config_from_file.md new file mode 100644 index 00000000..c1ebd48f --- /dev/null +++ b/functions/infra/config_from_file.md @@ -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. diff --git a/functions/infra/config_from_file_test.go b/functions/infra/config_from_file_test.go new file mode 100644 index 00000000..4f04a66f --- /dev/null +++ b/functions/infra/config_from_file_test.go @@ -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") + } + }) +} diff --git a/functions/infra/dotenv_load.go b/functions/infra/dotenv_load.go new file mode 100644 index 00000000..408ebca6 --- /dev/null +++ b/functions/infra/dotenv_load.go @@ -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 +} diff --git a/functions/infra/dotenv_load.md b/functions/infra/dotenv_load.md new file mode 100644 index 00000000..f45bb201 --- /dev/null +++ b/functions/infra/dotenv_load.md @@ -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). diff --git a/functions/infra/dotenv_load_test.go b/functions/infra/dotenv_load_test.go new file mode 100644 index 00000000..f7f26511 --- /dev/null +++ b/functions/infra/dotenv_load_test.go @@ -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 '='") + } + }) +} diff --git a/functions/infra/env_require.go b/functions/infra/env_require.go new file mode 100644 index 00000000..8d51bac5 --- /dev/null +++ b/functions/infra/env_require.go @@ -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 +} diff --git a/functions/infra/env_require.md b/functions/infra/env_require.md new file mode 100644 index 00000000..84367e98 --- /dev/null +++ b/functions/infra/env_require.md @@ -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. diff --git a/functions/infra/env_require_all.go b/functions/infra/env_require_all.go new file mode 100644 index 00000000..e51d5132 --- /dev/null +++ b/functions/infra/env_require_all.go @@ -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 +} diff --git a/functions/infra/env_require_all.md b/functions/infra/env_require_all.md new file mode 100644 index 00000000..899e1046 --- /dev/null +++ b/functions/infra/env_require_all.md @@ -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. diff --git a/functions/infra/env_require_test.go b/functions/infra/env_require_test.go new file mode 100644 index 00000000..6319a876 --- /dev/null +++ b/functions/infra/env_require_test.go @@ -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) + } + }) +}