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
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -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 '='")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user