From f8194bf1f046895d6c6c6e984e9d16730eaad1c3 Mon Sep 17 00:00:00 2001 From: Peter Spiess-Knafl Date: Sat, 28 Sep 2024 19:43:32 +0200 Subject: [PATCH] Refactored UI to allow for default values and value overrides. Also get better error messages for missing env values --- .env.sample | 3 +- README.md | 13 +++++-- tinyviper.go | 86 +++++++++++++++++++++++++---------------------- tinyviper_test.go | 36 +++++++++++++++++--- 4 files changed, 88 insertions(+), 50 deletions(-) diff --git a/.env.sample b/.env.sample index 869e18f..bbc15a2 100644 --- a/.env.sample +++ b/.env.sample @@ -1,4 +1,5 @@ MY_APP_EMAIL=someemail@someprovider.org #MY_APP_PASSWORD=password MY_APP_PASSWORD=password2 -MY_APP_ENDPOINT=some-endpoint \ No newline at end of file +MY_APP_ENDPOINT=some-endpoint +MY_APP_URL=https://some.exmpale.org/foo?token=bar \ No newline at end of file diff --git a/README.md b/README.md index 9150efa..2801757 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A minimalistic approach to [spf13/viper](https://github.com/spf13/viper). ## Features - Read `ENV` variables into a `struct` - Read a `.env` file into a `struct` -- `< 100` source lines of code +- `< 110` source lines of code - [No dependencies](go.mod) Only string fields are supported. @@ -15,6 +15,13 @@ Only string fields are supported. ## Usage ```go +package main + +import ( + "github.com/nobloat/tinyviper" + "fmt" +) + type Config struct { UserConfig struct { Email string `env:"MY_APP_EMAIL"` @@ -25,8 +32,8 @@ type Config struct { } func main() { - //cfg, err := NewEnvConfig[Config]() //Read from env - cfg, err := NewEnvFileConfig[Config](".env.sample") //Read from .env file + cfg := Config{} + cfg, err := tinyviper.LoadFromResolver(&cfg, tinyviper.EnvResolver{}, tinyviper.NewEnvFileResolver(".env.sample")) if err != nil { panic(err) } diff --git a/tinyviper.go b/tinyviper.go index 5e62e9e..5de5b19 100644 --- a/tinyviper.go +++ b/tinyviper.go @@ -11,67 +11,69 @@ import ( type Resolver interface { Get(key string) string } - type EnvResolver struct{} - type EnvFileResolver struct { Variables map[string]string } -func NewEnvFileResolver(filename string) (*EnvFileResolver, error) { +func (e EnvFileResolver) Get(key string) string { + return e.Variables[key] +} +func (e EnvResolver) Get(key string) string { + return os.Getenv(key) +} + +type multiResolver struct { + resolvers []Resolver +} + +func NewEnvFileResolver(filename string) *EnvFileResolver { readFile, err := os.Open(filename) if err != nil { - return nil, err + return nil } + defer readFile.Close() r := &EnvFileResolver{make(map[string]string, 0)} fileScanner := bufio.NewScanner(readFile) fileScanner.Split(bufio.ScanLines) for fileScanner.Scan() { text := fileScanner.Text() - parts := strings.Split(text, "=") - if len(parts) != 2 || strings.HasPrefix(text, "#") { + index := strings.Index(text, "=") + if index == -1 || strings.HasPrefix(text, "#") { continue } - r.Variables[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + r.Variables[text[0:index]] = strings.Trim(text[index+1:], " \"'") } - return r, readFile.Close() -} - -func (e EnvFileResolver) Get(key string) string { - return e.Variables[key] -} - -func (e EnvResolver) Get(key string) string { - return os.Getenv(key) + return r } -func NewEnvConfig[T any]() (*T, error) { - cfg := new(T) - res := EnvResolver{} - err := ReflectStruct(cfg, res) - if err != nil { - return nil, err +func (m multiResolver) Get(key string) string { + for _, r := range m.resolvers { + v := r.Get(key) + if r.Get(key) != "" { + return v + } } - return cfg, nil + return "" } -func NewEnvFileConfig[T any](filename string) (*T, error) { - cfg := new(T) - res, err := NewEnvFileResolver(filename) +func LoadFromResolver[T any](cfg *T, resolver ...Resolver) error { + res := multiResolver{resolvers: resolver} + missing := make([]string, 0) + missing, err := refelectStruct(cfg, res, missing) if err != nil { - return nil, err + return err } - err = ReflectStruct(cfg, res) - if err != nil { - return nil, err + if len(missing) > 0 { + return errors.New("missing config variables: " + strings.Join(missing, ",")) } - return cfg, nil + return nil } -func ReflectStruct(object any, resolver Resolver) error { +func refelectStruct(object any, resolver Resolver, missing []string) ([]string, error) { v := reflect.ValueOf(object) if v.Elem().Kind() != reflect.Struct { - return errors.New("type must be a struct") + return missing, errors.New("type must be a struct") } e := v.Elem() t := e.Type() @@ -81,22 +83,24 @@ func ReflectStruct(object any, resolver Resolver) error { envName := tf.Tag.Get("env") if envName != "" { if tf.Type != reflect.TypeOf("") { - return errors.New("env annotated field must have type string") + return missing, errors.New("env annotated field must have type string") } if !ef.CanSet() { - return errors.New("env field must be public") + return missing, errors.New("env field must be public") } value := resolver.Get(envName) - if value == "" { - return errors.New("env variable " + envName + " is not set") + if value != "" { + ef.SetString(resolver.Get(envName)) + } else if ef.String() == "" { + missing = append(missing, envName) } - ef.SetString(resolver.Get(envName)) } else if t.Kind() == reflect.Struct { - err := ReflectStruct(ef.Addr().Interface(), resolver) + m, err := refelectStruct(ef.Addr().Interface(), resolver, missing) if err != nil { - return err + return missing, err } + missing = append(missing, m...) } } - return nil + return missing, nil } diff --git a/tinyviper_test.go b/tinyviper_test.go index 5d02dce..4d99a8d 100644 --- a/tinyviper_test.go +++ b/tinyviper_test.go @@ -11,23 +11,49 @@ type Config struct { Password string `env:"MY_APP_PASSWORD"` } Endpoint string `env:"MY_APP_ENDPOINT"` + AppUrl string `env:"MY_APP_URL"` } -func TestConfig(t *testing.T) { - cfg, err := NewEnvFileConfig[Config](".env.sample") +type testEnvResolver struct{} + +func (t testEnvResolver) Get(key string) string { + switch key { + case "MY_APP_EMAIL": + return "someemail@someprovider.org" + case "MY_APP_PASSWORD": + return "somepassword@someprovider.org" + default: + return "" + } +} + +func TestConfigErrors(t *testing.T) { + cfg := Config{} + err := LoadFromResolver(&cfg, testEnvResolver{}) + if err == nil { + t.Fatalf("Expected error, got none") + } + if err.Error() != "missing config variables: MY_APP_ENDPOINT,MY_APP_URL" { + t.Error("Expected error, got wrong one: " + err.Error()) + } +} + +func TestConfigNew(t *testing.T) { + cfg := Config{} + err := LoadFromResolver(&cfg, EnvResolver{}, NewEnvFileResolver(".env.sample")) if err != nil { t.Error(err) } - if cfg.UserConfig.Email != "someemail@someprovider.org" { t.Error(errors.New("unexpected email")) } - if cfg.UserConfig.Password != "password2" { t.Error(errors.New("unexpected password")) } - if cfg.Endpoint != "some-endpoint" { t.Error(errors.New("unexpected endpoint")) } + if cfg.AppUrl != "https://some.exmpale.org/foo?token=bar" { + t.Error(errors.New("unexpected url")) + } }