Skip to content

Commit

Permalink
Update config loading to automatically call PostLoad hooks (#17)
Browse files Browse the repository at this point in the history
* Update config loading to implicitly call PostLoad on supported configs, and update to a more modern error handling pattern.
* Update testify version to support ErrorAs tests
  • Loading branch information
aphistic committed Apr 29, 2023
1 parent 9eec7fd commit fad0778
Show file tree
Hide file tree
Showing 8 changed files with 236 additions and 31 deletions.
17 changes: 10 additions & 7 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,25 +67,28 @@ func (c *Config) Load(target interface{}, modifiers ...TagModifier) error {
for i := 0; i < len(sourceFields); i++ {
targetFields[i].Field.Set(sourceFields[i].Field)
}
}

if err := loadError(errors); err != nil {
} else {
c.dumpSource()
return err
return newLoadError(errors)
}

chunk, err := dumpChunk(target)
if err != nil {
return fmt.Errorf("failed to serialize config (%s)", err.Error())
return fmt.Errorf("failed to serialize config: %w", err)
}

if err := c.postLoad(target); err != nil {
return newPostLoadError(err)
}

c.logger.Printf("Config loaded: %s", normalizeChunk(chunk))

return nil
}

// Call the PostLoad method of the given target if it conforms to
// the PostLoadConfig interface.
func (c *Config) PostLoad(target interface{}) error {
func (c *Config) postLoad(target interface{}) error {
if plc, ok := target.(PostLoadConfig); ok {
return plc.PostLoad()
}
Expand Down Expand Up @@ -288,5 +291,5 @@ func loadError(errors []error) error {
messages = append(messages, err.Error())
}

return fmt.Errorf("failed to load config (%s)", strings.Join(messages, ", "))
return fmt.Errorf("failed to load config: (%s)", strings.Join(messages, ", "))
}
102 changes: 84 additions & 18 deletions config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ func TestConfigRequired(t *testing.T) {

config := NewConfig(NewFakeSourcer("app", nil))
chunk := &C{}
assert.EqualError(t, config.Load(chunk), "failed to load config (no value supplied for field 'X')")
assert.EqualError(t,
config.Load(chunk),
"failed to load config: no value supplied for field 'X'",
)
}

func TestConfigRequiredBadTag(t *testing.T) {
Expand All @@ -70,7 +73,10 @@ func TestConfigRequiredBadTag(t *testing.T) {

config := NewConfig(NewFakeSourcer("app", nil))
chunk := &C{}
assert.EqualError(t, config.Load(chunk), "failed to load config (field 'X' has an invalid required tag)")
assert.EqualError(t,
config.Load(chunk),
"failed to load config: field 'X' has an invalid required tag",
)
}

func TestConfigDefault(t *testing.T) {
Expand Down Expand Up @@ -101,10 +107,16 @@ func TestConfigBadType(t *testing.T) {
}))

chunk := &C{}
assert.EqualError(t, config.Load(chunk), fmt.Sprintf("failed to load config (%s)", strings.Join([]string{
"value supplied for field 'Y' cannot be coerced into the expected type",
"value supplied for field 'Z' cannot be coerced into the expected type",
}, ", ")))

var errLoad *LoadError
require.ErrorAs(t, config.Load(chunk), &errLoad)
assert.ElementsMatch(t,
errLoad.Errors,
[]error{
fmt.Errorf("value supplied for field 'Y' cannot be coerced into the expected type"),
fmt.Errorf("value supplied for field 'Z' cannot be coerced into the expected type"),
},
)
}

