Skip to content

Commit

Permalink
feat: add default config handling (#6)
Browse files Browse the repository at this point in the history
Signed-off-by: Tronje Krop <[email protected]>
  • Loading branch information
Tronje Krop committed Sep 19, 2024
1 parent fbeaa35 commit fbdc81c
Show file tree
Hide file tree
Showing 9 changed files with 732 additions and 276 deletions.
25 changes: 20 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ type Config struct {
```

As usual in [Viper][viper], you can now create your config using the reader
that allows to create multiple configuration while applying the default setup
that allows creating multiple configuration while applying the default setup
mechanisms using the following convenience functions:

```go
Expand All @@ -86,17 +86,32 @@ mechanisms using the following convenience functions:
The defaults provided have via different options are overwriting each other in
the following order:

1. First the values provided via the `default`-tags are applied.
2. Second the values provided by [Viper][viper] setup calls are applied.
3. Third the values provided in the `<app>[-env].yaml`-file are applied.
4. And finally the values provided via environment variables are applied
1. First, the values provided via the `default`-tags are applied.
2. Second the values provided by the config prototype instance are applied.
3. Third the values provided by [Viper][viper] custom setup calls are applied.
This also includes the convenient methods provided in this package.
4. Forth the values provided in the `<app>[-env].yaml`-file are applied.
5. And finally the values provided via environment variables are applied
taking the highest precedence.

**Note**: While yo declare the reader with a default config structure, it is
still possible to customize the reader arbitrarily, e.g. with flag support, and
setup any other config structure by using the original [Viper][viper] interface
functions.

A special feature provided by `go-config` is to set up the defaults using a
partial or complete prototype config. You can provide a complete prototype in
the `New` constructor as well as any sub-prototype in the `SetSubDefaults`
method as follows:

```go
reader := config.New("TEST", "test", &config.Config{
Env: "prod",
}).SetSubDefaults("log", &log.Config{
Level: "debug",
}, false).
```


## Logger setup

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.0.2
0.0.3
117 changes: 66 additions & 51 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"

"github.com/spf13/viper"

"github.com/tkrop/go-config/info"
"github.com/tkrop/go-config/internal/reflect"
"github.com/tkrop/go-config/log"
Expand All @@ -25,8 +26,8 @@ type Config struct {
Log *log.Config
}

// ConfigReader common config reader based on viper.
type ConfigReader[C any] struct {
// Reader common config reader based on viper.
type Reader[C any] struct {
*viper.Viper
}

Expand All @@ -42,90 +43,104 @@ func GetEnvName(prefix string, name string) string {
return name
}

// New creates a new config reader with the given prefix, name, and pointer to
// a config struct. The config struct is used to evaluate the default config
// values and names from the `default`-tags.
// New creates a new config reader with the given prefix, name, and config
// struct. The config struct is evaluate for default config tags and available
// config values to initialize the map. The `default` tags are only used, if
// the config values are zero.
func New[C any](
prefix, name string, config *C,
) *ConfigReader[C] {
c := &ConfigReader[C]{
) *Reader[C] {
r := &Reader[C]{
Viper: viper.New(),
}

c.SetConfigName(GetEnvName(prefix, name))
c.SetConfigType("yaml")
c.AddConfigPath(".")
c.SetEnvPrefix(prefix)
c.AutomaticEnv()
c.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
c.AllowEmptyEnv(true)
r.SetConfigName(GetEnvName(prefix, name))
r.SetConfigType("yaml")
r.AddConfigPath(".")
r.SetEnvPrefix(prefix)
r.AutomaticEnv()
r.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
r.AllowEmptyEnv(true)

r.SetSubDefaults("", config, true)

// Determine parent directory of this code file.
if _, filename, _, ok := runtime.Caller(1); ok {
filepath := path.Join(path.Dir(filename), "../..")
c.AddConfigPath(filepath)
r.AddConfigPath(filepath)
}

// Set default values from config type.
reflect.NewTagWalker("default", nil).
Walk(config, "", c.setConfigDefault)

return c
}

// setConfigDefault sets the default value for the given path using the default
// tag value.
func (c *ConfigReader[C]) setConfigDefault(
_ reflect.Value, path, tag string,
) {
c.SetDefault(path, tag)
return r
}

// SetDefaults is a convenience method to configure the reader with defaults
// and standard values. It is also calling the provide function to customize
// values and add more defaults.
func (c *ConfigReader[C]) SetDefaults(
setup func(*ConfigReader[C]),
) *ConfigReader[C] {
info := info.GetDefault()
c.SetDefault("info.path", info.Path)
c.SetDefault("info.version", info.Version)
c.SetDefault("info.revision", info.Revision)
c.SetDefault("info.build", info.Build)
c.SetDefault("info.commit", info.Commit)
c.SetDefault("info.dirty", info.Dirty)
c.SetDefault("info.go", info.Go)
c.SetDefault("info.platform", info.Platform)
c.SetDefault("info.compiler", info.Compiler)

func (r *Reader[C]) SetDefaults(
setup func(*Reader[C]),
) *Reader[C] {
if setup != nil {
setup(c)
setup(r)
}

return c
return r
}

// SetSubDefaults is a convenience method to update the default values of a
// sub-section configured in the reader by using the given config struct. The
// config struct is scanned for `default`-tags and values to set the defaults.
// Depending on the `zero` flag the default values are either include setting
// zero values or ignoring them.
func (r *Reader[C]) SetSubDefaults(
key string, config any, zero bool,
) *Reader[C] {
info := info.GetDefault()
r.SetDefault("info.path", info.Path)
r.SetDefault("info.version", info.Version)
r.SetDefault("info.revision", info.Revision)
r.SetDefault("info.build", info.Build)
r.SetDefault("info.commit", info.Commit)
r.SetDefault("info.dirty", info.Dirty)
r.SetDefault("info.go", info.Go)
r.SetDefault("info.platform", info.Platform)
r.SetDefault("info.compiler", info.Compiler)

reflect.NewTagWalker("default", "mapstructure", zero).
Walk(key, config, r.SetDefault)

return r
}

// SetDefault is a convenience method to set the default value for the given
// key in the config reader and return the config reader.
//
// *Note:* This method is primarily kept to simplify debugging and testing.
// Currently, it contains no additional logic.
func (r *Reader[C]) SetDefault(key string, value any) {
r.Viper.SetDefault(key, value)
}

// ReadConfig is a convenience method to read the environment specific config
// file to extend the default config. The context is used to distinguish
// different calls in case of a panic by failures while loading the config
// file.
func (c *ConfigReader[C]) ReadConfig(context string) *ConfigReader[C] {
err := c.ReadInConfig()
func (r *Reader[C]) ReadConfig(context string) *Reader[C] {
err := r.ReadInConfig()
if err != nil {
log.WithError(err).Errorf("failed to load config [%s]", context)
panic(fmt.Errorf("failed to load config [%s]: %w", context, err))
}

return c
return r
}

// GetConfig is a convenience method to return the config without loading the
// environment specific config file. The context is used to distinguish
// different calls in case of a panic created by failures while unmarschalling
// the config.
func (c *ConfigReader[C]) GetConfig(context string) *C {
func (r *Reader[C]) GetConfig(context string) *C {
config := new(C)
err := c.Unmarshal(config)
err := r.Unmarshal(config)
if err != nil {
log.WithError(err).Errorf("failed to unmarshal config [%s]", context)
panic(fmt.Errorf("failed to unmarshal config [%s]: %w", context, err))
Expand All @@ -142,6 +157,6 @@ func (c *ConfigReader[C]) GetConfig(context string) *C {
// file and returns the config. The context is used to distinguish different
// calls in case of a panic created by failures while loading the config file
// and umarshalling the config.
func (c *ConfigReader[C]) LoadConfig(context string) *C {
return c.ReadConfig(context).GetConfig(context)
func (r *Reader[C]) LoadConfig(context string) *C {
return r.ReadConfig(context).GetConfig(context)
}
19 changes: 10 additions & 9 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ func TestReadConfig(t *testing.T) {

// Given
reader := config.New("TC", "test", &config.Config{}).
SetDefaults(func(c *config.ConfigReader[config.Config]) {
c.AddConfigPath("../fixtures")
SetDefaults(func(c *config.Reader[config.Config]) {
c.AddConfigPath("fixtures")
})

// When
Expand All @@ -29,6 +29,7 @@ func TestReadConfig(t *testing.T) {

func TestReadConfig_UnmarshalFailure(t *testing.T) {
t.Parallel()

type Config struct {
config.Config `mapstructure:",squash"`
Content int
Expand All @@ -37,8 +38,8 @@ func TestReadConfig_UnmarshalFailure(t *testing.T) {
// Given
defer func() { _ = recover() }()
reader := config.New("TC", "test", &Config{}).
SetDefaults(func(c *config.ConfigReader[Config]) {
c.AddConfigPath("../fixtures")
SetDefaults(func(c *config.Reader[Config]) {
c.AddConfigPath("fixtures")
})

// When
Expand All @@ -53,8 +54,8 @@ func TestReadConfig_FileNotFound(t *testing.T) {
defer func() { _ = recover() }()
t.Setenv("TC_ENV", "other")
reader := config.New("TC", "test", &config.Config{}).
SetDefaults(func(c *config.ConfigReader[config.Config]) {
c.AddConfigPath("../fixtures")
SetDefaults(func(c *config.Reader[config.Config]) {
c.AddConfigPath("fixtures")
})

// When
Expand All @@ -68,8 +69,8 @@ func TestReadConfig_OverridingEnv(t *testing.T) {
// Given
t.Setenv("TC_LOG_LEVEL", "trace")
reader := config.New("TC", "test", &config.Config{}).
SetDefaults(func(c *config.ConfigReader[config.Config]) {
c.AddConfigPath("../fixtures")
SetDefaults(func(c *config.Reader[config.Config]) {
c.AddConfigPath("fixtures")
})

// When
Expand All @@ -85,7 +86,7 @@ func TestReadConfig_OverridingFunc(t *testing.T) {

// Given
reader := config.New("TC", "test", &config.Config{}).
SetDefaults(func(c *config.ConfigReader[config.Config]) {
SetDefaults(func(c *config.Reader[config.Config]) {
c.SetDefault("log.level", "trace")
})

Expand Down
3 changes: 3 additions & 0 deletions fixtures/test.yaml → config/fixtures/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@ log:
level: info
caller: false

info:
path: github.com/tkrop/go-config

content: |
Hello, World!
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/tkrop/go-config
go 1.23.1

require (
github.com/golang/mock v1.6.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
Expand All @@ -13,7 +14,6 @@ require (
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
Expand Down
Loading

0 comments on commit fbdc81c

Please sign in to comment.