Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support query array in httpx.Parse #4440

Merged
merged 4 commits into from
Nov 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions core/mapping/unmarshaler.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type (

unmarshalOptions struct {
fillDefault bool
fromArray bool
fromString bool
opaqueKeys bool
canonicalKey func(key string) string
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
56 changes: 56 additions & 0 deletions core/mapping/unmarshaler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
13 changes: 10 additions & 3 deletions rest/httpx/requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
55 changes: 55 additions & 0 deletions rest/httpx/requests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
14 changes: 10 additions & 4 deletions rest/httpx/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
4 changes: 2 additions & 2 deletions rest/router/patrouter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down