diff --git a/core/mapping/unmarshaler.go b/core/mapping/unmarshaler.go index fc843891b632..b4fb356ee319 100644 --- a/core/mapping/unmarshaler.go +++ b/core/mapping/unmarshaler.go @@ -50,6 +50,7 @@ type ( unmarshalOptions struct { fillDefault bool + fromArray bool fromString bool opaqueKeys bool canonicalKey func(key string) string @@ -811,6 +812,19 @@ func (u *Unmarshaler) processNamedField(field reflect.StructField, value reflect return u.processNamedFieldWithoutValue(field.Type, value, opts, fullName) } + if u.opts.fromArray { + fieldKind := field.Type.Kind() + if fieldKind != reflect.Slice && fieldKind != reflect.Array { + valueKind := reflect.TypeOf(mapValue).Kind() + if valueKind == reflect.Slice || valueKind == reflect.Array { + val := reflect.ValueOf(mapValue) + if val.Len() > 0 { + mapValue = val.Index(0).Interface() + } + } + } + } + return u.processNamedFieldWithValue(field.Type, value, valueWithParent{ value: mapValue, parent: valuer, @@ -990,6 +1004,16 @@ func WithDefault() UnmarshalOption { } } +// WithFromArray customizes an Unmarshaler with converting array values to non-array types. +// For example, if the field type is []string, and the value is [hello], +// the field type can be `string`, instead of `[]string`. +// Typically, this option is used for unmarshaling from form values. +func WithFromArray() UnmarshalOption { + return func(opt *unmarshalOptions) { + opt.fromArray = true + } +} + // WithOpaqueKeys customizes an Unmarshaler with opaque keys. // Opaque keys are keys that are not processed by the unmarshaler. func WithOpaqueKeys() UnmarshalOption { diff --git a/core/mapping/unmarshaler_test.go b/core/mapping/unmarshaler_test.go index 229d9c4134d7..3270632dc447 100644 --- a/core/mapping/unmarshaler_test.go +++ b/core/mapping/unmarshaler_test.go @@ -5639,6 +5639,62 @@ func TestUnmarshalFromStringSliceForTypeMismatch(t *testing.T) { }, &v)) } +func TestUnmarshalWithFromArray(t *testing.T) { + t.Run("array", func(t *testing.T) { + var v struct { + Value []string `key:"value"` + } + unmarshaler := NewUnmarshaler("key", WithFromArray()) + if assert.NoError(t, unmarshaler.Unmarshal(map[string]any{ + "value": []string{"foo", "bar"}, + }, &v)) { + assert.ElementsMatch(t, []string{"foo", "bar"}, v.Value) + } + }) + + t.Run("not array", func(t *testing.T) { + var v struct { + Value string `key:"value"` + } + unmarshaler := NewUnmarshaler("key", WithFromArray()) + if assert.NoError(t, unmarshaler.Unmarshal(map[string]any{ + "value": []string{"foo"}, + }, &v)) { + assert.Equal(t, "foo", v.Value) + } + }) + + t.Run("not array and empty", func(t *testing.T) { + var v struct { + Value string `key:"value"` + } + unmarshaler := NewUnmarshaler("key", WithFromArray()) + if assert.NoError(t, unmarshaler.Unmarshal(map[string]any{ + "value": []string{""}, + }, &v)) { + assert.Empty(t, v.Value) + } + }) + + t.Run("not array and no value", func(t *testing.T) { + var v struct { + Value string `key:"value"` + } + unmarshaler := NewUnmarshaler("key", WithFromArray()) + assert.Error(t, unmarshaler.Unmarshal(map[string]any{}, &v)) + }) + + t.Run("not array and no value and optional", func(t *testing.T) { + var v struct { + Value string `key:"value,optional"` + } + unmarshaler := NewUnmarshaler("key", WithFromArray()) + if assert.NoError(t, unmarshaler.Unmarshal(map[string]any{}, &v)) { + assert.Empty(t, v.Value) + } + }) +} + func TestUnmarshalWithOpaqueKeys(t *testing.T) { var v struct { Opaque string `key:"opaque.key"` diff --git a/rest/httpx/requests.go b/rest/httpx/requests.go index cac81a58c803..c6199a83e7fd 100644 --- a/rest/httpx/requests.go +++ b/rest/httpx/requests.go @@ -23,9 +23,16 @@ const ( ) var ( - formUnmarshaler = mapping.NewUnmarshaler(formKey, mapping.WithStringValues(), mapping.WithOpaqueKeys()) - pathUnmarshaler = mapping.NewUnmarshaler(pathKey, mapping.WithStringValues(), mapping.WithOpaqueKeys()) - validator atomic.Value + formUnmarshaler = mapping.NewUnmarshaler( + formKey, + mapping.WithStringValues(), + mapping.WithOpaqueKeys(), + mapping.WithFromArray()) + pathUnmarshaler = mapping.NewUnmarshaler( + pathKey, + mapping.WithStringValues(), + mapping.WithOpaqueKeys()) + validator atomic.Value ) // Validator defines the interface for validating the request. diff --git a/rest/httpx/requests_test.go b/rest/httpx/requests_test.go index e2930ee449c0..02b7164ad804 100644 --- a/rest/httpx/requests_test.go +++ b/rest/httpx/requests_test.go @@ -49,6 +49,61 @@ func TestParseForm(t *testing.T) { }) } +func TestParseFormArray(t *testing.T) { + t.Run("slice", func(t *testing.T) { + var v struct { + Name []string `form:"name"` + Age []int `form:"age"` + Percent []float64 `form:"percent,optional"` + } + + r, err := http.NewRequest( + http.MethodGet, + "/a?name=hello&name=world&age=18&age=19&percent=3.4&percent=4.5", + http.NoBody) + assert.NoError(t, err) + if assert.NoError(t, Parse(r, &v)) { + assert.ElementsMatch(t, []string{"hello", "world"}, v.Name) + assert.ElementsMatch(t, []int{18, 19}, v.Age) + assert.ElementsMatch(t, []float64{3.4, 4.5}, v.Percent) + } + }) + + t.Run("slice with single value", func(t *testing.T) { + var v struct { + Name []string `form:"name"` + Age []int `form:"age"` + Percent []float64 `form:"percent,optional"` + } + + r, err := http.NewRequest( + http.MethodGet, + "/a?name=hello&age=18&percent=3.4", + http.NoBody) + assert.NoError(t, err) + if assert.NoError(t, Parse(r, &v)) { + assert.ElementsMatch(t, []string{"hello"}, v.Name) + assert.ElementsMatch(t, []int{18}, v.Age) + assert.ElementsMatch(t, []float64{3.4}, v.Percent) + } + }) + + t.Run("slice with empty and non-empty", func(t *testing.T) { + var v struct { + Name []string `form:"name"` + } + + r, err := http.NewRequest( + http.MethodGet, + "/a?name=&name=1", + http.NoBody) + assert.NoError(t, err) + if assert.NoError(t, Parse(r, &v)) { + assert.ElementsMatch(t, []string{"1"}, v.Name) + } + }) +} + func TestParseForm_Error(t *testing.T) { var v struct { Name string `form:"name"` diff --git a/rest/httpx/util.go b/rest/httpx/util.go index 9623761adaeb..19248ae74bb3 100644 --- a/rest/httpx/util.go +++ b/rest/httpx/util.go @@ -20,10 +20,16 @@ func GetFormValues(r *http.Request) (map[string]any, error) { } params := make(map[string]any, len(r.Form)) - for name := range r.Form { - formValue := r.Form.Get(name) - if len(formValue) > 0 { - params[name] = formValue + for name, values := range r.Form { + filtered := make([]string, 0, len(values)) + for _, v := range values { + if len(v) > 0 { + filtered = append(filtered, v) + } + } + + if len(filtered) > 0 { + params[name] = filtered } } diff --git a/rest/router/patrouter_test.go b/rest/router/patrouter_test.go index 4b3e8d1fe9ef..dca589e9fc00 100644 --- a/rest/router/patrouter_test.go +++ b/rest/router/patrouter_test.go @@ -137,7 +137,7 @@ func TestPatRouter(t *testing.T) { } func TestParseSlice(t *testing.T) { - body := `names=%5B%22first%22%2C%22second%22%5D` + body := `names=first&names=second` reader := strings.NewReader(body) r, err := http.NewRequest(http.MethodPost, "http://hello.com/", reader) assert.Nil(t, err) @@ -388,7 +388,7 @@ func TestParseQueryRequired(t *testing.T) { } func TestParseOptional(t *testing.T) { - r, err := http.NewRequest(http.MethodGet, "http://hello.com/kevin/2017?nickname=whatever&zipcode=", nil) + r, err := http.NewRequest(http.MethodGet, "http://hello.com/kevin/2017?nickname=whatever", nil) assert.Nil(t, err) router := NewRouter()