diff --git a/xload/example_test.go b/xload/example_test.go index deffb39..692f0dd 100644 --- a/xload/example_test.go +++ b/xload/example_test.go @@ -10,9 +10,9 @@ import ( func ExampleLoadEnv() { type AppConf struct { - Host string `config:"HOST"` - Debug bool `config:"DEBUG"` - Timeout time.Duration `config:"TIMEOUT"` + Host string `env:"HOST"` + Debug bool `env:"DEBUG"` + Timeout time.Duration `env:"TIMEOUT"` } var conf AppConf @@ -65,9 +65,9 @@ func ExampleLoad_customLoader() { func ExampleLoad_prefixLoader() { type AppConf struct { - Host string `config:"HOST"` - Debug bool `config:"DEBUG"` - Timeout time.Duration `config:"TIMEOUT"` + Host string `env:"HOST"` + Debug bool `env:"DEBUG"` + Timeout time.Duration `env:"TIMEOUT"` } var conf AppConf @@ -84,9 +84,9 @@ func ExampleLoad_prefixLoader() { func ExampleLoadEnv_required() { type AppConf struct { - Host string `config:"HOST,required"` - Debug bool `config:"DEBUG"` - Timeout time.Duration `config:"TIMEOUT"` + Host string `env:"HOST,required"` + Debug bool `env:"DEBUG"` + Timeout time.Duration `env:"TIMEOUT"` } var conf AppConf @@ -100,17 +100,17 @@ func ExampleLoadEnv_required() { func ExampleLoadEnv_structs() { type DBConf struct { - Host string `config:"HOST"` // will be loaded from DB_HOST - Port int `config:"PORT"` // will be loaded from DB_PORT + Host string `env:"HOST"` // will be loaded from DB_HOST + Port int `env:"PORT"` // will be loaded from DB_PORT } type HTTPConf struct { - Host string `config:"HTTP_HOST"` // will be loaded from HTTP_HOST - Port int `config:"HTTP_PORT"` // will be loaded from HTTP_PORT + Host string `env:"HTTP_HOST"` // will be loaded from HTTP_HOST + Port int `env:"HTTP_PORT"` // will be loaded from HTTP_PORT } type AppConf struct { - DB DBConf `config:",prefix=DB_"` // example of prefix for nested struct + DB DBConf `env:",prefix=DB_"` // example of prefix for nested struct HTTP HTTPConf // example of embedded struct } @@ -141,9 +141,9 @@ func ExampleLoadEnv_customDecoder() { // implements the Decoder interface. type AppConf struct { - Host Host `config:"HOST"` - Debug bool `config:"DEBUG"` - Timeout time.Duration `config:"TIMEOUT"` + Host Host `env:"HOST"` + Debug bool `env:"DEBUG"` + Timeout time.Duration `env:"TIMEOUT"` } var conf AppConf @@ -156,9 +156,9 @@ func ExampleLoadEnv_customDecoder() { func ExampleLoadEnv_transformFieldName() { type AppConf struct { - Host string `config:"MYAPP_HOST"` - Debug bool `config:"MYAPP_DEBUG"` - Timeout time.Duration `config:"MYAPP_TIMEOUT"` + Host string `env:"MYAPP_HOST"` + Debug bool `env:"MYAPP_DEBUG"` + Timeout time.Duration `env:"MYAPP_TIMEOUT"` } var conf AppConf @@ -182,3 +182,33 @@ func ExampleLoadEnv_transformFieldName() { panic(err) } } + +func ExampleLoadEnv_arrayDelimiter() { + type AppConf struct { + // value will be split by |, instead of , + // e.g. HOSTS=host1|host2|host3 + Hosts []string `env:"HOSTS,delimiter=|"` + } + + var conf AppConf + + err := xload.LoadEnv(context.Background(), &conf) + if err != nil { + panic(err) + } +} + +func ExampleLoadEnv_mapSeparator() { + type AppConf struct { + // key value pair will be split by :, instead of = + // e.g. HOSTS=db:localhost,cache:localhost + Hosts map[string]string `env:"HOSTS,separator=:"` + } + + var conf AppConf + + err := xload.LoadEnv(context.Background(), &conf) + if err != nil { + panic(err) + } +} diff --git a/xload/load.go b/xload/load.go index 6ff7321..234ffcc 100644 --- a/xload/load.go +++ b/xload/load.go @@ -32,8 +32,13 @@ var ( ) const ( - optRequired = "required" - optPrefix = "prefix=" + optRequired = "required" + optPrefix = "prefix=" + optDelimiter = "delimiter=" + optSeparator = "separator=" + + defaultDelimiter = "," + defaultSeparator = "=" ) // LoadEnv loads values from OS environment using default options. @@ -175,7 +180,7 @@ func process(ctx context.Context, obj any, tagKey string, loader Loader) error { } // set value - err = setVal(fVal, val) + err = setVal(fVal, val, meta) if err != nil { return err } @@ -185,9 +190,11 @@ func process(ctx context.Context, obj any, tagKey string, loader Loader) error { } type field struct { - name string - prefix string - required bool + name string + prefix string + required bool + delimiter string + separator string } func parseField(tag string) (*field, error) { @@ -195,7 +202,9 @@ func parseField(tag string) (*field, error) { key, tagOpts := strings.TrimSpace(parts[0]), parts[1:] f := &field{ - name: key, + name: key, + delimiter: defaultDelimiter, + separator: defaultSeparator, } for _, opt := range tagOpts { @@ -210,6 +219,10 @@ func parseField(tag string) (*field, error) { f.required = true case strings.HasPrefix(opt, optPrefix): f.prefix = strings.TrimPrefix(opt, optPrefix) + case strings.HasPrefix(opt, optDelimiter): + f.delimiter = strings.TrimPrefix(opt, optDelimiter) + case strings.HasPrefix(opt, optSeparator): + f.separator = strings.TrimPrefix(opt, optSeparator) default: return nil, ErrUnknownTagOption } @@ -219,7 +232,7 @@ func parseField(tag string) (*field, error) { } //nolint:funlen,nestif -func setVal(field reflect.Value, val string) error { +func setVal(field reflect.Value, val string, meta *field) error { for field.Kind() == reflect.Ptr { if field.IsNil() { field.Set(reflect.New(field.Type().Elem())) @@ -297,11 +310,11 @@ func setVal(field reflect.Value, val string) error { field.SetFloat(f) case reflect.Map: - vals := strings.Split(val, ",") + vals := strings.Split(val, meta.delimiter) m := reflect.MakeMapWithSize(ty, len(vals)) for _, v := range vals { - kv := strings.Split(v, ":") + kv := strings.Split(v, meta.separator) if len(kv) != 2 { return ErrInvalidMapValue } @@ -310,14 +323,14 @@ func setVal(field reflect.Value, val string) error { key := reflect.New(ty.Key()).Elem() - err := setVal(key, k) + err := setVal(key, k, meta) if err != nil { return err } value := reflect.New(ty.Elem()).Elem() - err = setVal(value, v) + err = setVal(value, v, meta) if err != nil { return err } @@ -335,13 +348,13 @@ func setVal(field reflect.Value, val string) error { return nil } - vals := strings.Split(val, ",") + vals := strings.Split(val, meta.delimiter) slice := reflect.MakeSlice(ty, len(vals), len(vals)) for i, v := range vals { v = strings.TrimSpace(v) - err := setVal(slice.Index(i), v) + err := setVal(slice.Index(i), v, meta) if err != nil { return err } diff --git a/xload/load_test.go b/xload/load_test.go index 1ff5749..a5df489 100644 --- a/xload/load_test.go +++ b/xload/load_test.go @@ -391,26 +391,164 @@ func TestLoad_NativeTypes(t *testing.T) { PtrInt64Map: &map[string]int64{"key7": 7, "key8": 8}, }, loader: MapLoader{ - "STRING_MAP": "key1:value1,key2:value2", - "PTR_STRING_MAP": "key3:value3,key4:value4", - "INT64_MAP": "key5:5,key6:6", - "PTR_INT64_MAP": "key7:7,key8:8", + "STRING_MAP": "key1=value1,key2=value2", + "PTR_STRING_MAP": "key3=value3,key4=value4", + "INT64_MAP": "key5=5,key6=6", + "PTR_INT64_MAP": "key7=7,key8=8", }, }, { - name: "map: invalid delimiter", + name: "map: invalid separator", input: &struct { StringMap map[string]string `env:"STRING_MAP"` }{}, - loader: MapLoader{"STRING_MAP": "key1=value1,key2=value2"}, + loader: MapLoader{"STRING_MAP": "key1::value1,key2::value2"}, err: ErrInvalidMapValue, }, { - name: "map: invalid delimiter", + name: "map: invalid value", input: &struct { Int64Map map[string]int64 `env:"INT64_MAP"` }{}, loader: MapLoader{"INT64_MAP": "key1=1,key2=invalid"}, + err: errors.New("unable to cast"), + }, + { + name: "map: invalid key", + input: &struct { + Int64Map map[int]int64 `env:"INT64_MAP"` + }{}, + loader: MapLoader{"INT64_MAP": "key1=1,key2=2"}, + err: errors.New("unable to cast"), + }, + } + + runTestcases(t, testcases) +} + +func TestLoad_ArrayTypes(t *testing.T) { + t.Parallel() + + testcases := []testcase{ + { + name: "slice", + input: &struct { + StringSlice []string `env:"STRING_SLICE"` + OptStringSlice []*string `env:"OPT_STRING_SLICE"` + PtrStringSlice *[]string `env:"PTR_STRING_SLICE"` + Int64Slice []int64 `env:"INT64_SLICE"` + OptInt64Slice []*int64 `env:"OPT_INT64_SLICE"` + PtrInt64Slice *[]int64 `env:"PTR_INT64_SLICE"` + }{}, + want: &struct { + StringSlice []string `env:"STRING_SLICE"` + OptStringSlice []*string `env:"OPT_STRING_SLICE"` + PtrStringSlice *[]string `env:"PTR_STRING_SLICE"` + Int64Slice []int64 `env:"INT64_SLICE"` + OptInt64Slice []*int64 `env:"OPT_INT64_SLICE"` + PtrInt64Slice *[]int64 `env:"PTR_INT64_SLICE"` + }{ + StringSlice: []string{"string1", "string2"}, + OptStringSlice: []*string{ptr.String("string3"), ptr.String("string4")}, + PtrStringSlice: &[]string{"string5", "string6"}, + Int64Slice: []int64{1, 2}, + OptInt64Slice: []*int64{ptr.Int64(3), ptr.Int64(4)}, + PtrInt64Slice: &[]int64{5, 6}, + }, + loader: MapLoader{ + "STRING_SLICE": "string1,string2", + "OPT_STRING_SLICE": "string3,string4", + "PTR_STRING_SLICE": "string5,string6", + "INT64_SLICE": "1,2", + "OPT_INT64_SLICE": "3,4", + "PTR_INT64_SLICE": "5,6", + }, + }, + { + name: "slice: custom delimiter", + input: &struct { + StringSlice []string `env:"STRING_SLICE,delimiter=;"` + }{}, + want: &struct { + StringSlice []string `env:"STRING_SLICE,delimiter=;"` + }{ + StringSlice: []string{"string1", "string2", "string3"}, + }, + loader: MapLoader{"STRING_SLICE": "string1;string2;string3"}, + }, + { + name: "slice: invalid value", + input: &struct { + Int64Slice []int64 `env:"INT64_SLICE"` + }{}, + loader: MapLoader{"INT64_SLICE": "invalid,2"}, + err: errors.New("unable to cast"), + }, + } + + runTestcases(t, testcases) +} + +func TestLoad_MapTypes(t *testing.T) { + t.Parallel() + + testcases := []testcase{ + { + name: "map", + input: &struct { + StringMap map[string]string `env:"STRING_MAP"` + PtrStringMap *map[string]string `env:"PTR_STRING_MAP"` + Int64Map map[string]int64 `env:"INT64_MAP"` + PtrInt64Map *map[string]int64 `env:"PTR_INT64_MAP"` + }{}, + want: &struct { + StringMap map[string]string `env:"STRING_MAP"` + PtrStringMap *map[string]string `env:"PTR_STRING_MAP"` + Int64Map map[string]int64 `env:"INT64_MAP"` + PtrInt64Map *map[string]int64 `env:"PTR_INT64_MAP"` + }{ + StringMap: map[string]string{"key1": "value1", "key2": "value2"}, + PtrStringMap: &map[string]string{"key3": "value3", "key4": "value4"}, + Int64Map: map[string]int64{"key5": 5, "key6": 6}, + PtrInt64Map: &map[string]int64{"key7": 7, "key8": 8}, + }, + loader: MapLoader{ + "STRING_MAP": "key1=value1,key2=value2", + "PTR_STRING_MAP": "key3=value3,key4=value4", + "INT64_MAP": "key5=5,key6=6", + "PTR_INT64_MAP": "key7=7,key8=8", + }, + }, + { + name: "map: custom delimiter", + input: &struct { + StringMap map[string]string `env:"STRING_MAP,delimiter=;"` + }{}, + want: &struct { + StringMap map[string]string `env:"STRING_MAP,delimiter=;"` + }{ + StringMap: map[string]string{"key1": "value1", "key2": "value2"}, + }, + loader: MapLoader{"STRING_MAP": "key1=value1;key2=value2"}, + }, + { + name: "map: custom separator", + input: &struct { + StringMap map[string]string `env:"STRING_MAP,separator=::"` + }{}, + want: &struct { + StringMap map[string]string `env:"STRING_MAP,separator=::"` + }{ + StringMap: map[string]string{"key1": "value1", "key2": "value2"}, + }, + loader: MapLoader{"STRING_MAP": "key1::value1,key2::value2"}, + }, + { + name: "map: invalid separator", + input: &struct { + StringMap map[string]string `env:"STRING_MAP"` + }{}, + loader: MapLoader{"STRING_MAP": "key1::value1,key2::value2"}, err: ErrInvalidMapValue, }, { @@ -418,7 +556,7 @@ func TestLoad_NativeTypes(t *testing.T) { input: &struct { Int64Map map[string]int64 `env:"INT64_MAP"` }{}, - loader: MapLoader{"INT64_MAP": "key1:1,key2:invalid"}, + loader: MapLoader{"INT64_MAP": "key1=1,key2=invalid"}, err: errors.New("unable to cast"), }, { @@ -426,7 +564,7 @@ func TestLoad_NativeTypes(t *testing.T) { input: &struct { Int64Map map[int]int64 `env:"INT64_MAP"` }{}, - loader: MapLoader{"INT64_MAP": "key1:1,key2:2"}, + loader: MapLoader{"INT64_MAP": "key1=1,key2=2"}, err: errors.New("unable to cast"), }, }