From 5bbd69d824fed25039ad383b72f2dcf4edaa606b Mon Sep 17 00:00:00 2001 From: ItalyPaleAle <43508+ItalyPaleAle@users.noreply.github.com> Date: Thu, 3 Aug 2023 00:11:39 +0000 Subject: [PATCH] DecodeMetadata: add support for aliases via "mapstructurealiases" struct tag This helps renaming metadata properties while maintaining aliases for backwards-compatibility Signed-off-by: ItalyPaleAle <43508+ItalyPaleAle@users.noreply.github.com> --- metadata/utils.go | 83 ++++++++++++++++++++++++++-- metadata/utils_test.go | 121 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 4 deletions(-) diff --git a/metadata/utils.go b/metadata/utils.go index 1c9248c9ab..f793ea07f0 100644 --- a/metadata/utils.go +++ b/metadata/utils.go @@ -23,6 +23,7 @@ import ( "time" "github.com/mitchellh/mapstructure" + "github.com/spf13/cast" "github.com/dapr/components-contrib/internal/utils" "github.com/dapr/kit/ptr" @@ -142,16 +143,27 @@ func GetMetadataProperty(props map[string]string, keys ...string) (val string, o // This is an extension of mitchellh/mapstructure which also supports decoding durations func DecodeMetadata(input any, result any) error { // avoids a common mistake of passing the metadata struct, instead of the properties map - // if input is of type struct, case it to metadata.Base and access the Properties instead + // if input is of type struct, cast it to metadata.Base and access the Properties instead v := reflect.ValueOf(input) if v.Kind() == reflect.Struct { f := v.FieldByName("Properties") if f.IsValid() && f.Kind() == reflect.Map { - properties := f.Interface().(map[string]string) - input = properties + input = f.Interface().(map[string]string) } } + inputMap, err := cast.ToStringMapStringE(input) + if err != nil { + return fmt.Errorf("input object cannot be cast to map[string]string: %w", err) + } + + // Handle aliases + err = resolveAliases(inputMap, result) + if err != nil { + return fmt.Errorf("failed to resolve aliases: %w", err) + } + + // Finally, decode the metadata using mapstructure decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ DecodeHook: mapstructure.ComposeDecodeHookFunc( toTimeDurationArrayHookFunc(), @@ -166,10 +178,73 @@ func DecodeMetadata(input any, result any) error { if err != nil { return err } - err = decoder.Decode(input) + err = decoder.Decode(inputMap) return err } +func resolveAliases(md map[string]string, result any) error { + // Get the list of all keys in the map + keys := make(map[string]string, len(md)) + for k := range md { + lk := strings.ToLower(k) + + // Check if there are duplicate keys after lowercasing + _, ok := keys[lk] + if ok { + return fmt.Errorf("key %s is duplicate in the metadata", lk) + } + + keys[lk] = k + } + + // Return if result is not struct or pointer to struct + t := reflect.TypeOf(result) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return fmt.Errorf("not a struct: %s", t.Kind().String()) + } + + // Iterate through all the properties of result to see if anyone has the "mapstructurealiases" property + for i := 0; i < t.NumField(); i++ { + currentField := t.Field(i) + + // Ignored fields that are not exported or that don't have a "mapstructure" tag + mapstructureTag := strings.ToLower(currentField.Tag.Get("mapstructure")) + if !currentField.IsExported() || mapstructureTag == "" { + continue + } + + // If the current property has a value in the metadata, then we don't need to handle aliases + _, ok := keys[mapstructureTag] + if ok { + continue + } + + // Check if there's a "mapstructurealiases" tag + aliasesTag := strings.ToLower(currentField.Tag.Get("mapstructurealiases")) + if aliasesTag == "" { + continue + } + + // Look for the first alias that has a value + var mdKey string + for _, alias := range strings.Split(aliasesTag, ",") { + mdKey, ok = keys[alias] + if !ok { + continue + } + + // We found an alias + md[mapstructureTag] = md[mdKey] + break + } + } + + return nil +} + func toTruthyBoolHookFunc() mapstructure.DecodeHookFunc { return func( f reflect.Type, diff --git a/metadata/utils_test.go b/metadata/utils_test.go index 33a4af23c5..cdca77b9dc 100644 --- a/metadata/utils_test.go +++ b/metadata/utils_test.go @@ -19,6 +19,8 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/exp/maps" ) func TestIsRawPayload(t *testing.T) { @@ -111,6 +113,9 @@ func TestMetadataDecode(t *testing.T) { MyRegularDurationDefaultValueUnset time.Duration `mapstructure:"myregulardurationdefaultvalueunset"` MyRegularDurationDefaultValueEmpty time.Duration `mapstructure:"myregulardurationdefaultvalueempty"` + + AliasedFieldA string `mapstructure:"aliasA1" mapstructurealiases:"aliasA2"` + AliasedFieldB string `mapstructure:"aliasB1" mapstructurealiases:"aliasB2"` } var m testMetadata @@ -131,6 +136,9 @@ func TestMetadataDecode(t *testing.T) { "mydurationarray": "1s,2s,3s,10", "mydurationarraypointer": "1s,10,2s,20,3s,30", "mydurationarraypointerempty": ",", + "aliasA2": "hello", + "aliasB1": "ciao", + "aliasB2": "bonjour", } err := DecodeMetadata(testData, &m) @@ -149,6 +157,8 @@ func TestMetadataDecode(t *testing.T) { assert.Equal(t, []time.Duration{time.Second, time.Second * 2, time.Second * 3, time.Second * 10}, m.MyDurationArray) assert.Equal(t, []time.Duration{time.Second, time.Second * 10, time.Second * 2, time.Second * 20, time.Second * 3, time.Second * 30}, *m.MyDurationArrayPointer) assert.Equal(t, []time.Duration{}, *m.MyDurationArrayPointerEmpty) + assert.Equal(t, "hello", m.AliasedFieldA) + assert.Equal(t, "ciao", m.AliasedFieldB) }) t.Run("Test metadata decode hook for truthy values", func(t *testing.T) { @@ -303,3 +313,114 @@ func TestMetadataStructToStringMap(t *testing.T) { assert.Empty(t, metadatainfo["ignored"].Aliases) }) } + +func TestResolveAliases(t *testing.T) { + tests := []struct { + name string + md map[string]string + result any + wantErr bool + wantMd map[string]string + }{ + { + name: "no aliases", + md: map[string]string{ + "hello": "world", + "ciao": "mondo", + }, + result: struct { + Hello string `mapstructure:"hello"` + Ciao string `mapstructure:"ciao"` + Bonjour string `mapstructure:"bonjour"` + }{}, + wantMd: map[string]string{ + "hello": "world", + "ciao": "mondo", + }, + }, + { + name: "set with aliased field", + md: map[string]string{ + "ciao": "mondo", + }, + result: struct { + Hello string `mapstructure:"hello" mapstructurealiases:"ciao"` + Bonjour string `mapstructure:"bonjour"` + }{}, + wantMd: map[string]string{ + "hello": "mondo", + "ciao": "mondo", + }, + }, + { + name: "do not overwrite existing fields with aliases", + md: map[string]string{ + "hello": "world", + "ciao": "mondo", + }, + result: struct { + Hello string `mapstructure:"hello" mapstructurealiases:"ciao"` + Bonjour string `mapstructure:"bonjour"` + }{}, + wantMd: map[string]string{ + "hello": "world", + "ciao": "mondo", + }, + }, + { + name: "no fields with aliased value", + md: map[string]string{ + "bonjour": "monde", + }, + result: struct { + Hello string `mapstructure:"hello" mapstructurealiases:"ciao"` + Bonjour string `mapstructure:"bonjour"` + }{}, + wantMd: map[string]string{ + "bonjour": "monde", + }, + }, + { + name: "multiple aliases", + md: map[string]string{ + "bonjour": "monde", + }, + result: struct { + Hello string `mapstructure:"hello" mapstructurealiases:"ciao,bonjour"` + }{}, + wantMd: map[string]string{ + "hello": "monde", + "bonjour": "monde", + }, + }, + { + name: "first alias wins", + md: map[string]string{ + "ciao": "mondo", + "bonjour": "monde", + }, + result: struct { + Hello string `mapstructure:"hello" mapstructurealiases:"ciao,bonjour"` + }{}, + wantMd: map[string]string{ + "hello": "mondo", + "ciao": "mondo", + "bonjour": "monde", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + md := maps.Clone(tt.md) + err := resolveAliases(md, tt.result) + + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tt.wantMd, md) + }) + } +}