From 9eead42f0120e1bb2c29099dc0bae68b8243ad81 Mon Sep 17 00:00:00 2001 From: Tronje Krop Date: Sun, 22 Sep 2024 23:50:20 +0200 Subject: [PATCH] feat: clean up and improve (#8) Signed-off-by: Tronje Krop --- README.md | 8 +- VERSION | 2 +- config/config.go | 73 ++++++++------ config/config_test.go | 168 ++++++++++++++++++-------------- config/fixtures/test.yaml | 2 +- go.mod | 4 +- go.sum | 4 +- internal/filepath/path.go | 23 +++++ internal/filepath/path_test.go | 81 +++++++++++++++ internal/reflect/walker.go | 2 +- internal/reflect/walker_test.go | 6 ++ log/log.go | 29 +++--- log/log_test.go | 43 +++++--- 13 files changed, 306 insertions(+), 139 deletions(-) create mode 100644 internal/filepath/path.go create mode 100644 internal/filepath/path_test.go diff --git a/README.md b/README.md index a40ba76..284e0bb 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,9 @@ In [`go-config`][go-config] you simply create your config as an extension of the config provided in this package as a base line as follows: ```go +// Import for config prototype and config reader. +import "github.com/tkrop/go-config/config" + // Config root element for configuration. type Config struct { config.Config `mapstructure:",squash"` @@ -130,11 +133,10 @@ in the `SetSubDefaults` method as follows: ## Logger setup The [`go-config`][go-config] framework supports to set up a [logrus][logrus] -`Logger`_out-of-the-box as follows: +`Logger`_out-of-the-box using the following two approaches: ```go - config := config.New("", "", &Config{}). - LoadConfig("main") + config := config.SetupLogger(logger) logger := config.Log.Setup(log.New()) ``` diff --git a/VERSION b/VERSION index bcab45a..81340c7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.3 +0.0.4 diff --git a/config/config.go b/config/config.go index 8772328..abe03c7 100644 --- a/config/config.go +++ b/config/config.go @@ -3,10 +3,9 @@ package config import ( + "errors" "fmt" "os" - "path" - "runtime" "strings" "github.com/spf13/viper" @@ -16,6 +15,15 @@ import ( "github.com/tkrop/go-config/log" ) +// ErrConfig is a common error to indicate a configuration error. +var ErrConfig = errors.New("config") + +// NewErrConfig is a convenience method to create a new config error with the +// given context wrapping the original error. +func NewErrConfig(message, context string, err error) error { + return fmt.Errorf("%w - %s [%s]: %w", ErrConfig, message, context, err) +} + // Config common application configuration. type Config struct { // Env contains the execution environment, e.g. local, prod, test. @@ -26,6 +34,13 @@ type Config struct { Log *log.Config } +// SetupLogger is a convenience method to setup the logger. +func (c *Config) SetupLogger(logger *log.Logger) *Config { + c.Log.Setup(logger) + + return c +} + // Reader common config reader based on viper. type Reader[C any] struct { *viper.Viper @@ -36,8 +51,7 @@ type Reader[C any] struct { // with the environment specific suffix for loading the config file in `yaml` // format. func GetEnvName(prefix string, name string) string { - env := strings.ToLower(os.Getenv(prefix + "_ENV")) - if env != "" { + if env := strings.ToLower(os.Getenv(prefix + "_ENV")); env != "" { return fmt.Sprintf("%s-%s", name, env) } return name @@ -54,22 +68,15 @@ func New[C any]( Viper: viper.New(), } + r.AutomaticEnv() + r.AllowEmptyEnv(true) + r.SetEnvPrefix(prefix) + r.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 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), "../..") - r.AddConfigPath(filepath) - } - return r } @@ -122,13 +129,16 @@ func (r *Reader[C]) SetDefault(key string, value any) { // 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. +// different calls in case of a failure loading the config file. 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)) + if err := r.ReadInConfig(); err != nil { + err := NewErrConfig("loading file", context, err) + log.WithFields(log.Fields{ + "context": context, + }).WithError(err).Warn("no config file found") + if r.GetBool("viper.panic.load") { + panic(err) + } } return r @@ -140,23 +150,28 @@ func (r *Reader[C]) ReadConfig(context string) *Reader[C] { // the config. func (r *Reader[C]) GetConfig(context string) *C { config := new(C) - 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)) + if err := r.Unmarshal(config); err != nil { + err := NewErrConfig("unmarshal config", context, err) + log.WithFields(log.Fields{ + "context": context, + }).WithError(err).Error("unmarshal config") + if r.GetBool("viper.panic.unmarshal") { + panic(err) + } } log.WithFields(log.Fields{ - "config": config, - }).Debugf("config loaded [%s]", context) + "context": context, + "config": config, + }).Debugf("config loaded") return config } // LoadConfig is a convenience method to load the environment specific config // 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. +// calls in case of a panic created by failures loading the config file or +// umarshalling the config. func (r *Reader[C]) LoadConfig(context string) *C { return r.ReadConfig(context).GetConfig(context) } diff --git a/config/config_test.go b/config/config_test.go index ed63acc..e222971 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,99 +1,123 @@ package config_test import ( + "fmt" "testing" + "github.com/mitchellh/mapstructure" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/tkrop/go-config/config" + "github.com/tkrop/go-config/internal/filepath" "github.com/tkrop/go-config/log" + "github.com/tkrop/go-testing/mock" + "github.com/tkrop/go-testing/test" ) -func TestReadConfig(t *testing.T) { - t.Parallel() - - // Given - reader := config.New("TC", "test", &config.Config{}). - SetDefaults(func(c *config.Reader[config.Config]) { - c.AddConfigPath("fixtures") - }) +var configPaths = []string{filepath.Normalize(".")} - // When - config := reader.LoadConfig(t.Name()) - - // Than - assert.Equal(t, "prod", config.Env) - assert.Equal(t, log.DefaultLogLevel, config.Log.Level) +type testConfigParam struct { + setenv func(test.Test) + setup func(*config.Reader[config.Config]) + expect mock.SetupFunc + expectEnv string + expectLogLevel string } -func TestReadConfig_UnmarshalFailure(t *testing.T) { - t.Parallel() - - type Config struct { - config.Config `mapstructure:",squash"` - Content int - } - - // Given - defer func() { _ = recover() }() - reader := config.New("TC", "test", &Config{}). - SetDefaults(func(c *config.Reader[Config]) { - c.AddConfigPath("fixtures") - }) - - // When - _ = reader.LoadConfig(t.Name()) - - // Then - require.Fail(t, "no panic after unmarschal failure") +var testConfigParams = map[string]testConfigParam{ + "default config without file": { + expectEnv: "prod", + expectLogLevel: "info", + }, + + "default config with file": { + setup: func(r *config.Reader[config.Config]) { + r.AddConfigPath("fixtures") + }, + expectEnv: "prod", + expectLogLevel: "debug", + }, + + "read config with overriding env": { + setenv: func(t test.Test) { + t.Setenv("TC_ENV", "test") + t.Setenv("TC_LOG_LEVEL", "trace") + }, + setup: func(r *config.Reader[config.Config]) { + r.AddConfigPath("fixtures") + }, + expectEnv: "test", + expectLogLevel: "trace", + }, + + "read config with overriding func": { + setup: func(r *config.Reader[config.Config]) { + r.SetDefault("log.level", "trace") + }, + expectEnv: "prod", + expectLogLevel: "trace", + }, + + "panic after file not found": { + setup: func(r *config.Reader[config.Config]) { + r.SetDefault("viper.panic.load", true) + }, + expect: test.Panic(config.NewErrConfig("loading file", "test", + test.Error(viper.ConfigFileNotFoundError{}).Set("name", "test"). + Set("locations", fmt.Sprintf("%s", configPaths)). + Get("").(error))), + }, + + "panic after unmarschal failure": { + setup: func(r *config.Reader[config.Config]) { + r.AddConfigPath("fixtures") + r.SetDefault("viper.panic.unmarshal", true) + r.SetDefault("info.dirty", "5s") + }, + expect: test.Panic(config.NewErrConfig("unmarshal config", + "test", &mapstructure.Error{ + Errors: []string{"cannot parse 'Info.Dirty' as bool: " + + "strconv.ParseBool: parsing \"5s\": invalid syntax"}, + })), + }, } -func TestReadConfig_FileNotFound(t *testing.T) { - // Given - defer func() { _ = recover() }() - t.Setenv("TC_ENV", "other") - reader := config.New("TC", "test", &config.Config{}). - SetDefaults(func(c *config.Reader[config.Config]) { - c.AddConfigPath("fixtures") - }) - - // When - _ = reader.LoadConfig(t.Name()) - - // Then - require.Fail(t, "no panic after missing config file") -} - -func TestReadConfig_OverridingEnv(t *testing.T) { - // Given - t.Setenv("TC_LOG_LEVEL", "trace") - reader := config.New("TC", "test", &config.Config{}). - SetDefaults(func(c *config.Reader[config.Config]) { - c.AddConfigPath("fixtures") +func TestConfig(t *testing.T) { + test.Map(t, testConfigParams). + RunSeq(func(t test.Test, param testConfigParam) { + // Given + mock.NewMocks(t).Expect(param.expect) + if param.setenv != nil { + param.setenv(t) + } + reader := config.New("TC", "test", &config.Config{}). + SetDefaults(param.setup) + + // When + reader.LoadConfig("test") + + // Then + assert.Equal(t, param.expectEnv, reader.GetString("env")) + assert.Equal(t, param.expectLogLevel, reader.GetString("log.level")) }) - - // When - config := reader.LoadConfig(t.Name()) - - // Then - assert.Equal(t, "prod", config.Env) - assert.Equal(t, "trace", config.Log.Level) } -func TestReadConfig_OverridingFunc(t *testing.T) { +func TestSetupLogger(t *testing.T) { t.Parallel() // Given - reader := config.New("TC", "test", &config.Config{}). - SetDefaults(func(c *config.Reader[config.Config]) { - c.SetDefault("log.level", "trace") - }) + logger := log.New() + config := config.New("TC", "test", &config.Config{}). + SetDefaults(func(r *config.Reader[config.Config]) { + r.AddConfigPath("fixtures") + r.SetDefault("log.level", "trace") + }). + GetConfig(t.Name()) // When - config := reader.GetConfig(t.Name()) + config.SetupLogger(logger) // Then - assert.Equal(t, "prod", config.Env) - assert.Equal(t, "trace", config.Log.Level) + assert.Equal(t, log.TraceLevel, logger.GetLevel()) } diff --git a/config/fixtures/test.yaml b/config/fixtures/test.yaml index 0491a5f..e1f169a 100644 --- a/config/fixtures/test.yaml +++ b/config/fixtures/test.yaml @@ -1,5 +1,5 @@ log: - level: info + level: debug caller: false info: diff --git a/go.mod b/go.mod index f8e4c3d..1bd80f4 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,11 @@ go 1.23.1 require ( github.com/golang/mock v1.6.0 + github.com/mitchellh/mapstructure v1.5.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 - github.com/tkrop/go-testing v0.0.13 + github.com/tkrop/go-testing v0.0.16 gopkg.in/yaml.v3 v3.0.1 ) @@ -16,7 +17,6 @@ require ( github.com/fsnotify/fsnotify v1.7.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 github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.6.0 // indirect diff --git a/go.sum b/go.sum index 6eb463a..ffa92e7 100644 --- a/go.sum +++ b/go.sum @@ -49,8 +49,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/tkrop/go-testing v0.0.13 h1:AEDmuSzYbY8/XIRd2j8J3EUzpVjJ1GN0zwjX87MHDy0= -github.com/tkrop/go-testing v0.0.13/go.mod h1:BFsOhvdwOvNKO8nqAEEMAYEeim3nXjvOzREs6lobJ/8= +github.com/tkrop/go-testing v0.0.16 h1:PkFe3T70BHvkDGEitXwNDiFa8PGgluaOCVKP2jQZjxA= +github.com/tkrop/go-testing v0.0.16/go.mod h1:6K65wzWs3dJbZm/oAnQld5fs34WdldSMc7k8ue1LRoI= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= diff --git a/internal/filepath/path.go b/internal/filepath/path.go new file mode 100644 index 0000000..5e37c3f --- /dev/null +++ b/internal/filepath/path.go @@ -0,0 +1,23 @@ +// Package filepath provides path utility functions for Unix systems. +package filepath + +import ( + "os" + "path/filepath" +) + +// Normalize the given path by expanding environment variables, resolving the +// absolute path, and cleaning the path. +func Normalize(path string) string { + path = os.ExpandEnv(path) + + if filepath.IsAbs(path) { + return filepath.Clean(path) + } + + if path, err := filepath.Abs(path); err == nil { + return filepath.Clean(path) + } + + return filepath.Clean(path) +} diff --git a/internal/filepath/path_test.go b/internal/filepath/path_test.go new file mode 100644 index 0000000..5e303f5 --- /dev/null +++ b/internal/filepath/path_test.go @@ -0,0 +1,81 @@ +package filepath_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/tkrop/go-config/internal/filepath" + "github.com/tkrop/go-testing/test" +) + +var currentDir, _ = os.Getwd() + +type testNormalizeParam struct { + path string + setup func(test.Test) + clean func(test.Test) + expectPath string +} + +var testNormalizeParams = map[string]testNormalizeParam{ + "path empty": { + path: "", + expectPath: currentDir, + }, + "path dot": { + path: "", + expectPath: currentDir, + }, + + "path absolute": { + path: "/tmp", + expectPath: "/tmp", + }, + + "path relative": { + path: "tmp", + expectPath: currentDir + "/tmp", + }, + + "path expand": { + path: "${HOME}/tmp", + setup: func(t test.Test) { + t.Setenv("HOME", "/home/user") + }, + expectPath: "/home/user/tmp", + }, + + "path expand error": { + path: "some/${INVALID}/path", + setup: func(t test.Test) { + assert.NoError(t, os.Mkdir("_tmp", 0o755), "mkdir") + assert.NoError(t, os.Chdir("_tmp"), "chdir") + assert.NoError(t, os.Remove("../_tmp"), "remove") + }, + expectPath: "some/path", + clean: func(t test.Test) { + assert.NoError(t, os.Chdir(".."), "chdir") + }, + }, +} + +func TestNormalize(t *testing.T) { + test.Map(t, testNormalizeParams). + RunSeq(func(t test.Test, param testNormalizeParam) { + // Given + if param.setup != nil { + param.setup(t) + } + + // When + path := filepath.Normalize(param.path) + + // Then + assert.Equal(t, param.expectPath, path) + if param.clean != nil { + param.clean(t) + } + }) +} diff --git a/internal/reflect/walker.go b/internal/reflect/walker.go index e4b51c1..40cb501 100644 --- a/internal/reflect/walker.go +++ b/internal/reflect/walker.go @@ -153,7 +153,7 @@ func isStruct(field reflect.StructField) bool { // key is the default key building function. It concatenates the current key // with the field name separated by a dot `.`. If the key is empty, the field // name is used as base key. -func (w *TagWalker) key(key, name string) string { +func (*TagWalker) key(key, name string) string { if key != "" { return key + "." + strings.ToLower(name) } diff --git a/internal/reflect/walker_test.go b/internal/reflect/walker_test.go index b015d90..c53f95b 100644 --- a/internal/reflect/walker_test.go +++ b/internal/reflect/walker_test.go @@ -8,8 +8,12 @@ import ( "github.com/tkrop/go-testing/test" ) +//revive:disable:line-length-limit // go generate line length. + //go:generate mockgen -package=reflect_test -destination=mock_callback_test.go -source=walker_test.go Callback +//revive:enable:line-length-limit + // Callback is a mock interface for testing. type Callback interface { Call(path string, value any) @@ -31,6 +35,8 @@ type tagWalkerParam struct { expect mock.SetupFunc } +//revive:disable:nested-structs // simplifies test cases a lot. + // testTagWalkerParams contains test cases for TagWalker.Walk. var testTagWalkerParams = map[string]tagWalkerParam{ // Test build-in values. diff --git a/log/log.go b/log/log.go index 6bfaf3e..1af0622 100644 --- a/log/log.go +++ b/log/log.go @@ -3,21 +3,9 @@ package log import ( - "time" - log "github.com/sirupsen/logrus" ) -const ( - // Default log level in configuration. - DefaultLogLevel = "info" - // Default report caller flag in configuration. - DefaultLogCaller = false -) - -// DefaultLogTimeFormat contains the default timestamp format. -var DefaultLogTimeFormat = time.RFC3339Nano[0:26] - // Config common configuration for logging. type Config struct { // Level is defining the logger level (default `info`). @@ -51,6 +39,8 @@ type ( JSONFormatter = log.JSONFormatter ) +//revive:disable:max-public-structs // export log types + // Exported log functions to be used in the application. var ( // New creates a new logger. @@ -140,6 +130,21 @@ var ( Panic = log.Panic // Fatal logs a message at level Fatal. Fatal = log.Fatal + + // TraceLevel is the log level Trace. + TraceLevel = log.TraceLevel + // DebugLevel is the log level Debug. + DebugLevel = log.DebugLevel + // InfoLevel is the log level Info. + InfoLevel = log.InfoLevel + // WarnLevel is the log level Warn. + WarnLevel = log.WarnLevel + // ErrorLevel is the log level Error. + ErrorLevel = log.ErrorLevel + // PanicLevel is the log level Panic. + PanicLevel = log.PanicLevel + // FatalLevel is the log level Fatal. + FatalLevel = log.FatalLevel ) // Setup is setting up the given logger using. It sets the formatter, the log diff --git a/log/log_test.go b/log/log_test.go index 8c4f47a..9af8524 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -2,6 +2,7 @@ package log_test import ( "testing" + "time" "github.com/stretchr/testify/assert" @@ -10,6 +11,16 @@ import ( "github.com/tkrop/go-testing/test" ) +const ( + // Default log level in configuration. + DefaultLogLevel = "info" + // Default report caller flag in configuration. + DefaultLogCaller = false +) + +// DefaultLogTimeFormat contains the default timestamp format. +var DefaultLogTimeFormat = time.RFC3339Nano[0:26] + type setupParams struct { config *log.Config expectTimeFormat string @@ -20,16 +31,16 @@ type setupParams struct { var testSetupParams = map[string]setupParams{ "read default log config no logger": { config: &log.Config{}, - expectLogLevel: log.DefaultLogLevel, - expectTimeFormat: log.DefaultLogTimeFormat, - expectLogCaller: log.DefaultLogCaller, + expectLogLevel: DefaultLogLevel, + expectTimeFormat: DefaultLogTimeFormat, + expectLogCaller: DefaultLogCaller, }, "read default log config": { config: &log.Config{}, - expectLogLevel: log.DefaultLogLevel, - expectTimeFormat: log.DefaultLogTimeFormat, - expectLogCaller: log.DefaultLogCaller, + expectLogLevel: DefaultLogLevel, + expectTimeFormat: DefaultLogTimeFormat, + expectLogCaller: DefaultLogCaller, }, "change log level debug": { @@ -37,8 +48,8 @@ var testSetupParams = map[string]setupParams{ Level: "debug", }, expectLogLevel: "debug", - expectTimeFormat: log.DefaultLogTimeFormat, - expectLogCaller: log.DefaultLogCaller, + expectTimeFormat: DefaultLogTimeFormat, + expectLogCaller: DefaultLogCaller, }, "invalid log level debug": { @@ -46,25 +57,25 @@ var testSetupParams = map[string]setupParams{ Level: "detail", }, expectLogLevel: "info", - expectTimeFormat: log.DefaultLogTimeFormat, - expectLogCaller: log.DefaultLogCaller, + expectTimeFormat: DefaultLogTimeFormat, + expectLogCaller: DefaultLogCaller, }, "change time format date": { config: &log.Config{ TimeFormat: "2024-12-31", }, - expectLogLevel: log.DefaultLogLevel, + expectLogLevel: DefaultLogLevel, expectTimeFormat: "2024-12-31", - expectLogCaller: log.DefaultLogCaller, + expectLogCaller: DefaultLogCaller, }, "change caller to true": { config: &log.Config{ Caller: true, }, - expectLogLevel: log.DefaultLogLevel, - expectTimeFormat: log.DefaultLogTimeFormat, + expectLogLevel: DefaultLogLevel, + expectTimeFormat: DefaultLogTimeFormat, expectLogCaller: true, }, } @@ -79,7 +90,7 @@ func TestSetup(t *testing.T) { GetConfig(t.Name()) // When - config.Log.Setup(logger) + config.SetupLogger(logger) // Then assert.Equal(t, param.expectTimeFormat, @@ -95,7 +106,7 @@ func TestSetupNil(t *testing.T) { GetConfig(t.Name()) // When - config.Log.Setup(nil) + config.SetupLogger(nil) // Then assert.True(t, true)