From 11441603b686704c57557283dc6db2c370fcdcdd Mon Sep 17 00:00:00 2001 From: kh Date: Tue, 12 May 2020 16:24:50 +0800 Subject: [PATCH 01/21] support time.Duration --- README.md | 3 +++ env.go | 11 +++++++++++ env_test.go | 13 +++++++++++++ 3 files changed, 27 insertions(+) diff --git a/README.md b/README.md index c657eae..acc5ca5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ package main import ( "log" + "time" env "github.com/Netflix/go-env" ) @@ -32,6 +33,8 @@ type Environment struct { } Extras env.EnvSet + + Duration time.Duration `env:"TYPE_DURATION"` } func main() { diff --git a/env.go b/env.go index 3ddc8c8..2d76cc4 100644 --- a/env.go +++ b/env.go @@ -23,6 +23,7 @@ import ( "reflect" "strconv" "strings" + "time" ) var ( @@ -130,6 +131,16 @@ func set(t reflect.Type, f reflect.Value, value string) error { } f.SetBool(v) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if t.PkgPath() == "time" && t.Name() == "Duration" { + duration, err := time.ParseDuration(value) + if err != nil { + return err + } + + f.Set(reflect.ValueOf(duration)) + break + } + v, err := strconv.Atoi(value) if err != nil { return err diff --git a/env_test.go b/env_test.go index 7dfae48..1ba2e95 100644 --- a/env_test.go +++ b/env_test.go @@ -51,6 +51,9 @@ type ValidStruct struct { Bool bool `env:"BOOL"` MultipleTags string `env:"npm_config_cache,NPM_CONFIG_CACHE"` + + // time.Duration is supported + Duration time.Duration `env:"TYPE_DURATION"` } type UnsupportedStruct struct { @@ -70,6 +73,7 @@ func TestUnmarshal(t *testing.T) { "BOOL": "true", "npm_config_cache": "first", "NPM_CONFIG_CACHE": "second", + "TYPE_DURATION": "5s", } var validStruct ValidStruct @@ -106,6 +110,10 @@ func TestUnmarshal(t *testing.T) { t.Errorf("Expected field value to be '%s' but got '%s'", "first", validStruct.MultipleTags) } + if validStruct.Duration != 5*time.Second { + t.Errorf("Expected field value to be '%s' but got '%s'", "5s", validStruct.Duration) + } + v, ok := environ["HOME"] if ok { t.Errorf("Expected field '%s' to not exist but got '%s'", "HOME", v) @@ -238,6 +246,7 @@ func TestMarshal(t *testing.T) { Int: 1, Bool: true, MultipleTags: "foobar", + Duration: 3 * time.Minute, } environ, err := Marshal(&validStruct) @@ -272,6 +281,10 @@ func TestMarshal(t *testing.T) { if environ["NPM_CONFIG_CACHE"] != "foobar" { t.Errorf("Expected field value to be '%s' but got '%s'", "foobar", environ["NPM_CONFIG_CACHE"]) } + + if environ["TYPE_DURATION"] != "3m0s" { + t.Errorf("Expected field value to be '%s' but got '%s'", "3m0s", environ["TYPE_DURATION"]) + } } func TestMarshalInvalid(t *testing.T) { From fa034df3fdab89be6dfde6f169d84b21657ff11b Mon Sep 17 00:00:00 2001 From: jimmykodes Date: Thu, 30 Jul 2020 15:31:27 -0700 Subject: [PATCH 02/21] Add default value option in env tag this will split a tag on a || and use the value following the pipes (if any) as a default value if none of the env var options return a value --- env.go | 15 +++++++++++++-- env_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/env.go b/env.go index 2d76cc4..3f3fae1 100644 --- a/env.go +++ b/env.go @@ -86,7 +86,7 @@ func Unmarshal(es EnvSet, v interface{}) error { return ErrUnexportedField } - envKeys := strings.Split(tag, ",") + envKeys, defaultVal := parseTag(tag) var ( envValue string @@ -99,7 +99,9 @@ func Unmarshal(es EnvSet, v interface{}) error { } } - if !ok { + if !ok && defaultVal != "" { + envValue = defaultVal + } else if !ok { continue } @@ -237,3 +239,12 @@ func Marshal(v interface{}) (EnvSet, error) { return es, nil } + +func parseTag(tag string) ([]string, string) { + tagData := strings.Split(tag, "||") + envKeys := strings.Split(tagData[0], ",") + if len(tagData) > 1 { + return envKeys, tagData[1] + } + return envKeys, "" +} diff --git a/env_test.go b/env_test.go index 1ba2e95..84d4914 100644 --- a/env_test.go +++ b/env_test.go @@ -64,6 +64,15 @@ type UnexportedStruct struct { home string `env:"HOME"` } +type DefaultValueStruct struct { + DefaultString string `env:"MISSING_STRING||found"` + DefaultBool bool `env:"MISSING_BOOL||true"` + DefaultInt int `env:"MISSING_INT||7"` + DefaultDuration time.Duration `env:"MISSING_DURATION||5s"` + DefaultWithOptionsMissing string `env:"MISSING_1,MISSING_2||present"` + DefaultWithOptionsPresent string `env:"MISSING_1,PRESENT||present"` +} + func TestUnmarshal(t *testing.T) { environ := map[string]string{ "HOME": "/home/test", @@ -233,6 +242,30 @@ func TestUnmarshalUnexported(t *testing.T) { } } +func TestUnmarshalDefaultValues(t *testing.T) { + environ := map[string]string { + "PRESENT": "youFoundMe", + } + var defaultValueStruct DefaultValueStruct + err := Unmarshal(environ, &defaultValueStruct) + if err != nil { + t.Errorf("Expected no error but got %s", err) + } + testCases := [][]interface{}{ + {defaultValueStruct.DefaultInt, 7}, + {defaultValueStruct.DefaultBool, true}, + {defaultValueStruct.DefaultString, "found"}, + {defaultValueStruct.DefaultDuration, 5 * time.Second}, + {defaultValueStruct.DefaultWithOptionsMissing, "present"}, + {defaultValueStruct.DefaultWithOptionsPresent, "youFoundMe"}, + } + for _, testCase := range testCases { + if testCase[0] != testCase[1] { + t.Errorf("Expected field value to be '%v' but got '%v'", testCase[0], testCase[1]) + } + } +} + func TestMarshal(t *testing.T) { validStruct := ValidStruct{ Home: "/home/test", From 4b25b379d2236ff5bbb5031a95058b29dc6f1b59 Mon Sep 17 00:00:00 2001 From: jimmykodes Date: Thu, 30 Jul 2020 15:39:45 -0700 Subject: [PATCH 03/21] add default value example to readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index acc5ca5..8613e84 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ type Environment struct { Extras env.EnvSet - Duration time.Duration `env:"TYPE_DURATION"` + Duration time.Duration `env:"TYPE_DURATION"` + DefaultValue string `env:"MISSING_VAR||default_value"` } func main() { From 8b9a491baff5af134c355781dea985cfc61f3718 Mon Sep 17 00:00:00 2001 From: jimmykodes Date: Sun, 2 Aug 2020 20:04:18 -0700 Subject: [PATCH 04/21] Use default= instead of || to specify default vals --- README.md | 5 +++-- env.go | 50 ++++++++++++++++++++++++++++++++++++++------------ env_test.go | 41 ++++++++++++++++++++++++++++++++++------- 3 files changed, 75 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 8613e84..20526d0 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,9 @@ type Environment struct { Extras env.EnvSet - Duration time.Duration `env:"TYPE_DURATION"` - DefaultValue string `env:"MISSING_VAR||default_value"` + Duration time.Duration `env:"TYPE_DURATION"` + DefaultValue string `env:"MISSING_VAR,default=default_value"` + RequiredValue string `env:"IM_REQUIRED,required=true"` } func main() { diff --git a/env.go b/env.go index 3f3fae1..b3c7aee 100644 --- a/env.go +++ b/env.go @@ -36,6 +36,9 @@ var ( // ErrUnexportedField returned when a field with tag "env" is not exported. ErrUnexportedField = errors.New("field must be exported") + + // ErrMissingRequiredValue returned when a field with required=true contains no value or default + ErrMissingRequiredValue = errors.New("value for this field is required") ) // Unmarshal parses an EnvSet and stores the result in the value pointed to by @@ -86,23 +89,27 @@ func Unmarshal(es EnvSet, v interface{}) error { return ErrUnexportedField } - envKeys, defaultVal := parseTag(tag) + envTag := parseTag(tag) var ( envValue string ok bool ) - for _, envKey := range envKeys { + for _, envKey := range envTag.Keys { envValue, ok = es[envKey] if ok { break } } - if !ok && defaultVal != "" { - envValue = defaultVal - } else if !ok { - continue + if !ok { + if envTag.Default != "" { + envValue = envTag.Default + } else if envTag.Required { + return ErrMissingRequiredValue + } else { + continue + } } err := set(typeField.Type, valueField, envValue) @@ -240,11 +247,30 @@ func Marshal(v interface{}) (EnvSet, error) { return es, nil } -func parseTag(tag string) ([]string, string) { - tagData := strings.Split(tag, "||") - envKeys := strings.Split(tagData[0], ",") - if len(tagData) > 1 { - return envKeys, tagData[1] +type tag struct { + Keys []string + Default string + Required bool +} + +func parseTag(tagString string) tag { + var t tag + envKeys := strings.Split(tagString, ",") + for _, key := range envKeys { + if strings.Contains(key, "=") { + keyData := strings.Split(key, "=") + switch strings.ToLower(keyData[0]) { + case "default": + t.Default = keyData[1] + case "required": + t.Required = strings.ToLower(keyData[1]) == "true" + default: + // just ignoring unsupported keys + continue + } + } else { + t.Keys = append(t.Keys, key) + } } - return envKeys, "" + return t } diff --git a/env_test.go b/env_test.go index 84d4914..e4b036f 100644 --- a/env_test.go +++ b/env_test.go @@ -65,12 +65,19 @@ type UnexportedStruct struct { } type DefaultValueStruct struct { - DefaultString string `env:"MISSING_STRING||found"` - DefaultBool bool `env:"MISSING_BOOL||true"` - DefaultInt int `env:"MISSING_INT||7"` - DefaultDuration time.Duration `env:"MISSING_DURATION||5s"` - DefaultWithOptionsMissing string `env:"MISSING_1,MISSING_2||present"` - DefaultWithOptionsPresent string `env:"MISSING_1,PRESENT||present"` + DefaultString string `env:"MISSING_STRING,default=found"` + DefaultBool bool `env:"MISSING_BOOL,default=true"` + DefaultInt int `env:"MISSING_INT,default=7"` + DefaultDuration time.Duration `env:"MISSING_DURATION,default=5s"` + DefaultWithOptionsMissing string `env:"MISSING_1,MISSING_2,default=present"` + DefaultWithOptionsPresent string `env:"MISSING_1,PRESENT,default=present"` +} + +type RequiredValueStruct struct { + Required string `env:"REQUIRED_VAL,required=true"` + RequiredWithDefault string `env:"REQUIRED_MISSING,default=myValue,required=true"` + NotRequired string `env:"NOT_REQUIRED,required=false"` + InvalidExtra string `env:"INVALID,invalid=invalid"` } func TestUnmarshal(t *testing.T) { @@ -261,11 +268,31 @@ func TestUnmarshalDefaultValues(t *testing.T) { } for _, testCase := range testCases { if testCase[0] != testCase[1] { - t.Errorf("Expected field value to be '%v' but got '%v'", testCase[0], testCase[1]) + t.Errorf("Expected field value to be '%v' but got '%v'", testCase[1], testCase[0]) } } } +func TestUnmarshalRequiredValues(t *testing.T) { + environ := map[string]string{} + var requiredValuesStruct RequiredValueStruct + err := Unmarshal(environ, &requiredValuesStruct) + if err != ErrMissingRequiredValue { + t.Errorf("Expected error 'ErrMissingRequiredValue' but go '%s'", err) + } + environ["REQUIRED_VAL"] = "required" + err = Unmarshal(environ, &requiredValuesStruct) + if err != nil { + t.Errorf("Expected no error but got '%s'", err) + } + if requiredValuesStruct.Required != "required" { + t.Errorf("Expected field value to be '%s' but got '%s'", "required", requiredValuesStruct.Required) + } + if requiredValuesStruct.RequiredWithDefault != "myValue" { + t.Errorf("Expected field value to be '%s' but got '%s'", "myValue", requiredValuesStruct.RequiredWithDefault) + } +} + func TestMarshal(t *testing.T) { validStruct := ValidStruct{ Home: "/home/test", From ded87dff775a7f38707eb045ef30bc55f0d3f67b Mon Sep 17 00:00:00 2001 From: Steve Teuber Date: Tue, 8 Sep 2020 16:10:32 +0200 Subject: [PATCH 05/21] Split default value only into two parts --- env.go | 2 +- env_test.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/env.go b/env.go index b3c7aee..29d6360 100644 --- a/env.go +++ b/env.go @@ -258,7 +258,7 @@ func parseTag(tagString string) tag { envKeys := strings.Split(tagString, ",") for _, key := range envKeys { if strings.Contains(key, "=") { - keyData := strings.Split(key, "=") + keyData := strings.SplitN(key, "=", 2) switch strings.ToLower(keyData[0]) { case "default": t.Default = keyData[1] diff --git a/env_test.go b/env_test.go index e4b036f..b1f6c90 100644 --- a/env_test.go +++ b/env_test.go @@ -66,6 +66,7 @@ type UnexportedStruct struct { type DefaultValueStruct struct { DefaultString string `env:"MISSING_STRING,default=found"` + DefaultKeyValueString string `env:"MISSING_KVSTRING,default=key=value"` DefaultBool bool `env:"MISSING_BOOL,default=true"` DefaultInt int `env:"MISSING_INT,default=7"` DefaultDuration time.Duration `env:"MISSING_DURATION,default=5s"` @@ -262,6 +263,7 @@ func TestUnmarshalDefaultValues(t *testing.T) { {defaultValueStruct.DefaultInt, 7}, {defaultValueStruct.DefaultBool, true}, {defaultValueStruct.DefaultString, "found"}, + {defaultValueStruct.DefaultKeyValueString, "key=value"}, {defaultValueStruct.DefaultDuration, 5 * time.Second}, {defaultValueStruct.DefaultWithOptionsMissing, "present"}, {defaultValueStruct.DefaultWithOptionsPresent, "youFoundMe"}, From 579d7ee122d59bb63ae4de905268674b69e2bfae Mon Sep 17 00:00:00 2001 From: Steve Hill Date: Mon, 2 Nov 2020 16:00:10 -0800 Subject: [PATCH 06/21] Update build status location --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 20526d0..f0cf04a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # go-env -[![Build Status](https://travis-ci.org/Netflix/go-env.svg?branch=master)](https://travis-ci.org/Netflix/go-env) +[![Build Status](https://travis-ci.com/Netflix/go-env.svg?branch=master)](https://travis-ci.com/Netflix/go-env) [![GoDoc](https://godoc.org/github.com/Netflix/go-env?status.svg)](https://godoc.org/github.com/Netflix/go-env) [![NetflixOSS Lifecycle](https://img.shields.io/osslifecycle/Netflix/go-expect.svg)]() From 124834763258ad9e72900028aea54d37a159a271 Mon Sep 17 00:00:00 2001 From: Jack Wink <57678801+mothershipper@users.noreply.github.com> Date: Wed, 25 Nov 2020 15:51:51 -0800 Subject: [PATCH 07/21] Add support for custom env unmarshaling This lets you unmarshal custom types, like structs or scalars with specific pre-processing steps required. --- README.md | 47 ++++++++++++++++++++++++++++++++++++++++ env.go | 39 +++++++++++++++++++++++++++++++--- env_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++- unmarshal.go | 8 +++++++ 4 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 unmarshal.go diff --git a/README.md b/README.md index f0cf04a..0971f14 100644 --- a/README.md +++ b/README.md @@ -72,3 +72,50 @@ func main() { environment.Extras = es } ``` + +## Custom Unmarshaler + +There is limited support for dictating how a field should be unmarshaled. The following example +shows how you could unmarshal from JSON + +```go +import ( + "encoding/json" + "fmt" + "log" + + env "github.com/Netflix/go-env" +) + +type SomeData struct { + SomeField int `json:"someField"` +} + +func (s *SomeData) UnmarshalEnvironmentValue(data string) error { + var tmp SomeData + err := json.Unmarshal([]byte(data), &tmp) + if err != nil { + return err + } + *s = tmp + return nil +} + +type Config struct { + SomeData *SomeData `env:"SOME_DATA"` +} + +func main() { + var cfg Config + _, err := env.UnmarshalFromEnviron(&cfg) + if err != nil { + log.Fatal(err) + } + + if cfg.SomeData != nil && cfg.SomeData.SomeField == 42 { + fmt.Println("Got 42!") + } else { + fmt.Printf("Got nil or some other value: %v\n", cfg.SomeData) + } +} +``` diff --git a/env.go b/env.go index 29d6360..5545b58 100644 --- a/env.go +++ b/env.go @@ -39,6 +39,8 @@ var ( // ErrMissingRequiredValue returned when a field with required=true contains no value or default ErrMissingRequiredValue = errors.New("value for this field is required") + + unmarshalType = reflect.TypeOf((*Unmarshaler)(nil)).Elem() ) // Unmarshal parses an EnvSet and stores the result in the value pointed to by @@ -93,7 +95,7 @@ func Unmarshal(es EnvSet, v interface{}) error { var ( envValue string - ok bool + ok bool ) for _, envKey := range envTag.Keys { envValue, ok = es[envKey] @@ -123,6 +125,37 @@ func Unmarshal(es EnvSet, v interface{}) error { } func set(t reflect.Type, f reflect.Value, value string) error { + // See if the type implements Unmarshaler and use that first, + // otherwise, fallback to the previous logic + var isUnmarshaler bool + isPtr := t.Kind() == reflect.Ptr + if isPtr { + isUnmarshaler = t.Implements(unmarshalType) && f.CanInterface() + } else if f.CanAddr() { + isUnmarshaler = f.Addr().Type().Implements(unmarshalType) && f.Addr().CanInterface() + } + + if isUnmarshaler { + var ptr reflect.Value + if isPtr { + // In the pointer case, we need to create a new element to have an + // address to point to + ptr = reflect.New(t.Elem()) + } else { + // And for scalars, we need the pointer to be able to modify the value + ptr = f.Addr() + } + if u, ok := ptr.Interface().(Unmarshaler); ok { + if err := u.UnmarshalEnvironmentValue(value); err != nil { + return err + } + if isPtr { + f.Set(ptr) + } + return nil + } + } + switch t.Kind() { case reflect.Ptr: ptr := reflect.New(t.Elem()) @@ -248,8 +281,8 @@ func Marshal(v interface{}) (EnvSet, error) { } type tag struct { - Keys []string - Default string + Keys []string + Default string Required bool } diff --git a/env_test.go b/env_test.go index b1f6c90..6f8c875 100644 --- a/env_test.go +++ b/env_test.go @@ -14,6 +14,8 @@ package env import ( + "encoding/base64" + "encoding/json" "os" "testing" "time" @@ -54,6 +56,11 @@ type ValidStruct struct { // time.Duration is supported Duration time.Duration `env:"TYPE_DURATION"` + + // Custom unmarshaler should support scalar types + Base64EncodedString *Base64EncodedString `env:"BASE64_ENCODED_STRING"` + // Custom unmarshaler should support struct types + JSONData *JSONData `env:"JSON_DATA"` } type UnsupportedStruct struct { @@ -81,6 +88,31 @@ type RequiredValueStruct struct { InvalidExtra string `env:"INVALID,invalid=invalid"` } +type Base64EncodedString string + +func (b *Base64EncodedString) UnmarshalEnvironmentValue(data string) error { + value, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return err + } + *b = Base64EncodedString(value) + return nil +} + +type JSONData struct { + SomeField int `json:"someField"` +} + +func (j *JSONData) UnmarshalEnvironmentValue(data string) error { + var tmp JSONData + err := json.Unmarshal([]byte(data), &tmp) + if err != nil { + return err + } + *j = tmp + return nil +} + func TestUnmarshal(t *testing.T) { environ := map[string]string{ "HOME": "/home/test", @@ -184,6 +216,32 @@ func TestUnmarshalPointer(t *testing.T) { } } +func TestCustomUnmarshal(t *testing.T) { + someValue := "some value" + environ := map[string]string{ + "BASE64_ENCODED_STRING": base64.StdEncoding.EncodeToString([]byte(someValue)), + "JSON_DATA": `{ "someField": 42 }`, + } + + var validStruct ValidStruct + err := Unmarshal(environ, &validStruct) + if err != nil { + t.Errorf("Expected no error but got '%s'", err) + } + + if validStruct.Base64EncodedString == nil { + t.Errorf("Expected field value to be '%s' but got '%v'", someValue, nil) + } else if *validStruct.Base64EncodedString != Base64EncodedString(someValue) { + t.Errorf("Expected field value to be '%s' but got '%s'", someValue, string(*validStruct.Base64EncodedString)) + } + + if validStruct.JSONData == nil { + t.Errorf("Expected field value to be '%s' but got '%v'", someValue, nil) + } else if validStruct.JSONData.SomeField != 42 { + t.Errorf("Expected field value to be '%d' but got '%d'", 42, validStruct.JSONData.SomeField) + } +} + func TestUnmarshalInvalid(t *testing.T) { environ := make(map[string]string) @@ -251,7 +309,7 @@ func TestUnmarshalUnexported(t *testing.T) { } func TestUnmarshalDefaultValues(t *testing.T) { - environ := map[string]string { + environ := map[string]string{ "PRESENT": "youFoundMe", } var defaultValueStruct DefaultValueStruct diff --git a/unmarshal.go b/unmarshal.go new file mode 100644 index 0000000..be00dcb --- /dev/null +++ b/unmarshal.go @@ -0,0 +1,8 @@ +package env + +// Unmarshaler is the interface implemented by types that can unmarshal an +// environment variable value representation of themselves. The input can be +// assumed to be the raw string value stored in the environment. +type Unmarshaler interface { + UnmarshalEnvironmentValue(data string) error +} From 260a4dc643089fb6b683e185bc6f55e0e11bee76 Mon Sep 17 00:00:00 2001 From: Jack Wink <57678801+mothershipper@users.noreply.github.com> Date: Wed, 25 Nov 2020 16:12:12 -0800 Subject: [PATCH 08/21] Adds custom marshaler interface and handling This lets you marshal custom types, like structs or scalars with specific post-processing steps required. For instance, you could marshal to JSON or base64. --- README.md | 20 +++++++++++++++++--- env.go | 17 ++++++++++++++--- env_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ marshal.go | 6 ++++++ 4 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 marshal.go diff --git a/README.md b/README.md index 0971f14..e50a5ba 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,10 @@ func main() { } ``` -## Custom Unmarshaler +## Custom Marshaler/Unmarshaler -There is limited support for dictating how a field should be unmarshaled. The following example -shows how you could unmarshal from JSON +There is limited support for dictating how a field should be marshaled or unmarshaled. The following example +shows how you could marshal/unmarshal from JSON ```go import ( @@ -101,6 +101,14 @@ func (s *SomeData) UnmarshalEnvironmentValue(data string) error { return nil } +func (s SomeData) MarshalEnvironmentValue() (string, error) { + bytes, err := json.Marshal(s) + if err != nil { + return "", err + } + return string(bytes), nil +} + type Config struct { SomeData *SomeData `env:"SOME_DATA"` } @@ -117,5 +125,11 @@ func main() { } else { fmt.Printf("Got nil or some other value: %v\n", cfg.SomeData) } + + es, err = env.Marshal(cfg) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Got the following: %+v\n", es) } ``` diff --git a/env.go b/env.go index 5545b58..b28d6e0 100644 --- a/env.go +++ b/env.go @@ -262,14 +262,25 @@ func Marshal(v interface{}) (EnvSet, error) { envKeys := strings.Split(tag, ",") - var envValue string + var el interface{} if typeField.Type.Kind() == reflect.Ptr { if valueField.IsNil() { continue } - envValue = fmt.Sprintf("%v", valueField.Elem().Interface()) + el = valueField.Elem().Interface() + } else { + el = valueField.Interface() + } + + var err error + var envValue string + if m, ok := el.(Marshaler); ok { + envValue, err = m.MarshalEnvironmentValue() + if err != nil { + return nil, err + } } else { - envValue = fmt.Sprintf("%v", valueField.Interface()) + envValue = fmt.Sprintf("%v", el) } for _, envKey := range envKeys { diff --git a/env_test.go b/env_test.go index 6f8c875..bab8d34 100644 --- a/env_test.go +++ b/env_test.go @@ -99,6 +99,10 @@ func (b *Base64EncodedString) UnmarshalEnvironmentValue(data string) error { return nil } +func (b Base64EncodedString) MarshalEnvironmentValue() (string, error) { + return base64.StdEncoding.EncodeToString([]byte(b)), nil +} + type JSONData struct { SomeField int `json:"someField"` } @@ -113,6 +117,14 @@ func (j *JSONData) UnmarshalEnvironmentValue(data string) error { return nil } +func (j JSONData) MarshalEnvironmentValue() (string, error) { + bytes, err := json.Marshal(j) + if err != nil { + return "", err + } + return string(bytes), nil +} + func TestUnmarshal(t *testing.T) { environ := map[string]string{ "HOME": "/home/test", @@ -448,3 +460,32 @@ func TestMarshalPointer(t *testing.T) { t.Errorf("Expected field '%s' to not exist but got '%s'", "JENKINS_POINTER_MISSING", v) } } + +func TestMarshalCustom(t *testing.T) { + someValue := Base64EncodedString("someValue") + validStruct := ValidStruct{ + Base64EncodedString: &someValue, + JSONData: &JSONData{ + SomeField: 42, + }, + } + es, err := Marshal(&validStruct) + if err != nil { + t.Errorf("Expected no error but got '%s'", err) + } + + v, ok := es["BASE64_ENCODED_STRING"] + if !ok { + t.Errorf("Expected field '%s' to exist but missing", "BASE64_ENCODED_STRING") + } else if v != base64.StdEncoding.EncodeToString([]byte(someValue)) { + t.Errorf("Expected field value to be '%s' but got '%s'", base64.StdEncoding.EncodeToString([]byte(someValue)), v) + } + + v, ok = es["JSON_DATA"] + if !ok { + t.Errorf("Expected field '%s' to exist but got '%s'", "JSON_DATA", v) + } else if v != `{"someField":42}` { + t.Errorf("Expected field value to be '%s' but got '%s'", `{"someField":42}`, v) + } + +} diff --git a/marshal.go b/marshal.go new file mode 100644 index 0000000..0e0aed5 --- /dev/null +++ b/marshal.go @@ -0,0 +1,6 @@ +package env + +// Marshaler is the interface implemented by types that can marshal themselves into valid environment variable values. +type Marshaler interface { + MarshalEnvironmentValue() (string, error) +} From fc6c08c71bf8d5e5dc56e7c43c6c6b334490472e Mon Sep 17 00:00:00 2001 From: Jack Wink <57678801+mothershipper@users.noreply.github.com> Date: Wed, 25 Nov 2020 16:42:45 -0800 Subject: [PATCH 09/21] Adds tests for values in custom (un)marshaler Ensures we support values in addition to pointer values when using the custom marshaler / unmarshaler. --- env_test.go | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/env_test.go b/env_test.go index bab8d34..6440263 100644 --- a/env_test.go +++ b/env_test.go @@ -58,9 +58,11 @@ type ValidStruct struct { Duration time.Duration `env:"TYPE_DURATION"` // Custom unmarshaler should support scalar types - Base64EncodedString *Base64EncodedString `env:"BASE64_ENCODED_STRING"` + Base64EncodedString Base64EncodedString `env:"BASE64_ENCODED_STRING"` // Custom unmarshaler should support struct types - JSONData *JSONData `env:"JSON_DATA"` + JSONData JSONData `env:"JSON_DATA"` + // Custom unmarshaler should support pointer types as well + PointerJSONData *JSONData `env:"POINTER_JSON_DATA"` } type UnsupportedStruct struct { @@ -233,6 +235,7 @@ func TestCustomUnmarshal(t *testing.T) { environ := map[string]string{ "BASE64_ENCODED_STRING": base64.StdEncoding.EncodeToString([]byte(someValue)), "JSON_DATA": `{ "someField": 42 }`, + "POINTER_JSON_DATA": `{ "someField": 43 }`, } var validStruct ValidStruct @@ -241,15 +244,17 @@ func TestCustomUnmarshal(t *testing.T) { t.Errorf("Expected no error but got '%s'", err) } - if validStruct.Base64EncodedString == nil { - t.Errorf("Expected field value to be '%s' but got '%v'", someValue, nil) - } else if *validStruct.Base64EncodedString != Base64EncodedString(someValue) { - t.Errorf("Expected field value to be '%s' but got '%s'", someValue, string(*validStruct.Base64EncodedString)) + if validStruct.Base64EncodedString != Base64EncodedString(someValue) { + t.Errorf("Expected field value to be '%s' but got '%s'", someValue, string(validStruct.Base64EncodedString)) } - if validStruct.JSONData == nil { + if validStruct.PointerJSONData == nil { t.Errorf("Expected field value to be '%s' but got '%v'", someValue, nil) - } else if validStruct.JSONData.SomeField != 42 { + } else if validStruct.PointerJSONData.SomeField != 43 { + t.Errorf("Expected field value to be '%d' but got '%d'", 43, validStruct.PointerJSONData.SomeField) + } + + if validStruct.JSONData.SomeField != 42 { t.Errorf("Expected field value to be '%d' but got '%d'", 42, validStruct.JSONData.SomeField) } } @@ -464,10 +469,13 @@ func TestMarshalPointer(t *testing.T) { func TestMarshalCustom(t *testing.T) { someValue := Base64EncodedString("someValue") validStruct := ValidStruct{ - Base64EncodedString: &someValue, - JSONData: &JSONData{ + Base64EncodedString: someValue, + JSONData: JSONData{ SomeField: 42, }, + PointerJSONData: &JSONData{ + SomeField: 43, + }, } es, err := Marshal(&validStruct) if err != nil { @@ -488,4 +496,11 @@ func TestMarshalCustom(t *testing.T) { t.Errorf("Expected field value to be '%s' but got '%s'", `{"someField":42}`, v) } + v, ok = es["POINTER_JSON_DATA"] + if !ok { + t.Errorf("Expected field '%s' to exist but got '%s'", "POINTER_JSON_DATA", v) + } else if v != `{"someField":43}` { + t.Errorf("Expected field value to be '%s' but got '%s'", `{"someField":43}`, v) + } + } From 0920e6e4a0965cba36d8facf5c47d59342f7621f Mon Sep 17 00:00:00 2001 From: Kostadin Plachkov Date: Wed, 23 Dec 2020 15:03:07 +0100 Subject: [PATCH 10/21] Add float32 and float64 support Signed-off-by: Kostadin Plachkov --- env.go | 18 +++++++++++++++--- env_test.go | 32 +++++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/env.go b/env.go index 29d6360..f6d1c2e 100644 --- a/env.go +++ b/env.go @@ -93,7 +93,7 @@ func Unmarshal(es EnvSet, v interface{}) error { var ( envValue string - ok bool + ok bool ) for _, envKey := range envTag.Keys { envValue, ok = es[envKey] @@ -139,6 +139,18 @@ func set(t reflect.Type, f reflect.Value, value string) error { return err } f.SetBool(v) + case reflect.Float32: + v, err := strconv.ParseFloat(value, 32) + if err != nil { + return err + } + f.SetFloat(v) + case reflect.Float64: + v, err := strconv.ParseFloat(value, 64) + if err != nil { + return err + } + f.SetFloat(v) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: if t.PkgPath() == "time" && t.Name() == "Duration" { duration, err := time.ParseDuration(value) @@ -248,8 +260,8 @@ func Marshal(v interface{}) (EnvSet, error) { } type tag struct { - Keys []string - Default string + Keys []string + Default string Required bool } diff --git a/env_test.go b/env_test.go index b1f6c90..d90621b 100644 --- a/env_test.go +++ b/env_test.go @@ -47,8 +47,10 @@ type ValidStruct struct { Extra string // Additional supported types - Int int `env:"INT"` - Bool bool `env:"BOOL"` + Int int `env:"INT"` + Float32 float32 `env:"FLOAT32"` + Float64 float64 `env:"FLOAT64"` + Bool bool `env:"BOOL"` MultipleTags string `env:"npm_config_cache,NPM_CONFIG_CACHE"` @@ -69,6 +71,8 @@ type DefaultValueStruct struct { DefaultKeyValueString string `env:"MISSING_KVSTRING,default=key=value"` DefaultBool bool `env:"MISSING_BOOL,default=true"` DefaultInt int `env:"MISSING_INT,default=7"` + DefaultFloat32 float32 `env:"MISSING_FLOAT32,default=8.9"` + DefaultFloat64 float64 `env:"MISSING_FLOAT64,default=10.11"` DefaultDuration time.Duration `env:"MISSING_DURATION,default=5s"` DefaultWithOptionsMissing string `env:"MISSING_1,MISSING_2,default=present"` DefaultWithOptionsPresent string `env:"MISSING_1,PRESENT,default=present"` @@ -87,6 +91,8 @@ func TestUnmarshal(t *testing.T) { "WORKSPACE": "/mnt/builds/slave/workspace/test", "EXTRA": "extra", "INT": "1", + "FLOAT32": "2.3", + "FLOAT64": "4.5", "BOOL": "true", "npm_config_cache": "first", "NPM_CONFIG_CACHE": "second", @@ -119,6 +125,14 @@ func TestUnmarshal(t *testing.T) { t.Errorf("Expected field value to be '%d' but got '%d'", 1, validStruct.Int) } + if validStruct.Float32 != 2.3 { + t.Errorf("Expected field value to be '%f' but got '%f'", 2.3, validStruct.Float32) + } + + if validStruct.Float64 != 4.5 { + t.Errorf("Expected field value to be '%f' but got '%f'", 4.5, validStruct.Float64) + } + if validStruct.Bool != true { t.Errorf("Expected field value to be '%t' but got '%t'", true, validStruct.Bool) } @@ -251,7 +265,7 @@ func TestUnmarshalUnexported(t *testing.T) { } func TestUnmarshalDefaultValues(t *testing.T) { - environ := map[string]string { + environ := map[string]string{ "PRESENT": "youFoundMe", } var defaultValueStruct DefaultValueStruct @@ -261,6 +275,8 @@ func TestUnmarshalDefaultValues(t *testing.T) { } testCases := [][]interface{}{ {defaultValueStruct.DefaultInt, 7}, + {defaultValueStruct.DefaultFloat32, float32(8.9)}, + {defaultValueStruct.DefaultFloat64, 10.11}, {defaultValueStruct.DefaultBool, true}, {defaultValueStruct.DefaultString, "found"}, {defaultValueStruct.DefaultKeyValueString, "key=value"}, @@ -306,6 +322,8 @@ func TestMarshal(t *testing.T) { }, Extra: "extra", Int: 1, + Float32: float32(2.3), + Float64: 4.5, Bool: true, MultipleTags: "foobar", Duration: 3 * time.Minute, @@ -332,6 +350,14 @@ func TestMarshal(t *testing.T) { t.Errorf("Expected field value to be '%s' but got '%s'", "1", environ["INT"]) } + if environ["FLOAT32"] != "2.3" { + t.Errorf("Expected field value to be '%s' but got '%s'", "2.3", environ["FLOAT32"]) + } + + if environ["FLOAT64"] != "4.5" { + t.Errorf("Expected field value to be '%s' but got '%s'", "4.5", environ["FLOAT64"]) + } + if environ["BOOL"] != "true" { t.Errorf("Expected field value to be '%s' but got '%s'", "true", environ["BOOL"]) } From e5bd5313289bcf8548485bdf08da8aa2ba1d72f2 Mon Sep 17 00:00:00 2001 From: ben Date: Sat, 16 Jan 2021 20:43:01 +0000 Subject: [PATCH 11/21] Fix tiny typo in test --- env_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/env_test.go b/env_test.go index e0157bb..8d92109 100644 --- a/env_test.go +++ b/env_test.go @@ -371,7 +371,7 @@ func TestUnmarshalRequiredValues(t *testing.T) { var requiredValuesStruct RequiredValueStruct err := Unmarshal(environ, &requiredValuesStruct) if err != ErrMissingRequiredValue { - t.Errorf("Expected error 'ErrMissingRequiredValue' but go '%s'", err) + t.Errorf("Expected error 'ErrMissingRequiredValue' but got '%s'", err) } environ["REQUIRED_VAL"] = "required" err = Unmarshal(environ, &requiredValuesStruct) From 36a1c278170b70c6d9226490bf6c7c059d161060 Mon Sep 17 00:00:00 2001 From: Thiwanon Chomcharoen Date: Tue, 16 Feb 2021 01:11:12 +0700 Subject: [PATCH 12/21] :adhesive_bandage: Add detail for required field error --- env.go | 2 +- env_test.go | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/env.go b/env.go index 044dba1..15b9bbb 100644 --- a/env.go +++ b/env.go @@ -108,7 +108,7 @@ func Unmarshal(es EnvSet, v interface{}) error { if envTag.Default != "" { envValue = envTag.Default } else if envTag.Required { - return ErrMissingRequiredValue + return fmt.Errorf("%s %s", ErrMissingRequiredValue, envTag.Keys) } else { continue } diff --git a/env_test.go b/env_test.go index 8d92109..039146f 100644 --- a/env_test.go +++ b/env_test.go @@ -16,6 +16,7 @@ package env import ( "encoding/base64" "encoding/json" + "fmt" "os" "testing" "time" @@ -89,6 +90,7 @@ type DefaultValueStruct struct { type RequiredValueStruct struct { Required string `env:"REQUIRED_VAL,required=true"` + RequiredMore string `env:"REQUIRED_VAL_MORE,required=true"` RequiredWithDefault string `env:"REQUIRED_MISSING,default=myValue,required=true"` NotRequired string `env:"NOT_REQUIRED,required=false"` InvalidExtra string `env:"INVALID,invalid=invalid"` @@ -369,12 +371,21 @@ func TestUnmarshalDefaultValues(t *testing.T) { func TestUnmarshalRequiredValues(t *testing.T) { environ := map[string]string{} var requiredValuesStruct RequiredValueStruct + + // Try missing REQUIRED_VAL and REQUIRED_VAL_MORE err := Unmarshal(environ, &requiredValuesStruct) - if err != ErrMissingRequiredValue { + if err.Error() != fmt.Errorf("%s [%s]", ErrMissingRequiredValue.Error(), "REQUIRED_VAL").Error() { t.Errorf("Expected error 'ErrMissingRequiredValue' but got '%s'", err) } + + // Fill REQUIRED_VAL and retry REQUIRED_VAL_MORE environ["REQUIRED_VAL"] = "required" err = Unmarshal(environ, &requiredValuesStruct) + if err.Error() != fmt.Errorf("%s [%s]", ErrMissingRequiredValue.Error(), "REQUIRED_VAL_MORE").Error() { + t.Errorf("Expected error 'ErrMissingRequiredValue' but got '%s'", err) + } + environ["REQUIRED_VAL_MORE"] = "required" + err = Unmarshal(environ, &requiredValuesStruct) if err != nil { t.Errorf("Expected no error but got '%s'", err) } From 40fa93bfff30e8db21c4cca37cc6bdfdb53d4230 Mon Sep 17 00:00:00 2001 From: Thiwanon Chomcharoen Date: Tue, 16 Feb 2021 02:26:59 +0700 Subject: [PATCH 13/21] :adhesive_bandage: Refactor error type --- env.go | 14 ++++++++++---- env_test.go | 7 ++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/env.go b/env.go index 15b9bbb..d5cb875 100644 --- a/env.go +++ b/env.go @@ -37,12 +37,18 @@ var ( // ErrUnexportedField returned when a field with tag "env" is not exported. ErrUnexportedField = errors.New("field must be exported") - // ErrMissingRequiredValue returned when a field with required=true contains no value or default - ErrMissingRequiredValue = errors.New("value for this field is required") - unmarshalType = reflect.TypeOf((*Unmarshaler)(nil)).Elem() ) +// ErrMissingRequiredValue returned when a field with required=true contains no value or default +type ErrMissingRequiredValue struct { + Value string +} + +func (e ErrMissingRequiredValue) Error() string { + return fmt.Sprintf("value for this field is required [%s]", e.Value) +} + // Unmarshal parses an EnvSet and stores the result in the value pointed to by // v. Fields that are matched in v will be deleted from EnvSet, resulting in // an EnvSet with the remaining environment variables. If v is nil or not a @@ -108,7 +114,7 @@ func Unmarshal(es EnvSet, v interface{}) error { if envTag.Default != "" { envValue = envTag.Default } else if envTag.Required { - return fmt.Errorf("%s %s", ErrMissingRequiredValue, envTag.Keys) + return &ErrMissingRequiredValue{Value: envTag.Keys[0]} } else { continue } diff --git a/env_test.go b/env_test.go index 039146f..92fc338 100644 --- a/env_test.go +++ b/env_test.go @@ -16,7 +16,6 @@ package env import ( "encoding/base64" "encoding/json" - "fmt" "os" "testing" "time" @@ -374,14 +373,16 @@ func TestUnmarshalRequiredValues(t *testing.T) { // Try missing REQUIRED_VAL and REQUIRED_VAL_MORE err := Unmarshal(environ, &requiredValuesStruct) - if err.Error() != fmt.Errorf("%s [%s]", ErrMissingRequiredValue.Error(), "REQUIRED_VAL").Error() { + errMissing := ErrMissingRequiredValue{Value: "REQUIRED_VAL"} + if err.Error() != errMissing.Error() { t.Errorf("Expected error 'ErrMissingRequiredValue' but got '%s'", err) } // Fill REQUIRED_VAL and retry REQUIRED_VAL_MORE environ["REQUIRED_VAL"] = "required" err = Unmarshal(environ, &requiredValuesStruct) - if err.Error() != fmt.Errorf("%s [%s]", ErrMissingRequiredValue.Error(), "REQUIRED_VAL_MORE").Error() { + errMissing = ErrMissingRequiredValue{Value: "REQUIRED_VAL_MORE"} + if err.Error() != errMissing.Error() { t.Errorf("Expected error 'ErrMissingRequiredValue' but got '%s'", err) } environ["REQUIRED_VAL_MORE"] = "required" From 3394e7c015acadbef283b2a17df7ef2cc8c333d6 Mon Sep 17 00:00:00 2001 From: Jorge Junior Date: Tue, 24 May 2022 13:38:54 -0300 Subject: [PATCH 14/21] Add uint uint8 uint16 uint32 uint64 support --- env.go | 6 ++++++ env_test.go | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/env.go b/env.go index d5cb875..61ffd2a 100644 --- a/env.go +++ b/env.go @@ -206,6 +206,12 @@ func set(t reflect.Type, f reflect.Value, value string) error { return err } f.SetInt(int64(v)) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + v, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return err + } + f.SetUint(v) default: return ErrUnsupportedType } diff --git a/env_test.go b/env_test.go index 92fc338..9f823fe 100644 --- a/env_test.go +++ b/env_test.go @@ -50,6 +50,7 @@ type ValidStruct struct { // Additional supported types Int int `env:"INT"` + Uint uint `env:"UINT"` Float32 float32 `env:"FLOAT32"` Float64 float64 `env:"FLOAT64"` Bool bool `env:"BOOL"` @@ -138,6 +139,7 @@ func TestUnmarshal(t *testing.T) { "WORKSPACE": "/mnt/builds/slave/workspace/test", "EXTRA": "extra", "INT": "1", + "UINT": "2", "FLOAT32": "2.3", "FLOAT64": "4.5", "BOOL": "true", @@ -409,6 +411,7 @@ func TestMarshal(t *testing.T) { }, Extra: "extra", Int: 1, + Uint: 2, Float32: float32(2.3), Float64: 4.5, Bool: true, @@ -437,6 +440,10 @@ func TestMarshal(t *testing.T) { t.Errorf("Expected field value to be '%s' but got '%s'", "1", environ["INT"]) } + if environ["UINT"] != "2" { + t.Errorf("Expected field value to be '%s' but got '%s'", "2", environ["UINT"]) + } + if environ["FLOAT32"] != "2.3" { t.Errorf("Expected field value to be '%s' but got '%s'", "2.3", environ["FLOAT32"]) } From 5fdd701b7ca84b985ef4e088a31fa9a03a92eec3 Mon Sep 17 00:00:00 2001 From: Jorge Junior Date: Tue, 24 May 2022 17:35:51 -0300 Subject: [PATCH 15/21] adding more tests for uint support --- env_test.go | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/env_test.go b/env_test.go index 9f823fe..3d120af 100644 --- a/env_test.go +++ b/env_test.go @@ -39,6 +39,9 @@ type ValidStruct struct { // PointerInt should work along with other supported types. PointerInt *int `env:"POINTER_INT"` + // PointerUint should work along with other supported types. + PointerUint *uint `env:"POINTER_UINT"` + // PointerPointerString should be recursed into. PointerPointerString **string `env:"POINTER_POINTER_STRING"` @@ -81,6 +84,7 @@ type DefaultValueStruct struct { DefaultKeyValueString string `env:"MISSING_KVSTRING,default=key=value"` DefaultBool bool `env:"MISSING_BOOL,default=true"` DefaultInt int `env:"MISSING_INT,default=7"` + DefaultUint uint `env:"MISSING_UINT,default=4294967295"` DefaultFloat32 float32 `env:"MISSING_FLOAT32,default=8.9"` DefaultFloat64 float64 `env:"MISSING_FLOAT64,default=10.11"` DefaultDuration time.Duration `env:"MISSING_DURATION,default=5s"` @@ -139,7 +143,7 @@ func TestUnmarshal(t *testing.T) { "WORKSPACE": "/mnt/builds/slave/workspace/test", "EXTRA": "extra", "INT": "1", - "UINT": "2", + "UINT": "4294967295", "FLOAT32": "2.3", "FLOAT64": "4.5", "BOOL": "true", @@ -174,6 +178,10 @@ func TestUnmarshal(t *testing.T) { t.Errorf("Expected field value to be '%d' but got '%d'", 1, validStruct.Int) } + if validStruct.Uint != 4294967295 { + t.Errorf("Expected field value to be '%d' but got '%d'", 4294967295, validStruct.Uint) + } + if validStruct.Float32 != 2.3 { t.Errorf("Expected field value to be '%f' but got '%f'", 2.3, validStruct.Float32) } @@ -211,6 +219,7 @@ func TestUnmarshalPointer(t *testing.T) { environ := map[string]string{ "POINTER_STRING": "", "POINTER_INT": "1", + "POINTER_UINT": "4294967295", "POINTER_POINTER_STRING": "", } @@ -232,6 +241,12 @@ func TestUnmarshalPointer(t *testing.T) { t.Errorf("Expected field value to be '%d' but got '%d'", 1, *validStruct.PointerInt) } + if validStruct.PointerUint == nil { + t.Errorf("Expected field value to be '%d' but got '%v'", 4294967295, nil) + } else if *validStruct.PointerUint != 4294967295 { + t.Errorf("Expected field value to be '%d' but got '%d'", 4294967295, *validStruct.PointerUint) + } + if validStruct.PointerPointerString == nil { t.Errorf("Expected field value to be '%s' but got '%v'", "", nil) } else { @@ -353,6 +368,7 @@ func TestUnmarshalDefaultValues(t *testing.T) { } testCases := [][]interface{}{ {defaultValueStruct.DefaultInt, 7}, + {defaultValueStruct.DefaultUint, uint(4294967295)}, {defaultValueStruct.DefaultFloat32, float32(8.9)}, {defaultValueStruct.DefaultFloat64, 10.11}, {defaultValueStruct.DefaultBool, true}, @@ -411,7 +427,7 @@ func TestMarshal(t *testing.T) { }, Extra: "extra", Int: 1, - Uint: 2, + Uint: 4294967295, Float32: float32(2.3), Float64: 4.5, Bool: true, @@ -440,7 +456,7 @@ func TestMarshal(t *testing.T) { t.Errorf("Expected field value to be '%s' but got '%s'", "1", environ["INT"]) } - if environ["UINT"] != "2" { + if environ["UINT"] != "4294967295" { t.Errorf("Expected field value to be '%s' but got '%s'", "2", environ["UINT"]) } From 4bce73d1be3f73d3712565c1123c5dcc8e225952 Mon Sep 17 00:00:00 2001 From: Patrick Sanders Date: Tue, 6 Aug 2024 15:48:43 -0700 Subject: [PATCH 16/21] Make repo a Go module, switch to GitHub Actions (#25) * add go.mod * swap travis for github action, update badges in readme * scrap the matrix, use latest --- .github/workflows/build.yml | 25 +++++++++++++++++++++++++ .travis.yml | 5 ----- README.md | 4 ++-- go.mod | 3 +++ 4 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/build.yml delete mode 100644 .travis.yml create mode 100644 go.mod diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..2a5734d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,25 @@ +name: Go + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.22 + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 8c2998f..0000000 --- a/.travis.yml +++ /dev/null @@ -1,5 +0,0 @@ -language: go - -go: - - "1.10.2" - - master diff --git a/README.md b/README.md index e50a5ba..b6d8fcf 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # go-env -[![Build Status](https://travis-ci.com/Netflix/go-env.svg?branch=master)](https://travis-ci.com/Netflix/go-env) -[![GoDoc](https://godoc.org/github.com/Netflix/go-env?status.svg)](https://godoc.org/github.com/Netflix/go-env) +![Build Status](https://github.com/Netflix/go-env/actions/workflows/build.yml/badge.svg) +[![Go Reference](https://pkg.go.dev/badge/github.com/Netflix/go-env.svg)](https://pkg.go.dev/github.com/Netflix/go-env) [![NetflixOSS Lifecycle](https://img.shields.io/osslifecycle/Netflix/go-expect.svg)]() diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fb049dd --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/Netflix/go-env + +go 1.22.5 From e66d55ae4c98dc1369d8bd21e61daa90224214ad Mon Sep 17 00:00:00 2001 From: Daniel Francesconi Date: Thu, 29 Aug 2024 22:20:06 +0200 Subject: [PATCH 17/21] Remove go minor revision specification (#26) --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index fb049dd..e02a2a8 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/Netflix/go-env -go 1.22.5 +go 1.22 From 131cef9fd1660019a633aebf343528bf68d312c9 Mon Sep 17 00:00:00 2001 From: Jimmy Keith Date: Thu, 29 Aug 2024 13:24:06 -0700 Subject: [PATCH 18/21] Slice support (#13) * add slice support * update test * fix default slice values * add additional kv string test * switch to a configurable slice separator * style: fix trailing newline * fix: use tag field for separator --------- Co-authored-by: jimmykodes --- env.go | 34 +++++++++++++++++++++++++++------ env_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/env.go b/env.go index 61ffd2a..1a82ee4 100644 --- a/env.go +++ b/env.go @@ -120,7 +120,7 @@ func Unmarshal(es EnvSet, v interface{}) error { } } - err := set(typeField.Type, valueField, envValue) + err := set(typeField.Type, valueField, envValue, envTag.Separator) if err != nil { return err } @@ -130,7 +130,7 @@ func Unmarshal(es EnvSet, v interface{}) error { return nil } -func set(t reflect.Type, f reflect.Value, value string) error { +func set(t reflect.Type, f reflect.Value, value, sliceSeparator string) error { // See if the type implements Unmarshaler and use that first, // otherwise, fallback to the previous logic var isUnmarshaler bool @@ -165,7 +165,7 @@ func set(t reflect.Type, f reflect.Value, value string) error { switch t.Kind() { case reflect.Ptr: ptr := reflect.New(t.Elem()) - err := set(t.Elem(), ptr.Elem(), value) + err := set(t.Elem(), ptr.Elem(), value, sliceSeparator) if err != nil { return err } @@ -212,6 +212,25 @@ func set(t reflect.Type, f reflect.Value, value string) error { return err } f.SetUint(v) + case reflect.Slice: + if sliceSeparator == "" { + sliceSeparator = "|" + } + values := strings.Split(value, sliceSeparator) + switch t.Elem().Kind() { + case reflect.String: + // already []string, just set directly + f.Set(reflect.ValueOf(values)) + default: + dest := reflect.MakeSlice(reflect.SliceOf(t.Elem()), len(values), len(values)) + for i, v := range values { + err := set(t.Elem(), dest.Index(i), v, sliceSeparator) + if err != nil { + return err + } + } + f.Set(dest) + } default: return ErrUnsupportedType } @@ -316,9 +335,10 @@ func Marshal(v interface{}) (EnvSet, error) { } type tag struct { - Keys []string - Default string - Required bool + Keys []string + Default string + Required bool + Separator string } func parseTag(tagString string) tag { @@ -332,6 +352,8 @@ func parseTag(tagString string) tag { t.Default = keyData[1] case "required": t.Required = strings.ToLower(keyData[1]) == "true" + case "separator": + t.Separator = keyData[1] default: // just ignoring unsupported keys continue diff --git a/env_test.go b/env_test.go index 3d120af..d68ee29 100644 --- a/env_test.go +++ b/env_test.go @@ -4,7 +4,7 @@ // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// http://www.apache.org/licenses/LICENSE-2.0 +// http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, @@ -17,6 +17,7 @@ import ( "encoding/base64" "encoding/json" "os" + "reflect" "testing" "time" ) @@ -88,6 +89,9 @@ type DefaultValueStruct struct { DefaultFloat32 float32 `env:"MISSING_FLOAT32,default=8.9"` DefaultFloat64 float64 `env:"MISSING_FLOAT64,default=10.11"` DefaultDuration time.Duration `env:"MISSING_DURATION,default=5s"` + DefaultStringSlice []string `env:"MISSING_STRING_SLICE,default=separate|values"` + DefaultSliceWithSeparator []string `env:"ANOTHER_MISSING_STRING_SLICE,default=separate&values,separator=&"` + DefaultRequiredSlice []string `env:"MISSING_REQUIRED_DEFAULT,default=other|things,required=true"` DefaultWithOptionsMissing string `env:"MISSING_1,MISSING_2,default=present"` DefaultWithOptionsPresent string `env:"MISSING_1,PRESENT,default=present"` } @@ -137,6 +141,16 @@ func (j JSONData) MarshalEnvironmentValue() (string, error) { return string(bytes), nil } +type IterValuesStruct struct { + StringSlice []string `env:"STRING"` + IntSlice []int `env:"INT"` + Int64Slice []int64 `env:"INT64"` + DurationSlice []time.Duration `env:"DURATION"` + BoolSlice []bool `env:"BOOL"` + KVStringSlice []string `env:"KV"` + WithSeparator []int `env:"SEPARATOR,separator=&"` +} + func TestUnmarshal(t *testing.T) { environ := map[string]string{ "HOME": "/home/test", @@ -357,6 +371,38 @@ func TestUnmarshalUnexported(t *testing.T) { } } +func TestUnmarshalSlice(t *testing.T) { + environ := map[string]string{ + "STRING": "separate|values", + "INT": "1|2", + "INT64": "3|4", + "DURATION": "60s|70h", + "BOOL": "true|false", + "KV": "k1=v1|k2=v2", + "SEPARATOR": "1&2", // struct has `separator=&` + } + var iterValStruct IterValuesStruct + err := Unmarshal(environ, &iterValStruct) + if err != nil { + t.Errorf("Expected no error but got %v", err) + return + } + testCases := [][]interface{}{ + {iterValStruct.StringSlice, []string{"separate", "values"}}, + {iterValStruct.IntSlice, []int{1, 2}}, + {iterValStruct.Int64Slice, []int64{3, 4}}, + {iterValStruct.DurationSlice, []time.Duration{time.Second * 60, time.Hour * 70}}, + {iterValStruct.BoolSlice, []bool{true, false}}, + {iterValStruct.KVStringSlice, []string{"k1=v1", "k2=v2"}}, + {iterValStruct.WithSeparator, []int{1, 2}}, + } + for _, testCase := range testCases { + if !reflect.DeepEqual(testCase[0], testCase[1]) { + t.Errorf("Expected field value to be '%v' but got '%v'", testCase[1], testCase[0]) + } + } +} + func TestUnmarshalDefaultValues(t *testing.T) { environ := map[string]string{ "PRESENT": "youFoundMe", @@ -375,11 +421,14 @@ func TestUnmarshalDefaultValues(t *testing.T) { {defaultValueStruct.DefaultString, "found"}, {defaultValueStruct.DefaultKeyValueString, "key=value"}, {defaultValueStruct.DefaultDuration, 5 * time.Second}, + {defaultValueStruct.DefaultStringSlice, []string{"separate", "values"}}, + {defaultValueStruct.DefaultSliceWithSeparator, []string{"separate", "values"}}, + {defaultValueStruct.DefaultRequiredSlice, []string{"other", "things"}}, {defaultValueStruct.DefaultWithOptionsMissing, "present"}, {defaultValueStruct.DefaultWithOptionsPresent, "youFoundMe"}, } for _, testCase := range testCases { - if testCase[0] != testCase[1] { + if !reflect.DeepEqual(testCase[0], testCase[1]) { t.Errorf("Expected field value to be '%v' but got '%v'", testCase[1], testCase[0]) } } @@ -563,5 +612,4 @@ func TestMarshalCustom(t *testing.T) { } else if v != `{"someField":43}` { t.Errorf("Expected field value to be '%s' but got '%s'", `{"someField":43}`, v) } - } From ec74a1d48a82cfcf7f1cfe7d5cbe1d35a3d473b4 Mon Sep 17 00:00:00 2001 From: Rachel Sheikh Date: Fri, 25 Oct 2024 15:10:39 -0700 Subject: [PATCH 19/21] chore: go 1.23 upgrade, README fix, docstring additions, & go styling updates (#28) * [chore] go 1.23.2 upgrade, README fix, docstring additions, & go styling updates Signed-off-by: sheikhrachel * fix: remove local test file Signed-off-by: sheikhrachel * fix: 1.23 go version & refactor redundant switch clauses Signed-off-by: Rachel * fix: remove bin dir Signed-off-by: Rachel --------- Signed-off-by: sheikhrachel Signed-off-by: Rachel --- README.md | 29 +++--- env.go | 88 ++++++++++------- env_test.go | 240 ++++++++++++++++++++++++---------------------- go.mod | 2 +- transform.go | 19 ++-- transform_test.go | 9 +- 6 files changed, 215 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index b6d8fcf..5fb2875 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ import ( "log" "time" - env "github.com/Netflix/go-env" + "github.com/Netflix/go-env" ) type Environment struct { @@ -50,7 +50,7 @@ func main() { // ... - es, err = env.Marshal(environment) + es, err = env.Marshal(&environment) if err != nil { log.Fatal(err) } @@ -64,8 +64,7 @@ func main() { es.Apply(cs) environment = Environment{} - err = env.Unmarshal(es, &environment) - if err != nil { + if err = env.Unmarshal(es, &environment); err != nil { log.Fatal(err) } @@ -73,18 +72,28 @@ func main() { } ``` +This will initially throw an error if `IM_REQUIRED` is not set in the environment as part of the env struct validation. + +This error can be resolved by setting the `IM_REQUIRED` environment variable manually in the environment or by setting it in the +code prior to calling `UnmarshalFromEnviron` with: +```go +os.Setenv("IM_REQUIRED", "some_value") +``` + ## Custom Marshaler/Unmarshaler There is limited support for dictating how a field should be marshaled or unmarshaled. The following example shows how you could marshal/unmarshal from JSON ```go +package main + import ( "encoding/json" "fmt" "log" - env "github.com/Netflix/go-env" + "github.com/Netflix/go-env" ) type SomeData struct { @@ -93,8 +102,7 @@ type SomeData struct { func (s *SomeData) UnmarshalEnvironmentValue(data string) error { var tmp SomeData - err := json.Unmarshal([]byte(data), &tmp) - if err != nil { + if err := json.Unmarshal([]byte(data), &tmp); err != nil { return err } *s = tmp @@ -114,9 +122,8 @@ type Config struct { } func main() { - var cfg Config - _, err := env.UnmarshalFromEnviron(&cfg) - if err != nil { + var cfg Config + if _, err := env.UnmarshalFromEnviron(&cfg); err != nil { log.Fatal(err) } @@ -126,7 +133,7 @@ func main() { fmt.Printf("Got nil or some other value: %v\n", cfg.SomeData) } - es, err = env.Marshal(cfg) + es, err := env.Marshal(&cfg) if err != nil { log.Fatal(err) } diff --git a/env.go b/env.go index 1a82ee4..c9b78ac 100644 --- a/env.go +++ b/env.go @@ -26,6 +26,17 @@ import ( "time" ) +const ( + // tagKeyDefault is the key used in the struct field tag to specify a default + tagKeyDefault = "default" + // tagKeyRequired is the key used in the struct field tag to specify that the + // field is required + tagKeyRequired = "required" + // tagKeySeparator is the key used in the struct field tag to specify a + // separator for slice fields + tagKeySeparator = "separator" +) + var ( // ErrInvalidValue returned when the value passed to Unmarshal is nil or not a // pointer to a struct. @@ -37,11 +48,14 @@ var ( // ErrUnexportedField returned when a field with tag "env" is not exported. ErrUnexportedField = errors.New("field must be exported") + // unmarshalType is the reflect.Type element of the Unmarshaler interface unmarshalType = reflect.TypeOf((*Unmarshaler)(nil)).Elem() ) // ErrMissingRequiredValue returned when a field with required=true contains no value or default type ErrMissingRequiredValue struct { + // Value is the type of value that is required to provide error context to + // the caller Value string } @@ -72,17 +86,13 @@ func Unmarshal(es EnvSet, v interface{}) error { } t := rv.Type() - for i := 0; i < rv.NumField(); i++ { + for i := range rv.NumField() { valueField := rv.Field(i) - switch valueField.Kind() { - case reflect.Struct: + if valueField.Kind() == reflect.Struct { if !valueField.Addr().CanInterface() { continue } - - iface := valueField.Addr().Interface() - err := Unmarshal(es, iface) - if err != nil { + if err := Unmarshal(es, valueField.Addr().Interface()); err != nil { return err } } @@ -120,8 +130,7 @@ func Unmarshal(es EnvSet, v interface{}) error { } } - err := set(typeField.Type, valueField, envValue, envTag.Separator) - if err != nil { + if err := set(typeField.Type, valueField, envValue, envTag.Separator); err != nil { return err } delete(es, tag) @@ -165,8 +174,7 @@ func set(t reflect.Type, f reflect.Value, value, sliceSeparator string) error { switch t.Kind() { case reflect.Ptr: ptr := reflect.New(t.Elem()) - err := set(t.Elem(), ptr.Elem(), value, sliceSeparator) - if err != nil { + if err := set(t.Elem(), ptr.Elem(), value, sliceSeparator); err != nil { return err } f.Set(ptr) @@ -224,8 +232,7 @@ func set(t reflect.Type, f reflect.Value, value, sliceSeparator string) error { default: dest := reflect.MakeSlice(reflect.SliceOf(t.Elem()), len(values), len(values)) for i, v := range values { - err := set(t.Elem(), dest.Index(i), v, sliceSeparator) - if err != nil { + if err := set(t.Elem(), dest.Index(i), v, sliceSeparator); err != nil { return err } } @@ -278,16 +285,14 @@ func Marshal(v interface{}) (EnvSet, error) { es := make(EnvSet) t := rv.Type() - for i := 0; i < rv.NumField(); i++ { + for i := range rv.NumField() { valueField := rv.Field(i) - switch valueField.Kind() { - case reflect.Struct: + if valueField.Kind() == reflect.Struct { if !valueField.Addr().CanInterface() { continue } - iface := valueField.Addr().Interface() - nes, err := Marshal(iface) + nes, err := Marshal(valueField.Addr().Interface()) if err != nil { return nil, err } @@ -315,8 +320,10 @@ func Marshal(v interface{}) (EnvSet, error) { el = valueField.Interface() } - var err error - var envValue string + var ( + err error + envValue string + ) if m, ok := el.(Marshaler); ok { envValue, err = m.MarshalEnvironmentValue() if err != nil { @@ -334,32 +341,39 @@ func Marshal(v interface{}) (EnvSet, error) { return es, nil } +// tag is a struct used to store the parsed "env" field tag when unmarshalling. type tag struct { - Keys []string - Default string - Required bool + // Keys is used to store the keys specified in the "env" field tag + Keys []string + // Default is used to specify a default value for the field + Default string + // Required is used to specify that the field is required + Required bool + // Separator is used to split the value of a slice field Separator string } +// parseTag is used in the Unmarshal function to parse the "env" field tags +// into a tag struct for use in the set function. func parseTag(tagString string) tag { var t tag envKeys := strings.Split(tagString, ",") for _, key := range envKeys { - if strings.Contains(key, "=") { - keyData := strings.SplitN(key, "=", 2) - switch strings.ToLower(keyData[0]) { - case "default": - t.Default = keyData[1] - case "required": - t.Required = strings.ToLower(keyData[1]) == "true" - case "separator": - t.Separator = keyData[1] - default: - // just ignoring unsupported keys - continue - } - } else { + if !strings.Contains(key, "=") { t.Keys = append(t.Keys, key) + continue + } + keyData := strings.SplitN(key, "=", 2) + switch strings.ToLower(keyData[0]) { + case tagKeyDefault: + t.Default = keyData[1] + case tagKeyRequired: + t.Required = strings.ToLower(keyData[1]) == "true" + case tagKeySeparator: + t.Separator = keyData[1] + default: + // just ignoring unsupported keys + continue } } return t diff --git a/env_test.go b/env_test.go index d68ee29..2bd6983 100644 --- a/env_test.go +++ b/env_test.go @@ -16,6 +16,7 @@ package env import ( "encoding/base64" "encoding/json" + "errors" "os" "reflect" "testing" @@ -125,8 +126,7 @@ type JSONData struct { func (j *JSONData) UnmarshalEnvironmentValue(data string) error { var tmp JSONData - err := json.Unmarshal([]byte(data), &tmp) - if err != nil { + if err := json.Unmarshal([]byte(data), &tmp); err != nil { return err } *j = tmp @@ -152,23 +152,25 @@ type IterValuesStruct struct { } func TestUnmarshal(t *testing.T) { - environ := map[string]string{ - "HOME": "/home/test", - "WORKSPACE": "/mnt/builds/slave/workspace/test", - "EXTRA": "extra", - "INT": "1", - "UINT": "4294967295", - "FLOAT32": "2.3", - "FLOAT64": "4.5", - "BOOL": "true", - "npm_config_cache": "first", - "NPM_CONFIG_CACHE": "second", - "TYPE_DURATION": "5s", - } + t.Parallel() + var ( + environ = map[string]string{ + "HOME": "/home/test", + "WORKSPACE": "/mnt/builds/slave/workspace/test", + "EXTRA": "extra", + "INT": "1", + "UINT": "4294967295", + "FLOAT32": "2.3", + "FLOAT64": "4.5", + "BOOL": "true", + "npm_config_cache": "first", + "NPM_CONFIG_CACHE": "second", + "TYPE_DURATION": "5s", + } + validStruct ValidStruct + ) - var validStruct ValidStruct - err := Unmarshal(environ, &validStruct) - if err != nil { + if err := Unmarshal(environ, &validStruct); err != nil { t.Errorf("Expected no error but got '%s'", err) } @@ -216,13 +218,11 @@ func TestUnmarshal(t *testing.T) { t.Errorf("Expected field value to be '%s' but got '%s'", "5s", validStruct.Duration) } - v, ok := environ["HOME"] - if ok { + if v, ok := environ["HOME"]; ok { t.Errorf("Expected field '%s' to not exist but got '%s'", "HOME", v) } - v, ok = environ["EXTRA"] - if !ok { + if v, ok := environ["EXTRA"]; !ok { t.Errorf("Expected field '%s' to exist but missing", "EXTRA") } else if v != "extra" { t.Errorf("Expected field value to be '%s' but got '%s'", "extra", v) @@ -230,16 +230,18 @@ func TestUnmarshal(t *testing.T) { } func TestUnmarshalPointer(t *testing.T) { - environ := map[string]string{ - "POINTER_STRING": "", - "POINTER_INT": "1", - "POINTER_UINT": "4294967295", - "POINTER_POINTER_STRING": "", - } + t.Parallel() + var ( + environ = map[string]string{ + "POINTER_STRING": "", + "POINTER_INT": "1", + "POINTER_UINT": "4294967295", + "POINTER_POINTER_STRING": "", + } + validStruct ValidStruct + ) - var validStruct ValidStruct - err := Unmarshal(environ, &validStruct) - if err != nil { + if err := Unmarshal(environ, &validStruct); err != nil { t.Errorf("Expected no error but got '%s'", err) } @@ -277,16 +279,18 @@ func TestUnmarshalPointer(t *testing.T) { } func TestCustomUnmarshal(t *testing.T) { - someValue := "some value" - environ := map[string]string{ - "BASE64_ENCODED_STRING": base64.StdEncoding.EncodeToString([]byte(someValue)), - "JSON_DATA": `{ "someField": 42 }`, - "POINTER_JSON_DATA": `{ "someField": 43 }`, - } + t.Parallel() + var ( + someValue = "some value" + environ = map[string]string{ + "BASE64_ENCODED_STRING": base64.StdEncoding.EncodeToString([]byte(someValue)), + "JSON_DATA": `{ "someField": 42 }`, + "POINTER_JSON_DATA": `{ "someField": 43 }`, + } + validStruct ValidStruct + ) - var validStruct ValidStruct - err := Unmarshal(environ, &validStruct) - if err != nil { + if err := Unmarshal(environ, &validStruct); err != nil { t.Errorf("Expected no error but got '%s'", err) } @@ -306,34 +310,36 @@ func TestCustomUnmarshal(t *testing.T) { } func TestUnmarshalInvalid(t *testing.T) { - environ := make(map[string]string) + t.Parallel() + var ( + environ = make(map[string]string) + validStruct ValidStruct + ) - var validStruct ValidStruct - err := Unmarshal(environ, validStruct) - if err != ErrInvalidValue { + if err := Unmarshal(environ, validStruct); !errors.Is(err, ErrInvalidValue) { t.Errorf("Expected error 'ErrInvalidValue' but got '%s'", err) } ptr := &validStruct - err = Unmarshal(environ, &ptr) - if err != ErrInvalidValue { + if err := Unmarshal(environ, &ptr); !errors.Is(err, ErrInvalidValue) { t.Errorf("Expected error 'ErrInvalidValue' but got '%s'", err) } } func TestUnmarshalUnsupported(t *testing.T) { - environ := map[string]string{ - "TIMESTAMP": "2016-07-15T12:00:00.000Z", - } + t.Parallel() + var ( + environ = map[string]string{"TIMESTAMP": "2016-07-15T12:00:00.000Z"} + unsupportedStruct UnsupportedStruct + ) - var unsupportedStruct UnsupportedStruct - err := Unmarshal(environ, &unsupportedStruct) - if err != ErrUnsupportedType { + if err := Unmarshal(environ, &unsupportedStruct); !errors.Is(err, ErrUnsupportedType) { t.Errorf("Expected error 'ErrUnsupportedType' but got '%s'", err) } } func TestUnmarshalFromEnviron(t *testing.T) { + t.Parallel() environ := os.Environ() es, err := EnvironToEnvSet(environ) @@ -353,40 +359,43 @@ func TestUnmarshalFromEnviron(t *testing.T) { t.Errorf("Expected environment variable to be '%s' but got '%s'", home, validStruct.Home) } - v, ok := es["HOME"] - if ok { + if v, ok := es["HOME"]; ok { t.Errorf("Expected field '%s' to not exist but got '%s'", "HOME", v) } } func TestUnmarshalUnexported(t *testing.T) { - environ := map[string]string{ - "HOME": "/home/edgarl", - } + t.Parallel() + var ( + environ = map[string]string{"HOME": "/home/edgarl"} + unexportedStruct UnexportedStruct + ) - var unexportedStruct UnexportedStruct - err := Unmarshal(environ, &unexportedStruct) - if err != ErrUnexportedField { + if err := Unmarshal(environ, &unexportedStruct); !errors.Is(err, ErrUnexportedField) { t.Errorf("Expected error 'ErrUnexportedField' but got '%s'", err) } } func TestUnmarshalSlice(t *testing.T) { - environ := map[string]string{ - "STRING": "separate|values", - "INT": "1|2", - "INT64": "3|4", - "DURATION": "60s|70h", - "BOOL": "true|false", - "KV": "k1=v1|k2=v2", - "SEPARATOR": "1&2", // struct has `separator=&` - } - var iterValStruct IterValuesStruct - err := Unmarshal(environ, &iterValStruct) - if err != nil { + t.Parallel() + var ( + environ = map[string]string{ + "STRING": "separate|values", + "INT": "1|2", + "INT64": "3|4", + "DURATION": "60s|70h", + "BOOL": "true|false", + "KV": "k1=v1|k2=v2", + "SEPARATOR": "1&2", // struct has `separator=&` + } + iterValStruct IterValuesStruct + ) + + if err := Unmarshal(environ, &iterValStruct); err != nil { t.Errorf("Expected no error but got %v", err) return } + testCases := [][]interface{}{ {iterValStruct.StringSlice, []string{"separate", "values"}}, {iterValStruct.IntSlice, []int{1, 2}}, @@ -404,14 +413,16 @@ func TestUnmarshalSlice(t *testing.T) { } func TestUnmarshalDefaultValues(t *testing.T) { - environ := map[string]string{ - "PRESENT": "youFoundMe", - } - var defaultValueStruct DefaultValueStruct - err := Unmarshal(environ, &defaultValueStruct) - if err != nil { + t.Parallel() + var ( + environ = map[string]string{"PRESENT": "youFoundMe"} + defaultValueStruct DefaultValueStruct + ) + + if err := Unmarshal(environ, &defaultValueStruct); err != nil { t.Errorf("Expected no error but got %s", err) } + testCases := [][]interface{}{ {defaultValueStruct.DefaultInt, 7}, {defaultValueStruct.DefaultUint, uint(4294967295)}, @@ -435,11 +446,17 @@ func TestUnmarshalDefaultValues(t *testing.T) { } func TestUnmarshalRequiredValues(t *testing.T) { - environ := map[string]string{} - var requiredValuesStruct RequiredValueStruct + t.Parallel() + var ( + environ = make(map[string]string) + requiredValuesStruct RequiredValueStruct + ) // Try missing REQUIRED_VAL and REQUIRED_VAL_MORE err := Unmarshal(environ, &requiredValuesStruct) + if err == nil { + t.Errorf("Expected error 'ErrMissingRequiredValue' but got '%s'", err) + } errMissing := ErrMissingRequiredValue{Value: "REQUIRED_VAL"} if err.Error() != errMissing.Error() { t.Errorf("Expected error 'ErrMissingRequiredValue' but got '%s'", err) @@ -448,13 +465,16 @@ func TestUnmarshalRequiredValues(t *testing.T) { // Fill REQUIRED_VAL and retry REQUIRED_VAL_MORE environ["REQUIRED_VAL"] = "required" err = Unmarshal(environ, &requiredValuesStruct) + if err == nil { + t.Errorf("Expected error 'ErrMissingRequiredValue' but got '%s'", err) + } errMissing = ErrMissingRequiredValue{Value: "REQUIRED_VAL_MORE"} if err.Error() != errMissing.Error() { t.Errorf("Expected error 'ErrMissingRequiredValue' but got '%s'", err) } + environ["REQUIRED_VAL_MORE"] = "required" - err = Unmarshal(environ, &requiredValuesStruct) - if err != nil { + if err = Unmarshal(environ, &requiredValuesStruct); err != nil { t.Errorf("Expected no error but got '%s'", err) } if requiredValuesStruct.Required != "required" { @@ -466,6 +486,7 @@ func TestUnmarshalRequiredValues(t *testing.T) { } func TestMarshal(t *testing.T) { + t.Parallel() validStruct := ValidStruct{ Home: "/home/test", Jenkins: struct { @@ -535,79 +556,74 @@ func TestMarshal(t *testing.T) { } func TestMarshalInvalid(t *testing.T) { + t.Parallel() var validStruct ValidStruct - _, err := Marshal(validStruct) - if err != ErrInvalidValue { + if _, err := Marshal(validStruct); !errors.Is(err, ErrInvalidValue) { t.Errorf("Expected error 'ErrInvalidValue' but got '%s'", err) } ptr := &validStruct - _, err = Marshal(&ptr) - if err != ErrInvalidValue { + if _, err := Marshal(&ptr); !errors.Is(err, ErrInvalidValue) { t.Errorf("Expected error 'ErrInvalidValue' but got '%s'", err) } } func TestMarshalPointer(t *testing.T) { - empty := "" - validStruct := ValidStruct{ - PointerString: &empty, - } + t.Parallel() + var ( + empty = "" + validStruct = ValidStruct{PointerString: &empty} + ) + es, err := Marshal(&validStruct) if err != nil { t.Errorf("Expected no error but got '%s'", err) } - v, ok := es["POINTER_STRING"] - if !ok { + if v, ok := es["POINTER_STRING"]; !ok { t.Errorf("Expected field '%s' to exist but missing", "POINTER_STRING") } else if v != "" { t.Errorf("Expected field value to be '%s' but got '%s'", "", v) } - v, ok = es["POINTER_MISSING"] - if ok { + if v, ok := es["POINTER_MISSING"]; ok { t.Errorf("Expected field '%s' to not exist but got '%s'", "POINTER_MISSING", v) } - v, ok = es["JENKINS_POINTER_MISSING"] - if ok { + if v, ok := es["JENKINS_POINTER_MISSING"]; ok { t.Errorf("Expected field '%s' to not exist but got '%s'", "JENKINS_POINTER_MISSING", v) } } func TestMarshalCustom(t *testing.T) { - someValue := Base64EncodedString("someValue") - validStruct := ValidStruct{ - Base64EncodedString: someValue, - JSONData: JSONData{ - SomeField: 42, - }, - PointerJSONData: &JSONData{ - SomeField: 43, - }, - } + t.Parallel() + var ( + someValue = Base64EncodedString("someValue") + validStruct = ValidStruct{ + Base64EncodedString: someValue, + JSONData: JSONData{SomeField: 42}, + PointerJSONData: &JSONData{SomeField: 43}, + } + ) + es, err := Marshal(&validStruct) if err != nil { t.Errorf("Expected no error but got '%s'", err) } - v, ok := es["BASE64_ENCODED_STRING"] - if !ok { + if v, ok := es["BASE64_ENCODED_STRING"]; !ok { t.Errorf("Expected field '%s' to exist but missing", "BASE64_ENCODED_STRING") } else if v != base64.StdEncoding.EncodeToString([]byte(someValue)) { t.Errorf("Expected field value to be '%s' but got '%s'", base64.StdEncoding.EncodeToString([]byte(someValue)), v) } - v, ok = es["JSON_DATA"] - if !ok { + if v, ok := es["JSON_DATA"]; !ok { t.Errorf("Expected field '%s' to exist but got '%s'", "JSON_DATA", v) } else if v != `{"someField":42}` { t.Errorf("Expected field value to be '%s' but got '%s'", `{"someField":42}`, v) } - v, ok = es["POINTER_JSON_DATA"] - if !ok { + if v, ok := es["POINTER_JSON_DATA"]; !ok { t.Errorf("Expected field '%s' to exist but got '%s'", "POINTER_JSON_DATA", v) } else if v != `{"someField":43}` { t.Errorf("Expected field value to be '%s' but got '%s'", `{"someField":43}`, v) diff --git a/go.mod b/go.mod index e02a2a8..49cef6d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/Netflix/go-env -go 1.22 +go 1.23 diff --git a/transform.go b/transform.go index 944e71d..63351f2 100644 --- a/transform.go +++ b/transform.go @@ -19,10 +19,8 @@ import ( "strings" ) -var ( - // ErrInvalidEnviron returned when environ has an incorrect format. - ErrInvalidEnviron = errors.New("items in environ must have format key=value") -) +// ErrInvalidEnviron returned when environ has an incorrect format. +var ErrInvalidEnviron = errors.New("items in environ must have format key=value") // EnvSet represents a set of environment variables. type EnvSet map[string]string @@ -37,10 +35,10 @@ func (e EnvSet) Apply(cs ChangeSet) { if v == nil { // Equivalent to os.Unsetenv delete(e, k) - } else { - // Equivalent to os.Setenv - e[k] = *v + continue } + // Equivalent to os.Setenv + e[k] = *v } } @@ -48,7 +46,10 @@ func (e EnvSet) Apply(cs ChangeSet) { // the corresponding EnvSet. If any item in environ does follow the format, // EnvironToEnvSet returns ErrInvalidEnviron. func EnvironToEnvSet(environ []string) (EnvSet, error) { - m := make(EnvSet) + // We error out the function on the first invalid item found, so we can + // optimistically pre-allocate the EnvSet map with the correct size and + // let the GC clean up in the invalid/exit case alongside the function call. + m := make(EnvSet, len(environ)) for _, v := range environ { parts := strings.SplitN(v, "=", 2) if len(parts) != 2 { @@ -62,7 +63,7 @@ func EnvironToEnvSet(environ []string) (EnvSet, error) { // EnvSetToEnviron transforms a EnvSet into a slice of strings with the format // "key=value". func EnvSetToEnviron(m EnvSet) []string { - var environ []string + environ := make([]string, 0, len(m)) for k, v := range m { environ = append(environ, fmt.Sprintf("%s=%s", k, v)) } diff --git a/transform_test.go b/transform_test.go index 77c0b8b..d523e55 100644 --- a/transform_test.go +++ b/transform_test.go @@ -14,11 +14,13 @@ package env import ( + "errors" "fmt" "testing" ) func TestEnvSetApply(t *testing.T) { + t.Parallel() es := EnvSet{ "HOME": "/home/edgarl", "WORKSPACE": "/mnt/builds/slave/workspace/test", @@ -46,6 +48,7 @@ func TestEnvSetApply(t *testing.T) { } func TestEnvironToEnvSet(t *testing.T) { + t.Parallel() environ := []string{"HOME=/home/edgarl", "WORKSPACE=/mnt/builds/slave/workspace/test"} m, err := EnvironToEnvSet(environ) @@ -63,15 +66,16 @@ func TestEnvironToEnvSet(t *testing.T) { } func TestEnvironToEnvSetInvalid(t *testing.T) { + t.Parallel() environ := []string{"INVALID"} - _, err := EnvironToEnvSet(environ) - if err != ErrInvalidEnviron { + if _, err := EnvironToEnvSet(environ); !errors.Is(err, ErrInvalidEnviron) { t.Errorf("Expected 'ErrInvalidEnviron' but got '%s'", err) } } func TestEnvironToEnvSetSplitN(t *testing.T) { + t.Parallel() environ := []string{"SPLIT=one=two"} m, err := EnvironToEnvSet(environ) @@ -85,6 +89,7 @@ func TestEnvironToEnvSetSplitN(t *testing.T) { } func TestEnvSetToEnviron(t *testing.T) { + t.Parallel() m := EnvSet{ "HOME": "/home/test", "WORKSPACE": "/mnt/builds/slave/workspace/test", From 682abbac5849554c65518b095392276cffee0852 Mon Sep 17 00:00:00 2001 From: Flemming Date: Sat, 26 Oct 2024 00:13:18 +0200 Subject: [PATCH 20/21] Add an example for a array (#29) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5fb2875..92bc39e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ type Environment struct { Duration time.Duration `env:"TYPE_DURATION"` DefaultValue string `env:"MISSING_VAR,default=default_value"` RequiredValue string `env:"IM_REQUIRED,required=true"` + ArrayValue []string `env:"ARRAY_VALUE,default=value1|value2|value3"` } func main() { From 6b7f89893152c6fd09ac70c4bc7d7d7ed7df5aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=B0=2E=20Kaan=20Y=C4=B1lmaz?= Date: Mon, 28 Oct 2024 22:38:50 +0100 Subject: [PATCH 21/21] fix: Marshal() including "default=" tags in EnvSet (#27) * fix: Marshal() including "default=" tags in EnvSet * chore: added tests * chore: added more tests * chore: added comment to clarify skipping of tag options in Marshal --- env.go | 7 +++++++ env_test.go | 60 ++++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/env.go b/env.go index c9b78ac..dc09f74 100644 --- a/env.go +++ b/env.go @@ -334,6 +334,13 @@ func Marshal(v interface{}) (EnvSet, error) { } for _, envKey := range envKeys { + // Skip keys with '=', as they represent tag options and not environment variable names. + if strings.Contains(envKey, "=") { + switch strings.ToLower(strings.SplitN(envKey, "=", 2)[0]) { + case "separator", "required", "default": + continue + } + } es[envKey] = envValue } } diff --git a/env_test.go b/env_test.go index 2bd6983..a7ea948 100644 --- a/env_test.go +++ b/env_test.go @@ -62,6 +62,14 @@ type ValidStruct struct { MultipleTags string `env:"npm_config_cache,NPM_CONFIG_CACHE"` + MultipleTagsWithDefault string `env:"multiple_tags_with_default,MULTIPLE_TAGS_WITH_DEFAULT,default=default_tags_value"` + + TagWithDefault string `env:"tag_with_default,default=default_tag_value"` + + TagWithRequired string `env:"tag_with_required,required=false"` + + TagWithSeparator string `env:"tag_with_separator,separator=&"` + // time.Duration is supported Duration time.Duration `env:"TYPE_DURATION"` @@ -495,14 +503,18 @@ func TestMarshal(t *testing.T) { }{ Workspace: "/mnt/builds/slave/workspace/test", }, - Extra: "extra", - Int: 1, - Uint: 4294967295, - Float32: float32(2.3), - Float64: 4.5, - Bool: true, - MultipleTags: "foobar", - Duration: 3 * time.Minute, + Extra: "extra", + Int: 1, + Uint: 4294967295, + Float32: float32(2.3), + Float64: 4.5, + Bool: true, + MultipleTags: "foobar", + MultipleTagsWithDefault: "baz", + TagWithDefault: "bar", + TagWithRequired: "foo", + TagWithSeparator: "val1&val2", + Duration: 3 * time.Minute, } environ, err := Marshal(&validStruct) @@ -550,6 +562,38 @@ func TestMarshal(t *testing.T) { t.Errorf("Expected field value to be '%s' but got '%s'", "foobar", environ["NPM_CONFIG_CACHE"]) } + if environ["multiple_tags_with_default"] != "baz" { + t.Errorf("Expected field value to be '%s' but got '%s'", "baz", environ["multiple_tags_with_default"]) + } + + if environ["default=default_tags_value"] != "" { + t.Errorf("'default=default_tags_value' not expected to be a valid field value.") + } + + if environ["tag_with_default"] != "bar" { + t.Errorf("Expected field value to be '%s' but got '%s'", "bar", environ["tag_with_default"]) + } + + if environ["tag_with_required"] != "foo" { + t.Errorf("Expected field value to be '%s' but got '%s'", "foo", environ["tag_with_required"]) + } + + if environ["tag_with_separator"] != "val1&val2" { + t.Errorf("Expected field value to be '%s' but got '%s'", "val1&val2", environ["tag_with_separator"]) + } + + if environ["required=true"] != "" { + t.Errorf("'required=true' not expected to be a valid field value.") + } + + if environ["separator=&"] != "" { + t.Errorf("'separator=&' not expected to be a valid field value.") + } + + if environ["default=default_tag_value"] != "" { + t.Errorf("'default=default_tag_value' not expected to be a valid field value.") + } + if environ["TYPE_DURATION"] != "3m0s" { t.Errorf("Expected field value to be '%s' but got '%s'", "3m0s", environ["TYPE_DURATION"]) }