diff --git a/featureflag/config.go b/featureflag/config.go index 3c312d4..8cd14e8 100644 --- a/featureflag/config.go +++ b/featureflag/config.go @@ -1,17 +1,16 @@ package featureflag import ( - "time" - + "github.com/netlify/netlify-commons/util" ld "gopkg.in/launchdarkly/go-server-sdk.v4" ) type Config struct { Key string `json:"key" yaml:"key"` - RequestTimeout time.Duration `json:"request_timeout" yaml:"request_timeout" mapstructure:"request_timeout" split_words:"true" default:"5s"` + RequestTimeout util.Duration `json:"request_timeout" yaml:"request_timeout" mapstructure:"request_timeout" split_words:"true" default:"5s"` Enabled bool `json:"enabled" yaml:"enabled" default:"false"` - updateProcessorFactory ld.UpdateProcessorFactory `json:"-"` + updateProcessorFactory ld.UpdateProcessorFactory // Drop telemetry events (not needed in local-dev/CI environments) DisableEvents bool `json:"disable_events" yaml:"disable_events" mapstructure:"disable_events" split_words:"true"` diff --git a/featureflag/featureflag.go b/featureflag/featureflag.go index d6eff8a..bffd957 100644 --- a/featureflag/featureflag.go +++ b/featureflag/featureflag.go @@ -50,7 +50,7 @@ func NewClient(cfg *Config, logger logrus.FieldLogger) (Client, error) { config.SendEvents = false } - inner, err := ld.MakeCustomClient(cfg.Key, config, cfg.RequestTimeout) + inner, err := ld.MakeCustomClient(cfg.Key, config, cfg.RequestTimeout.Duration) if err != nil { logger.WithError(err).Error("Unable to construct LD client") } diff --git a/featureflag/featureflag_test.go b/featureflag/featureflag_test.go index 6890d82..708263f 100644 --- a/featureflag/featureflag_test.go +++ b/featureflag/featureflag_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/netlify/netlify-commons/util" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -14,7 +15,7 @@ import ( func TestOfflineClient(t *testing.T) { cfg := Config{ Key: "ABCD", - RequestTimeout: time.Second, + RequestTimeout: util.Duration{time.Second}, Enabled: false, } client, err := NewClient(&cfg, nil) @@ -48,7 +49,7 @@ func TestAllEnabledFlags(t *testing.T) { fileSource := ldfiledata.NewFileDataSourceFactory(ldfiledata.FilePaths("./fixtures/flags.yml")) cfg := Config{ Key: "ABCD", - RequestTimeout: time.Second, + RequestTimeout: util.Duration{time.Second}, Enabled: true, updateProcessorFactory: fileSource, } @@ -63,7 +64,7 @@ func TestAllEnabledFlags(t *testing.T) { func TestLogging(t *testing.T) { cfg := Config{ Key: "ABCD", - RequestTimeout: time.Second, + RequestTimeout: util.Duration{time.Second}, Enabled: false, } diff --git a/nconf/args.go b/nconf/args.go index 9dc3dad..b632be2 100644 --- a/nconf/args.go +++ b/nconf/args.go @@ -19,26 +19,9 @@ type RootArgs struct { } func (args *RootArgs) Setup(config interface{}, serviceName, version string) (logrus.FieldLogger, error) { - // first load the logger and BugSnag config - rootConfig := &struct { - Log *LoggingConfig - BugSnag *BugSnagConfig - Metrics metriks.Config - Tracing tracing.Config - FeatureFlag featureflag.Config - }{} - - loader := func(cfg interface{}) error { - return LoadFromEnv(args.Prefix, args.ConfigFile, cfg) - } - if !strings.HasSuffix(args.ConfigFile, ".env") { - loader = func(cfg interface{}) error { - return LoadFromFile(args.ConfigFile, cfg) - } - } - - if err := loader(rootConfig); err != nil { - return nil, errors.Wrap(err, "Failed to load the logging configuration") + rootConfig, err := args.loadDefaultConfig() + if err != nil { + return nil, err } log, err := ConfigureLogging(rootConfig.Log) @@ -71,7 +54,7 @@ func (args *RootArgs) Setup(config interface{}, serviceName, version string) (lo if config != nil { // second load the config for this project - if err := loader(config); err != nil { + if err := args.load(config); err != nil { return log, errors.Wrap(err, "Failed to load the config object") } log.Debug("Loaded configuration") @@ -79,6 +62,18 @@ func (args *RootArgs) Setup(config interface{}, serviceName, version string) (lo return log, nil } +func (args *RootArgs) load(cfg interface{}) error { + loader := func(cfg interface{}) error { + return LoadFromEnv(args.Prefix, args.ConfigFile, cfg) + } + if !strings.HasSuffix(args.ConfigFile, ".env") { + loader = func(cfg interface{}) error { + return LoadConfigFromFile(args.ConfigFile, cfg) + } + } + return loader(cfg) +} + func (args *RootArgs) MustSetup(config interface{}, serviceName, version string) logrus.FieldLogger { logger, err := args.Setup(config, serviceName, version) if err != nil { @@ -92,6 +87,16 @@ func (args *RootArgs) MustSetup(config interface{}, serviceName, version string) return logger } +func (args *RootArgs) loadDefaultConfig() (*RootConfig, error) { + c := DefaultConfig() + + if err := args.load(&c); err != nil { + return nil, errors.Wrap(err, "Failed to load the default configuration") + } + + return &c, nil +} + func (args *RootArgs) AddFlags(cmd *cobra.Command) *cobra.Command { cmd.Flags().AddFlag(args.ConfigFlag()) cmd.Flags().AddFlag(args.PrefixFlag()) diff --git a/nconf/args_test.go b/nconf/args_test.go index 1d365bc..b0e84fb 100644 --- a/nconf/args_test.go +++ b/nconf/args_test.go @@ -1,15 +1,17 @@ package nconf import ( + "encoding/json" "io/ioutil" + "os" "testing" - - "github.com/spf13/cobra" + "time" "github.com/sirupsen/logrus" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) func TestArgsLoad(t *testing.T) { @@ -67,3 +69,98 @@ func TestArgsAddToCmd(t *testing.T) { require.NoError(t, cmd.Execute()) assert.Equal(t, 1, called) } + +func TestArgsLoadDefault(t *testing.T) { + configVals := map[string]interface{}{ + "log": map[string]interface{}{ + "level": "debug", + "fields": map[string]interface{}{ + "something": 1, + }, + }, + "bugsnag": map[string]interface{}{ + "api_key": "secrets", + "project_package": "package", + }, + "metrics": map[string]interface{}{ + "enabled": true, + "port": 8125, + "tags": map[string]string{ + "env": "prod", + }, + }, + "tracing": map[string]interface{}{ + "enabled": true, + "port": "9125", + "enable_debug": true, + }, + "featureflag": map[string]interface{}{ + "key": "magicalkey", + "request_timeout": "10s", + "enabled": true, + }, + } + + scenes := []struct { + ext string + enc func(v interface{}) ([]byte, error) + }{ + {"json", json.Marshal}, + {"yaml", yaml.Marshal}, + } + for _, s := range scenes { + t.Run(s.ext, func(t *testing.T) { + f, err := ioutil.TempFile("", "test-config-*."+s.ext) + require.NoError(t, err) + defer os.Remove(f.Name()) + + b, err := s.enc(&configVals) + require.NoError(t, err) + _, err = f.Write(b) + require.NoError(t, err) + + args := RootArgs{ + ConfigFile: f.Name(), + } + cfg, err := args.loadDefaultConfig() + require.NoError(t, err) + + // logging + assert.Equal(t, "debug", cfg.Log.Level) + assert.Equal(t, true, cfg.Log.QuoteEmptyFields) + assert.Equal(t, "", cfg.Log.File) + assert.Equal(t, false, cfg.Log.DisableColors) + assert.Equal(t, "", cfg.Log.TSFormat) + + assert.Len(t, cfg.Log.Fields, 1) + assert.EqualValues(t, 1, cfg.Log.Fields["something"]) + assert.Equal(t, false, cfg.Log.UseNewLogger) + + // bugsnag + assert.Equal(t, "", cfg.BugSnag.Environment) + assert.Equal(t, "secrets", cfg.BugSnag.APIKey) + assert.Equal(t, false, cfg.BugSnag.LogHook) + assert.Equal(t, "package", cfg.BugSnag.ProjectPackage) + + // metrics + assert.Equal(t, true, cfg.Metrics.Enabled) + assert.Equal(t, "localhost", cfg.Metrics.Host) + assert.Equal(t, 8125, cfg.Metrics.Port) + assert.Equal(t, map[string]string{"env": "prod"}, cfg.Metrics.Tags) + + // tracing + assert.Equal(t, true, cfg.Tracing.Enabled) + assert.Equal(t, "localhost", cfg.Tracing.Host) + assert.Equal(t, "9125", cfg.Tracing.Port) + assert.Empty(t, cfg.Tracing.Tags) + assert.Equal(t, true, cfg.Tracing.EnableDebug) + + // featureflag + assert.Equal(t, "magicalkey", cfg.FeatureFlag.Key) + assert.Equal(t, 10*time.Second, cfg.FeatureFlag.RequestTimeout.Duration) + assert.Equal(t, true, cfg.FeatureFlag.Enabled) + assert.Equal(t, false, cfg.FeatureFlag.DisableEvents) + assert.Equal(t, "", cfg.FeatureFlag.RelayHost) + }) + } +} diff --git a/nconf/bugsnag.go b/nconf/bugsnag.go index 338ff91..4a0b5db 100644 --- a/nconf/bugsnag.go +++ b/nconf/bugsnag.go @@ -8,16 +8,16 @@ import ( type BugSnagConfig struct { Environment string - APIKey string `envconfig:"api_key"` - LogHook bool `envconfig:"log_hook"` - ProjectPackage string `envconfig:"project_package"` + APIKey string `envconfig:"api_key" json:"api_key" yaml:"api_key"` + LogHook bool `envconfig:"log_hook" json:"log_hook" yaml:"log_hook"` + ProjectPackage string `envconfig:"project_package" json:"project_package" yaml:"project_package"` } func SetupBugSnag(config *BugSnagConfig, version string) error { if config == nil || config.APIKey == "" { return nil } - + projectPackages := make([]string, 0, 2) projectPackages = append(projectPackages, "main") if config.ProjectPackage != "" { @@ -25,11 +25,11 @@ func SetupBugSnag(config *BugSnagConfig, version string) error { } bugsnag.Configure(bugsnag.Configuration{ - APIKey: config.APIKey, - ReleaseStage: config.Environment, - AppVersion: version, - ProjectPackages: projectPackages, - PanicHandler: func() {}, // this is to disable panic handling. The lib was forking and restarting the process (causing races) + APIKey: config.APIKey, + ReleaseStage: config.Environment, + AppVersion: version, + ProjectPackages: projectPackages, + PanicHandler: func() {}, // this is to disable panic handling. The lib was forking and restarting the process (causing races) }) if config.LogHook { diff --git a/nconf/configuration.go b/nconf/configuration.go index 9ea80cc..996a03e 100644 --- a/nconf/configuration.go +++ b/nconf/configuration.go @@ -1,15 +1,60 @@ package nconf import ( + "encoding/json" + "fmt" + "io/ioutil" "os" + "path/filepath" "strings" "github.com/joho/godotenv" "github.com/kelseyhightower/envconfig" + "github.com/netlify/netlify-commons/featureflag" + "github.com/netlify/netlify-commons/metriks" + "github.com/netlify/netlify-commons/tracing" "github.com/pkg/errors" "github.com/spf13/viper" + "gopkg.in/yaml.v3" ) +// ErrUnknownConfigFormat indicates the extension of the config file is not supported as a config source +type ErrUnknownConfigFormat struct { + ext string +} + +func (e *ErrUnknownConfigFormat) Error() string { + return fmt.Sprintf("Unknown config format: %s", e.ext) +} + +type RootConfig struct { + Log LoggingConfig + BugSnag *BugSnagConfig + Metrics metriks.Config + Tracing tracing.Config + FeatureFlag featureflag.Config +} + +func DefaultConfig() RootConfig { + return RootConfig{ + Log: LoggingConfig{ + QuoteEmptyFields: true, + }, + Tracing: tracing.Config{ + Host: "localhost", + Port: "8126", + }, + Metrics: metriks.Config{ + Host: "localhost", + Port: 8125, + }, + } +} + +/* + Deprecated: This method relies on parsing the json/yaml to a map, then running it through mapstructure. + This required that both tags exist (annoying!). And so there is now LoadConfigFromFile. +*/ // LoadFromFile will load the configuration from the specified file based on the file type // There is only support for .json and .yml now func LoadFromFile(configFile string, input interface{}) error { @@ -37,6 +82,30 @@ func LoadFromFile(configFile string, input interface{}) error { return viper.Unmarshal(input) } +// LoadConfigFromFile will load the configuration from the specified file based on the file type +// There is only support for .json and .yml now. It will use the underlying json/yaml packages directly. +// meaning those should be the only required tags. +func LoadConfigFromFile(configFile string, input interface{}) error { + if configFile == "" { + return nil + } + + // read in all the bytes + data, err := ioutil.ReadFile(configFile) + if err != nil { + return err + } + + configExt := filepath.Ext(configFile) + switch configExt { + case ".json": + return json.Unmarshal(data, input) + case ".yaml", ".yml": + return yaml.Unmarshal(data, input) + } + return &ErrUnknownConfigFormat{configExt} +} + func LoadFromEnv(prefix, filename string, face interface{}) error { var err error if filename == "" { diff --git a/nconf/configuration_test.go b/nconf/configuration_test.go index b2aeb9b..0b0df29 100644 --- a/nconf/configuration_test.go +++ b/nconf/configuration_test.go @@ -9,13 +9,16 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) type testConfig struct { - Hero string `json:",omitempty"` - Villian string `json:",omitempty"` - Matchups map[string]string `json:",omitempty"` - Cities []string `json:",omitempty"` + Hero string + Villian string + Matchups map[string]string + Cities []string + + ShootingLocation string `json:"shooting_location" yaml:"shooting_location" split_words:"true"` } func exampleConfig() testConfig { @@ -90,19 +93,19 @@ func TestFileLoadJSON(t *testing.T) { defer os.Remove(filename) var results testConfig - require.NoError(t, LoadFromFile(filename, &results)) + require.NoError(t, LoadConfigFromFile(filename, &results)) validateConfig(t, expected, results) } func TestFileLoadYAML(t *testing.T) { expected := exampleConfig() - bytes, err := json.Marshal(&expected) + bytes, err := yaml.Marshal(&expected) require.NoError(t, err) filename := writeTestFile(t, "yaml", bytes) defer os.Remove(filename) var results testConfig - require.NoError(t, LoadFromFile(filename, &results)) + require.NoError(t, LoadConfigFromFile(filename, &results)) validateConfig(t, expected, results) } diff --git a/nconf/logging.go b/nconf/logging.go index 30b8586..b9507d7 100644 --- a/nconf/logging.go +++ b/nconf/logging.go @@ -8,16 +8,16 @@ import ( ) type LoggingConfig struct { - Level string `mapstructure:"log_level" json:"log_level"` - File string `mapstructure:"log_file" json:"log_file"` - DisableColors bool `mapstructure:"disable_colors" split_words:"true" json:"disable_colors"` - QuoteEmptyFields bool `mapstructure:"quote_empty_fields" split_words:"true" json:"quote_empty_fields"` - TSFormat string `mapstructure:"ts_format" json:"ts_format"` - Fields map[string]interface{} `mapstructure:"fields" json:"fields"` - UseNewLogger bool `mapstructure:"use_new_logger",split_words:"true"` + Level string `mapstructure:"log_level"` + File string `mapstructure:"log_file"` + DisableColors bool `mapstructure:"disable_colors" split_words:"true" json:"disable_colors" yaml:"disable_colors"` + QuoteEmptyFields bool `mapstructure:"quote_empty_fields" split_words:"true" json:"quote_empty_fields" yaml:"quote_empty_fields"` + TSFormat string `mapstructure:"ts_format" json:"ts_format" yaml:"ts_format"` + Fields map[string]interface{} `mapstructure:"fields"` + UseNewLogger bool `mapstructure:"use_new_logger" split_words:"true" json:"use_new_logger" yaml:"use_new_logger"` } -func ConfigureLogging(config *LoggingConfig) (*logrus.Entry, error) { +func ConfigureLogging(config LoggingConfig) (*logrus.Entry, error) { logger := logrus.New() tsFormat := time.RFC3339Nano diff --git a/nconf/timeout.go b/nconf/timeout.go index 84495e2..a4d8437 100644 --- a/nconf/timeout.go +++ b/nconf/timeout.go @@ -1,30 +1,32 @@ package nconf -import "time" +import ( + "github.com/netlify/netlify-commons/util" +) // HTTPServerTimeoutConfig represents common HTTP server timeout values type HTTPServerTimeoutConfig struct { // Read = http.Server.ReadTimeout - Read time.Duration `mapstructure:"read"` + Read util.Duration `mapstructure:"read"` // Write = http.Server.WriteTimeout - Write time.Duration `mapstructure:"write"` + Write util.Duration `mapstructure:"write"` // Handler = http.TimeoutHandler (or equivalent). // The maximum amount of time a server handler can take. - Handler time.Duration `mapstructure:"handler"` + Handler util.Duration `mapstructure:"handler"` } // HTTPClientTimeoutConfig represents common HTTP client timeout values type HTTPClientTimeoutConfig struct { // Dial = net.Dialer.Timeout - Dial time.Duration `mapstructure:"dial"` + Dial util.Duration `mapstructure:"dial"` // KeepAlive = net.Dialer.KeepAlive - KeepAlive time.Duration `mapstructure:"keep_alive" split_words:"true"` + KeepAlive util.Duration `mapstructure:"keep_alive" split_words:"true" json:"keep_alive" yaml:"keep_alive"` // TLSHandshake = http.Transport.TLSHandshakeTimeout - TLSHandshake time.Duration `mapstructure:"tls_handshake" split_words:"true"` + TLSHandshake util.Duration `mapstructure:"tls_handshake" split_words:"true" json:"tls_handshake" yaml:"tls_handshake"` // ResponseHeader = http.Transport.ResponseHeaderTimeout - ResponseHeader time.Duration `mapstructure:"response_header" split_words:"true"` + ResponseHeader util.Duration `mapstructure:"response_header" split_words:"true" json:"response_header" yaml:"response_header"` // Total = http.Client.Timeout or equivalent // The maximum amount of time a client request can take. - Total time.Duration `mapstructure:"total"` + Total util.Duration `mapstructure:"total"` } diff --git a/nconf/timeout_test.go b/nconf/timeout_test.go new file mode 100644 index 0000000..f3583c0 --- /dev/null +++ b/nconf/timeout_test.go @@ -0,0 +1,53 @@ +package nconf + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestParseTimeoutValues(t *testing.T) { + raw := map[string]string{ + "dial": "10s", + "keep_alive": "11s", + "tls_handshake": "12s", + "response_header": "13s", + "total": "14s", + } + + // write it to json & yaml + // then load it through the RootArgs.load + scenes := []struct { + name string + enc func(interface{}) ([]byte, error) + dec func([]byte, interface{}) error + }{ + {"json", json.Marshal, json.Unmarshal}, + {"yaml", yaml.Marshal, yaml.Unmarshal}, + } + for _, s := range scenes { + t.Run(s.name, func(t *testing.T) { + bs, err := s.enc(&raw) + require.NoError(t, err) + + var cfg HTTPClientTimeoutConfig + + require.NoError(t, s.dec(bs, &cfg)) + + assert.Equal(t, "10s", cfg.Dial.String()) + assert.Equal(t, "11s", cfg.KeepAlive.String()) + assert.Equal(t, "12s", cfg.TLSHandshake.String()) + assert.Equal(t, "13s", cfg.ResponseHeader.String()) + assert.Equal(t, "14s", cfg.Total.String()) + assert.Equal(t, 10*time.Second, cfg.Dial.Duration) + assert.Equal(t, 11*time.Second, cfg.KeepAlive.Duration) + assert.Equal(t, 12*time.Second, cfg.TLSHandshake.Duration) + assert.Equal(t, 13*time.Second, cfg.ResponseHeader.Duration) + assert.Equal(t, 14*time.Second, cfg.Total.Duration) + }) + } +} diff --git a/nconf/tls.go b/nconf/tls.go index 30887c8..025649e 100644 --- a/nconf/tls.go +++ b/nconf/tls.go @@ -10,9 +10,9 @@ import ( ) type TLSConfig struct { - CAFiles []string `mapstructure:"ca_files" envconfig:"ca_files"` - KeyFile string `mapstructure:"key_file" split_words:"true"` - CertFile string `mapstructure:"cert_file" split_words:"true"` + CAFiles []string `mapstructure:"ca_files" envconfig:"ca_files" json:"ca_files" yaml:"ca_files"` + KeyFile string `mapstructure:"key_file" split_words:"true" json:"key_file" yaml:"key_file"` + CertFile string `mapstructure:"cert_file" split_words:"true" json:"cert_file" yaml:"cert_file"` Cert string `mapstructure:"cert"` Key string `mapstructure:"key"` diff --git a/tracing/config.go b/tracing/config.go index 8802ed8..3381a7b 100644 --- a/tracing/config.go +++ b/tracing/config.go @@ -19,7 +19,7 @@ type Config struct { Host string `default:"localhost"` Port string `default:"8126"` Tags map[string]string - EnableDebug bool `default:"false" split_words:"true" mapstructure:"enable_debug"` + EnableDebug bool `default:"false" split_words:"true" mapstructure:"enable_debug" json:"enable_debug" yaml:"enable_debug"` } func Configure(tc *Config, log logrus.FieldLogger, svcName string) { diff --git a/util/duration.go b/util/duration.go new file mode 100644 index 0000000..530a6c8 --- /dev/null +++ b/util/duration.go @@ -0,0 +1,65 @@ +package util + +import ( + "encoding/json" + "errors" + "time" + + "gopkg.in/yaml.v3" +) + +// Duration is a serializable version version of a time.Duration +// it supports setting in yaml & json via: +// - string: 10s +// - float32/64, int/32/64: 10 (nanoseconds) +type Duration struct { + time.Duration +} + +func (d Duration) MarshalYAML() ([]byte, error) { + return yaml.Marshal(d.String()) +} + +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(d.String()) +} + +func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error { + var v interface{} + if err := unmarshal(&v); err != nil { + return err + } + return d.setValue(v) +} + +func (d *Duration) UnmarshalJSON(b []byte) error { + var v interface{} + if err := json.Unmarshal(b, &v); err != nil { + return err + } + return d.setValue(v) +} + +func (d *Duration) setValue(v interface{}) error { + switch value := v.(type) { + case float64: + d.Duration = time.Duration(value) + case float32: + d.Duration = time.Duration(value) + case int: + d.Duration = time.Duration(value) + case int32: + d.Duration = time.Duration(value) + case int64: + d.Duration = time.Duration(value) + case string: + var err error + d.Duration, err = time.ParseDuration(value) + if err != nil { + return err + } + default: + return errors.New("invalid duration") + } + return nil +} diff --git a/util/duration_test.go b/util/duration_test.go new file mode 100644 index 0000000..f24cc8d --- /dev/null +++ b/util/duration_test.go @@ -0,0 +1,53 @@ +package util + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestDurationParsing(t *testing.T) { + scenes := []struct { + name string + dur interface{} + }{ + {"float", 1e9}, + {"int", int(1e9)}, + {"str", "1s"}, + } + + for _, s := range scenes { + t.Run(s.name, func(t *testing.T) { + cfg := struct { + Dur interface{} + }{ + Dur: s.dur, + } + t.Run("yaml", func(t *testing.T) { + bs, err := yaml.Marshal(&cfg) + require.NoError(t, err) + + res := struct { + Dur Duration + }{} + require.NoError(t, yaml.Unmarshal(bs, &res)) + assert.Equal(t, time.Second, res.Dur.Duration) + }) + + t.Run("json", func(t *testing.T) { + bs, err := json.Marshal(&cfg) + require.NoError(t, err) + res := struct { + Dur Duration + }{} + + require.NoError(t, json.Unmarshal(bs, &res)) + assert.Equal(t, time.Second, res.Dur.Duration) + }) + }) + } +}