package env import ( "errors" "fmt" "net/http" "net/url" "os" "path/filepath" "reflect" "runtime" "strconv" "strings" "testing" "time" "github.com/matryer/is" ) type unmarshaler struct { time.Duration } // TextUnmarshaler implements encoding.TextUnmarshaler. func (d *unmarshaler) UnmarshalText(data []byte) (err error) { if len(data) != 0 { d.Duration, err = time.ParseDuration(string(data)) } else { d.Duration = 0 } return err } // nolint: maligned type Config struct { String string `env:"STRING"` StringPtr *string `env:"STRING"` Strings []string `env:"STRINGS"` StringPtrs []*string `env:"STRINGS"` Bool bool `env:"BOOL"` BoolPtr *bool `env:"BOOL"` Bools []bool `env:"BOOLS"` BoolPtrs []*bool `env:"BOOLS"` Int int `env:"INT"` IntPtr *int `env:"INT"` Ints []int `env:"INTS"` IntPtrs []*int `env:"INTS"` Int8 int8 `env:"INT8"` Int8Ptr *int8 `env:"INT8"` Int8s []int8 `env:"INT8S"` Int8Ptrs []*int8 `env:"INT8S"` Int16 int16 `env:"INT16"` Int16Ptr *int16 `env:"INT16"` Int16s []int16 `env:"INT16S"` Int16Ptrs []*int16 `env:"INT16S"` Int32 int32 `env:"INT32"` Int32Ptr *int32 `env:"INT32"` Int32s []int32 `env:"INT32S"` Int32Ptrs []*int32 `env:"INT32S"` Int64 int64 `env:"INT64"` Int64Ptr *int64 `env:"INT64"` Int64s []int64 `env:"INT64S"` Int64Ptrs []*int64 `env:"INT64S"` Uint uint `env:"UINT"` UintPtr *uint `env:"UINT"` Uints []uint `env:"UINTS"` UintPtrs []*uint `env:"UINTS"` Uint8 uint8 `env:"UINT8"` Uint8Ptr *uint8 `env:"UINT8"` Uint8s []uint8 `env:"UINT8S"` Uint8Ptrs []*uint8 `env:"UINT8S"` Uint16 uint16 `env:"UINT16"` Uint16Ptr *uint16 `env:"UINT16"` Uint16s []uint16 `env:"UINT16S"` Uint16Ptrs []*uint16 `env:"UINT16S"` Uint32 uint32 `env:"UINT32"` Uint32Ptr *uint32 `env:"UINT32"` Uint32s []uint32 `env:"UINT32S"` Uint32Ptrs []*uint32 `env:"UINT32S"` Uint64 uint64 `env:"UINT64"` Uint64Ptr *uint64 `env:"UINT64"` Uint64s []uint64 `env:"UINT64S"` Uint64Ptrs []*uint64 `env:"UINT64S"` Float32 float32 `env:"FLOAT32"` Float32Ptr *float32 `env:"FLOAT32"` Float32s []float32 `env:"FLOAT32S"` Float32Ptrs []*float32 `env:"FLOAT32S"` Float64 float64 `env:"FLOAT64"` Float64Ptr *float64 `env:"FLOAT64"` Float64s []float64 `env:"FLOAT64S"` Float64Ptrs []*float64 `env:"FLOAT64S"` Duration time.Duration `env:"DURATION"` Durations []time.Duration `env:"DURATIONS"` DurationPtr *time.Duration `env:"DURATION"` DurationPtrs []*time.Duration `env:"DURATIONS"` Unmarshaler unmarshaler `env:"UNMARSHALER"` UnmarshalerPtr *unmarshaler `env:"UNMARSHALER"` Unmarshalers []unmarshaler `env:"UNMARSHALERS"` UnmarshalerPtrs []*unmarshaler `env:"UNMARSHALERS"` URL url.URL `env:"URL"` URLPtr *url.URL `env:"URL"` URLs []url.URL `env:"URLS"` URLPtrs []*url.URL `env:"URLS"` StringWithdefault string `env:"DATABASE_URL" envDefault:"postgres://localhost:5432/db"` CustomSeparator []string `env:"SEPSTRINGS" envSeparator:":"` NonDefined struct { String string `env:"NONDEFINED_STR"` } NotAnEnv string unexported string `env:"FOO"` } type ParentStruct struct { InnerStruct *InnerStruct unexported *InnerStruct Ignored *http.Client } type InnerStruct struct { Inner string `env:"innervar"` Number uint `env:"innernum"` } type ForNestedStruct struct { NestedStruct } type NestedStruct struct { NestedVar string `env:"nestedvar"` } func TestParsesEnv(t *testing.T) { is := is.New(t) defer os.Clearenv() tos := func(v interface{}) string { return fmt.Sprintf("%v", v) } toss := func(v ...interface{}) string { ss := []string{} for _, s := range v { ss = append(ss, tos(s)) } return strings.Join(ss, ",") } str1 := "str1" str2 := "str2" os.Setenv("STRING", str1) os.Setenv("STRINGS", toss(str1, str2)) bool1 := true bool2 := false os.Setenv("BOOL", tos(bool1)) os.Setenv("BOOLS", toss(bool1, bool2)) int1 := -1 int2 := 2 os.Setenv("INT", tos(int1)) os.Setenv("INTS", toss(int1, int2)) var int81 int8 = -2 var int82 int8 = 5 os.Setenv("INT8", tos(int81)) os.Setenv("INT8S", toss(int81, int82)) var int161 int16 = -24 var int162 int16 = 15 os.Setenv("INT16", tos(int161)) os.Setenv("INT16S", toss(int161, int162)) var int321 int32 = -14 var int322 int32 = 154 os.Setenv("INT32", tos(int321)) os.Setenv("INT32S", toss(int321, int322)) var int641 int64 = -12 var int642 int64 = 150 os.Setenv("INT64", tos(int641)) os.Setenv("INT64S", toss(int641, int642)) var uint1 uint = 1 var uint2 uint = 2 os.Setenv("UINT", tos(uint1)) os.Setenv("UINTS", toss(uint1, uint2)) var uint81 uint8 = 15 var uint82 uint8 = 51 os.Setenv("UINT8", tos(uint81)) os.Setenv("UINT8S", toss(uint81, uint82)) var uint161 uint16 = 532 var uint162 uint16 = 123 os.Setenv("UINT16", tos(uint161)) os.Setenv("UINT16S", toss(uint161, uint162)) var uint321 uint32 = 93 var uint322 uint32 = 14 os.Setenv("UINT32", tos(uint321)) os.Setenv("UINT32S", toss(uint321, uint322)) var uint641 uint64 = 5 var uint642 uint64 = 43 os.Setenv("UINT64", tos(uint641)) os.Setenv("UINT64S", toss(uint641, uint642)) var float321 float32 = 9.3 var float322 float32 = 1.1 os.Setenv("FLOAT32", tos(float321)) os.Setenv("FLOAT32S", toss(float321, float322)) float641 := 1.53 float642 := 0.5 os.Setenv("FLOAT64", tos(float641)) os.Setenv("FLOAT64S", toss(float641, float642)) duration1 := time.Second duration2 := time.Second * 4 os.Setenv("DURATION", tos(duration1)) os.Setenv("DURATIONS", toss(duration1, duration2)) unmarshaler1 := unmarshaler{time.Minute} unmarshaler2 := unmarshaler{time.Millisecond * 1232} os.Setenv("UNMARSHALER", tos(unmarshaler1.Duration)) os.Setenv("UNMARSHALERS", toss(unmarshaler1.Duration, unmarshaler2.Duration)) url1 := "https://goreleaser.com" url2 := "https://caarlos0.dev" os.Setenv("URL", tos(url1)) os.Setenv("URLS", toss(url1, url2)) os.Setenv("SEPSTRINGS", strings.Join([]string{str1, str2}, ":")) nonDefinedStr := "nonDefinedStr" os.Setenv("NONDEFINED_STR", nonDefinedStr) cfg := Config{} is.NoErr(Parse(&cfg)) is.Equal(str1, cfg.String) is.Equal(&str1, cfg.StringPtr) is.Equal(str1, cfg.Strings[0]) is.Equal(str2, cfg.Strings[1]) is.Equal(&str1, cfg.StringPtrs[0]) is.Equal(&str2, cfg.StringPtrs[1]) is.Equal(bool1, cfg.Bool) is.Equal(&bool1, cfg.BoolPtr) is.Equal(bool1, cfg.Bools[0]) is.Equal(bool2, cfg.Bools[1]) is.Equal(&bool1, cfg.BoolPtrs[0]) is.Equal(&bool2, cfg.BoolPtrs[1]) is.Equal(int1, cfg.Int) is.Equal(&int1, cfg.IntPtr) is.Equal(int1, cfg.Ints[0]) is.Equal(int2, cfg.Ints[1]) is.Equal(&int1, cfg.IntPtrs[0]) is.Equal(&int2, cfg.IntPtrs[1]) is.Equal(int81, cfg.Int8) is.Equal(&int81, cfg.Int8Ptr) is.Equal(int81, cfg.Int8s[0]) is.Equal(int82, cfg.Int8s[1]) is.Equal(&int81, cfg.Int8Ptrs[0]) is.Equal(&int82, cfg.Int8Ptrs[1]) is.Equal(int161, cfg.Int16) is.Equal(&int161, cfg.Int16Ptr) is.Equal(int161, cfg.Int16s[0]) is.Equal(int162, cfg.Int16s[1]) is.Equal(&int161, cfg.Int16Ptrs[0]) is.Equal(&int162, cfg.Int16Ptrs[1]) is.Equal(int321, cfg.Int32) is.Equal(&int321, cfg.Int32Ptr) is.Equal(int321, cfg.Int32s[0]) is.Equal(int322, cfg.Int32s[1]) is.Equal(&int321, cfg.Int32Ptrs[0]) is.Equal(&int322, cfg.Int32Ptrs[1]) is.Equal(int641, cfg.Int64) is.Equal(&int641, cfg.Int64Ptr) is.Equal(int641, cfg.Int64s[0]) is.Equal(int642, cfg.Int64s[1]) is.Equal(&int641, cfg.Int64Ptrs[0]) is.Equal(&int642, cfg.Int64Ptrs[1]) is.Equal(uint1, cfg.Uint) is.Equal(&uint1, cfg.UintPtr) is.Equal(uint1, cfg.Uints[0]) is.Equal(uint2, cfg.Uints[1]) is.Equal(&uint1, cfg.UintPtrs[0]) is.Equal(&uint2, cfg.UintPtrs[1]) is.Equal(uint81, cfg.Uint8) is.Equal(&uint81, cfg.Uint8Ptr) is.Equal(uint81, cfg.Uint8s[0]) is.Equal(uint82, cfg.Uint8s[1]) is.Equal(&uint81, cfg.Uint8Ptrs[0]) is.Equal(&uint82, cfg.Uint8Ptrs[1]) is.Equal(uint161, cfg.Uint16) is.Equal(&uint161, cfg.Uint16Ptr) is.Equal(uint161, cfg.Uint16s[0]) is.Equal(uint162, cfg.Uint16s[1]) is.Equal(&uint161, cfg.Uint16Ptrs[0]) is.Equal(&uint162, cfg.Uint16Ptrs[1]) is.Equal(uint321, cfg.Uint32) is.Equal(&uint321, cfg.Uint32Ptr) is.Equal(uint321, cfg.Uint32s[0]) is.Equal(uint322, cfg.Uint32s[1]) is.Equal(&uint321, cfg.Uint32Ptrs[0]) is.Equal(&uint322, cfg.Uint32Ptrs[1]) is.Equal(uint641, cfg.Uint64) is.Equal(&uint641, cfg.Uint64Ptr) is.Equal(uint641, cfg.Uint64s[0]) is.Equal(uint642, cfg.Uint64s[1]) is.Equal(&uint641, cfg.Uint64Ptrs[0]) is.Equal(&uint642, cfg.Uint64Ptrs[1]) is.Equal(float321, cfg.Float32) is.Equal(&float321, cfg.Float32Ptr) is.Equal(float321, cfg.Float32s[0]) is.Equal(float322, cfg.Float32s[1]) is.Equal(&float321, cfg.Float32Ptrs[0]) is.Equal(&float322, cfg.Float32Ptrs[1]) is.Equal(float641, cfg.Float64) is.Equal(&float641, cfg.Float64Ptr) is.Equal(float641, cfg.Float64s[0]) is.Equal(float642, cfg.Float64s[1]) is.Equal(&float641, cfg.Float64Ptrs[0]) is.Equal(&float642, cfg.Float64Ptrs[1]) is.Equal(duration1, cfg.Duration) is.Equal(&duration1, cfg.DurationPtr) is.Equal(duration1, cfg.Durations[0]) is.Equal(duration2, cfg.Durations[1]) is.Equal(&duration1, cfg.DurationPtrs[0]) is.Equal(&duration2, cfg.DurationPtrs[1]) is.Equal(unmarshaler1, cfg.Unmarshaler) is.Equal(&unmarshaler1, cfg.UnmarshalerPtr) is.Equal(unmarshaler1, cfg.Unmarshalers[0]) is.Equal(unmarshaler2, cfg.Unmarshalers[1]) is.Equal(&unmarshaler1, cfg.UnmarshalerPtrs[0]) is.Equal(&unmarshaler2, cfg.UnmarshalerPtrs[1]) is.Equal(url1, cfg.URL.String()) is.Equal(url1, cfg.URLPtr.String()) is.Equal(url1, cfg.URLs[0].String()) is.Equal(url2, cfg.URLs[1].String()) is.Equal(url1, cfg.URLPtrs[0].String()) is.Equal(url2, cfg.URLPtrs[1].String()) is.Equal("postgres://localhost:5432/db", cfg.StringWithdefault) is.Equal(nonDefinedStr, cfg.NonDefined.String) is.Equal(str1, cfg.CustomSeparator[0]) is.Equal(str2, cfg.CustomSeparator[1]) is.Equal(cfg.NotAnEnv, "") is.Equal(cfg.unexported, "") } func TestSetEnvAndTagOptsChain(t *testing.T) { is := is.New(t) defer os.Clearenv() type config struct { Key1 string `mytag:"KEY1,required"` Key2 int `mytag:"KEY2,required"` } envs := map[string]string{ "KEY1": "VALUE1", "KEY2": "3", } cfg := config{} is.NoErr(Parse(&cfg, Options{TagName: "mytag"}, Options{Environment: envs})) is.Equal("VALUE1", cfg.Key1) is.Equal(3, cfg.Key2) } func TestJSONTag(t *testing.T) { is := is.New(t) defer os.Clearenv() type config struct { Key1 string `json:"KEY1"` Key2 int `json:"KEY2"` } os.Setenv("KEY1", "VALUE7") os.Setenv("KEY2", "5") cfg := config{} is.NoErr(Parse(&cfg, Options{TagName: "json"})) is.Equal("VALUE7", cfg.Key1) is.Equal(5, cfg.Key2) } func TestParsesEnvInner(t *testing.T) { is := is.New(t) os.Setenv("innervar", "someinnervalue") os.Setenv("innernum", "8") defer os.Clearenv() cfg := ParentStruct{ InnerStruct: &InnerStruct{}, unexported: &InnerStruct{}, } is.NoErr(Parse(&cfg)) is.Equal("someinnervalue", cfg.InnerStruct.Inner) is.Equal(uint(8), cfg.InnerStruct.Number) } func TestParsesEnvInnerFails(t *testing.T) { defer os.Clearenv() type config struct { Foo struct { Number int `env:"NUMBER"` } } os.Setenv("NUMBER", "not-a-number") isErrorWithMessage(t, Parse(&config{}), `env: parse error on field "Number" of type "int": strconv.ParseInt: parsing "not-a-number": invalid syntax`) } func TestParsesEnvInnerNil(t *testing.T) { is := is.New(t) os.Setenv("innervar", "someinnervalue") defer os.Clearenv() cfg := ParentStruct{} is.NoErr(Parse(&cfg)) } func TestParsesEnvInnerInvalid(t *testing.T) { os.Setenv("innernum", "-547") defer os.Clearenv() cfg := ParentStruct{ InnerStruct: &InnerStruct{}, } isErrorWithMessage(t, Parse(&cfg), `env: parse error on field "Number" of type "uint": strconv.ParseUint: parsing "-547": invalid syntax`) } func TestParsesEnvNested(t *testing.T) { is := is.New(t) os.Setenv("nestedvar", "somenestedvalue") defer os.Clearenv() var cfg ForNestedStruct is.NoErr(Parse(&cfg)) is.Equal("somenestedvalue", cfg.NestedVar) } func TestEmptyVars(t *testing.T) { is := is.New(t) os.Clearenv() cfg := Config{} is.NoErr(Parse(&cfg)) is.Equal("", cfg.String) is.Equal(false, cfg.Bool) is.Equal(0, cfg.Int) is.Equal(uint(0), cfg.Uint) is.Equal(uint64(0), cfg.Uint64) is.Equal(int64(0), cfg.Int64) is.Equal(0, len(cfg.Strings)) is.Equal(0, len(cfg.CustomSeparator)) is.Equal(0, len(cfg.Ints)) is.Equal(0, len(cfg.Bools)) } func TestPassAnInvalidPtr(t *testing.T) { var thisShouldBreak int isErrorWithMessage(t, Parse(&thisShouldBreak), "env: expected a pointer to a Struct") } func TestPassReference(t *testing.T) { cfg := Config{} isErrorWithMessage(t, Parse(cfg), "env: expected a pointer to a Struct") } func TestInvalidBool(t *testing.T) { os.Setenv("BOOL", "should-be-a-bool") defer os.Clearenv() isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Bool" of type "bool": strconv.ParseBool: parsing "should-be-a-bool": invalid syntax`) } func TestInvalidInt(t *testing.T) { os.Setenv("INT", "should-be-an-int") defer os.Clearenv() isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Int" of type "int": strconv.ParseInt: parsing "should-be-an-int": invalid syntax`) } func TestInvalidUint(t *testing.T) { os.Setenv("UINT", "-44") defer os.Clearenv() isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Uint" of type "uint": strconv.ParseUint: parsing "-44": invalid syntax`) } func TestInvalidFloat32(t *testing.T) { os.Setenv("FLOAT32", "AAA") defer os.Clearenv() isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Float32" of type "float32": strconv.ParseFloat: parsing "AAA": invalid syntax`) } func TestInvalidFloat64(t *testing.T) { os.Setenv("FLOAT64", "AAA") defer os.Clearenv() isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Float64" of type "float64": strconv.ParseFloat: parsing "AAA": invalid syntax`) } func TestInvalidUint64(t *testing.T) { os.Setenv("UINT64", "AAA") defer os.Clearenv() isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Uint64" of type "uint64": strconv.ParseUint: parsing "AAA": invalid syntax`) } func TestInvalidInt64(t *testing.T) { os.Setenv("INT64", "AAA") defer os.Clearenv() isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Int64" of type "int64": strconv.ParseInt: parsing "AAA": invalid syntax`) } func TestInvalidInt64Slice(t *testing.T) { os.Setenv("BADINTS", "A,2,3") defer os.Clearenv() type config struct { BadFloats []int64 `env:"BADINTS"` } isErrorWithMessage(t, Parse(&config{}), `env: parse error on field "BadFloats" of type "[]int64": strconv.ParseInt: parsing "A": invalid syntax`) } func TestInvalidUInt64Slice(t *testing.T) { os.Setenv("BADINTS", "A,2,3") defer os.Clearenv() type config struct { BadFloats []uint64 `env:"BADINTS"` } isErrorWithMessage(t, Parse(&config{}), `env: parse error on field "BadFloats" of type "[]uint64": strconv.ParseUint: parsing "A": invalid syntax`) } func TestInvalidFloat32Slice(t *testing.T) { os.Setenv("BADFLOATS", "A,2.0,3.0") defer os.Clearenv() type config struct { BadFloats []float32 `env:"BADFLOATS"` } isErrorWithMessage(t, Parse(&config{}), `env: parse error on field "BadFloats" of type "[]float32": strconv.ParseFloat: parsing "A": invalid syntax`) } func TestInvalidFloat64Slice(t *testing.T) { os.Setenv("BADFLOATS", "A,2.0,3.0") defer os.Clearenv() type config struct { BadFloats []float64 `env:"BADFLOATS"` } isErrorWithMessage(t, Parse(&config{}), `env: parse error on field "BadFloats" of type "[]float64": strconv.ParseFloat: parsing "A": invalid syntax`) } func TestInvalidBoolsSlice(t *testing.T) { os.Setenv("BADBOOLS", "t,f,TRUE,faaaalse") defer os.Clearenv() type config struct { BadBools []bool `env:"BADBOOLS"` } isErrorWithMessage(t, Parse(&config{}), `env: parse error on field "BadBools" of type "[]bool": strconv.ParseBool: parsing "faaaalse": invalid syntax`) } func TestInvalidDuration(t *testing.T) { os.Setenv("DURATION", "should-be-a-valid-duration") defer os.Clearenv() isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Duration" of type "time.Duration": unable to parse duration: time: invalid duration "should-be-a-valid-duration"`) } func TestInvalidDurations(t *testing.T) { os.Setenv("DURATIONS", "1s,contains-an-invalid-duration,3s") defer os.Clearenv() isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Durations" of type "[]time.Duration": unable to parse duration: time: invalid duration "contains-an-invalid-duration"`) } func TestParseStructWithoutEnvTag(t *testing.T) { is := is.New(t) cfg := Config{} is.NoErr(Parse(&cfg)) is.Equal(cfg.NotAnEnv, "") } func TestParseStructWithInvalidFieldKind(t *testing.T) { type config struct { WontWorkByte byte `env:"BLAH"` } os.Setenv("BLAH", "a") isErrorWithMessage(t, Parse(&config{}), `env: parse error on field "WontWorkByte" of type "uint8": strconv.ParseUint: parsing "a": invalid syntax`) } func TestUnsupportedSliceType(t *testing.T) { type config struct { WontWork []map[int]int `env:"WONTWORK"` } os.Setenv("WONTWORK", "1,2,3") defer os.Clearenv() isErrorWithMessage(t, Parse(&config{}), `env: no parser found for field "WontWork" of type "[]map[int]int"`) } func TestBadSeparator(t *testing.T) { type config struct { WontWork []int `env:"WONTWORK" envSeparator:":"` } os.Setenv("WONTWORK", "1,2,3,4") defer os.Clearenv() isErrorWithMessage(t, Parse(&config{}), `env: parse error on field "WontWork" of type "[]int": strconv.ParseInt: parsing "1,2,3,4": invalid syntax`) } func TestNoErrorRequiredSet(t *testing.T) { is := is.New(t) type config struct { IsRequired string `env:"IS_REQUIRED,required"` } cfg := &config{} os.Setenv("IS_REQUIRED", "") defer os.Clearenv() is.NoErr(Parse(cfg)) is.Equal("", cfg.IsRequired) } func TestHook(t *testing.T) { is := is.New(t) type config struct { Something string `env:"SOMETHING" envDefault:"important"` Another string `env:"ANOTHER"` } cfg := &config{} os.Setenv("ANOTHER", "1") defer os.Clearenv() type onSetArgs struct { tag string key interface{} isDefault bool } var onSetCalled []onSetArgs is.NoErr(Parse(cfg, Options{ OnSet: func(tag string, value interface{}, isDefault bool) { onSetCalled = append(onSetCalled, onSetArgs{tag, value, isDefault}) }, })) is.Equal("important", cfg.Something) is.Equal("1", cfg.Another) is.Equal(2, len(onSetCalled)) is.Equal(onSetArgs{"SOMETHING", "important", true}, onSetCalled[0]) is.Equal(onSetArgs{"ANOTHER", "1", false}, onSetCalled[1]) } func TestErrorRequiredWithDefault(t *testing.T) { is := is.New(t) type config struct { IsRequired string `env:"IS_REQUIRED,required" envDefault:"important"` } cfg := &config{} os.Setenv("IS_REQUIRED", "") defer os.Clearenv() is.NoErr(Parse(cfg)) is.Equal("", cfg.IsRequired) } func TestErrorRequiredNotSet(t *testing.T) { type config struct { IsRequired string `env:"IS_REQUIRED,required"` } isErrorWithMessage(t, Parse(&config{}), `env: required environment variable "IS_REQUIRED" is not set`) } func TestNoErrorNotEmptySet(t *testing.T) { is := is.New(t) os.Setenv("IS_REQUIRED", "1") defer os.Clearenv() type config struct { IsRequired string `env:"IS_REQUIRED,notEmpty"` } is.NoErr(Parse(&config{})) } func TestNoErrorRequiredAndNotEmptySet(t *testing.T) { is := is.New(t) os.Setenv("IS_REQUIRED", "1") defer os.Clearenv() type config struct { IsRequired string `env:"IS_REQUIRED,required,notEmpty"` } is.NoErr(Parse(&config{})) } func TestErrorNotEmptySet(t *testing.T) { os.Setenv("IS_REQUIRED", "") defer os.Clearenv() type config struct { IsRequired string `env:"IS_REQUIRED,notEmpty"` } isErrorWithMessage(t, Parse(&config{}), `env: environment variable "IS_REQUIRED" should not be empty`) } func TestErrorRequiredAndNotEmptySet(t *testing.T) { os.Setenv("IS_REQUIRED", "") defer os.Clearenv() type config struct { IsRequired string `env:"IS_REQUIRED,notEmpty,required"` } isErrorWithMessage(t, Parse(&config{}), `env: environment variable "IS_REQUIRED" should not be empty`) } func TestErrorRequiredNotSetWithDefault(t *testing.T) { is := is.New(t) type config struct { IsRequired string `env:"IS_REQUIRED,required" envDefault:"important"` } cfg := &config{} is.NoErr(Parse(cfg)) is.Equal("important", cfg.IsRequired) } func TestParseExpandOption(t *testing.T) { is := is.New(t) type config struct { Host string `env:"HOST" envDefault:"localhost"` Port int `env:"PORT" envDefault:"3000" envExpand:"True"` SecretKey string `env:"SECRET_KEY" envExpand:"True"` ExpandKey string `env:"EXPAND_KEY"` CompoundKey string `env:"HOST_PORT" envDefault:"${HOST}:${PORT}" envExpand:"True"` Default string `env:"DEFAULT" envDefault:"def1" envExpand:"True"` } defer os.Clearenv() os.Setenv("HOST", "localhost") os.Setenv("PORT", "3000") os.Setenv("EXPAND_KEY", "qwerty12345") os.Setenv("SECRET_KEY", "${EXPAND_KEY}") cfg := config{} err := Parse(&cfg) is.NoErr(err) is.Equal("localhost", cfg.Host) is.Equal(3000, cfg.Port) is.Equal("qwerty12345", cfg.SecretKey) is.Equal("qwerty12345", cfg.ExpandKey) is.Equal("localhost:3000", cfg.CompoundKey) is.Equal("def1", cfg.Default) } func TestParseUnsetRequireOptions(t *testing.T) { is := is.New(t) type config struct { Password string `env:"PASSWORD,unset,required"` } defer os.Clearenv() cfg := config{} isErrorWithMessage(t, Parse(&cfg), `env: required environment variable "PASSWORD" is not set`) os.Setenv("PASSWORD", "superSecret") is.NoErr(Parse(&cfg)) is.Equal("superSecret", cfg.Password) unset, exists := os.LookupEnv("PASSWORD") is.Equal("", unset) is.Equal(false, exists) } func TestCustomParser(t *testing.T) { is := is.New(t) type foo struct { name string } type bar struct { Name string `env:"OTHER"` Foo *foo `env:"BLAH"` } type config struct { Var foo `env:"VAR"` Foo *foo `env:"BLAH"` Other *bar } os.Setenv("VAR", "test") defer os.Unsetenv("VAR") os.Setenv("OTHER", "test2") defer os.Unsetenv("OTHER") os.Setenv("BLAH", "test3") defer os.Unsetenv("BLAH") cfg := &config{ Other: &bar{}, } err := ParseWithFuncs(cfg, map[reflect.Type]ParserFunc{ reflect.TypeOf(foo{}): func(v string) (interface{}, error) { return foo{name: v}, nil }, }) is.NoErr(err) is.Equal(cfg.Var.name, "test") is.Equal(cfg.Foo.name, "test3") is.Equal(cfg.Other.Name, "test2") is.Equal(cfg.Other.Foo.name, "test3") } func TestParseWithFuncsNoPtr(t *testing.T) { type foo struct{} isErrorWithMessage(t, ParseWithFuncs(foo{}, nil), "env: expected a pointer to a Struct") } func TestParseWithFuncsInvalidType(t *testing.T) { var c int isErrorWithMessage(t, ParseWithFuncs(&c, nil), "env: expected a pointer to a Struct") } func TestCustomParserError(t *testing.T) { type foo struct { name string } customParserFunc := func(v string) (interface{}, error) { return nil, errors.New("something broke") } t.Run("single", func(t *testing.T) { is := is.New(t) type config struct { Var foo `env:"VAR"` } os.Setenv("VAR", "single") cfg := &config{} err := ParseWithFuncs(cfg, map[reflect.Type]ParserFunc{ reflect.TypeOf(foo{}): customParserFunc, }) is.Equal(cfg.Var.name, "") isErrorWithMessage(t, err, `env: parse error on field "Var" of type "env.foo": something broke`) }) t.Run("slice", func(t *testing.T) { is := is.New(t) type config struct { Var []foo `env:"VAR2"` } os.Setenv("VAR2", "slice,slace") cfg := &config{} err := ParseWithFuncs(cfg, map[reflect.Type]ParserFunc{ reflect.TypeOf(foo{}): customParserFunc, }) is.Equal(cfg.Var, nil) isErrorWithMessage(t, err, `env: parse error on field "Var" of type "[]env.foo": something broke`) }) } func TestCustomParserBasicType(t *testing.T) { is := is.New(t) type ConstT int32 type config struct { Const ConstT `env:"CONST_"` } exp := ConstT(123) os.Setenv("CONST_", fmt.Sprintf("%d", exp)) customParserFunc := func(v string) (interface{}, error) { i, err := strconv.Atoi(v) if err != nil { return nil, err } r := ConstT(i) return r, nil } cfg := &config{} err := ParseWithFuncs(cfg, map[reflect.Type]ParserFunc{ reflect.TypeOf(ConstT(0)): customParserFunc, }) is.NoErr(err) is.Equal(exp, cfg.Const) } func TestCustomParserUint64Alias(t *testing.T) { is := is.New(t) type T uint64 var one T = 1 type config struct { Val T `env:"" envDefault:"1x"` } parserCalled := false tParser := func(value string) (interface{}, error) { parserCalled = true trimmed := strings.TrimSuffix(value, "x") i, err := strconv.Atoi(trimmed) if err != nil { return nil, err } return T(i), nil } cfg := config{} err := ParseWithFuncs(&cfg, map[reflect.Type]ParserFunc{ reflect.TypeOf(one): tParser, }) is.True(parserCalled) // tParser should have been called is.NoErr(err) is.Equal(T(1), cfg.Val) } func TestTypeCustomParserBasicInvalid(t *testing.T) { is := is.New(t) type ConstT int32 type config struct { Const ConstT `env:"CONST_"` } os.Setenv("CONST_", "foobar") customParserFunc := func(_ string) (interface{}, error) { return nil, errors.New("random error") } cfg := &config{} err := ParseWithFuncs(cfg, map[reflect.Type]ParserFunc{ reflect.TypeOf(ConstT(0)): customParserFunc, }) is.Equal(cfg.Const, ConstT(0)) isErrorWithMessage(t, err, `env: parse error on field "Const" of type "env.ConstT": random error`) } func TestCustomParserNotCalledForNonAlias(t *testing.T) { is := is.New(t) type T uint64 type U uint64 type config struct { Val uint64 `env:"" envDefault:"33"` Other U `env:"OTHER" envDefault:"44"` } tParserCalled := false tParser := func(value string) (interface{}, error) { tParserCalled = true return T(99), nil } cfg := config{} err := ParseWithFuncs(&cfg, map[reflect.Type]ParserFunc{ reflect.TypeOf(T(0)): tParser, }) is.True(!tParserCalled) // tParser should not have been called is.NoErr(err) is.Equal(uint64(33), cfg.Val) is.Equal(U(44), cfg.Other) } func TestCustomParserBasicUnsupported(t *testing.T) { is := is.New(t) type ConstT struct { A int } type config struct { Const ConstT `env:"CONST_"` } os.Setenv("CONST_", "42") cfg := &config{} err := Parse(cfg) is.Equal(cfg.Const, ConstT{0}) isErrorWithMessage(t, err, `env: no parser found for field "Const" of type "env.ConstT"`) } func TestUnsupportedStructType(t *testing.T) { type config struct { Foo http.Client `env:"FOO"` } os.Setenv("FOO", "foo") defer os.Clearenv() isErrorWithMessage(t, Parse(&config{}), `env: no parser found for field "Foo" of type "http.Client"`) } func TestEmptyOption(t *testing.T) { is := is.New(t) type config struct { Var string `env:"VAR,"` } cfg := &config{} os.Setenv("VAR", "") defer os.Clearenv() is.NoErr(Parse(cfg)) is.Equal("", cfg.Var) } func TestErrorOptionNotRecognized(t *testing.T) { type config struct { Var string `env:"VAR,not_supported!"` } isErrorWithMessage(t, Parse(&config{}), `env: tag option "not_supported!" not supported`) } func TestTextUnmarshalerError(t *testing.T) { type config struct { Unmarshaler unmarshaler `env:"UNMARSHALER"` } os.Setenv("UNMARSHALER", "invalid") isErrorWithMessage(t, Parse(&config{}), `env: parse error on field "Unmarshaler" of type "env.unmarshaler": time: invalid duration "invalid"`) } func TestTextUnmarshalersError(t *testing.T) { type config struct { Unmarshalers []unmarshaler `env:"UNMARSHALERS"` } os.Setenv("UNMARSHALERS", "1s,invalid") isErrorWithMessage(t, Parse(&config{}), `env: parse error on field "Unmarshalers" of type "[]env.unmarshaler": time: invalid duration "invalid"`) } func TestParseURL(t *testing.T) { is := is.New(t) type config struct { ExampleURL url.URL `env:"EXAMPLE_URL" envDefault:"https://google.com"` } var cfg config is.NoErr(Parse(&cfg)) is.Equal("https://google.com", cfg.ExampleURL.String()) } func TestParseInvalidURL(t *testing.T) { type config struct { ExampleURL url.URL `env:"EXAMPLE_URL_2"` } os.Setenv("EXAMPLE_URL_2", "nope://s s/") isErrorWithMessage(t, Parse(&config{}), `env: parse error on field "ExampleURL" of type "url.URL": unable to parse URL: parse "nope://s s/": invalid character " " in host name`) } func ExampleParse() { type inner struct { Foo string `env:"FOO" envDefault:"foobar"` } type config struct { Home string `env:"HOME,required"` Port int `env:"PORT" envDefault:"3000"` IsProduction bool `env:"PRODUCTION"` Inner inner } os.Setenv("HOME", "/tmp/fakehome") var cfg config if err := Parse(&cfg); err != nil { fmt.Println("failed:", err) } fmt.Printf("%+v", cfg) // Output: {Home:/tmp/fakehome Port:3000 IsProduction:false Inner:{Foo:foobar}} } func ExampleParse_onSet() { type config struct { Home string `env:"HOME,required"` Port int `env:"PORT" envDefault:"3000"` IsProduction bool `env:"PRODUCTION"` } os.Setenv("HOME", "/tmp/fakehome") var cfg config if err := Parse(&cfg, Options{ OnSet: func(tag string, value interface{}, isDefault bool) { fmt.Printf("Set %s to %v (default? %v)\n", tag, value, isDefault) }, }); err != nil { fmt.Println("failed:", err) } fmt.Printf("%+v", cfg) // Output: Set HOME to /tmp/fakehome (default? false) // Set PORT to 3000 (default? true) // Set PRODUCTION to (default? false) // {Home:/tmp/fakehome Port:3000 IsProduction:false} } func ExampleParse_defaults() { type config struct { A string `env:"FOO" envDefault:"foo"` B string `env:"FOO"` } // env FOO is not set cfg := config{ A: "A", B: "B", } if err := Parse(&cfg); err != nil { fmt.Println("failed:", err) } fmt.Printf("%+v", cfg) // Output: {A:foo B:B} } func TestIgnoresUnexported(t *testing.T) { is := is.New(t) type unexportedConfig struct { home string `env:"HOME"` Home2 string `env:"HOME"` } cfg := unexportedConfig{} os.Setenv("HOME", "/tmp/fakehome") is.NoErr(Parse(&cfg)) is.Equal(cfg.home, "") is.Equal("/tmp/fakehome", cfg.Home2) } type LogLevel int8 func (l *LogLevel) UnmarshalText(text []byte) error { txt := string(text) switch txt { case "debug": *l = DebugLevel case "info": *l = InfoLevel default: return fmt.Errorf("unknown level: %q", txt) } return nil } const ( DebugLevel LogLevel = iota - 1 InfoLevel ) func TestPrecedenceUnmarshalText(t *testing.T) { is := is.New(t) os.Setenv("LOG_LEVEL", "debug") os.Setenv("LOG_LEVELS", "debug,info") defer os.Unsetenv("LOG_LEVEL") defer os.Unsetenv("LOG_LEVELS") type config struct { LogLevel LogLevel `env:"LOG_LEVEL"` LogLevels []LogLevel `env:"LOG_LEVELS"` } var cfg config is.NoErr(Parse(&cfg)) is.Equal(DebugLevel, cfg.LogLevel) is.Equal([]LogLevel{DebugLevel, InfoLevel}, cfg.LogLevels) } func ExampleParseWithFuncs() { type thing struct { desc string } type conf struct { Thing thing `env:"THING"` } os.Setenv("THING", "my thing") c := conf{} err := ParseWithFuncs(&c, map[reflect.Type]ParserFunc{ reflect.TypeOf(thing{}): func(v string) (interface{}, error) { return thing{desc: v}, nil }, }) if err != nil { fmt.Println(err) } fmt.Println(c.Thing.desc) // Output: // my thing } func TestFile(t *testing.T) { is := is.New(t) type config struct { SecretKey string `env:"SECRET_KEY,file"` } dir := t.TempDir() file := filepath.Join(dir, "sec_key") is.NoErr(os.WriteFile(file, []byte("secret"), 0o660)) defer os.Clearenv() os.Setenv("SECRET_KEY", file) cfg := config{} is.NoErr(Parse(&cfg)) is.Equal("secret", cfg.SecretKey) } func TestFileNoParam(t *testing.T) { is := is.New(t) type config struct { SecretKey string `env:"SECRET_KEY,file"` } defer os.Clearenv() cfg := config{} is.NoErr(Parse(&cfg)) } func TestFileNoParamRequired(t *testing.T) { type config struct { SecretKey string `env:"SECRET_KEY,file,required"` } isErrorWithMessage(t, Parse(&config{}), `env: required environment variable "SECRET_KEY" is not set`) } func TestFileBadFile(t *testing.T) { type config struct { SecretKey string `env:"SECRET_KEY,file"` } filename := "not-a-real-file" defer os.Clearenv() os.Setenv("SECRET_KEY", filename) oserr := "no such file or directory" if runtime.GOOS == "windows" { oserr = "The system cannot find the file specified." } isErrorWithMessage(t, Parse(&config{}), fmt.Sprintf(`env: could not load content of file "%s" from variable SECRET_KEY: open %s: %s`, filename, filename, oserr)) } func TestFileWithDefault(t *testing.T) { is := is.New(t) type config struct { SecretKey string `env:"SECRET_KEY,file" envDefault:"${FILE}" envExpand:"true"` } defer os.Clearenv() dir := t.TempDir() file := filepath.Join(dir, "sec_key") is.NoErr(os.WriteFile(file, []byte("secret"), 0o660)) defer os.Clearenv() os.Setenv("FILE", file) cfg := config{} is.NoErr(Parse(&cfg)) is.Equal("secret", cfg.SecretKey) } func TestCustomSliceType(t *testing.T) { is := is.New(t) type customslice []byte type config struct { SecretKey customslice `env:"SECRET_KEY"` } parsecustomsclice := func(value string) (interface{}, error) { return customslice(value), nil } defer os.Clearenv() os.Setenv("SECRET_KEY", "somesecretkey") var cfg config is.NoErr(ParseWithFuncs(&cfg, map[reflect.Type]ParserFunc{reflect.TypeOf(customslice{}): parsecustomsclice})) } func TestBlankKey(t *testing.T) { is := is.New(t) type testStruct struct { Blank string BlankWithTag string `env:""` } val := testStruct{} defer os.Clearenv() os.Setenv("", "You should not see this") is.NoErr(Parse(&val)) is.Equal("", val.Blank) is.Equal("", val.BlankWithTag) } type MyTime time.Time func (t *MyTime) UnmarshalText(text []byte) error { tt, err := time.Parse("2006-01-02", string(text)) *t = MyTime(tt) return err } func TestCustomTimeParser(t *testing.T) { is := is.New(t) type config struct { SomeTime MyTime `env:"SOME_TIME"` } os.Setenv("SOME_TIME", "2021-05-06") defer os.Unsetenv("SOME_TIME") var cfg config is.NoErr(Parse(&cfg)) is.Equal(2021, time.Time(cfg.SomeTime).Year()) is.Equal(time.Month(5), time.Time(cfg.SomeTime).Month()) is.Equal(6, time.Time(cfg.SomeTime).Day()) } func TestRequiredIfNoDefOption(t *testing.T) { type Tree struct { Fruit string `env:"FRUIT"` } type config struct { Name string `env:"NAME"` Genre string `env:"GENRE" envDefault:"Unknown"` Tree } var cfg config t.Run("missing", func(t *testing.T) { isErrorWithMessage(t, Parse(&cfg, Options{RequiredIfNoDef: true}), `env: required environment variable "NAME" is not set`) os.Setenv("NAME", "John") t.Cleanup(os.Clearenv) isErrorWithMessage(t, Parse(&cfg, Options{RequiredIfNoDef: true}), `env: required environment variable "FRUIT" is not set`) }) t.Run("all set", func(t *testing.T) { os.Setenv("NAME", "John") os.Setenv("FRUIT", "Apple") t.Cleanup(os.Clearenv) // should not trigger an error for the missing 'GENRE' env because it has a default value. is.New(t).NoErr(Parse(&cfg, Options{RequiredIfNoDef: true})) }) } func TestPrefix(t *testing.T) { is := is.New(t) type Config struct { Home string `env:"HOME"` } type ComplexConfig struct { Foo Config `envPrefix:"FOO_"` Bar Config `envPrefix:"BAR_"` Clean Config } cfg := ComplexConfig{} err := Parse(&cfg, Options{Environment: map[string]string{"FOO_HOME": "/foo", "BAR_HOME": "/bar", "HOME": "/clean"}}) is.NoErr(err) is.Equal("/foo", cfg.Foo.Home) is.Equal("/bar", cfg.Bar.Home) is.Equal("/clean", cfg.Clean.Home) } func TestComplePrefix(t *testing.T) { is := is.New(t) type Config struct { Home string `env:"HOME"` } type ComplexConfig struct { Foo Config `envPrefix:"FOO_"` Clean Config Bar Config `envPrefix:"BAR_"` Blah string `env:"BLAH"` } cfg := ComplexConfig{} err := Parse(&cfg, Options{ Prefix: "T_", Environment: map[string]string{ "T_FOO_HOME": "/foo", "T_BAR_HOME": "/bar", "T_BLAH": "blahhh", "T_HOME": "/clean", }, }) is.NoErr(err) is.Equal("/foo", cfg.Foo.Home) is.Equal("/bar", cfg.Bar.Home) is.Equal("/clean", cfg.Clean.Home) is.Equal("blahhh", cfg.Blah) } func isErrorWithMessage(tb testing.TB, err error, msg string) { tb.Helper() is := is.New(tb) is.True(err != nil) // should have failed is.Equal(err.Error(), msg) // should have the expected message }