Skip to content

Commit

Permalink
DecodeMetadata: add support for aliases via "mapstructurealiases" str…
Browse files Browse the repository at this point in the history
…uct tag

This helps renaming metadata properties while maintaining aliases for backwards-compatibility

Signed-off-by: ItalyPaleAle <[email protected]>
  • Loading branch information
ItalyPaleAle committed Aug 3, 2023
1 parent 566c7fd commit 5bbd69d
Show file tree
Hide file tree
Showing 2 changed files with 200 additions and 4 deletions.
83 changes: 79 additions & 4 deletions metadata/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(),
Expand All @@ -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,
Expand Down
121 changes: 121 additions & 0 deletions metadata/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
})
}
}

0 comments on commit 5bbd69d

Please sign in to comment.