diff --git a/README.md b/README.md index a88991a..b8fa07a 100644 --- a/README.md +++ b/README.md @@ -558,6 +558,39 @@ Obviously this example is missing a lot of detail, but you can refer to the code Coming soon! +## Testing + +Confire ships with some testing helper functions so that you can test your configuration's specific configuration or loading functionality. To manage the environment for a test: + +```go +env := contest.Env{ + "MYAPP_DEBUG": "true", + "MYAPP_PORT": "8888", + "MYAPP_TIMEOUT": "5s", + "MYAPP_RATE": "0.25", + "MYAPP_COLORS": "red:1,green:2,blue:3", + "MYAPP_PEERS": "alpha,bravo,charlie", +} + +func TestMyConf(t *testing.T) { + // Will set the environment variables describe by env and clean them back up to the + // original state after the test is completed. + t.Cleanup(env.Set()) +} +``` + +You can also clear the environment of the specified environment variables to check validation rules or to specify a specific subset of keys: + +```go +func TestConfValidation(t *testing.T) { + t.Cleanup(env.Clear()) +} + +func TestSubsetOfKeys(t *testing.T) { + t.Cleanup(env.Set("MYAPP_TIMEOUT", "MYAPP_RATE", "MYAPP_PEERS")) +} +``` + ## Credits Special thanks to the following libraries for providing inspiration and code snippets using their open sources licenses: diff --git a/assert/assert.go b/assert/assert.go index c5f4b2a..50037d2 100644 --- a/assert/assert.go +++ b/assert/assert.go @@ -10,6 +10,7 @@ package assert import ( "errors" + "os" "reflect" "testing" ) @@ -46,6 +47,15 @@ func Ok(tb testing.TB, err error) { } } +// NotOk fails the test if an err is nil. +func NotOk(tb testing.TB, err error) { + tb.Helper() + if err == nil { + tb.Logf("\nexpected error to be non-nil") + tb.FailNow() + } +} + // Equals fails the test if exp (expected) is not equal to act (actual). func Equals(tb testing.TB, exp, act interface{}) { tb.Helper() @@ -60,3 +70,25 @@ func ErrorIs(tb testing.TB, err, target error) { tb.Helper() Assert(tb, errors.Is(err, target), "expected target to be in error chain") } + +// EnvUnset asserts that the environment variable is not set. +func EnvUnset(tb testing.TB, key string) { + tb.Helper() + _, ok := os.LookupEnv(key) + Assert(tb, !ok, "expected environment variable %s to be unset", key) +} + +// EnvIsSet asserts that the environment variable is set. +func EnvIsSet(tb testing.TB, key string) { + tb.Helper() + _, ok := os.LookupEnv(key) + Assert(tb, ok, "expected environment variable %s to be set", key) +} + +// EnvEquals asserts that the environment variable is set and equals the expected value. +func EnvEquals(tb testing.TB, key, exp string) { + tb.Helper() + act, isSet := os.LookupEnv(key) + Assert(tb, isSet, "expected environment variable %s to be set", key) + Assert(tb, reflect.DeepEqual(exp, act), "expected environment variable %s to be set as\n\n\t- exp: %#v\n\t- got: %#v\n", key, exp, act) +} diff --git a/confire_test.go b/confire_test.go index 8511725..61ebfe0 100644 --- a/confire_test.go +++ b/confire_test.go @@ -1 +1,233 @@ package confire_test + +import ( + "encoding/hex" + "strings" + "testing" + "time" + + "go.rtnl.ai/confire" + "go.rtnl.ai/confire/assert" + "go.rtnl.ai/confire/contest" +) + +//============================================================================ +// Configuration Types +//============================================================================ + +// Test Configuration +type Config struct { + Debug bool `default:"false" desc:"enable debug mode"` + ServiceName string `required:"true" split_words:"true" desc:"name of the service"` + Host string `default:"0.0.0.0" desc:"host to listen on"` + Port int `default:"8080" desc:"port to listen on"` + Rate float64 `default:"1.0" desc:"percentage of requests to process"` + Timeout time.Duration `default:"10s" desc:"timeout for requests"` + LogLevel LevelDecoder `env:"OTEL_LOG_LEVEL" default:"info" desc:"log level"` + Database DatabaseConfig + UI UIConfig +} + +type DatabaseConfig struct { + URL string `env:"DATABASE_URL" required:"true" desc:"database connection URL"` + ReadOnly bool `default:"false" desc:"read only mode"` +} + +type UIConfig struct { + Enabled bool `default:"true" desc:"enable the customized user interface"` + Primary Color `default:"#cc6699" desc:"primary color"` + Secondary Color `default:"#eeffee" desc:"secondary color"` + Palette map[string]Color `desc:"palette of colors"` +} + +func (c Config) Validate() (err error) { + if c.Port < 1024 || c.Port > 65535 { + err = confire.Join(err, confire.Invalid("", "port", "must be in the integer range [1024, 65535]")) + } + + if c.Rate < 0.0 || c.Rate > 1.0 { + err = confire.Join(err, confire.Invalid("", "rate", "must be in the float range [0.0, 1.0]")) + } + + return err +} + +func (c *UIConfig) Validate() (err error) { + if c.Enabled { + if len(c.Palette) == 0 { + err = confire.Join(err, confire.Required("ui", "palette")) + } + + if len(c.Palette) > 8 { + err = confire.Join(err, confire.Invalid("ui", "palette", "palette must be less than 8 colors")) + } + + hasPrimary, hasSecondary := false, false + for _, color := range c.Palette { + if color == c.Primary { + hasPrimary = true + } + if color == c.Secondary { + hasSecondary = true + } + } + + if !hasPrimary { + err = confire.Join(err, confire.Invalid("ui", "palette", "primary color must be included in the palette")) + } + + if !hasSecondary { + err = confire.Join(err, confire.Invalid("ui", "palette", "secondary color must be included in the palette")) + } + } + return err +} + +type LevelDecoder uint8 + +const ( + LevelDebug LevelDecoder = iota + LevelInfo + LevelWarning + LevelError + LevelFatal + LevelPanic +) + +func (l *LevelDecoder) Decode(value string) error { + value = strings.TrimSpace(strings.ToLower(value)) + switch value { + case "debug": + *l = LevelDebug + case "info": + *l = LevelInfo + case "warning": + *l = LevelWarning + case "error": + *l = LevelError + case "fatal": + *l = LevelFatal + case "panic": + *l = LevelPanic + default: + return confire.Invalid("", "log level", "invalid log level: %q", value) + } + return nil +} + +type Color [3]uint8 + +func (c *Color) Decode(value string) error { + value = strings.TrimSpace(strings.ToLower(value)) + value = strings.TrimPrefix(value, "#") + + if len(value) != 6 { + return confire.Invalid("", "color", "invalid color: %q", value) + } + + if n, err := hex.Decode(c[:], []byte(value)); n != 3 || err != nil { + return confire.Invalid("", "color", "could not decode hex color") + } + return nil +} + +//============================================================================ +// Configuration Tests +//============================================================================ + +var testEnv = contest.Env{ + "CONFIRE_DEBUG": "true", + "CONFIRE_SERVICE_NAME": "myapp", + "CONFIRE_HOST": "127.0.0.1", + "CONFIRE_PORT": "8000", + "CONFIRE_RATE": "0.75", + "CONFIRE_TIMEOUT": "1m30s", + "OTEL_LOG_LEVEL": "warning", + "DATABASE_URL": "sqlite://myapp.db", + "CONFIRE_DATABASE_READONLY": "true", + "CONFIRE_UI_ENABLED": "true", + "CONFIRE_UI_PRIMARY": "#ff0000", + "CONFIRE_UI_SECONDARY": "#00ff00", + "CONFIRE_UI_PALETTE": "primary:#ff0000,secondary:#00ff00", +} + +var validConfig = Config{ + Debug: true, + ServiceName: "myapp", + Host: "127.0.0.1", + Port: 8000, + Rate: 0.75, + Timeout: time.Minute + (30 * time.Second), + LogLevel: LevelWarning, + Database: DatabaseConfig{ + URL: "sqlite://myapp.db", + ReadOnly: true, + }, + UI: UIConfig{ + Enabled: true, + Primary: Color{255, 0, 0}, + Secondary: Color{0, 255, 0}, + Palette: map[string]Color{ + "primary": {255, 0, 0}, + "secondary": {0, 255, 0}, + }, + }, +} + +func TestConfig(t *testing.T) { + t.Cleanup(testEnv.Set()) + + var conf Config + err := confire.Process("confire", &conf) + assert.Ok(t, err) + + assert.Equals(t, validConfig, conf) +} + +func TestValidation(t *testing.T) { + + t.Run("Defaults", func(t *testing.T) { + t.Cleanup(testEnv.Clear()) + + var conf Config + err := confire.Process("confire", &conf) + assert.NotOk(t, err) + assert.True(t, confire.IsValidationErrors(err)) + + errs, ok := confire.ValidationErrors(err) + assert.True(t, ok) + assert.Assert(t, len(errs) == 5, "expected 5 validation errors got %d", len(errs)) + assert.Equals(t, "invalid configuration: ServiceName is required but not set", errs[0].Error()) + assert.Equals(t, "invalid configuration: URL is required but not set", errs[1].Error()) + assert.Equals(t, "invalid configuration: ui.palette is required but not set", errs[2].Error()) + assert.Equals(t, "invalid configuration: ui.palette primary color must be included in the palette", errs[3].Error()) + assert.Equals(t, "invalid configuration: ui.palette secondary color must be included in the palette", errs[4].Error()) + }) + + t.Run("SoWrong", func(t *testing.T) { + env := contest.Env{ + "CONFIRE_PORT": "22", + "CONFIRE_RATE": "1.2", + "DATABASE_URL": "", + "CONFIRE_UI_ENABLED": "true", + "CONFIRE_UI_PALETTE": "primary:#000000,secondary:#000000,warning:#000000,error:#000000,fatal:#000000,panic:#000000", + } + + t.Cleanup(env.Set()) + + var conf Config + err := confire.Process("confire", &conf) + assert.NotOk(t, err) + assert.Assert(t, confire.IsValidationErrors(err), "expected validation errors got %s", err.Error()) + + errs, ok := confire.ValidationErrors(err) + assert.True(t, ok) + assert.Assert(t, len(errs) == 6, "expected 6 validation errors got %d", len(errs)) + assert.Equals(t, "invalid configuration: port must be in the integer range [1024, 65535]", errs[0].Error()) + assert.Equals(t, "invalid configuration: rate must be in the float range [0.0, 1.0]", errs[1].Error()) + assert.Equals(t, "invalid configuration: ServiceName is required but not set", errs[2].Error()) + assert.Equals(t, "invalid configuration: URL is required but not set", errs[3].Error()) + assert.Equals(t, "invalid configuration: ui.palette primary color must be included in the palette", errs[4].Error()) + assert.Equals(t, "invalid configuration: ui.palette secondary color must be included in the palette", errs[5].Error()) + }) +} diff --git a/contest/contest.go b/contest/contest.go new file mode 100644 index 0000000..0428dfb --- /dev/null +++ b/contest/contest.go @@ -0,0 +1,101 @@ +package contest + +import "os" + +// Env allows the user to set environment variables for tests and restore the original +// environment variables after the test is complete. +type Env map[string]string + +type Cleanup func() + +// Sets the environment variables from the Vars map, tracking the original values and +// returning a cleanup function that will restore the environment to its original state +// to ensure subsequent tests are not affected. +// +// Specify a list of keys to set, or no keys to set all environment variables. +// +// Usage: t.Cleanup(env.Set()) +func (e Env) Set(keys ...string) Cleanup { + orig := make(map[string]*string) + + if len(keys) > 0 { + for _, key := range keys { + // Skip if the key is not in the Env map + val, ok := e[key] + if !ok { + continue + } + + if oval, ok := os.LookupEnv(key); ok { + orig[key] = &oval + } else { + orig[key] = nil + } + os.Setenv(key, val) + } + } else { + for key, val := range e { + if oval, ok := os.LookupEnv(key); ok { + orig[key] = &oval + } else { + orig[key] = nil + } + os.Setenv(key, val) + } + } + + return func() { + for key, val := range orig { + if val != nil { + os.Setenv(key, *val) + } else { + os.Unsetenv(key) + } + } + } +} + +// Clear the environment variables defined by the Vars map, tracking the original +// values and returning a cleanup function that will restore the environment to its +// original state to ensure subsequent tests are not affected. +// +// Specify a list of keys to clear, or no keys to clear all environment variables. +// +// Usage: t.Cleanup(env.Clear()) +func (e Env) Clear(keys ...string) Cleanup { + orig := make(map[string]*string) + if len(keys) > 0 { + for _, key := range keys { + // Skip if the key is not in the Env map + if _, ok := e[key]; !ok { + continue + } + + if oval, ok := os.LookupEnv(key); ok { + orig[key] = &oval + } else { + orig[key] = nil + } + os.Unsetenv(key) + } + } else { + for key := range e { + if oval, ok := os.LookupEnv(key); ok { + orig[key] = &oval + } else { + orig[key] = nil + } + os.Unsetenv(key) + } + } + + return func() { + for key, val := range orig { + if val != nil { + os.Setenv(key, *val) + } else { + os.Unsetenv(key) + } + } + } +} diff --git a/contest/contest_test.go b/contest/contest_test.go new file mode 100644 index 0000000..61fc62d --- /dev/null +++ b/contest/contest_test.go @@ -0,0 +1,111 @@ +package contest_test + +import ( + "os" + "testing" + + "go.rtnl.ai/confire/assert" + "go.rtnl.ai/confire/contest" +) + +func TestEnv(t *testing.T) { + // Set some environment variables for the "original" environment + // Debug, Port, and User are values set in the "original" environment + // Rate and Timeout are not set in the "original" environment + // + // NOTE: CONFIRE_FOO is not managed by the contest.Env as part of the tests. + env := contest.Env{ + "CONFIRE_DEBUG": "true", + "CONFIRE_PORT": "8888", + "CONFIRE_RATE": "0.25", + "CONFIRE_USER": "werebear", + "CONFIRE_TIMEOUT": "5m", + } + + // Ensure the "original" environment variables are as expected + for key := range env { + // Ensure the environment variable is not already set prior to testing + if _, ok := os.LookupEnv(key); ok { + t.Fatalf("environment variable %s is already set", key) + } + + switch key { + case "CONFIRE_DEBUG", "CONFIRE_PORT", "CONFIRE_USER": + os.Setenv(key, "1") + case "CONFIRE_RATE", "CONFIRE_TIMEOUT", "CONFIRE_FOO": + os.Unsetenv(key) + default: + t.Fatalf("unhandled environment variable: %s", key) + } + } + + assertIsOriginal := func(t *testing.T) { + t.Helper() + for key := range env { + switch key { + case "CONFIRE_DEBUG", "CONFIRE_PORT", "CONFIRE_USER": + assert.EnvEquals(t, key, "1") + case "CONFIRE_RATE", "CONFIRE_TIMEOUT", "CONFIRE_FOO": + assert.EnvUnset(t, key) + default: + t.Fatalf("unhandled environment variable: %s", key) + } + } + } + + t.Run("Set", func(t *testing.T) { + cleanup := env.Set() + for key, val := range env { + assert.EnvEquals(t, key, val) + } + + cleanup() + assertIsOriginal(t) + }) + + t.Run("SetKeys", func(t *testing.T) { + keys := []string{"CONFIRE_DEBUG", "CONFIRE_RATE", "CONFIRE_FOO", "CONFIRE_USER"} + cleanup := env.Set(keys...) + + for _, key := range keys { + if val, ok := env[key]; ok { + assert.EnvEquals(t, key, val) + } else { + assert.EnvUnset(t, key) + } + } + + // Omitted keys should remain unchanged + assert.EnvEquals(t, "CONFIRE_PORT", "1") + assert.EnvUnset(t, "CONFIRE_TIMEOUT") + + cleanup() + assertIsOriginal(t) + }) + + t.Run("Clear", func(t *testing.T) { + cleanup := env.Clear() + for key := range env { + assert.EnvUnset(t, key) + } + + cleanup() + assertIsOriginal(t) + }) + + t.Run("ClearKeys", func(t *testing.T) { + keys := []string{"CONFIRE_DEBUG", "CONFIRE_RATE", "CONFIRE_FOO", "CONFIRE_USER"} + cleanup := env.Clear(keys...) + + for _, key := range keys { + assert.EnvUnset(t, key) + } + + // Omitted keys should remain unchanged + assert.EnvEquals(t, "CONFIRE_PORT", "1") + assert.EnvUnset(t, "CONFIRE_TIMEOUT") + + cleanup() + assertIsOriginal(t) + }) +} diff --git a/errors.go b/errors.go index 5e75e40..cc4997b 100644 --- a/errors.go +++ b/errors.go @@ -21,6 +21,21 @@ func IsParseError(err error) bool { return errors.As(err, &target) } +// Extract validation errors from an error if it is one. +func ValidationErrors(err error) (confireErrors.ValidationErrors, bool) { + target := confireErrors.ValidationErrors{} + if ok := errors.As(err, &target); ok { + return target, true + } + return nil, false +} + +// Returns true if the underlying error is a validation error. +func IsValidationErrors(err error) bool { + target := confireErrors.ValidationErrors{} + return errors.As(err, &target) +} + // Extract a configuration error from an error if it is one. func InvalidConfig(err error) (*confireErrors.InvalidConfig, bool) { target := &confireErrors.InvalidConfig{} diff --git a/structs/structs.go b/structs/structs.go index 12f17d0..351561f 100644 --- a/structs/structs.go +++ b/structs/structs.go @@ -79,6 +79,16 @@ func (s *Struct) Field(name string) (*Field, error) { return &Field{field: field, value: s.value.FieldByName(name)}, nil } +// Implements checks if the struct implements the specified interface. +func (s *Struct) Implements(iface interface{}) bool { + return s.value.Type().Implements(reflect.TypeOf(iface).Elem()) +} + +// Interface returns the underlying interface value of the struct. +func (s *Struct) Interface() interface{} { + return s.raw +} + // IsZero returns true if all fields on the struct are a zero-value for their type. // Primitive types such as string, bool, int have zero-values "", false, 0, etc. // Collection types are generally zero-valued if they are empty or nil. Nested structs diff --git a/validate/required.go b/validate/required.go index 9851bfa..6fba3d7 100644 --- a/validate/required.go +++ b/validate/required.go @@ -16,6 +16,7 @@ type required struct { func (r required) Validate() error { if r.field.IsZero() { + // TODO: add parent to the error return errors.Required("", r.field.Name()) } return nil diff --git a/validate/validate.go b/validate/validate.go index 8a9967b..fd06003 100644 --- a/validate/validate.go +++ b/validate/validate.go @@ -37,7 +37,11 @@ func Validate(spec interface{}) (err error) { errs := make(errors.ValidationErrors, 0, len(infos)) for _, info := range infos { if verr := info.Validate.Validate(); verr != nil { - errs = append(errs, asValidationError(verr, info.Field.Name())) + if info.Field != nil { + errs = append(errs, asValidationError(verr, info.Field.Name())...) + } else { + errs = append(errs, asValidationError(verr, "")...) + } } } @@ -57,7 +61,15 @@ func Gather(spec interface{}) (infos []Info, err error) { return nil, errors.ErrInvalidSpecification } + // Create the infos to gather infos = make([]Info, 0, s.NumField()) + + // If the spec implements the Validate method, add it to the infos + if validator, ok := ValidatorAs(s); ok && validator != nil { + infos = append(infos, Info{Validate: validator}) + } + + // Find validators for the fields for _, field := range s.Fields() { // If the field is unexported, skip the field if !field.IsExported() { @@ -147,6 +159,14 @@ func ValidatorFrom(field *structs.Field) (v Validator) { return v } +// Attempts to get a Validator variable from the specified struct. +func ValidatorAs(s *structs.Struct) (Validator, bool) { + if s.Implements((*Validator)(nil)) { + return s.Interface().(Validator), true + } + return nil, false +} + func isTrue(s string) bool { b, _ := strconv.ParseBool(s) return b @@ -160,10 +180,18 @@ func ignoreValidation(s string) bool { return false } -func asValidationError(err error, source string) *errors.InvalidConfig { +func asValidationError(err error, source string) errors.ValidationErrors { + out := make(errors.ValidationErrors, 0, 1) + target := &errors.InvalidConfig{} if goerrors.As(err, &target) { - return target + return append(out, target) } - return errors.Wrap("", source, err.Error(), err) + + if errs, ok := err.(errors.ValidationErrors); ok { + out = append(out, errs...) + return out + } + + return append(out, errors.Wrap("", source, err.Error(), err)) }