diff --git a/envconfig.go b/envconfig.go index c9dfbfe..fce6968 100644 --- a/envconfig.go +++ b/envconfig.go @@ -82,13 +82,14 @@ import ( const ( envTag = "env" - optDefault = "default=" - optDelimiter = "delimiter=" - optNoInit = "noinit" - optOverwrite = "overwrite" - optPrefix = "prefix=" - optRequired = "required" - optSeparator = "separator=" + optDecodeUnset = "decodeunset" + optDefault = "default=" + optDelimiter = "delimiter=" + optNoInit = "noinit" + optOverwrite = "overwrite" + optPrefix = "prefix=" + optRequired = "required" + optSeparator = "separator=" ) // Error is a custom error type for errors returned by envconfig. @@ -231,13 +232,14 @@ type Decoder interface { // options are internal options for decoding. type options struct { - Default string - Delimiter string - Prefix string - Separator string - NoInit bool - Overwrite bool - Required bool + Default string + Delimiter string + Prefix string + Separator string + NoInit bool + Overwrite bool + DecodeUnset bool + Required bool } // Config represent inputs to the envconfig decoding. @@ -268,6 +270,10 @@ type Config struct { // on the struct before processing. The default value is false. DefaultOverwrite bool + // DefaultDecodeUnset is the default value for running decoders even when no + // value was given for the environment variable. + DefaultDecodeUnset bool + // DefaultRequired is the default value for marking a field as required. The // default value is false. DefaultRequired bool @@ -339,6 +345,7 @@ func processWith(ctx context.Context, c *Config) error { } structOverwrite := c.DefaultOverwrite + structDecodeUnset := c.DefaultDecodeUnset structRequired := c.DefaultRequired mutators := c.Mutators @@ -386,6 +393,7 @@ func processWith(ctx context.Context, c *Config) error { noInit := structNoInit || opts.NoInit overwrite := structOverwrite || opts.Overwrite + decodeUnset := structDecodeUnset || opts.DecodeUnset required := structRequired || opts.Required isNilStructPtr := false @@ -432,18 +440,20 @@ func processWith(ctx context.Context, c *Config) error { // Lookup the value, ignoring an error if the key isn't defined. This is // required for nested structs that don't declare their own `env` keys, // but have internal fields with an `env` defined. - val, _, _, err := lookup(key, required, opts.Default, l) + val, found, usedDefault, err := lookup(key, required, opts.Default, l) if err != nil && !errors.Is(err, ErrMissingKey) { return fmt.Errorf("%s: %w", tf.Name, err) } - if ok, err := processAsDecoder(val, ef); ok { - if err != nil { - return err - } + if found || usedDefault || decodeUnset { + if ok, err := processAsDecoder(val, ef); ok { + if err != nil { + return err + } - setNilStruct(ef) - continue + setNilStruct(ef) + continue + } } plu := l @@ -558,20 +568,24 @@ func keyAndOpts(tag string) (string, *options, error) { LOOP: for i, o := range tagOpts { o = strings.TrimLeftFunc(o, unicode.IsSpace) + search := strings.ToLower(o) + switch { - case o == optOverwrite: + case search == optDecodeUnset: + opts.DecodeUnset = true + case search == optOverwrite: opts.Overwrite = true - case o == optRequired: + case search == optRequired: opts.Required = true - case o == optNoInit: + case search == optNoInit: opts.NoInit = true - case strings.HasPrefix(o, optPrefix): + case strings.HasPrefix(search, optPrefix): opts.Prefix = strings.TrimPrefix(o, optPrefix) - case strings.HasPrefix(o, optDelimiter): + case strings.HasPrefix(search, optDelimiter): opts.Delimiter = strings.TrimPrefix(o, optDelimiter) - case strings.HasPrefix(o, optSeparator): + case strings.HasPrefix(search, optSeparator): opts.Separator = strings.TrimPrefix(o, optSeparator) - case strings.HasPrefix(o, optDefault): + case strings.HasPrefix(search, optDefault): // If a default value was given, assume everything after is the provided // value, including comma-seprated items. o = strings.TrimLeft(strings.Join(tagOpts[i:], ","), " ") diff --git a/envconfig_test.go b/envconfig_test.go index 22c92fe..b43f3db 100644 --- a/envconfig_test.go +++ b/envconfig_test.go @@ -47,21 +47,21 @@ func (c *CustomDecoderType) EnvDecode(val string) error { type Level int8 const ( - DebugLevel Level = 0 - InfoLevel Level = 5 - ErrorLevel Level = 100 + LevelDebug Level = 0 + LevelInfo Level = 5 + LevelError Level = 100 ) func (l *Level) UnmarshalText(text []byte) error { switch string(text) { case "debug": - *l = DebugLevel + *l = LevelDebug return nil case "info", "": // default - *l = InfoLevel + *l = LevelInfo return nil case "error": - *l = ErrorLevel + *l = LevelError return nil default: return fmt.Errorf("unknown level %s", string(text)) @@ -233,18 +233,19 @@ func TestProcessWith(t *testing.T) { t.Parallel() cases := []struct { - name string - target any - lookuper Lookuper - defDelimiter string - defSeparator string - defNoInit bool - defOverwrite bool - defRequired bool - mutators []Mutator - exp any - err error - errMsg string + name string + target any + lookuper Lookuper + defDelimiter string + defSeparator string + defNoInit bool + defOverwrite bool + defDecodeUnset bool + defRequired bool + mutators []Mutator + exp any + err error + errMsg string }{ // nil pointer { @@ -1022,6 +1023,44 @@ func TestProcessWith(t *testing.T) { lookuper: MapLookuper(nil), }, + // Decode Unset + { + name: "decodeunset/present", + target: &struct { + Field Level `env:"FIELD,decodeunset"` + }{}, + exp: &struct { + Field Level `env:"FIELD,decodeunset"` + }{ + Field: LevelInfo, + }, + lookuper: MapLookuper(nil), + }, + { + name: "decodeunset/present_space", + target: &struct { + Field Level `env:"FIELD, decodeunset"` + }{}, + exp: &struct { + Field Level `env:"FIELD, decodeunset"` + }{ + Field: LevelInfo, + }, + lookuper: MapLookuper(nil), + }, + { + name: "decodeunset/present_camelcase", + target: &struct { + Field Level `env:"FIELD, decodeUnset"` + }{}, + exp: &struct { + Field Level `env:"FIELD, decodeUnset"` + }{ + Field: LevelInfo, + }, + lookuper: MapLookuper(nil), + }, + // Required { name: "required/present", @@ -1484,6 +1523,34 @@ func TestProcessWith(t *testing.T) { }), errMsg: "broken", }, + { + name: "custom_decoder/called_when_default", + target: &struct { + Field *CustomDecoderType `env:"FIELD, default=foo"` + }{}, + exp: &struct { + Field *CustomDecoderType `env:"FIELD, default=foo"` + }{ + Field: &CustomDecoderType{ + value: "CUSTOM-foo", + }, + }, + lookuper: MapLookuper(nil), + }, + { + name: "custom_decoder/called_on_decodeunset", + target: &struct { + Field *CustomDecoderType `env:"FIELD, decodeunset"` + }{}, + exp: &struct { + Field *CustomDecoderType `env:"FIELD, decodeunset"` + }{ + Field: &CustomDecoderType{ + value: "CUSTOM-", + }, + }, + lookuper: MapLookuper(nil), + }, // Expand { @@ -2477,6 +2544,26 @@ func TestProcessWith(t *testing.T) { "FIELD": "zip,zap", }), }, + { + name: "inherited/decodeunset", + target: &struct { + Sub *struct { + Level Level `env:"FIELD, decodeunset"` + } + }{}, + exp: &struct { + Sub *struct { + Level Level `env:"FIELD, decodeunset"` + } + }{ + Sub: &struct { + Level Level `env:"FIELD, decodeunset"` + }{ + Level: LevelInfo, + }, + }, + lookuper: MapLookuper(nil), + }, { name: "inherited/required", target: &struct { @@ -2573,6 +2660,27 @@ func TestProcessWith(t *testing.T) { "FIELD": "zip,zap", }), }, + { + name: "global/decodeunset", + target: &struct { + Sub *struct { + Level Level `env:"LEVEL"` + } + }{}, + exp: &struct { + Sub *struct { + Level Level `env:"LEVEL"` + } + }{ + Sub: &struct { + Level Level `env:"LEVEL"` + }{ + Level: LevelInfo, + }, + }, + defDecodeUnset: true, + lookuper: MapLookuper(nil), + }, { name: "global/required", target: &struct { @@ -2643,7 +2751,7 @@ func TestProcessWith(t *testing.T) { exp: &struct { Level Level `env:"LEVEL,overwrite,default=error"` }{ - Level: ErrorLevel, + Level: LevelError, }, lookuper: MapLookuper(nil), }, @@ -2656,7 +2764,7 @@ func TestProcessWith(t *testing.T) { exp: &struct { Level Level `env:"LEVEL,overwrite,default=error"` }{ - Level: DebugLevel, + Level: LevelDebug, }, lookuper: MapLookuper(map[string]string{ "LEVEL": "debug", @@ -2668,12 +2776,12 @@ func TestProcessWith(t *testing.T) { target: &struct { Level Level `env:"LEVEL,overwrite,default=error"` }{ - Level: InfoLevel, + Level: LevelInfo, }, exp: &struct { Level Level `env:"LEVEL,overwrite,default=error"` }{ - Level: InfoLevel, + Level: LevelInfo, }, lookuper: MapLookuper(nil), }, @@ -2683,12 +2791,12 @@ func TestProcessWith(t *testing.T) { target: &struct { Level Level `env:"LEVEL,overwrite,default=error"` }{ - Level: InfoLevel, + Level: LevelInfo, }, exp: &struct { Level Level `env:"LEVEL,overwrite,default=error"` }{ - Level: DebugLevel, + Level: LevelDebug, }, lookuper: MapLookuper(map[string]string{ "LEVEL": "debug", @@ -2698,25 +2806,12 @@ func TestProcessWith(t *testing.T) { // https://github.com/sethvargo/go-envconfig/issues/64 name: "custom_decoder_uses_decoder_no_env", target: &struct { - URL *url.URL + URL *url.URL `env:",noinit"` }{}, exp: &struct { - URL *url.URL - }{ - URL: &url.URL{}, - }, - lookuper: MapLookuper(nil), - }, - { - // https://github.com/sethvargo/go-envconfig/issues/64 - name: "custom_decoder_uses_decoder_env_no_value", - target: &struct { - URL *url.URL `env:"URL"` - }{}, - exp: &struct { - URL *url.URL `env:"URL"` + URL *url.URL `env:",noinit"` }{ - URL: &url.URL{}, + URL: nil, }, lookuper: MapLookuper(nil), }, @@ -2778,14 +2873,15 @@ func TestProcessWith(t *testing.T) { ctx := context.Background() if err := ProcessWith(ctx, &Config{ - Target: tc.target, - Lookuper: tc.lookuper, - DefaultDelimiter: tc.defDelimiter, - DefaultSeparator: tc.defSeparator, - DefaultNoInit: tc.defNoInit, - DefaultOverwrite: tc.defOverwrite, - DefaultRequired: tc.defRequired, - Mutators: tc.mutators, + Target: tc.target, + Lookuper: tc.lookuper, + DefaultDelimiter: tc.defDelimiter, + DefaultSeparator: tc.defSeparator, + DefaultNoInit: tc.defNoInit, + DefaultOverwrite: tc.defOverwrite, + DefaultDecodeUnset: tc.defDecodeUnset, + DefaultRequired: tc.defRequired, + Mutators: tc.mutators, }); err != nil { if tc.err == nil && tc.errMsg == "" { t.Fatal(err)