func TestConfigBadDefaultType(t *testing.T) {
Expand All @@ -114,7 +126,15 @@ func TestConfigBadDefaultType(t *testing.T) {

config := NewConfig(NewFakeSourcer("app", nil))
chunk := &C{}
assert.EqualError(t, config.Load(chunk), "failed to load config (default value for field 'X' cannot be coerced into the expected type)")

var errLoad *LoadError
require.ErrorAs(t, config.Load(chunk), &errLoad)
assert.ElementsMatch(t,
errLoad.Errors,
[]error{
fmt.Errorf("default value for field 'X' cannot be coerced into the expected type"),
},
)
}

func TestConfigPostLoadConfig(t *testing.T) {
Expand All @@ -124,14 +144,17 @@ func TestConfigPostLoadConfig(t *testing.T) {

chunk := &testPostLoadConfig{}
require.Nil(t, config.Load(chunk))
require.Nil(t, config.PostLoad(chunk))

config = NewConfig(NewFakeSourcer("app", map[string]string{
"APP_X": "-4",
}))

assert.Nil(t, config.Load(chunk))
assert.EqualError(t, config.PostLoad(chunk), "X must be positive")
var postErr *PostLoadError
require.ErrorAs(t, config.Load(chunk), &postErr)
assert.EqualError(t,
postErr.Unwrap(),
"X must be positive",
)
}

type testPostLoadConfig struct {
Expand All @@ -153,7 +176,15 @@ func TestConfigUnsettableFields(t *testing.T) {

config := NewConfig(NewFakeSourcer("app", nil))
chunk := &C{}
assert.EqualError(t, config.Load(chunk), "failed to load config (field 'x' can not be set)")

var errLoad *LoadError
require.ErrorAs(t, config.Load(chunk), &errLoad)
assert.ElementsMatch(t,
errLoad.Errors,
[]error{
fmt.Errorf("field 'x' can not be set"),
},
)
}

func TestConfigLoad(t *testing.T) {
Expand Down Expand Up @@ -205,7 +236,6 @@ func TestConfigLoadPostLoadWithConversion(t *testing.T) {

chunk := &testPostLoadConversion{}
require.Nil(t, config.Load(chunk))
require.Nil(t, config.PostLoad(chunk))
assert.Equal(t, time.Second*3, chunk.Duration)
}

Expand All @@ -216,7 +246,6 @@ func TestConfigLoadPostLoadWithTags(t *testing.T) {

chunk := &testPostLoadConversion{}
require.Nil(t, config.Load(chunk, NewEnvTagPrefixer("foo")))
require.Nil(t, config.PostLoad(chunk))
assert.Equal(t, time.Second*3, chunk.Duration)
}

Expand All @@ -231,8 +260,32 @@ func (c *testPostLoadConversion) PostLoad() error {
}

func TestConfigBadConfigObjectTypes(t *testing.T) {
assert.EqualError(t, NewConfig(NewFakeSourcer("app", nil)).Load(nil), "failed to load config (configuration target is not a pointer to struct)")
assert.EqualError(t, NewConfig(NewFakeSourcer("app", nil)).Load("foo"), "failed to load config (configuration target is not a pointer to struct)")
t.Run("nil config object", func(t *testing.T) {
var errLoad *LoadError
require.ErrorAs(t,
NewConfig(NewFakeSourcer("app", nil)).Load(nil),
&errLoad,
)
assert.ElementsMatch(t,
errLoad.Errors,
[]error{
fmt.Errorf("configuration target is not a pointer to struct"),
},
)
})
t.Run("string config object", func(t *testing.T) {
var errLoad *LoadError
require.ErrorAs(t,
NewConfig(NewFakeSourcer("app", nil)).Load("foo"),
&errLoad,
)
assert.ElementsMatch(t,
errLoad.Errors,
[]error{
fmt.Errorf("configuration target is not a pointer to struct"),
},
)
})
}

func TestConfigEmbeddedConfig(t *testing.T) {
Expand Down Expand Up @@ -281,8 +334,13 @@ func TestConfigEmbeddedConfigPostLoad(t *testing.T) {
}))

chunk := &testParentConfig{}
assert.Nil(t, config.Load(chunk))
assert.EqualError(t, config.PostLoad(chunk), "fields must be increasing")

var postErr *PostLoadError
require.ErrorAs(t, config.Load(chunk), &postErr)
assert.EqualError(t,
postErr.Unwrap(),
"fields must be increasing",
)
}

type testParentConfig struct {
Expand Down Expand Up @@ -314,7 +372,15 @@ func TestConfigBadEmbeddedObjectType(t *testing.T) {

config := NewConfig(NewFakeSourcer("app", nil))
chunk := &C{}
assert.EqualError(t, config.Load(chunk), "failed to load config (invalid embedded type in configuration struct)")

var loadErr *LoadError
require.ErrorAs(t, config.Load(chunk), &loadErr)
assert.ElementsMatch(t,
loadErr.Errors,
[]error{
fmt.Errorf("invalid embedded type in configuration struct"),
},
)
}

//
Expand Down
70 changes: 70 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package config

import (
"errors"
"fmt"
"strings"
)

var (
ErrInvalidMaskTag = errors.New("invalid mask tag")
)

// LoadError is an error returned when a config fails to load. It contains Errors, a slice of
// any errors that occurred during config load.
type LoadError struct {
// Errors that were found while loading a config.
Errors []error
}

func newLoadError(errs []error) *LoadError {
return &LoadError{Errors: errs}
}

func (le *LoadError) Error() string {
var errs []string
for _, errStr := range le.Errors {
errs = append(errs, errStr.Error())
}

return "failed to load config: " + strings.Join(errs, ", ")
}

// SerializeError is an error returned when a config loader fails to serialize a field
// in a config struct.
type SerializeError struct {
FieldName string
Err error
}

func newSerializeError(fieldName string, serializeErr error) *SerializeError {
return &SerializeError{
FieldName: fieldName,
Err: serializeErr,
}
}

func (se *SerializeError) Error() string {
return fmt.Sprintf("field '%s': %s", se.FieldName, se.Err)
}

func (se *SerializeError) Unwrap() error {
return se.Err
}

// PostLoadError is an error returned when a config's PostLoad method fails.
type PostLoadError struct {
Err error
}

func newPostLoadError(err error) *PostLoadError {
return &PostLoadError{Err: err}
}

func (ple *PostLoadError) Error() string {
return "post load callback failed: " + ple.Err.Error()
}

func (ple *PostLoadError) Unwrap() error {
return ple.Err
}
57 changes: 57 additions & 0 deletions errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package config

import (
"fmt"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestLoadError(t *testing.T) {
t.Run("error string includes error slice", func(t *testing.T) {
err := newLoadError([]error{
fmt.Errorf("err1"),
fmt.Errorf("err2"),
})

assert.EqualError(t,
err,
"failed to load config: err1, err2",
)
})
}

func TestSerializeError(t *testing.T) {
t.Run("unwrap error", func(t *testing.T) {
innerErr := fmt.Errorf("inner")
err := newSerializeError("X", innerErr)
assert.Equal(t, "X", err.FieldName)
assert.ErrorIs(t, err, innerErr)
})
t.Run("error string", func(t *testing.T) {
innerErr := fmt.Errorf("inner")
err := newSerializeError("X", innerErr)
assert.EqualError(t,
err,
"field 'X': inner",
)
})
}

func TestPostLoadError(t *testing.T) {
t.Run("error string", func(t *testing.T) {
err := newPostLoadError(fmt.Errorf("inner err"))
assert.EqualError(t,
err,
"post load callback failed: inner err",
)
})
t.Run("unwrap error", func(t *testing.T) {
err := newPostLoadError(fmt.Errorf("inner err"))

innerErr := err.Unwrap()
require.NotNil(t, innerErr)
assert.EqualError(t, innerErr, "inner err")
})
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ require (
github.com/ghodss/yaml v1.0.0
github.com/kr/pretty v0.2.1 // indirect
github.com/mattn/go-zglob v0.0.3
github.com/stretchr/testify v1.6.1
github.com/stretchr/testify v1.8.2
gopkg.in/yaml.v2 v2.4.0 // indirect
)
11 changes: 8 additions & 3 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,13 @@ github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
Expand Down Expand Up @@ -91,5 +95,6 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
2 changes: 1 addition & 1 deletion logging_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func dumpChunk(obj interface{}) (map[string]interface{}, error) {
if maskTagValue != "" {
val, err := strconv.ParseBool(maskTagValue)
if err != nil {
return nil, fmt.Errorf("field '%s' has an invalid mask tag", fieldType.Name)
return nil, newSerializeError(fieldType.Name, ErrInvalidMaskTag)
}

if val {
Expand Down

0 comments on commit fad0778

Please sign in to comment.