diff --git a/config/config.go b/config/config.go index 17381769..adc74f77 100644 --- a/config/config.go +++ b/config/config.go @@ -2,6 +2,7 @@ package config import ( "bytes" + "errors" "fmt" "io" "os" @@ -9,6 +10,7 @@ import ( "strings" "text/template" + "github.com/go-playground/validator/v10" "github.com/mitchellh/mapstructure" "github.com/spf13/viper" ) @@ -47,7 +49,6 @@ func WithConfigYamlDir[T any](dir string) ConfigOption[T] { func LoadConfiguration[T any](cfg *T, opts ...ConfigOption[T]) error { configYamlDir := defaultConfigYamlDir if len(os.Getenv("CONFIG_YAML_DIRECTORY")) > 0 { - fmt.Println("CONFIG_YAML_DIRECTORY", os.Getenv("CONFIG_YAML_DIRECTORY")) configYamlDir = os.Getenv("CONFIG_YAML_DIRECTORY") } @@ -72,7 +73,7 @@ func LoadConfiguration[T any](cfg *T, opts ...ConfigOption[T]) error { } } - return nil + return validateConfiguration(cfg) } func (c *ConfigLoader[T]) populateConfiguration(cfg *T) error { @@ -170,7 +171,7 @@ func evaluateConfigWithEnv(configFile io.Reader, writers ...io.Writer) (io.Reade return nil, fmt.Errorf("unable to read the config file: %w", err) } - t := template.New("appConfigTemplate") + t := template.New("appConfigTemplate").Option("missingkey=zero") tmpl, err := t.Parse(string(b)) if err != nil { return nil, fmt.Errorf("unable to parse template from: \n%s: %w", string(b), err) @@ -193,3 +194,15 @@ func getAppEnv() string { } return env } + +func validateConfiguration[T any](cfg *T) error { + validate := validator.New() + err := validate.Struct(*cfg) + if err != nil { + errSlice := &validator.ValidationErrors{} + errors.As(err, errSlice) + return errSlice + } + + return nil +} diff --git a/config/config_test.go b/config/config_test.go index d6d92e3a..05f77507 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -23,7 +23,7 @@ func TestEnvToMap(t *testing.T) { r.Contains(m, "ENV2") } -func TesEvaluateConfigWithEnv(t *testing.T) { +func TestEvaluateConfigWithEnv(t *testing.T) { r := require.New(t) test := `blah={{.ENV1}} blah2={{.ENV2}}` @@ -39,3 +39,114 @@ blah2=test2` r.NoError(err) r.Equal(expected, string(b)) } + +func TestEvaluateConfigWithMissingEnv(t *testing.T) { + r := require.New(t) + test := `blah={{.ENV1}} +blah2={{.ENV2}}` + os.Setenv("ENV1", "test1") + defer os.Unsetenv("ENV1") + + eval, err := evaluateConfigWithEnv(strings.NewReader(test)) + r.NoError(err) + expected := `blah=test1 +blah2=` + b, err := io.ReadAll(eval) + r.NoError(err) + r.Equal(expected, string(b)) +} + +type nestedConfig struct { + Value1 string `json:"value1"` +} + +type testConfig struct { + Value1 string `json:"value1"` + Value2 string `json:"value2"` + Nested nestedConfig `json:"nested"` +} + +func TestLoadConfigurationBasic(t *testing.T) { + r := require.New(t) + + cfg := &testConfig{} + err := LoadConfiguration(cfg, WithConfigYamlDir[testConfig]("./testData/basic")) + r.NoError(err) + + r.Equal("foo", cfg.Value1) + r.Equal("bar", cfg.Value2) + r.Equal("zap", cfg.Nested.Value1) +} + +func TestLoadConfigurationOverlay(t *testing.T) { + r := require.New(t) + + // Set the deployment stage to test so it overlays the app-config.test.yaml file + os.Setenv("DEPLOYMENT_STAGE", "test") + defer os.Unsetenv("DEPLOYMENT_STAGE") + + cfg := &testConfig{} + err := LoadConfiguration(cfg, WithConfigYamlDir[testConfig]("./testData/overlay")) + r.NoError(err) + + r.Equal("testval1", cfg.Value1) + r.Equal("testval2", cfg.Value2) + r.Equal("zap", cfg.Nested.Value1) +} + +type validatedConfig struct { + Value1 string `json:"value1" validate:"required"` + Value2 string `json:"value2" validate:"required"` + Value3 string `json:"value3"` +} + +// this should fail because the values are missing from the yaml file +func TestLoadConfigurationValidatedFail(t *testing.T) { + r := require.New(t) + + cfg := &validatedConfig{} + err := LoadConfiguration(cfg, WithConfigYamlDir[validatedConfig]("./testData/validation")) + r.Error(err) + + r.Contains(err.Error(), "Value1") + r.Contains(err.Error(), "Value2") + r.Equal("zap", cfg.Value3) +} + +// this should succeed because the values are supplied by the editor function +func TestLoadConfigurationValidatedSucceedWithConfigEditor(t *testing.T) { + r := require.New(t) + + cfg := &validatedConfig{} + err := LoadConfiguration( + cfg, + WithConfigYamlDir[validatedConfig]("./testData/validation"), + WithConfigEditorFn(func(cfg *validatedConfig) error { + cfg.Value1 = "edited-value1" + cfg.Value2 = "edited-value2" + return nil + }), + ) + r.NoError(err) + + r.Equal("edited-value1", cfg.Value1) + r.Equal("edited-value2", cfg.Value2) + r.Equal("zap", cfg.Value3) +} + +// this should succeed because the values are supplied by the overlay file +func TestLoadConfigurationValidatedSucceedWithOverlay(t *testing.T) { + r := require.New(t) + + // Set the deployment stage to test so it overlays the app-config.test.yaml file + os.Setenv("DEPLOYMENT_STAGE", "withvalues") + defer os.Unsetenv("DEPLOYMENT_STAGE") + + cfg := &validatedConfig{} + err := LoadConfiguration(cfg, WithConfigYamlDir[validatedConfig]("./testData/validation")) + r.NoError(err) + + r.Equal("foo", cfg.Value1) + r.Equal("bar", cfg.Value2) + r.Equal("zap", cfg.Value3) +} diff --git a/config/go.mod b/config/go.mod index d179b584..0ba3fb21 100644 --- a/config/go.mod +++ b/config/go.mod @@ -1,8 +1,9 @@ module github.com/chanzuckerberg/go-misc/config/v2 -go 1.21.1 +go 1.21 require ( + github.com/go-playground/validator/v10 v10.19.0 github.com/mitchellh/mapstructure v1.5.0 github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.8.4 @@ -11,7 +12,11 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -24,8 +29,10 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect + golang.org/x/crypto v0.19.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/sys v0.15.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.17.0 // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/config/go.sum b/config/go.sum index eaf76d37..bededdb7 100644 --- a/config/go.sum +++ b/config/go.sum @@ -6,6 +6,16 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4= +github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -14,6 +24,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -53,10 +65,14 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/config/testData/basic/app-config.yaml b/config/testData/basic/app-config.yaml new file mode 100644 index 00000000..499560f3 --- /dev/null +++ b/config/testData/basic/app-config.yaml @@ -0,0 +1,4 @@ +value1: foo +value2: bar +nested: + value1: zap diff --git a/config/testData/overlay/app-config.test.yaml b/config/testData/overlay/app-config.test.yaml new file mode 100644 index 00000000..2f160980 --- /dev/null +++ b/config/testData/overlay/app-config.test.yaml @@ -0,0 +1,2 @@ +value1: testval1 +value2: testval2 diff --git a/config/testData/overlay/app-config.yaml b/config/testData/overlay/app-config.yaml new file mode 100644 index 00000000..08c174ae --- /dev/null +++ b/config/testData/overlay/app-config.yaml @@ -0,0 +1,3 @@ +value1: foo +nested: + value1: zap diff --git a/config/testData/validation/app-config.withvalues.yaml b/config/testData/validation/app-config.withvalues.yaml new file mode 100644 index 00000000..051e77bc --- /dev/null +++ b/config/testData/validation/app-config.withvalues.yaml @@ -0,0 +1,4 @@ +value1: foo +value2: bar +value3: zap + diff --git a/config/testData/validation/app-config.yaml b/config/testData/validation/app-config.yaml new file mode 100644 index 00000000..a74acace --- /dev/null +++ b/config/testData/validation/app-config.yaml @@ -0,0 +1 @@ +value3: zap