Skip to content

Commit

Permalink
Enable parse config map properties from environment string value (#8657)
Browse files Browse the repository at this point in the history
* Config decode string to map[string]string

Hook decode func to parse key1=value1,... as string value to
map[string]string.
This will handle the case we like to provide map values throw
environment variable.

* trim spaces from key and value

* Unit test DecodeStringToMap
  • Loading branch information
nopcoder authored Feb 16, 2025
1 parent 4e569da commit ea3d7df
Show file tree
Hide file tree
Showing 3 changed files with 96 additions and 1 deletion.
5 changes: 4 additions & 1 deletion pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -597,7 +597,10 @@ func Unmarshal(c Config) error {
return viper.UnmarshalExact(&c,
viper.DecodeHook(
mapstructure.ComposeDecodeHookFunc(
DecodeStrings, mapstructure.StringToTimeDurationHookFunc())))
DecodeStrings,
mapstructure.StringToTimeDurationHookFunc(),
DecodeStringToMap(),
)))
}

func stringReverse(s string) string {
Expand Down
39 changes: 39 additions & 0 deletions pkg/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"fmt"
"reflect"
"strings"

"github.com/mitchellh/mapstructure"
)

// Strings is a []string that mapstructure can deserialize from a single string or from a list
Expand All @@ -15,6 +17,8 @@ var (
ourStringsType = reflect.TypeOf(Strings{})
stringType = reflect.TypeOf("")
stringSliceType = reflect.TypeOf([]string{})

ErrInvalidKeyValuePair = errors.New("invalid key-value pair")
)

// DecodeStrings is a mapstructure.HookFuncType that decodes a single string value or a slice
Expand Down Expand Up @@ -80,3 +84,38 @@ func DecodeOnlyString(fromValue reflect.Value, toValue reflect.Value) (interface
}
return OnlyString(fromValue.Interface().(string)), nil
}

// DecodeStringToMap returns a DecodeHookFunc that converts a string to a map[string]string.
// The string is expected to be a comma-separated list of key-value pairs, where the key and value
// are separated by an equal sign.
func DecodeStringToMap() mapstructure.DecodeHookFunc {
return func(f reflect.Kind, t reflect.Kind, data interface{}) (interface{}, error) {
// check if field is a string and target is a map
if f != reflect.String || t != reflect.Map {
return data, nil
}
// check if target is map[string]string
if t != reflect.TypeOf(map[string]string{}).Kind() {
return data, nil
}

raw := data.(string)
if raw == "" {
return map[string]string{}, nil
}
// parse raw string as key1=value1,key2=value2
const pairSep = ","
const valueSep = "="
pairs := strings.Split(raw, pairSep)
m := make(map[string]string, len(pairs))
for _, pair := range pairs {
key, value, found := strings.Cut(pair, valueSep)
if !found {
return nil, fmt.Errorf("%w: %s", ErrInvalidKeyValuePair, pair)
}
m[strings.TrimSpace(key)] = strings.TrimSpace(value)
}

return m, nil
}
}
53 changes: 53 additions & 0 deletions pkg/config/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,59 @@ func TestStrings(t *testing.T) {
}
}

func TestDecodeStringToMap(t *testing.T) {
cases := []struct {
Name string
Source string
Expected map[string]string
ExpectedErr error
}{
{
Name: "empty string",
Source: "",
Expected: map[string]string{},
}, {
Name: "single pair",
Source: "key=value",
Expected: map[string]string{"key": "value"},
}, {
Name: "multiple pairs",
Source: "key1=value1,key2=value2",
Expected: map[string]string{"key1": "value1", "key2": "value2"},
}, {
Name: "pair with spaces",
Source: "key = value , key2 = value2",
Expected: map[string]string{"key": "value", "key2": "value2"},
}, {
Name: "invalid pair",
Source: "key1=value1,key2",
ExpectedErr: config.ErrInvalidKeyValuePair,
},
}
for _, c := range cases {
t.Run(c.Name, func(t *testing.T) {
result := make(map[string]string)
dc := mapstructure.DecoderConfig{
DecodeHook: config.DecodeStringToMap(),
Result: &result,
}
decoder, err := mapstructure.NewDecoder(&dc)
testutil.MustDo(t, "new decoder", err)
err = decoder.Decode(c.Source)
if c.ExpectedErr == nil {
testutil.MustDo(t, "decode", err)
if diffs := deep.Equal(result, c.Expected); diffs != nil {
t.Error(diffs)
}
} else {
if !errorsMatch(err, c.ExpectedErr) {
t.Errorf("Got error \"%v\", expected error \"%v\"", err, c.ExpectedErr)
}
}
})
}
}

type OnlyStringStruct struct {
S config.OnlyString
}
Expand Down

0 comments on commit ea3d7df

Please sign in to comment.