fix(infra): gradle_run detecta android-sdk — issue 0076 #2

Open
dataforge wants to merge 538 commits from auto/0076-gradle-sdk-detect into master
34 changed files with 2211 additions and 0 deletions
Showing only changes of commit b0038aab43 - Show all commits
+205
View File
@@ -0,0 +1,205 @@
# 0018 — Config & Env Management
## Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0018 |
| **Estado** | pendiente |
| **Prioridad** | media |
| **Tipo** | feature |
## Dependencias
Ninguna.
---
## Objetivo
Funciones reutilizables para cargar, validar y gestionar configuracion de apps usando struct tags en Go y dataclasses en Python. Reemplazar los `os.Getenv` dispersos por un patron consistente con validacion, defaults y soporte para `.env`.
## Contexto
- Las apps cargan config ad-hoc: `os.Getenv` repetidos, sin validacion, sin defaults, sin mensajes de error descriptivos.
- Cuando falta una variable de entorno, el error aparece mucho despues (conexion fallida, panic) en vez de al arrancar.
- No hay soporte para archivos `.env` — cada desarrollador tiene que exportar variables manualmente.
- Patron deseado: definir un struct con tags, poblarlo automaticamente, validar al arrancar, fallar rapido con errores claros.
## Funciones Go (dominio infra)
| Funcion | Purity | Firma (simplificada) |
|---------|--------|---------------------|
| `config_from_env` | impure | `(dst any) error` |
| `config_from_file` | impure | `(path string, dst any) error` |
| `config_validate` | pure | `(cfg any) (ConfigValidation, error)` |
| `config_merge` | pure | `(base, override map[string]string) map[string]string` |
| `config_dump` | pure | `(cfg any) map[string]string` |
| `env_require` | impure | `(key string) (string, error)` |
| `env_require_all` | impure | `(keys []string) (map[string]string, error)` |
| `dotenv_load` | impure | `(path string) error` |
### Struct tags
```go
type AppConfig struct {
Port int `env:"PORT" default:"8080" required:"true"`
DBPath string `env:"DB_PATH" default:"data.db"`
APIKey string `env:"API_KEY" required:"true" secret:"true"`
LogLevel string `env:"LOG_LEVEL" default:"info"`
}
```
- `env:"VAR"` — nombre de la variable de entorno
- `default:"valor"` — valor si la variable no existe
- `required:"true"` — error si no tiene valor (ni env ni default)
- `secret:"true"``config_dump` redacta el valor como `***`
### Detalle por funcion
**`config_from_env`** — Recorre campos del struct via reflection. Para cada campo lee `os.Getenv(tag_env)`, aplica default si vacio, convierte al tipo del campo (`int`, `bool`, `string`, `float64`, `[]string`). Error si un campo `required` queda vacio.
**`config_from_file`** — Detecta formato por extension (`.json`, `.yaml`). Deserializa al struct destino. Solo JSON usa stdlib; YAML solo si ya existe dependencia en el proyecto (sino stub con error).
**`config_validate`** — Valida un struct ya poblado: campos required no vacios, rangos numericos (futuro: tag `min`/`max`), formatos basicos (URL, email via regex simple). Retorna `ConfigValidation` con todos los errores acumulados.
**`config_merge`** — Merge de dos maps `string→string` donde override gana. Util para: defaults → file → env (cada capa overridea la anterior).
**`config_dump`** — Convierte struct a `map[string]string` para logging. Campos con `secret:"true"` se muestran como `***`. Util para imprimir config al arrancar sin leakear secrets.
**`env_require` / `env_require_all`** — Helpers ligeros para cuando no se necesita un struct completo. `env_require_all` acumula todos los errores en vez de fallar en el primero.
**`dotenv_load`** — Parsea un archivo `.env` (lineas `KEY=VALUE`, ignora comentarios `#` y lineas vacias) y hace `os.Setenv` para cada entrada. No sobreescribe variables que ya existen en el entorno.
## Funciones Python (dominio infra)
| Funcion | Purity | Firma (simplificada) |
|---------|--------|---------------------|
| `config_from_env` | impure | `(cls: type[T]) -> T` |
| `dotenv_load` | impure | `(path: str) -> None` |
**`config_from_env_py_infra`** — Recibe una dataclass con field metadata y la puebla desde `os.environ`. Soporta defaults de la dataclass y type hints para conversion (`int`, `float`, `bool`, `str`).
```python
@dataclass
class AppConfig:
port: int = field(default=8080, metadata={"env": "PORT"})
db_path: str = field(default="data.db", metadata={"env": "DB_PATH"})
api_key: str = field(default="", metadata={"env": "API_KEY", "required": True})
```
**`dotenv_load_py_infra`** — Mismo comportamiento que la version Go: parsea `.env`, setea en `os.environ`, no sobreescribe existentes.
## Tipos (dominio infra)
| Tipo | Algebraic | Campos |
|------|-----------|--------|
| `ConfigError` | product | `field string`, `message string`, `tag string` |
| `ConfigValidation` | product | `errors []ConfigError`, `is_valid bool` |
`ConfigError.tag` indica que validacion fallo (`required`, `format`, `range`). Permite que el caller filtre o agrupe errores programaticamente.
## Tareas
### Fase 1: Tipos y funciones puras
- [ ] **1.1** Crear tipos `ConfigError`, `ConfigValidation` en `functions/infra/` + `.md` en `types/infra/`
- [ ] **1.2** `config_validate` — validacion de struct con acumulacion de errores
- [ ] **1.3** `config_merge` — merge de maps con prioridad
- [ ] **1.4** `config_dump` — dump a map con redaccion de secrets
- [ ] **1.5** Tests unitarios para las tres funciones puras
### Fase 2: Funciones impuras Go
- [ ] **2.1** `dotenv_load` — parser de `.env` + `os.Setenv`
- [ ] **2.2** `env_require` y `env_require_all` — getenv con errores descriptivos
- [ ] **2.3** `config_from_env` — reflection sobre struct tags + conversion de tipos
- [ ] **2.4** `config_from_file` — JSON (stdlib), YAML (stub si no hay dep)
- [ ] **2.5** Tests con `t.Setenv` para aislar variables de entorno
### Fase 3: Funciones Python + indexado
- [ ] **3.1** `config_from_env_py_infra` — dataclass + `os.environ`
- [ ] **3.2** `dotenv_load_py_infra` — parser `.env`
- [ ] **3.3** `fn index` y verificar todas las funciones en registry.db
- [ ] **3.4** `go vet -tags fts5` limpio
---
## Ejemplo de uso
```go
// 1. Definir config como struct con tags
type ServerConfig struct {
Port int `env:"PORT" default:"8080" required:"true"`
DBPath string `env:"DB_PATH" default:"registry.db"`
APIKey string `env:"API_KEY" required:"true" secret:"true"`
LogLevel string `env:"LOG_LEVEL" default:"info"`
}
// 2. Cargar .env si existe, poblar struct, validar
func main() {
_ = infra.DotenvLoad(".env")
var cfg ServerConfig
if err := infra.ConfigFromEnv(&cfg); err != nil {
log.Fatalf("config: %v", err)
}
validation, _ := infra.ConfigValidate(&cfg)
if !validation.IsValid {
for _, e := range validation.Errors {
log.Printf(" %s: %s (%s)", e.Field, e.Message, e.Tag)
}
os.Exit(1)
}
// 3. Log config al arrancar (secrets redactados)
for k, v := range infra.ConfigDump(&cfg) {
log.Printf(" %s=%s", k, v)
}
// Output: PORT=8080 DB_PATH=registry.db API_KEY=*** LOG_LEVEL=info
}
```
```go
// Caso simple: solo necesito dos variables
dbURL, err := infra.EnvRequire("DATABASE_URL")
if err != nil {
log.Fatal(err) // "DATABASE_URL: variable de entorno requerida no definida"
}
vars, err := infra.EnvRequireAll([]string{"DB_HOST", "DB_PORT", "DB_NAME"})
if err != nil {
log.Fatal(err) // "variables no definidas: DB_HOST, DB_NAME"
}
```
```python
# Python
from infra import config_from_env, dotenv_load
@dataclass
class Config:
port: int = field(default=8080, metadata={"env": "PORT"})
api_key: str = field(default="", metadata={"env": "API_KEY", "required": True})
dotenv_load(".env")
cfg = config_from_env(Config)
print(f"Listening on :{cfg.port}")
```
## Decisiones de diseno
- **Solo stdlib para Go:** reflection + `os` + `encoding/json`. Sin viper, envconfig, ni dependencias externas. YAML queda como stub hasta que el proyecto lo necesite.
- **Struct tags como fuente de verdad:** la definicion del struct ES la documentacion de la config. Un nuevo campo = una linea de codigo.
- **Fail-fast con errores acumulados:** `config_validate` reporta TODOS los problemas, no solo el primero. Asi el usuario los arregla todos de una vez.
- **`dotenv_load` no sobreescribe:** las variables ya seteadas en el entorno tienen prioridad. Esto permite overrides en CI/deploy sin tocar el `.env`.
- **`secret` tag para redaccion:** evita leakear API keys en logs. `config_dump` es seguro para imprimir al arrancar.
- **Python usa dataclasses nativo:** sin pydantic ni attrs. Consistente con el enfoque zero-deps del registry.
## Riesgos
- **Reflection en Go es fragil:** tipos no soportados (nested structs, maps) darian panics. Mitigado limitando v1 a tipos planos (`string`, `int`, `bool`, `float64`, `[]string`) y documentando la restriccion.
- **Parser de `.env` demasiado simple:** no soportaria valores multilinea, comillas con escape, ni interpolacion. Suficiente para el 95% de casos; documentar limitaciones.
- **Duplicacion Go/Python:** dos implementaciones del mismo concepto. Mitigado porque son simples y el comportamiento esta bien definido en esta spec.
+48
View File
@@ -0,0 +1,48 @@
package infra
import (
"fmt"
"reflect"
)
const secretMask = "***"
// ConfigDump converts a configuration struct to a map[string]string.
// Exported fields are included. Fields tagged with secret:"true" have their
// value replaced with "***". Nested structs are not traversed — only top-level
// exported fields are included.
//
// The map key is the field name. Values are formatted with fmt.Sprintf("%v", ...).
func ConfigDump(cfg any) map[string]string {
result := make(map[string]string)
v := reflect.ValueOf(cfg)
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return result
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return result
}
t := v.Type()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if !field.IsExported() {
continue
}
key := field.Name
if field.Tag.Get("secret") == "true" {
result[key] = secretMask
continue
}
fv := v.Field(i)
result[key] = fmt.Sprintf("%v", fv.Interface())
}
return result
}
+49
View File
@@ -0,0 +1,49 @@
---
name: config_dump
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: pure
signature: "func ConfigDump(cfg any) map[string]string"
description: "Convierte una struct de configuracion a map[string]string para logging o inspeccion. Campos con tag secret:\"true\" aparecen como \"***\". Solo campos exportados de nivel superior."
tags: [config, dump, debug, logging, infra, reflect]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [fmt, reflect]
params:
- name: cfg
desc: "struct de configuracion (valor o puntero) cuyos campos exportados se van a serializar a strings"
output: "mapa string→string con cada campo exportado; valores secretos reemplazados con ***"
tested: true
tests:
- "campos normales se incluyen como string"
- "campos secret aparecen como tres asteriscos"
- "campos no exportados no aparecen"
- "puntero a struct funciona"
- "struct con bool y float se formatean correctamente"
test_file_path: "functions/infra/config_dump_test.go"
file_path: "functions/infra/config_dump.go"
---
## Ejemplo
```go
type AppConfig struct {
Host string
Port int
APIKey string `secret:"true"`
}
cfg := AppConfig{Host: "localhost", Port: 8080, APIKey: "sk-abc123"}
dump := ConfigDump(cfg)
// dump = {"Host": "localhost", "Port": "8080", "APIKey": "***"}
log.Printf("startup config: %v", dump)
```
## Notas
Funcion pura. Usa fmt.Sprintf("%v") para formatear todos los tipos. No desciende a structs anidados. Util para logging seguro de configuracion al inicio de una app.
+67
View File
@@ -0,0 +1,67 @@
package infra
import (
"testing"
)
func TestConfigDump(t *testing.T) {
t.Run("campos normales se incluyen como string", func(t *testing.T) {
type Cfg struct {
Host string
Port int
}
got := ConfigDump(Cfg{Host: "localhost", Port: 8080})
if got["Host"] != "localhost" {
t.Errorf("expected Host=localhost, got %q", got["Host"])
}
if got["Port"] != "8080" {
t.Errorf("expected Port=8080, got %q", got["Port"])
}
})
t.Run("campos secret aparecen como tres asteriscos", func(t *testing.T) {
type Cfg struct {
APIKey string `secret:"true"`
}
got := ConfigDump(Cfg{APIKey: "super-secret-key"})
if got["APIKey"] != "***" {
t.Errorf("expected ***, got %q", got["APIKey"])
}
})
t.Run("campos no exportados no aparecen", func(t *testing.T) {
type Cfg struct {
Public string
private string //nolint:structcheck
}
_ = Cfg{} // ensure it compiles
got := ConfigDump(Cfg{Public: "yes"})
if _, ok := got["private"]; ok {
t.Error("private field should not be in dump")
}
})
t.Run("puntero a struct funciona", func(t *testing.T) {
type Cfg struct {
Name string
}
got := ConfigDump(&Cfg{Name: "test"})
if got["Name"] != "test" {
t.Errorf("expected Name=test, got %q", got["Name"])
}
})
t.Run("struct con bool y float se formatean correctamente", func(t *testing.T) {
type Cfg struct {
Debug bool
Timeout float64
}
got := ConfigDump(Cfg{Debug: true, Timeout: 3.14})
if got["Debug"] != "true" {
t.Errorf("expected Debug=true, got %q", got["Debug"])
}
if got["Timeout"] != "3.14" {
t.Errorf("expected Timeout=3.14, got %q", got["Timeout"])
}
})
}
+8
View File
@@ -0,0 +1,8 @@
package infra
// ConfigError represents a single validation error for a configuration field.
type ConfigError struct {
Field string // struct field name or env var name
Message string // human-readable error description
Tag string // validation tag that triggered the error (e.g. "required", "format")
}
+133
View File
@@ -0,0 +1,133 @@
package infra
import (
"fmt"
"os"
"reflect"
"strconv"
"strings"
)
// ConfigFromEnv populates a configuration struct from environment variables
// using struct field tags. The target must be a non-nil pointer to a struct.
//
// Supported tags:
// - env:"VAR_NAME" — environment variable to read (required tag)
// - default:"value" — value used when env var is unset/empty
// - required:"true" — error if env var is unset/empty and no default
// - secret:"true" — documented only; no runtime effect on loading
//
// Supported field types: string, int, int64, bool, float64, []string.
// []string fields are parsed from comma-separated values.
//
// Example struct:
//
// type Config struct {
// Port int `env:"PORT" default:"8080" required:"true"`
// APIKey string `env:"API_KEY" required:"true" secret:"true"`
// Tags []string `env:"TAGS" default:"a,b"`
// }
func ConfigFromEnv(target any) error {
v := reflect.ValueOf(target)
if v.Kind() != reflect.Ptr || v.IsNil() {
return fmt.Errorf("config_from_env: target must be a non-nil pointer to a struct")
}
v = v.Elem()
if v.Kind() != reflect.Struct {
return fmt.Errorf("config_from_env: target must point to a struct, got %s", v.Kind())
}
t := v.Type()
var errs []string
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if !field.IsExported() {
continue
}
envKey := field.Tag.Get("env")
if envKey == "" {
continue // no env tag → skip
}
defaultVal := field.Tag.Get("default")
required := field.Tag.Get("required") == "true"
raw := os.Getenv(envKey)
if raw == "" {
if defaultVal != "" {
raw = defaultVal
} else if required {
errs = append(errs, fmt.Sprintf("field %s: env var %q is required but not set", field.Name, envKey))
continue
} else {
continue // optional and unset — leave field at zero value
}
}
fv := v.Field(i)
if err := setField(fv, field.Name, raw); err != nil {
errs = append(errs, err.Error())
}
}
if len(errs) > 0 {
return fmt.Errorf("config_from_env: %s", strings.Join(errs, "; "))
}
return nil
}
// setField converts the string raw to the appropriate type and sets fv.
func setField(fv reflect.Value, fieldName, raw string) error {
switch fv.Kind() {
case reflect.String:
fv.SetString(raw)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("field %s: cannot parse %q as int: %w", fieldName, raw, err)
}
fv.SetInt(n)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
n, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return fmt.Errorf("field %s: cannot parse %q as uint: %w", fieldName, raw, err)
}
fv.SetUint(n)
case reflect.Float32, reflect.Float64:
f, err := strconv.ParseFloat(raw, 64)
if err != nil {
return fmt.Errorf("field %s: cannot parse %q as float: %w", fieldName, raw, err)
}
fv.SetFloat(f)
case reflect.Bool:
b, err := strconv.ParseBool(raw)
if err != nil {
return fmt.Errorf("field %s: cannot parse %q as bool (use true/false/1/0): %w", fieldName, raw, err)
}
fv.SetBool(b)
case reflect.Slice:
if fv.Type().Elem().Kind() != reflect.String {
return fmt.Errorf("field %s: slice of non-string is not supported, use []string", fieldName)
}
parts := strings.Split(raw, ",")
trimmed := make([]string, 0, len(parts))
for _, p := range parts {
t := strings.TrimSpace(p)
if t != "" {
trimmed = append(trimmed, t)
}
}
fv.Set(reflect.ValueOf(trimmed))
default:
return fmt.Errorf("field %s: unsupported type %s", fieldName, fv.Kind())
}
return nil
}
+56
View File
@@ -0,0 +1,56 @@
---
name: config_from_env
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func ConfigFromEnv(target any) error"
description: "Puebla una struct de configuracion desde variables de entorno usando reflection y struct tags (env, default, required, secret). Soporta string, int, int64, bool, float64 y []string (comma-separated). Acumula todos los errores antes de retornar."
tags: [config, env, reflect, infra, tags]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os, reflect, strconv, strings]
params:
- name: target
desc: "puntero no-nil a struct con tags env, default, required y/o secret en sus campos exportados"
output: "nil si la struct se poblo correctamente, error si falta una variable required o hay un tipo incompatible"
tested: true
tests:
- "populate string field desde env"
- "populate int field con conversion"
- "populate bool field con conversion"
- "populate float64 field"
- "populate slice string comma-separated"
- "valor default usado cuando env no seteada"
- "required sin valor retorna error"
- "int invalido retorna error descriptivo"
- "puntero nil retorna error"
test_file_path: "functions/infra/config_from_env_test.go"
file_path: "functions/infra/config_from_env.go"
---
## Ejemplo
```go
type AppConfig struct {
Host string `env:"HOST" default:"localhost"`
Port int `env:"PORT" default:"8080" required:"true"`
APIKey string `env:"API_KEY" required:"true" secret:"true"`
Tags []string `env:"TAGS" default:"prod,stable"`
Debug bool `env:"DEBUG" default:"false"`
Timeout float64 `env:"TIMEOUT" default:"30.0"`
}
var cfg AppConfig
if err := ConfigFromEnv(&cfg); err != nil {
log.Fatal(err)
}
```
## Notas
Sin dependencias externas — solo stdlib. Tipos soportados: string, int/int64, uint/uint64, bool, float32/float64, []string. Los []string se parsean splitteando por coma y trimeando espacios. El tag secret:"true" no tiene efecto en runtime (solo documentacion — ver ConfigDump para ocultar al loggear).
+121
View File
@@ -0,0 +1,121 @@
package infra
import (
"testing"
)
func TestConfigFromEnv(t *testing.T) {
t.Run("populate string field desde env", func(t *testing.T) {
type Cfg struct {
Host string `env:"CFE_HOST"`
}
t.Setenv("CFE_HOST", "myhost")
var c Cfg
if err := ConfigFromEnv(&c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c.Host != "myhost" {
t.Errorf("expected myhost, got %q", c.Host)
}
})
t.Run("populate int field con conversion", func(t *testing.T) {
type Cfg struct {
Port int `env:"CFE_PORT"`
}
t.Setenv("CFE_PORT", "9090")
var c Cfg
if err := ConfigFromEnv(&c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c.Port != 9090 {
t.Errorf("expected 9090, got %d", c.Port)
}
})
t.Run("populate bool field con conversion", func(t *testing.T) {
type Cfg struct {
Debug bool `env:"CFE_DEBUG"`
}
t.Setenv("CFE_DEBUG", "true")
var c Cfg
if err := ConfigFromEnv(&c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !c.Debug {
t.Error("expected Debug=true")
}
})
t.Run("populate float64 field", func(t *testing.T) {
type Cfg struct {
Ratio float64 `env:"CFE_RATIO"`
}
t.Setenv("CFE_RATIO", "3.14")
var c Cfg
if err := ConfigFromEnv(&c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c.Ratio < 3.13 || c.Ratio > 3.15 {
t.Errorf("expected ~3.14, got %f", c.Ratio)
}
})
t.Run("populate slice string comma-separated", func(t *testing.T) {
type Cfg struct {
Tags []string `env:"CFE_TAGS"`
}
t.Setenv("CFE_TAGS", "a,b,c")
var c Cfg
if err := ConfigFromEnv(&c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(c.Tags) != 3 || c.Tags[0] != "a" || c.Tags[2] != "c" {
t.Errorf("unexpected tags: %v", c.Tags)
}
})
t.Run("valor default usado cuando env no seteada", func(t *testing.T) {
type Cfg struct {
Port int `env:"CFE_PORT_DEFAULT" default:"8080"`
}
var c Cfg
if err := ConfigFromEnv(&c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c.Port != 8080 {
t.Errorf("expected default 8080, got %d", c.Port)
}
})
t.Run("required sin valor retorna error", func(t *testing.T) {
type Cfg struct {
APIKey string `env:"CFE_API_KEY_REQUIRED" required:"true"`
}
var c Cfg
err := ConfigFromEnv(&c)
if err == nil {
t.Error("expected error for missing required var")
}
})
t.Run("int invalido retorna error descriptivo", func(t *testing.T) {
type Cfg struct {
Port int `env:"CFE_PORT_BAD"`
}
t.Setenv("CFE_PORT_BAD", "not-a-number")
var c Cfg
err := ConfigFromEnv(&c)
if err == nil {
t.Error("expected error for invalid int")
}
})
t.Run("puntero nil retorna error", func(t *testing.T) {
var p *struct{ X string }
err := ConfigFromEnv(p)
if err == nil {
t.Error("expected error for nil pointer")
}
})
}
+40
View File
@@ -0,0 +1,40 @@
package infra
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// ConfigFromFile loads configuration from a file into target.
// The target must be a non-nil pointer to a struct (same as json.Unmarshal).
//
// Supported formats:
// - JSON (.json) — full support via encoding/json
// - YAML (.yaml, .yml) — stub: returns "not implemented"
//
// The format is determined by the file extension (case-insensitive).
func ConfigFromFile(path string, target any) error {
lower := strings.ToLower(path)
switch {
case strings.HasSuffix(lower, ".json"):
return loadJSON(path, target)
case strings.HasSuffix(lower, ".yaml"), strings.HasSuffix(lower, ".yml"):
return fmt.Errorf("config_from_file: YAML support not implemented; use a JSON file or add gopkg.in/yaml.v3")
default:
return fmt.Errorf("config_from_file: unsupported file extension for %q (supported: .json, .yaml, .yml)", path)
}
}
func loadJSON(path string, target any) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("config_from_file: read %q: %w", path, err)
}
if err := json.Unmarshal(data, target); err != nil {
return fmt.Errorf("config_from_file: parse JSON %q: %w", path, err)
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
---
name: config_from_file
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func ConfigFromFile(path string, target any) error"
description: "Carga configuracion desde un archivo en target. Soporta JSON (encoding/json stdlib). YAML es stub: retorna 'not implemented'. Extension determina el formato."
tags: [config, file, json, yaml, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [encoding/json, fmt, os, strings]
params:
- name: path
desc: "ruta al archivo de configuracion (.json, .yaml, .yml)"
- name: target
desc: "puntero a struct o mapa donde se va a deserializar la configuracion"
output: "nil si el archivo se cargo y parseo correctamente, error si el archivo no existe, tiene formato invalido, o la extension no esta soportada"
tested: true
tests:
- "carga JSON valido en struct"
- "JSON invalido retorna error"
- "archivo inexistente retorna error"
- "extension YAML retorna not implemented"
- "extension desconocida retorna error"
test_file_path: "functions/infra/config_from_file_test.go"
file_path: "functions/infra/config_from_file.go"
---
## Ejemplo
```go
type DBConfig struct {
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
}
var cfg DBConfig
if err := ConfigFromFile("config/database.json", &cfg); err != nil {
log.Fatal(err)
}
```
## Notas
JSON: implementacion completa via encoding/json de stdlib. YAML: stub que retorna error "not implemented" — para YAML real agregar gopkg.in/yaml.v3. El formato se determina por la extension del archivo (case-insensitive). Se puede combinar con ConfigFromEnv: cargar defaults del archivo y luego sobreescribir con env vars.
+66
View File
@@ -0,0 +1,66 @@
package infra
import (
"os"
"path/filepath"
"testing"
)
func TestConfigFromFile(t *testing.T) {
t.Run("carga JSON valido en struct", func(t *testing.T) {
type Cfg struct {
Host string `json:"host"`
Port int `json:"port"`
}
content := `{"host":"testhost","port":5432}`
dir := t.TempDir()
p := filepath.Join(dir, "config.json")
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
var c Cfg
if err := ConfigFromFile(p, &c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c.Host != "testhost" || c.Port != 5432 {
t.Errorf("unexpected: %+v", c)
}
})
t.Run("JSON invalido retorna error", func(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "bad.json")
os.WriteFile(p, []byte("{not valid json}"), 0o600)
var target map[string]any
err := ConfigFromFile(p, &target)
if err == nil {
t.Error("expected error for invalid JSON")
}
})
t.Run("archivo inexistente retorna error", func(t *testing.T) {
var target map[string]any
err := ConfigFromFile("/nonexistent/config.json", &target)
if err == nil {
t.Error("expected error for missing file")
}
})
t.Run("extension YAML retorna not implemented", func(t *testing.T) {
var target map[string]any
err := ConfigFromFile("config.yaml", &target)
if err == nil {
t.Error("expected not-implemented error for YAML")
}
})
t.Run("extension desconocida retorna error", func(t *testing.T) {
var target map[string]any
err := ConfigFromFile("config.toml", &target)
if err == nil {
t.Error("expected error for unsupported extension")
}
})
}
+16
View File
@@ -0,0 +1,16 @@
package infra
// ConfigMerge merges two string maps, with override taking precedence over base.
// Keys present in both maps take the value from override.
// Keys present only in base are kept. Keys present only in override are added.
// Neither input map is mutated — a new map is returned.
func ConfigMerge(base, override map[string]string) map[string]string {
result := make(map[string]string, len(base)+len(override))
for k, v := range base {
result[k] = v
}
for k, v := range override {
result[k] = v
}
return result
}
+45
View File
@@ -0,0 +1,45 @@
---
name: config_merge
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: pure
signature: "func ConfigMerge(base, override map[string]string) map[string]string"
description: "Merge de dos map[string]string. El map override tiene precedencia sobre base en claves comunes. Ninguno de los inputs es mutado — retorna un mapa nuevo."
tags: [config, merge, map, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: base
desc: "mapa de configuracion base, valores por defecto o de menor prioridad"
- name: override
desc: "mapa de configuracion con mayor prioridad, sobreescribe claves de base"
output: "nuevo mapa con todas las claves de base y override, donde override gana en conflictos"
tested: true
tests:
- "override gana sobre base en claves comunes"
- "claves solo en base se mantienen"
- "claves solo en override se agregan"
- "no muta el map base"
- "merge con maps vacios retorna mapa vacio"
test_file_path: "functions/infra/config_merge_test.go"
file_path: "functions/infra/config_merge.go"
---
## Ejemplo
```go
defaults := map[string]string{"host": "localhost", "port": "5432", "debug": "false"}
env := map[string]string{"port": "9999", "debug": "true"}
merged := ConfigMerge(defaults, env)
// merged = {"host": "localhost", "port": "9999", "debug": "true"}
```
## Notas
Funcion pura. Util para combinar defaults hardcodeados con valores del archivo .env o de variables de entorno. Patron tipico: ConfigMerge(hardcoded_defaults, dotenv_values).
+53
View File
@@ -0,0 +1,53 @@
package infra
import (
"testing"
)
func TestConfigMerge(t *testing.T) {
t.Run("override gana sobre base en claves comunes", func(t *testing.T) {
base := map[string]string{"host": "localhost", "port": "5432"}
over := map[string]string{"port": "9999"}
got := ConfigMerge(base, over)
if got["port"] != "9999" {
t.Errorf("expected port=9999, got %q", got["port"])
}
if got["host"] != "localhost" {
t.Errorf("expected host=localhost, got %q", got["host"])
}
})
t.Run("claves solo en base se mantienen", func(t *testing.T) {
base := map[string]string{"a": "1", "b": "2"}
over := map[string]string{}
got := ConfigMerge(base, over)
if got["a"] != "1" || got["b"] != "2" {
t.Errorf("unexpected result: %v", got)
}
})
t.Run("claves solo en override se agregan", func(t *testing.T) {
base := map[string]string{}
over := map[string]string{"x": "new"}
got := ConfigMerge(base, over)
if got["x"] != "new" {
t.Errorf("expected x=new, got %q", got["x"])
}
})
t.Run("no muta el map base", func(t *testing.T) {
base := map[string]string{"key": "original"}
over := map[string]string{"key": "changed"}
_ = ConfigMerge(base, over)
if base["key"] != "original" {
t.Errorf("base was mutated: got %q", base["key"])
}
})
t.Run("merge con maps vacios retorna mapa vacio", func(t *testing.T) {
got := ConfigMerge(map[string]string{}, map[string]string{})
if len(got) != 0 {
t.Errorf("expected empty map, got %v", got)
}
})
}
+191
View File
@@ -0,0 +1,191 @@
package infra
import (
"fmt"
"reflect"
"regexp"
"strings"
)
// emailRE is a basic email format check.
var emailRE = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
// urlRE checks for http:// or https:// prefix.
var urlRE = regexp.MustCompile(`^https?://`)
// ConfigValidate validates a configuration struct populated with values.
// It reads struct field tags to determine validation rules:
// - required:"true" — field must be non-zero / non-empty
// - format:"email" — field value must match email pattern
// - format:"url" — field value must start with http:// or https://
//
// Only exported fields are inspected. Validation errors are accumulated; all
// fields are checked even if earlier ones fail. Returns a ConfigValidation
// with IsValid=true when no errors are found.
func ConfigValidate(cfg any) ConfigValidation {
var errs []ConfigError
v := reflect.ValueOf(cfg)
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return ConfigValidation{Errors: []ConfigError{{Field: "", Message: "cfg is nil", Tag: "required"}}, IsValid: false}
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return ConfigValidation{Errors: []ConfigError{{Field: "", Message: "cfg must be a struct", Tag: "required"}}, IsValid: false}
}
t := v.Type()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if !field.IsExported() {
continue
}
fv := v.Field(i)
fieldName := field.Name
// required check
required := field.Tag.Get("required")
if required == "true" {
if isZero(fv) {
errs = append(errs, ConfigError{
Field: fieldName,
Message: fmt.Sprintf("field %s is required", fieldName),
Tag: "required",
})
continue // skip format check when value is missing
}
}
// format check (only for string fields)
format := field.Tag.Get("format")
if format != "" && fv.Kind() == reflect.String {
val := fv.String()
switch format {
case "email":
if !emailRE.MatchString(val) {
errs = append(errs, ConfigError{
Field: fieldName,
Message: fmt.Sprintf("field %s has invalid email format: %q", fieldName, val),
Tag: "format",
})
}
case "url":
if !urlRE.MatchString(val) {
errs = append(errs, ConfigError{
Field: fieldName,
Message: fmt.Sprintf("field %s has invalid URL format (must start with http:// or https://): %q", fieldName, val),
Tag: "format",
})
}
default:
errs = append(errs, ConfigError{
Field: fieldName,
Message: fmt.Sprintf("field %s has unknown format tag: %q", fieldName, format),
Tag: "format",
})
}
}
// min/max for numeric fields
minTag := field.Tag.Get("min")
maxTag := field.Tag.Get("max")
if (minTag != "" || maxTag != "") && isNumeric(fv) {
val := toFloat(fv)
if minTag != "" {
var minVal float64
fmt.Sscanf(minTag, "%f", &minVal)
if val < minVal {
errs = append(errs, ConfigError{
Field: fieldName,
Message: fmt.Sprintf("field %s value %v is below minimum %v", fieldName, val, minVal),
Tag: "min",
})
}
}
if maxTag != "" {
var maxVal float64
fmt.Sscanf(maxTag, "%f", &maxVal)
if val > maxVal {
errs = append(errs, ConfigError{
Field: fieldName,
Message: fmt.Sprintf("field %s value %v is above maximum %v", fieldName, val, maxVal),
Tag: "max",
})
}
}
}
// oneof check for string fields
oneof := field.Tag.Get("oneof")
if oneof != "" && fv.Kind() == reflect.String {
val := fv.String()
allowed := strings.Split(oneof, " ")
found := false
for _, a := range allowed {
if a == val {
found = true
break
}
}
if !found {
errs = append(errs, ConfigError{
Field: fieldName,
Message: fmt.Sprintf("field %s value %q is not one of: %s", fieldName, val, oneof),
Tag: "oneof",
})
}
}
}
return ConfigValidation{
Errors: errs,
IsValid: len(errs) == 0,
}
}
func isZero(v reflect.Value) bool {
switch v.Kind() {
case reflect.String:
return v.Len() == 0
case reflect.Slice, reflect.Map:
return v.IsNil() || v.Len() == 0
case reflect.Ptr, reflect.Interface:
return v.IsNil()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int() == 0
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return v.Uint() == 0
case reflect.Float32, reflect.Float64:
return v.Float() == 0
case reflect.Bool:
return !v.Bool()
default:
return false
}
}
func isNumeric(v reflect.Value) bool {
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
return true
default:
return false
}
}
func toFloat(v reflect.Value) float64 {
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return float64(v.Int())
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return float64(v.Uint())
case reflect.Float32, reflect.Float64:
return v.Float()
default:
return 0
}
}
+53
View File
@@ -0,0 +1,53 @@
---
name: config_validate
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: pure
signature: "func ConfigValidate(cfg any) ConfigValidation"
description: "Valida una struct de configuracion usando struct tags. Acumula todos los errores (required, format:email, format:url, min, max, oneof) sin detener en el primero. Retorna ConfigValidation con IsValid=true si no hay errores."
tags: [config, validation, env, infra, reflect]
uses_functions: []
uses_types: [config_validation_go_infra, config_error_go_infra]
returns: [config_validation_go_infra]
returns_optional: false
error_type: ""
imports: [fmt, reflect, regexp, strings]
params:
- name: cfg
desc: "struct de configuracion (valor o puntero) con tags required, format, min, max, oneof en sus campos exportados"
output: "ConfigValidation con la lista de errores acumulados y el flag IsValid"
tested: true
tests:
- "struct valido retorna IsValid true"
- "campo required vacio acumula error"
- "format email invalido produce error"
- "format email valido pasa"
- "format url invalido produce error"
- "puntero a struct funciona igual"
- "todos los errores acumulados aunque falle el primero"
test_file_path: "functions/infra/config_validate_test.go"
file_path: "functions/infra/config_validate.go"
---
## Ejemplo
```go
type ServerConfig struct {
Host string `required:"true"`
Port int `required:"true" min:"1" max:"65535"`
Email string `format:"email"`
}
v := ConfigValidate(ServerConfig{Host: "localhost", Port: 8080})
if !v.IsValid {
for _, e := range v.Errors {
log.Printf("[%s] %s: %s", e.Tag, e.Field, e.Message)
}
}
```
## Notas
Funcion pura — usa solo reflect, sin I/O. Soporta tags: `required:"true"`, `format:"email"`, `format:"url"`, `min:"N"`, `max:"N"`, `oneof:"a b c"`. Solo inspecciona campos exportados. No es recursiva — no baja a structs anidados.
+98
View File
@@ -0,0 +1,98 @@
package infra
import (
"testing"
)
func TestConfigValidate(t *testing.T) {
t.Run("struct valido retorna IsValid true", func(t *testing.T) {
type Cfg struct {
Host string `required:"true"`
Port int `required:"true"`
}
got := ConfigValidate(Cfg{Host: "localhost", Port: 8080})
if !got.IsValid {
t.Errorf("expected IsValid=true, errors: %v", got.Errors)
}
if len(got.Errors) != 0 {
t.Errorf("expected no errors, got %v", got.Errors)
}
})
t.Run("campo required vacio acumula error", func(t *testing.T) {
type Cfg struct {
Host string `required:"true"`
Key string `required:"true"`
}
got := ConfigValidate(Cfg{})
if got.IsValid {
t.Error("expected IsValid=false")
}
if len(got.Errors) != 2 {
t.Errorf("expected 2 errors, got %d: %v", len(got.Errors), got.Errors)
}
for _, e := range got.Errors {
if e.Tag != "required" {
t.Errorf("expected tag 'required', got %q", e.Tag)
}
}
})
t.Run("format email invalido produce error", func(t *testing.T) {
type Cfg struct {
Email string `format:"email"`
}
got := ConfigValidate(Cfg{Email: "not-an-email"})
if got.IsValid {
t.Error("expected IsValid=false")
}
if len(got.Errors) == 0 || got.Errors[0].Tag != "format" {
t.Errorf("expected format error, got %v", got.Errors)
}
})
t.Run("format email valido pasa", func(t *testing.T) {
type Cfg struct {
Email string `format:"email"`
}
got := ConfigValidate(Cfg{Email: "user@example.com"})
if !got.IsValid {
t.Errorf("expected IsValid=true, errors: %v", got.Errors)
}
})
t.Run("format url invalido produce error", func(t *testing.T) {
type Cfg struct {
BaseURL string `format:"url"`
}
got := ConfigValidate(Cfg{BaseURL: "ftp://bad.url"})
if got.IsValid {
t.Error("expected IsValid=false")
}
if len(got.Errors) == 0 || got.Errors[0].Tag != "format" {
t.Errorf("expected format error, got %v", got.Errors)
}
})
t.Run("puntero a struct funciona igual", func(t *testing.T) {
type Cfg struct {
Name string `required:"true"`
}
got := ConfigValidate(&Cfg{Name: "test"})
if !got.IsValid {
t.Errorf("expected IsValid=true, errors: %v", got.Errors)
}
})
t.Run("todos los errores acumulados aunque falle el primero", func(t *testing.T) {
type Cfg struct {
A string `required:"true"`
B string `required:"true"`
C string `required:"true"`
}
got := ConfigValidate(Cfg{})
if len(got.Errors) != 3 {
t.Errorf("expected 3 errors, got %d", len(got.Errors))
}
})
}
+7
View File
@@ -0,0 +1,7 @@
package infra
// ConfigValidation holds the result of validating a configuration struct.
type ConfigValidation struct {
Errors []ConfigError // all validation errors found; empty if valid
IsValid bool // true if Errors is empty
}
+73
View File
@@ -0,0 +1,73 @@
package infra
import (
"bufio"
"fmt"
"os"
"strings"
)
// DotenvLoad parses a .env file and sets environment variables via os.Setenv.
// It does NOT overwrite variables that are already set in the environment.
//
// Parsing rules:
// - Lines starting with # (after trimming) are ignored as comments.
// - Blank lines are ignored.
// - Lines must follow the KEY=VALUE format.
// - Values may be optionally quoted with single or double quotes; quotes are stripped.
// - Inline comments (# after value) are NOT stripped — the raw value after = is used.
//
// Returns an error if the file cannot be opened or if a line has no '=' separator.
func DotenvLoad(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("dotenv_load: open %q: %w", path, err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
// skip blank lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.IndexByte(line, '=')
if idx < 0 {
return fmt.Errorf("dotenv_load: %q line %d: missing '=' in %q", path, lineNum, line)
}
key := strings.TrimSpace(line[:idx])
val := line[idx+1:]
// strip surrounding quotes from value
val = stripQuotes(val)
// only set if not already present
if os.Getenv(key) == "" {
if err := os.Setenv(key, val); err != nil {
return fmt.Errorf("dotenv_load: setenv %q: %w", key, err)
}
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("dotenv_load: scan %q: %w", path, err)
}
return nil
}
// stripQuotes removes surrounding single or double quotes from a string.
func stripQuotes(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') ||
(s[0] == '\'' && s[len(s)-1] == '\'') {
return s[1 : len(s)-1]
}
}
return s
}
+46
View File
@@ -0,0 +1,46 @@
---
name: dotenv_load
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func DotenvLoad(path string) error"
description: "Parsea un archivo .env (KEY=VALUE), ignora comentarios # y lineas vacias, y llama os.Setenv para cada par. No sobreescribe variables ya presentes en el entorno. Soporta valores con comillas simples o dobles."
tags: [dotenv, env, config, os, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [bufio, fmt, os, strings]
params:
- name: path
desc: "ruta al archivo .env a parsear (ej: \".env\", \"config/.env.production\")"
output: "nil si el archivo se parseo y aplico correctamente, error si el archivo no existe o tiene formato invalido"
tested: true
tests:
- "parsea KEY=VALUE y setea variable"
- "ignora lineas comentario y vacias"
- "no sobreescribe variables ya seteadas"
- "valores con comillas dobles se stripean"
- "valores con comillas simples se stripean"
- "archivo inexistente retorna error"
- "linea sin signo igual retorna error"
test_file_path: "functions/infra/dotenv_load_test.go"
file_path: "functions/infra/dotenv_load.go"
---
## Ejemplo
```go
// Al inicio de la app, cargar .env sin sobrescribir vars de CI/CD
if err := DotenvLoad(".env"); err != nil {
log.Printf("warning: %v (ok if running in CI)", err)
}
// Ahora os.Getenv("DATABASE_URL") funciona
```
## Notas
Solo stdlib. Semantica de no-sobreescritura permite que variables inyectadas por CI/CD o Docker tengan precedencia sobre el .env local. Formato soportado: KEY=VALUE, KEY="valor con espacios", KEY='valor'. Los comentarios inline (#) no se stripean — el valor raw tras = se usa completo (sin comillas externas).
+97
View File
@@ -0,0 +1,97 @@
package infra
import (
"os"
"path/filepath"
"testing"
)
func TestDotenvLoad(t *testing.T) {
// helper to write a temp .env file
writeEnv := func(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, ".env")
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return p
}
t.Run("parsea KEY=VALUE y setea variable", func(t *testing.T) {
p := writeEnv(t, "DOTENV_TEST_FOO=hello\n")
t.Setenv("DOTENV_TEST_FOO", "") // ensure unset
os.Unsetenv("DOTENV_TEST_FOO")
if err := DotenvLoad(p); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := os.Getenv("DOTENV_TEST_FOO"); got != "hello" {
t.Errorf("expected DOTENV_TEST_FOO=hello, got %q", got)
}
})
t.Run("ignora lineas comentario y vacias", func(t *testing.T) {
content := "# this is a comment\n\nDOTENV_TEST_BAR=world\n"
p := writeEnv(t, content)
os.Unsetenv("DOTENV_TEST_BAR")
if err := DotenvLoad(p); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := os.Getenv("DOTENV_TEST_BAR"); got != "world" {
t.Errorf("expected world, got %q", got)
}
})
t.Run("no sobreescribe variables ya seteadas", func(t *testing.T) {
p := writeEnv(t, "DOTENV_TEST_EXISTING=new\n")
t.Setenv("DOTENV_TEST_EXISTING", "original")
if err := DotenvLoad(p); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := os.Getenv("DOTENV_TEST_EXISTING"); got != "original" {
t.Errorf("expected original (not overwritten), got %q", got)
}
})
t.Run("valores con comillas dobles se stripean", func(t *testing.T) {
p := writeEnv(t, `DOTENV_TEST_QUOTED="quoted value"`)
os.Unsetenv("DOTENV_TEST_QUOTED")
if err := DotenvLoad(p); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := os.Getenv("DOTENV_TEST_QUOTED"); got != "quoted value" {
t.Errorf("expected 'quoted value', got %q", got)
}
})
t.Run("valores con comillas simples se stripean", func(t *testing.T) {
p := writeEnv(t, "DOTENV_TEST_SINGLE='single quoted'")
os.Unsetenv("DOTENV_TEST_SINGLE")
if err := DotenvLoad(p); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := os.Getenv("DOTENV_TEST_SINGLE"); got != "single quoted" {
t.Errorf("expected 'single quoted', got %q", got)
}
})
t.Run("archivo inexistente retorna error", func(t *testing.T) {
err := DotenvLoad("/nonexistent/.env.does.not.exist")
if err == nil {
t.Error("expected error for missing file")
}
})
t.Run("linea sin signo igual retorna error", func(t *testing.T) {
p := writeEnv(t, "INVALID_LINE\n")
err := DotenvLoad(p)
if err == nil {
t.Error("expected error for line without '='")
}
})
}
+16
View File
@@ -0,0 +1,16 @@
package infra
import (
"fmt"
"os"
)
// EnvRequire returns the value of the environment variable named key.
// If the variable is unset or empty it returns an error with a descriptive message.
func EnvRequire(key string) (string, error) {
val := os.Getenv(key)
if val == "" {
return "", fmt.Errorf("env_require: environment variable %q is not set or empty", key)
}
return val, nil
}
+41
View File
@@ -0,0 +1,41 @@
---
name: env_require
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func EnvRequire(key string) (string, error)"
description: "Lee una variable de entorno con os.Getenv y retorna error descriptivo si esta vacia o no seteada. Fail-fast para variables obligatorias al arrancar la app."
tags: [env, config, os, infra, required]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os]
params:
- name: key
desc: "nombre de la variable de entorno requerida (ej: DATABASE_URL, API_KEY)"
output: "valor de la variable si esta presente y no vacia, error descriptivo si falta"
tested: true
tests:
- "retorna valor cuando variable esta seteada"
- "retorna error con mensaje descriptivo si variable no existe"
- "variable vacia se trata como no seteada"
test_file_path: "functions/infra/env_require_test.go"
file_path: "functions/infra/env_require.go"
---
## Ejemplo
```go
dbURL, err := EnvRequire("DATABASE_URL")
if err != nil {
log.Fatal(err) // "env_require: environment variable \"DATABASE_URL\" is not set or empty"
}
```
## Notas
Wrapper minimo sobre os.Getenv. Trata vacio y no-seteado igual (ambos son error). Para multiples variables, usar EnvRequireAll que acumula todos los errores en una sola llamada.
+31
View File
@@ -0,0 +1,31 @@
package infra
import (
"fmt"
"os"
"strings"
)
// EnvRequireAll returns a map of all requested environment variables.
// All keys are checked even if some are missing. If any variable is unset or
// empty, a single error listing all missing variables is returned.
// On success, the returned map contains every key with its current value.
func EnvRequireAll(keys []string) (map[string]string, error) {
result := make(map[string]string, len(keys))
var missing []string
for _, key := range keys {
val := os.Getenv(key)
if val == "" {
missing = append(missing, key)
} else {
result[key] = val
}
}
if len(missing) > 0 {
return nil, fmt.Errorf("env_require_all: missing environment variables: %s", strings.Join(missing, ", "))
}
return result, nil
}
+43
View File
@@ -0,0 +1,43 @@
---
name: env_require_all
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func EnvRequireAll(keys []string) (map[string]string, error)"
description: "Verifica y retorna multiples variables de entorno. Todas las claves se comprueban aunque algunas fallen, acumulando los nombres de las faltantes en un unico error. Util para validacion exhaustiva al arranque."
tags: [env, config, os, infra, required, batch]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os, strings]
params:
- name: keys
desc: "lista de nombres de variables de entorno que deben estar presentes y no vacias"
output: "mapa key→value con todos los valores si todos existen, error con lista de las faltantes si alguna falla"
tested: true
tests:
- "retorna mapa con todos los valores cuando estan seteados"
- "acumula todos los errores de variables faltantes"
- "lista vacia retorna mapa vacio sin error"
test_file_path: "functions/infra/env_require_test.go"
file_path: "functions/infra/env_require_all.go"
---
## Ejemplo
```go
vars, err := EnvRequireAll([]string{"DATABASE_URL", "API_KEY", "JWT_SECRET"})
if err != nil {
log.Fatal(err)
// "env_require_all: missing environment variables: API_KEY, JWT_SECRET"
}
dbURL := vars["DATABASE_URL"]
```
## Notas
Diferencia con EnvRequire: comprueba todas las claves de golpe y lista todas las faltantes en el error. Mejor UX en apps con muchas variables requeridas — el usuario ve todo lo que le falta en un solo error.
+74
View File
@@ -0,0 +1,74 @@
package infra
import (
"testing"
)
func TestEnvRequire(t *testing.T) {
t.Run("retorna valor cuando variable esta seteada", func(t *testing.T) {
t.Setenv("ENV_REQUIRE_TEST_KEY", "myvalue")
got, err := EnvRequire("ENV_REQUIRE_TEST_KEY")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "myvalue" {
t.Errorf("expected myvalue, got %q", got)
}
})
t.Run("retorna error con mensaje descriptivo si variable no existe", func(t *testing.T) {
got, err := EnvRequire("ENV_REQUIRE_DEFINITELY_NOT_SET_XYZ")
if err == nil {
t.Error("expected error for missing variable")
}
if got != "" {
t.Errorf("expected empty string on error, got %q", got)
}
if err != nil && len(err.Error()) == 0 {
t.Error("error message should not be empty")
}
})
t.Run("variable vacia se trata como no seteada", func(t *testing.T) {
t.Setenv("ENV_REQUIRE_EMPTY_KEY", "")
_, err := EnvRequire("ENV_REQUIRE_EMPTY_KEY")
if err == nil {
t.Error("expected error for empty variable")
}
})
}
func TestEnvRequireAll(t *testing.T) {
t.Run("retorna mapa con todos los valores cuando estan seteados", func(t *testing.T) {
t.Setenv("REQUIRE_ALL_A", "alpha")
t.Setenv("REQUIRE_ALL_B", "beta")
got, err := EnvRequireAll([]string{"REQUIRE_ALL_A", "REQUIRE_ALL_B"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got["REQUIRE_ALL_A"] != "alpha" || got["REQUIRE_ALL_B"] != "beta" {
t.Errorf("unexpected map: %v", got)
}
})
t.Run("acumula todos los errores de variables faltantes", func(t *testing.T) {
_, err := EnvRequireAll([]string{"REQUIRE_ALL_MISSING_1", "REQUIRE_ALL_MISSING_2"})
if err == nil {
t.Error("expected error")
}
msg := err.Error()
if len(msg) == 0 {
t.Error("expected non-empty error message")
}
})
t.Run("lista vacia retorna mapa vacio sin error", func(t *testing.T) {
got, err := EnvRequireAll([]string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 0 {
t.Errorf("expected empty map, got %v", got)
}
})
}
+54
View File
@@ -0,0 +1,54 @@
---
name: config_from_env
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "config_from_env(target_class: type) -> object"
description: "Crea una instancia de un dataclass poblada desde variables de entorno usando field metadata (env, required, default, secret). Soporta str, int, float, bool, list. Acumula todos los errores antes de lanzar ValueError."
tags: [config, env, dataclass, infra, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [os, dataclasses]
params:
- name: target_class
desc: "clase dataclass con field metadata declarando env, required, default y/o secret por cada campo"
output: "instancia del dataclass con todos los campos poblados desde env o defaults"
tested: true
tests:
- "populate string desde env"
- "populate int con conversion"
- "populate bool true"
- "populate float field"
- "populate list comma separated"
- "default usado cuando env no seteada"
- "required sin valor lanza error"
- "no dataclass lanza type error"
test_file_path: "python/functions/infra/config_from_env_test.py"
file_path: "python/functions/infra/config_from_env.py"
---
## Ejemplo
```python
from dataclasses import dataclass, field
from config_from_env import config_from_env
@dataclass
class AppConfig:
host: str = field(default="localhost", metadata={"env": "HOST", "default": "localhost"})
port: int = field(default=8080, metadata={"env": "PORT", "default": "8080", "required": True})
api_key: str = field(default="", metadata={"env": "API_KEY", "required": True, "secret": True})
tags: list = field(default_factory=list, metadata={"env": "TAGS", "default": "prod,stable"})
cfg = config_from_env(AppConfig)
print(cfg.port) # 8080 o el valor de PORT
```
## Notas
Solo stdlib (os, dataclasses). Conversion de tipos: str sin cambio, int/float con parseo, bool desde "1"/"true"/"yes", list desde split por coma. El tag secret no tiene efecto en runtime. La funcion inspecciona todos los fields via dataclasses.fields() y solo procesa los que tienen metadata["env"].
+106
View File
@@ -0,0 +1,106 @@
"""config_from_env — popula dataclasses desde variables de entorno usando field metadata."""
import os
from dataclasses import fields as dc_fields
def config_from_env(target_class: type) -> object:
"""Crea una instancia de un dataclass poblada desde variables de entorno.
Cada campo del dataclass puede tener metadata que guia el mapeo:
- ``env``: nombre de la variable de entorno (obligatorio para usar este sistema)
- ``required``: bool, si True y la var no esta seteada lanza ValueError
- ``default``: valor string usado cuando la var no esta seteada (opcional)
- ``secret``: bool, documentacion solamente, sin efecto en runtime
La conversion de tipo es automatica segun el tipo anotado del campo:
- ``str``: sin conversion
- ``int``: int(value)
- ``float``: float(value)
- ``bool``: True para "1", "true", "yes" (case-insensitive); False para el resto
- ``list[str]`` o ``list``: split por coma, stripping espacios
Args:
target_class: clase dataclass con campo metadata en sus fields.
Returns:
instancia de target_class con todos los campos poblados.
Raises:
ValueError: si un campo requerido no esta seteado, o si la conversion de tipo falla.
TypeError: si target_class no es un dataclass.
"""
try:
all_fields = dc_fields(target_class) # raises TypeError if not dataclass
except TypeError:
raise TypeError(f"config_from_env: {target_class.__name__} is not a dataclass")
kwargs: dict = {}
errors: list[str] = []
for f in all_fields:
meta = f.metadata
env_key: str | None = meta.get("env")
if env_key is None:
# no env tag → leave at dataclass default (will be provided via default_factory)
continue
required: bool = meta.get("required", False)
default_str: str | None = meta.get("default")
raw = os.environ.get(env_key, "")
if not raw:
if default_str is not None:
raw = default_str
elif required:
errors.append(f"field '{f.name}': env var '{env_key}' is required but not set")
continue
else:
continue # optional, unset — use dataclass default
# type conversion
try:
kwargs[f.name] = _coerce(raw, f.type, f.name)
except (ValueError, TypeError) as exc:
errors.append(str(exc))
if errors:
raise ValueError("config_from_env: " + "; ".join(errors))
return target_class(**kwargs)
def _coerce(raw: str, annotation: type | str, field_name: str) -> object:
"""Coerce raw string to the annotated type."""
# handle string annotations (from __future__ import annotations or forward refs)
if isinstance(annotation, str):
ann_str = annotation.lower().strip()
else:
# Get the type name for comparison
ann_str = getattr(annotation, "__name__", str(annotation)).lower()
if ann_str in ("str", "string"):
return raw
if ann_str == "int":
try:
return int(raw)
except ValueError:
raise ValueError(f"field '{field_name}': cannot convert {raw!r} to int")
if ann_str == "float":
try:
return float(raw)
except ValueError:
raise ValueError(f"field '{field_name}': cannot convert {raw!r} to float")
if ann_str == "bool":
return raw.strip().lower() in ("1", "true", "yes")
# list / list[str]
if "list" in ann_str:
parts = [p.strip() for p in raw.split(",") if p.strip()]
return parts
# fallback: return as string
return raw
@@ -0,0 +1,92 @@
"""Tests para config_from_env."""
import os
from dataclasses import dataclass, field
import pytest
from config_from_env import config_from_env
@dataclass
class SimpleConfig:
host: str = field(default="localhost", metadata={"env": "CFE_PY_HOST", "default": "localhost"})
port: int = field(default=8080, metadata={"env": "CFE_PY_PORT", "default": "8080"})
debug: bool = field(default=False, metadata={"env": "CFE_PY_DEBUG", "default": "false"})
ratio: float = field(default=1.0, metadata={"env": "CFE_PY_RATIO", "default": "1.0"})
tags: list = field(default_factory=list, metadata={"env": "CFE_PY_TAGS", "default": "a,b"})
@dataclass
class RequiredConfig:
api_key: str = field(default="", metadata={"env": "CFE_PY_API_KEY", "required": True})
def _clean(*keys: str) -> None:
for k in keys:
os.environ.pop(k, None)
def test_populate_string_desde_env():
os.environ["CFE_PY_HOST"] = "myhost"
try:
cfg = config_from_env(SimpleConfig)
assert cfg.host == "myhost"
finally:
_clean("CFE_PY_HOST")
def test_populate_int_con_conversion():
os.environ["CFE_PY_PORT"] = "9090"
try:
cfg = config_from_env(SimpleConfig)
assert cfg.port == 9090
finally:
_clean("CFE_PY_PORT")
def test_populate_bool_true():
os.environ["CFE_PY_DEBUG"] = "true"
try:
cfg = config_from_env(SimpleConfig)
assert cfg.debug is True
finally:
_clean("CFE_PY_DEBUG")
def test_populate_float_field():
os.environ["CFE_PY_RATIO"] = "3.14"
try:
cfg = config_from_env(SimpleConfig)
assert abs(cfg.ratio - 3.14) < 1e-9
finally:
_clean("CFE_PY_RATIO")
def test_populate_list_comma_separated():
os.environ["CFE_PY_TAGS"] = "x,y,z"
try:
cfg = config_from_env(SimpleConfig)
assert cfg.tags == ["x", "y", "z"]
finally:
_clean("CFE_PY_TAGS")
def test_default_usado_cuando_env_no_seteada():
_clean("CFE_PY_PORT")
cfg = config_from_env(SimpleConfig)
assert cfg.port == 8080
def test_required_sin_valor_lanza_error():
_clean("CFE_PY_API_KEY")
with pytest.raises(ValueError, match="required"):
config_from_env(RequiredConfig)
def test_no_dataclass_lanza_type_error():
class NotADataclass:
pass
with pytest.raises(TypeError):
config_from_env(NotADataclass)
+46
View File
@@ -0,0 +1,46 @@
---
name: dotenv_load
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "dotenv_load(path: str) -> None"
description: "Parsea un archivo .env (KEY=VALUE) e inyecta pares en os.environ. Ignora comentarios # y lineas vacias. No sobreescribe variables ya presentes. Soporta valores con comillas simples o dobles."
tags: [dotenv, env, config, os, infra, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [os]
params:
- name: path
desc: "ruta al archivo .env a parsear (ej: '.env', 'config/.env.production')"
output: "None — efecto lateral: variables seteadas en os.environ"
tested: true
tests:
- "parsea key value y setea variable"
- "ignora comentarios y lineas vacias"
- "no sobreescribe variables existentes"
- "valores con comillas dobles se stripean"
- "valores con comillas simples se stripean"
- "archivo inexistente lanza error"
- "linea sin igual lanza value error"
test_file_path: "python/functions/infra/dotenv_load_test.py"
file_path: "python/functions/infra/dotenv_load.py"
---
## Ejemplo
```python
from dotenv_load import dotenv_load
import os
dotenv_load(".env")
db_url = os.environ["DATABASE_URL"]
```
## Notas
Solo stdlib (os). Semantica de no-sobreescritura: variables inyectadas por Docker/CI/CD tienen precedencia sobre el .env local. Separador es el primer '=' encontrado en la linea (permite valores con '='). Formato: KEY=VALUE, KEY="val", KEY='val'.
+53
View File
@@ -0,0 +1,53 @@
"""dotenv_load — parsea un archivo .env y popula os.environ sin sobreescribir."""
import os
def dotenv_load(path: str) -> None:
"""Parsea un archivo .env y setea variables en os.environ.
Reglas de parseo:
- Lineas que empiezan con # (tras strip) son comentarios y se ignoran.
- Lineas vacias se ignoran.
- Formato esperado: KEY=VALUE (separador es el primer =).
- Valores con comillas simples o dobles externas se stripean.
- Variables ya presentes en os.environ NO se sobreescriben.
Args:
path: ruta al archivo .env.
Returns:
None
Raises:
FileNotFoundError: si el archivo no existe.
ValueError: si una linea no tiene el separador '='.
"""
with open(path, encoding="utf-8") as fh:
for lineno, raw_line in enumerate(fh, start=1):
line = raw_line.strip()
# skip blanks and comments
if not line or line.startswith("#"):
continue
if "=" not in line:
raise ValueError(
f"dotenv_load: {path!r} line {lineno}: missing '=' in {line!r}"
)
key, _, val = line.partition("=")
key = key.strip()
val = _strip_quotes(val)
# only set if not already present
if key not in os.environ or os.environ[key] == "":
os.environ[key] = val
def _strip_quotes(s: str) -> str:
"""Remove surrounding single or double quotes."""
if len(s) >= 2:
if (s[0] == '"' and s[-1] == '"') or (s[0] == "'" and s[-1] == "'"):
return s[1:-1]
return s
@@ -0,0 +1,91 @@
"""Tests para dotenv_load."""
import os
import tempfile
import pytest
from dotenv_load import dotenv_load
def write_env(content: str) -> str:
"""Write content to a temp file and return its path."""
fd, path = tempfile.mkstemp(suffix=".env")
os.write(fd, content.encode())
os.close(fd)
return path
def _clean(*keys: str) -> None:
for k in keys:
os.environ.pop(k, None)
def test_parsea_key_value_y_setea_variable():
path = write_env("DL_PY_FOO=hello\n")
_clean("DL_PY_FOO")
try:
dotenv_load(path)
assert os.environ.get("DL_PY_FOO") == "hello"
finally:
_clean("DL_PY_FOO")
os.unlink(path)
def test_ignora_comentarios_y_lineas_vacias():
content = "# comment\n\nDL_PY_BAR=world\n"
path = write_env(content)
_clean("DL_PY_BAR")
try:
dotenv_load(path)
assert os.environ.get("DL_PY_BAR") == "world"
finally:
_clean("DL_PY_BAR")
os.unlink(path)
def test_no_sobreescribe_variables_existentes():
path = write_env("DL_PY_EXIST=new\n")
os.environ["DL_PY_EXIST"] = "original"
try:
dotenv_load(path)
assert os.environ["DL_PY_EXIST"] == "original"
finally:
_clean("DL_PY_EXIST")
os.unlink(path)
def test_valores_con_comillas_dobles_se_stripean():
path = write_env('DL_PY_QUOTED="quoted value"\n')
_clean("DL_PY_QUOTED")
try:
dotenv_load(path)
assert os.environ.get("DL_PY_QUOTED") == "quoted value"
finally:
_clean("DL_PY_QUOTED")
os.unlink(path)
def test_valores_con_comillas_simples_se_stripean():
path = write_env("DL_PY_SINGLE='single quoted'\n")
_clean("DL_PY_SINGLE")
try:
dotenv_load(path)
assert os.environ.get("DL_PY_SINGLE") == "single quoted"
finally:
_clean("DL_PY_SINGLE")
os.unlink(path)
def test_archivo_inexistente_lanza_error():
with pytest.raises(FileNotFoundError):
dotenv_load("/nonexistent/.env.does.not.exist")
def test_linea_sin_igual_lanza_value_error():
path = write_env("INVALID_LINE\n")
try:
with pytest.raises(ValueError, match="missing '='"):
dotenv_load(path)
finally:
os.unlink(path)
+21
View File
@@ -0,0 +1,21 @@
---
name: config_error
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type ConfigError struct {
Field string
Message string
Tag string
}
description: "Error de validacion de un campo de configuracion. Field identifica el campo, Message describe el problema, Tag indica la regla que fallo (required, format, etc.)."
tags: [config, validation, error, infra]
uses_types: []
file_path: "functions/infra/config_error.go"
---
## Notas
Tipo producto — todos los campos siempre presentes. Se acumula en ConfigValidation.Errors. Tag toma valores como "required" o "format" para que el consumidor pueda filtrar por tipo de error.
+20
View File
@@ -0,0 +1,20 @@
---
name: config_validation
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type ConfigValidation struct {
Errors []ConfigError
IsValid bool
}
description: "Resultado de validar una struct de configuracion. Contiene todos los errores acumulados y un bool de conveniencia. IsValid es true solo cuando Errors esta vacio."
tags: [config, validation, result, infra]
uses_types: [config_error_go_infra]
file_path: "functions/infra/config_validation.go"
---
## Notas
Tipo producto. IsValid se computa derivadamente de len(Errors)==0. Permite al consumidor tanto iterar sobre todos los errores como hacer un check rapido con IsValid.