Skip to content

Commit

Permalink
Allow configurable decoding (#100)
Browse files Browse the repository at this point in the history
**:warning: Breaking!** Envconfig no longer runs decoders on unset values! To restore the old behavior, add the `decodeunset` struct field annotation or pass the `DefaultDecodeUnset` configuration option as `true`.

Prior to this change, envconfig would always call decoding functions, even for unset or empty values. This proved problematic for some libraries like `url` and `zap` which implement `TextUnmarshaller`, but error on the empty string (#61). #62 changed the behavior to only call the decoder if a value was explicitly provided, but that broke users unexpectedly (#64), so the change was reverted.

After much discussion, we decided to keep the existing behavior until the 1.0 release. However, after further reflection, I think we need to support a user-level configurable option. Some fields and structs may still want the decoder to run on empty values.

This PR changes envconfig to only process a field if any of the following are true:

- A value was provided (the value can be set to the empty string, there is a distinction between "unset" and "set to empty")
- A default value was provided
- The `decodeunset` struct field tag is set
- The `DefaultDecodeUnset` configuration option is true
  • Loading branch information
sethvargo committed Dec 27, 2023
1 parent 668ea4a commit ed1aec1
Show file tree
Hide file tree
Showing 2 changed files with 186 additions and 76 deletions.
70 changes: 42 additions & 28 deletions envconfig.go
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -339,6 +345,7 @@ func processWith(ctx context.Context, c *Config) error {
}

structOverwrite := c.DefaultOverwrite
structDecodeUnset := c.DefaultDecodeUnset
structRequired := c.DefaultRequired

mutators := c.Mutators
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:], ","), " ")
Expand Down

0 comments on commit ed1aec1

Please sign in to comment.