From 54b0569f62c1fe438f5a3cda5a47db242cf8cecd Mon Sep 17 00:00:00 2001 From: fumoboy007 Date: Wed, 28 Oct 2020 23:42:37 -0700 Subject: [PATCH] require fields to be present if not tagged with `optional` --- README.md | 11 ++++++-- env.go | 72 +++++++++++++++++++++++++++++++++++++++++-------- env_test.go | 66 +++++++++++++++++++++++++++++++++------------ example_test.go | 7 +++-- 4 files changed, 124 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 0ab3e84..906809f 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ import ( ) type Config struct { - Host string - Port int + Host string + Port int + Password string `env:",optional"` } func main() { @@ -53,12 +54,18 @@ func main() { } fmt.Println(cfg.Host) fmt.Println(cfg.Port) + fmt.Println(cfg.Password) // Output: // 127.0.0.1 // 8080 + // } ``` +In the above code, the `Password` field is tagged as `optional` whereas the other fields are untagged. +If a field is not tagged as `optional`, it is a required field, so `env.Load()` will return an error if +the corresponding environment variable is missing. + ### Environment Variable Names When go-env populates a struct from environment variables, it uses the following rules to match diff --git a/env.go b/env.go index c4d65cf..a99ecb5 100644 --- a/env.go +++ b/env.go @@ -36,6 +36,11 @@ type ( // Set sets the object with a string value. Set(value string) error } + + options struct { + optional bool + secret bool + } ) var ( @@ -123,7 +128,7 @@ func (l *Loader) Load(structPtr interface{}) error { continue } - name, secret := getName(ft.Tag.Get(TagName), ft.Name) + name, options := getName(ft.Tag.Get(TagName), ft.Name) if name == "-" { continue } @@ -133,7 +138,7 @@ func (l *Loader) Load(structPtr interface{}) error { if value, ok := l.lookup(name); ok { logValue := value if l.log != nil { - if secret { + if options.secret { l.log("set %v with $%v=\"***\"", ft.Name, name) } else { l.log("set %v with $%v=\"%v\"", ft.Name, name, logValue) @@ -142,6 +147,8 @@ func (l *Loader) Load(structPtr interface{}) error { if err := setValue(f, value); err != nil { return fmt.Errorf("error reading \"%v\": %v", ft.Name, err) } + } else if !options.optional { + return fmt.Errorf("missing required environment variable \"%v\"", name) } } return nil @@ -159,18 +166,61 @@ func indirect(v reflect.Value) reflect.Value { return v } -// getName generates the environment variable name from a struct field tag and the field name. -func getName(tag string, field string) (string, bool) { - name := strings.TrimSuffix(tag, ",secret") - nameLen := len(name) +// getName extracts the environment variable name and options from the given struct field tag or if unspecified, +// generates the environment variable name from the given field name. +func getName(tag string, field string) (string, options) { + name, options := getOptions(tag) + if name == "" { + name = camelCaseToUpperSnakeCase(field) + } + return name, options +} - // If the `,secret` suffix was found, it would have been trimmed, so the length should be different. - secret := nameLen < len(tag) +// getOptions extracts the environment variable name and options from the given struct field tag. +func getOptions(tag string) (string, options) { + var options options - if nameLen == 0 { - name = camelCaseToUpperSnakeCase(field) + optionNamesAndPointers := []struct { + name string + pointer *bool + }{ + {"optional", &options.optional}, + {"secret", &options.secret}, + } + + trimmedTag := tag + // We do not know the order that the options will be specified in, so we need to do extra checking. + // `O(n^2)` but `n` is really small. +outerLoop: + for { + for i, optionNameAndPointer := range optionNamesAndPointers { + var option bool + if trimmedTag, option = getOption(trimmedTag, optionNameAndPointer.name); option { + *optionNameAndPointer.pointer = option + + // We found the option, so remove it from the slice and retry the rest of the options. + optionNamesAndPointers[i] = optionNamesAndPointers[len(optionNamesAndPointers)-1] + optionNamesAndPointers = optionNamesAndPointers[:len(optionNamesAndPointers)-1] + continue outerLoop + } + } + + // We checked for all the options and none were specified, so we are done. + break } - return name, secret + + return trimmedTag, options +} + +// getOption checks whether the given struct field tag contains the suffix for the given option and +// returns the tag without the suffix. +func getOption(tag string, optionName string) (string, bool) { + trimmedTag := strings.TrimSuffix(tag, ","+optionName) + + // If the suffix for the option was found, it would have been trimmed, so the length should be different. + option := len(trimmedTag) < len(tag) + + return trimmedTag, option } // camelCaseToUpperSnakeCase converts a name from camelCase format into UPPER_SNAKE_CASE format. diff --git a/env_test.go b/env_test.go index 444ada5..be49ee2 100644 --- a/env_test.go +++ b/env_test.go @@ -150,25 +150,30 @@ func Test_camelCaseToUpperSnakeCase(t *testing.T) { func Test_getName(t *testing.T) { tests := []struct { - tag string - tg string - field string - name string - secret bool + tag string + tg string + field string + name string + options options }{ - {"t1", "", "Name", "NAME", false}, - {"t2", "", "MyName", "MY_NAME", false}, - {"t3", "NaME", "Name", "NaME", false}, - {"t4", "NaME,secret", "Name", "NaME", true}, - {"t5", ",secret", "Name", "NAME", true}, - {"t6", "NameWith,Comma", "Name", "NameWith,Comma", false}, - {"t7", "NameWith,Comma,secret", "Name", "NameWith,Comma", true}, + {"t1", "", "Name", "NAME", options{optional: false, secret: false}}, + {"t2", "", "MyName", "MY_NAME", options{optional: false, secret: false}}, + {"t3", "NaME", "Name", "NaME", options{optional: false, secret: false}}, + {"t4", "NaME,optional", "Name", "NaME", options{optional: true, secret: false}}, + {"t5", "NaME,secret", "Name", "NaME", options{optional: false, secret: true}}, + {"t6", ",optional", "Name", "NAME", options{optional: true, secret: false}}, + {"t7", ",secret", "Name", "NAME", options{optional: false, secret: true}}, + {"t8", ",optional,secret", "Name", "NAME", options{optional: true, secret: true}}, + {"t9", ",secret,optional", "Name", "NAME", options{optional: true, secret: true}}, + {"t10", "NameWith,Comma", "Name", "NameWith,Comma", options{optional: false, secret: false}}, + {"t11", "NameWith,Comma,optional", "Name", "NameWith,Comma", options{optional: true, secret: false}}, + {"t12", "NameWith,Comma,optional,secret", "Name", "NameWith,Comma", options{optional: true, secret: true}}, } for _, test := range tests { - name, secret := getName(test.tg, test.field) + name, options := getName(test.tg, test.field) assert.Equal(t, test.name, name, test.tag) - assert.Equal(t, test.secret, secret, test.tag) + assert.Equal(t, test.options, options, test.tag) } } @@ -207,7 +212,18 @@ func mockLookup2(name string) (string, bool) { func mockLookup3(name string) (string, bool) { data := map[string]string{ - "PORT": "a8080", + "HOST": "localhost", + "PORT": "a8080", // invalid `int` + "URL": "http://example.com", + } + value, ok := data[name] + return value, ok +} + +func mockLookup4(name string) (string, bool) { + data := map[string]string{ + "PORT": "8080", + "URL": "http://example.com", } value, ok := data[name] return value, ok @@ -235,6 +251,12 @@ type Config3 struct { Embedded } +type Config4 struct { + Host string `env:",optional"` + Port int + Embedded +} + func TestLoader_Load(t *testing.T) { l := NewWithLookup("", mockLookup, nil) @@ -267,12 +289,22 @@ func TestLoader_Load(t *testing.T) { var cfg3 Config1 l = NewWithLookup("", mockLookup3, nil) err = l.Load(&cfg3) - assert.NotNil(t, err) + assert.EqualError(t, err, "error reading \"Port\": strconv.ParseInt: parsing \"a8080\": invalid syntax") var cfg4 Config3 l = NewWithLookup("", mockLookup3, nil) err = l.Load(&cfg4) - assert.NotNil(t, err) + assert.EqualError(t, err, "error reading \"Port\": strconv.ParseInt: parsing \"a8080\": invalid syntax") + + var cfg5 Config1 + l = NewWithLookup("T_", mockLookup4, nil) + err = l.Load(&cfg5) + assert.EqualError(t, err, "missing required environment variable \"T_HOST\"") + + var cfg6 Config4 + l = NewWithLookup("", mockLookup4, nil) + err = l.Load(&cfg6) + assert.Nil(t, err) } func TestNew(t *testing.T) { diff --git a/example_test.go b/example_test.go index 05d0356..fe39323 100644 --- a/example_test.go +++ b/example_test.go @@ -2,15 +2,16 @@ package env_test import ( "fmt" - "github.com/qiangxue/go-env" "log" "os" + + "github.com/qiangxue/go-env" ) type Config struct { Host string Port int - Password string `env:",secret"` + Password string `env:",optional,secret"` } func Example_one() { @@ -23,9 +24,11 @@ func Example_one() { } fmt.Println(cfg.Host) fmt.Println(cfg.Port) + fmt.Println(cfg.Password) // Output: // 127.0.0.1 // 8080 + // } func Example_two() {