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 (#3034)

Signed-off-by: ItalyPaleAle <[email protected]>
  • Loading branch information
ItalyPaleAle authored Aug 3, 2023
1 parent 4a84a01 commit 46b7535
Show file tree
Hide file tree
Showing 3 changed files with 264 additions and 22 deletions.
87 changes: 83 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,77 @@ 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
}

// Error if result is not pointer to struct, or pointer to pointer to struct
t := reflect.TypeOf(result)
if t.Kind() != reflect.Pointer {
return fmt.Errorf("not a pointer: %s", t.Kind().String())
}
t = t.Elem()
if t.Kind() == reflect.Pointer {
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 := 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[strings.ToLower(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
179 changes: 179 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,172 @@ 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",
},
},
{
name: "no aliases with mixed case",
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 with mixed case",
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 with mixed cases",
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: "multiple aliases with mixed cases",
md: map[string]string{
"bonjour": "monde",
},
result: &struct {
Hello string `mapstructure:"HELLO" mapstructurealiases:"CIAO,BONJOUR"`
}{},
wantMd: map[string]string{
"HELLO": "monde",
"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)
})
}
}
20 changes: 2 additions & 18 deletions middleware/http/bearer/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,12 @@ import (

type bearerMiddlewareMetadata struct {
// Issuer authority.
Issuer string `json:"issuer" mapstructure:"issuer"`
Issuer string `json:"issuer" mapstructure:"issuer" mapstructurealiases:"issuerURL"`
// Audience to expect in the token (usually, a client ID).
Audience string `json:"audience" mapstructure:"audience"`
Audience string `json:"audience" mapstructure:"audience" mapstructurealiases:"clientID"`
// Optional address of the JKWS file.
// If missing, will try to fetch the URL set in the OpenID Configuration document `<issuer>/.well-known/openid-configuration`.
JWKSURL string `json:"jwksURL" mapstructure:"jwksURL"`
// Deprecated - use "issuer" instead.
IssuerURL string `json:"issuerURL" mapstructure:"issuerURL"`
// Deprecated - use "audience" instead.
ClientID string `json:"clientID" mapstructure:"clientID"`

// Internal properties
logger logger.Logger `json:"-" mapstructure:"-"`
Expand All @@ -52,18 +48,6 @@ func (md *bearerMiddlewareMetadata) fromMetadata(metadata middleware.Metadata) e
return err
}

// Support IssuerURL as deprecated alias for Issuer
if md.Issuer == "" && md.IssuerURL != "" {
md.Issuer = md.IssuerURL
md.logger.Warnf("Metadata property 'issuerURL' is deprecated and will be removed in the future. Please use 'issuer' instead.")
}

// Support ClientID as deprecated alias for Audience
if md.Audience == "" && md.ClientID != "" {
md.Audience = md.ClientID
md.logger.Warnf("Metadata property 'clientID' is deprecated and will be removed in the future. Please use 'audience' instead.")
}

// Validate properties
if md.Issuer == "" {
return errors.New("metadata property 'issuer' is required")
Expand Down

0 comments on commit 46b7535

Please sign in to comment.