Skip to content

Commit 3fe2926

Browse files
committed
feat: add EnvCollector for dynamic environment variables
Add EnvCollector interface for handling env vars that can't be expressed via struct tags (e.g., USER_1, PASS_1, USER_2, PASS_2). Types implementing EnvCollector receive an EnvGetter with Lookup, ReadValue, and Read methods. Breaking changes: - Custom unmarshalers (TextUnmarshaler, etc.) now require `env` tag, not `envPrefix` - Removed whole-struct decoding via envPrefix (was buggy, returned nil-wrapped errors) Other changes: - Add pointer validation in EnvGetter.ReadValue - Fix README: OS env takes precedence over .env file, not vice versa - Update README with EnvCollector docs and improved custom lookup example - Remove stale whole-struct decoding documentation
1 parent b54c3a5 commit 3fe2926

3 files changed

Lines changed: 298 additions & 76 deletions

File tree

README.md

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ type HTTPServer struct {
4444

4545
func (cfg HTTPServer) Validate() error {
4646
return envconfig.Assert(
47-
envconfig.OneOf(cfg.Env, "ENVIRONMENT", "production", "prod"),
47+
envconfig.OneOf(cfg.Env, "ENV", "production", "prod"),
4848
envconfig.Not(
4949
envconfig.Range(cfg.Port, 0, 1023, "PORT"),
5050
"PORT: must not be a reserved port (0-1023)",
@@ -94,7 +94,7 @@ type App struct {
9494

9595
func main() {
9696
var cfg App
97-
97+
9898
if err := envconfig.Read(&cfg, envconfig.EnvFileLookup(".env")); err != nil {
9999
panic(err)
100100
}
@@ -104,22 +104,75 @@ func main() {
104104
```
105105

106106
Notes:
107-
- If both the .env file and the OS define a key, the .env value wins for that lookup.
107+
108+
- If both the .env file and the OS define a key, the OS environment value wins.
108109
- EnvFileLookup panics if the file cannot be read.
109110

110111
## Tags
111112

112113
Add struct field tags to control how values are loaded:
113-
- `env`: the env variable name. Use env:"-" to skip a field.
114+
115+
- `env`: the env variable name. Use `env:"-"` to skip a field.
114116
- `envDefault`: fallback value if the variable is not set.
115117
- `envRequired:"true"`: marks the field as required, returns error when not set, and no default provided.
116-
- `envPrefix`: only for struct-typed fields; prepends a prefix (with underscore) for all nested fields under that struct.
118+
- `envPrefix`: for struct-typed fields; prepends a prefix (with underscore) for all nested fields under that struct.
117119

118120
Precedence per field:
121+
119122
1. Value from lookupEnv(name)
120123
2. envDefault (if present)
121124
3. Error if `envRequired:"true"`
122125

126+
127+
## Dynamic Environment Variables
128+
129+
For environment variables that can't be expressed via struct tags, like numbered sequences (USER_1, PASS_1, USER_2, PASS_2) – implement the EnvCollector interface:
130+
131+
```go
132+
package main
133+
134+
import (
135+
"github.com/struct0x/envconfig"
136+
)
137+
138+
type Config struct {
139+
Credentials Credentials `envPrefix:"CREDS"`
140+
}
141+
142+
type Credentials []Credential
143+
144+
type Credential struct {
145+
User string `env:"USER"`
146+
Pass string `env:"PASS"`
147+
}
148+
149+
func (c *Credentials) CollectEnv(prefix string, env envconfig.EnvGetter) error {
150+
// Read IDs from CREDS=0,1,2
151+
var ids []string
152+
if err := env.ReadValue(prefix, &ids); err != nil {
153+
return err
154+
}
155+
156+
for _, id := range ids {
157+
var cred Credential
158+
// Reads CREDS_0_USER, CREDS_0_PASS, etc.
159+
if err := env.Read(prefix+"_"+id, &cred); err != nil {
160+
return err
161+
}
162+
*c = append(*c, cred)
163+
}
164+
return nil
165+
}
166+
167+
```
168+
169+
Fields implementing EnvCollector must use envPrefix (not env).
170+
The EnvGetter provides three methods:
171+
- Lookup for raw access,
172+
- ReadValue for parsing single values, and
173+
- Read for populating nested structs with full tag support.
174+
175+
123176
### Examples
124177

125178
Basic tags:
@@ -177,42 +230,66 @@ type T struct {
177230

178231
If a value cannot be parsed into the target type, `Read` returns a descriptive error.
179232

180-
## Custom lookup (probably don't need this)
233+
## Custom lookup (For Secret Managers, Vaults, etc.)
181234

182-
You can provide any lookup function with signature `func(string) (string, bool)`
183-
for example, a map-based lookup in tests:
235+
By default, Read uses os.LookupEnv, for more advanced use cases like reading values from secret managers like AWS Secret Manager, HashiCorp Vault you can provide a custom lookup function:
184236

185237
```go
186238
package main
187239

188240
import (
189-
"github.com/struct0x/envconfig"
241+
"context"
242+
"os"
243+
"log/slog"
244+
245+
"github.com/struct0x/envconfig"
190246
)
191247

192-
func mapLookup(m map[string]string) func(string) (string, bool) {
193-
return func(k string) (string, bool) { v, ok := m[k]; return v, ok }
248+
type SecretResolver struct {
249+
startingCtx context.Context
250+
sm SecretManager
251+
}
252+
253+
func (s *SecretResolver) Lookup(key string) (string, bool) {
254+
val, ok := os.LookupEnv(key)
255+
if s.isSecret(val) {
256+
val, err := s.sm.ResolveSecret(s.startingCtx, val)
257+
if err != nil {
258+
slog.Error("missing value", "err", err)
259+
return "", false
260+
}
261+
return val, true
262+
}
263+
264+
// fallback to standard lookup
265+
return val, ok
194266
}
195267

196268
type C struct {
197-
N int `env:"N"`
269+
N int `env:"N"`
198270
}
199271

200272
func main() {
201-
var c C
202-
_ = envconfig.Read(&c, mapLookup(map[string]string{"N": "42"}))
273+
sm := &SecretResolver{ /*...*/ }
274+
275+
var c C
276+
_ = envconfig.Read(&c, sm.Lookup)
203277
}
278+
204279
```
205280

281+
Error handling is your responsibility, use `envRequired` to ensure values are present regardless of lookup failures.
282+
206283
## Error handling
207284

208285
`Read` returns an error when:
286+
209287
- The holder is not a non-nil pointer to a struct
210288
- A required field is missing and no default is provided
211289
- A value cannot be parsed into the target type
212290

213291
Errors include the env variable name and context to aid debugging.
214292

215-
216293
## License
217294

218295
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

envconfig.go

Lines changed: 72 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"time"
1212
)
1313

14+
type LookupEnv = func(string) (string, bool)
15+
1416
// Read populates holder (a pointer to struct) using the provided lookup function to resolve values.
1517
//
1618
// Usage:
@@ -52,18 +54,6 @@ import (
5254
// - Named struct fields may also carry `envPrefix:"PFX"`; they must NOT
5355
// also have an `env` tag.
5456
//
55-
// Whole-struct (single-key) decoding:
56-
//
57-
// If a struct-typed field (or embedded struct) has an effective prefix PFX_,
58-
// and the holder type implements one of the standard decoders below, a single
59-
// env variable named "PFX" (without the trailing underscore) can be used to
60-
// populate the entire struct at once. When present, this whole-struct value
61-
// takes precedence and field-by-field decoding is skipped.
62-
// Supported decoders:
63-
// - encoding.TextUnmarshaler
64-
// - encoding.BinaryUnmarshaler
65-
// - json.Unmarshaler
66-
//
6757
// Supported field types:
6858
// - primitives: string, bool, all int/uint sizes, float32/64
6959
// - time.Duration (parsed via time.ParseDuration)
@@ -96,7 +86,7 @@ import (
9686
// considered "set": it suppresses `envDefault` and does not trigger
9787
// `envRequired`. If you want defaulting on empty strings, use IgnoreEmptyEnvLookup,
9888
// which wraps os.LookupEnv and treats empty values as unset (returns ok == false when value == "").
99-
func Read[T any](holder *T, lookupEnv ...func(string) (string, bool)) error {
89+
func Read[T any](holder *T, lookupEnv ...LookupEnv) error {
10090
lookupEnvFunc := os.LookupEnv
10191
if len(lookupEnv) >= 1 {
10292
lookupEnvFunc = lookupEnv[0]
@@ -119,13 +109,60 @@ type Validator interface {
119109
Validate() error
120110
}
121111

122-
func read(le func(string) (string, bool), prefix string, holder any) error {
123-
if len(prefix) > 0 {
124-
if err, ok := tryUnmarshalKnownInterface(le, prefix, holder); ok {
125-
return fmt.Errorf("envconfig: %q prefix failed to populate: %w", prefix, err)
126-
}
112+
// EnvGetter provides a convenient way to get values from env variables.
113+
// It is passed to EnvCollector.CollectEnv to allow a custom env collection.
114+
// Under the hood it uses the provided Lookup in Read function.
115+
type EnvGetter interface {
116+
// Lookup performs a raw lookup for an environment variable.
117+
Lookup(key string) (string, bool)
118+
119+
// ReadValue parses a single environment variable into target.
120+
// Target must be a pointer.
121+
ReadValue(key string, target any) error
122+
123+
// Read populates target struct with the given prefix.
124+
Read(prefix string, target any) error
125+
}
126+
127+
type getter struct {
128+
lookup LookupEnv
129+
}
130+
131+
func (g *getter) Lookup(key string) (string, bool) {
132+
return g.lookup(key)
133+
}
134+
135+
func (g *getter) ReadValue(key string, target any) error {
136+
v := reflect.ValueOf(target)
137+
if v.Kind() != reflect.Ptr {
138+
return fmt.Errorf("%q not a pointer", v.Type())
139+
}
140+
141+
val, ok := g.lookup(key)
142+
if !ok {
143+
return nil
127144
}
128145

146+
return setValue(v, val)
147+
}
148+
149+
func (g *getter) Read(prefix string, target any) error {
150+
return read(g.lookup, prefix+"_", target)
151+
}
152+
153+
// EnvCollector is an advanced interface for collecting custom environment variables
154+
// that can't be easily expressed via struct tags.
155+
// For example, a custom collector can handle environment variables with complex
156+
// naming conventions like USER_1, PASS_1, USER_2, PASS_2.
157+
//
158+
// Fields implementing EnvCollector MUST have the `envPrefix` tag (not `env`).
159+
// See TestEnvCollector for a concrete example.
160+
type EnvCollector interface {
161+
// CollectEnv is called with the computed prefix and an EnvGetter for reading env values.
162+
CollectEnv(prefix string, env EnvGetter) error
163+
}
164+
165+
func read(le func(string) (string, bool), prefix string, holder any) error {
129166
holderPtr := reflect.ValueOf(holder)
130167
holderValue := holderPtr.Elem()
131168
fields := reflect.VisibleFields(holderValue.Type())
@@ -155,6 +192,23 @@ func read(le func(string) (string, bool), prefix string, holder any) error {
155192
fieldVal = fieldVal.Elem()
156193
}
157194

195+
if field.PkgPath == "" && fieldVal.CanAddr() {
196+
if collector, ok := fieldVal.Addr().Interface().(EnvCollector); ok {
197+
if hasEnv {
198+
return fmt.Errorf("envconfig: %q implements EnvCollector, use \"envPrefix\" instead of env", field.Name)
199+
}
200+
if pref == "" {
201+
return fmt.Errorf("envconfig: %q implements EnvCollector with empty \"envPrefix\"", field.Name)
202+
}
203+
204+
get := &getter{lookup: le}
205+
if err := collector.CollectEnv(prefix+pref, get); err != nil {
206+
return fmt.Errorf("envconfig: %q CollectEnv failed: %w", field.Name, err)
207+
}
208+
continue
209+
}
210+
}
211+
158212
if field.Anonymous {
159213
if hasEnv {
160214
return fmt.Errorf("envconfig: %q is embedded use \"envPrefix\" to add prefix or remove \"env\" to treat struct flat", field.Name)
@@ -220,40 +274,6 @@ func read(le func(string) (string, bool), prefix string, holder any) error {
220274
return nil
221275
}
222276

223-
func tryUnmarshalKnownInterface(le func(string) (string, bool), prefix string, holder any) (error, bool) {
224-
if i, ok := holder.(encoding.TextUnmarshaler); ok {
225-
envValue, ok := le(prefix[:len(prefix)-1])
226-
if !ok {
227-
return nil, true
228-
}
229-
230-
if err := i.UnmarshalText([]byte(envValue)); err != nil {
231-
return err, true
232-
}
233-
}
234-
if i, ok := holder.(encoding.BinaryUnmarshaler); ok {
235-
envValue, ok := le(prefix[:len(prefix)-1])
236-
if !ok {
237-
return nil, true
238-
}
239-
240-
if err := i.UnmarshalBinary([]byte(envValue)); err != nil {
241-
return err, true
242-
}
243-
}
244-
if i, ok := holder.(json.Unmarshaler); ok {
245-
envValue, ok := le(prefix[:len(prefix)-1])
246-
if !ok {
247-
return nil, true
248-
}
249-
250-
if err := i.UnmarshalJSON([]byte(envValue)); err != nil {
251-
return err, true
252-
}
253-
}
254-
return nil, false
255-
}
256-
257277
var durationType = reflect.TypeOf(time.Duration(0))
258278

259279
func setValue(inp reflect.Value, value string) error {

0 commit comments

Comments
 (0)