feat: support query array in httpx.Parse (#4440)

This commit is contained in:
Kevin Wan 2024-11-02 21:55:37 +08:00 committed by GitHub
parent f822c9a94f
commit 18cb3141ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 157 additions and 9 deletions

View File

@ -50,6 +50,7 @@ type (
unmarshalOptions struct { unmarshalOptions struct {
fillDefault bool fillDefault bool
fromArray bool
fromString bool fromString bool
opaqueKeys bool opaqueKeys bool
canonicalKey func(key string) string 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) 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{ return u.processNamedFieldWithValue(field.Type, value, valueWithParent{
value: mapValue, value: mapValue,
parent: valuer, 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. // WithOpaqueKeys customizes an Unmarshaler with opaque keys.
// Opaque keys are keys that are not processed by the unmarshaler. // Opaque keys are keys that are not processed by the unmarshaler.
func WithOpaqueKeys() UnmarshalOption { func WithOpaqueKeys() UnmarshalOption {

View File

@ -5639,6 +5639,62 @@ func TestUnmarshalFromStringSliceForTypeMismatch(t *testing.T) {
}, &v)) }, &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) { func TestUnmarshalWithOpaqueKeys(t *testing.T) {
var v struct { var v struct {
Opaque string `key:"opaque.key"` Opaque string `key:"opaque.key"`

View File

@ -23,9 +23,16 @@ const (
) )
var ( var (
formUnmarshaler = mapping.NewUnmarshaler(formKey, mapping.WithStringValues(), mapping.WithOpaqueKeys()) formUnmarshaler = mapping.NewUnmarshaler(
pathUnmarshaler = mapping.NewUnmarshaler(pathKey, mapping.WithStringValues(), mapping.WithOpaqueKeys()) formKey,
validator atomic.Value 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. // Validator defines the interface for validating the request.

View File

@ -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) { func TestParseForm_Error(t *testing.T) {
var v struct { var v struct {
Name string `form:"name"` Name string `form:"name"`

View File

@ -20,10 +20,16 @@ func GetFormValues(r *http.Request) (map[string]any, error) {
} }
params := make(map[string]any, len(r.Form)) params := make(map[string]any, len(r.Form))
for name := range r.Form { for name, values := range r.Form {
formValue := r.Form.Get(name) filtered := make([]string, 0, len(values))
if len(formValue) > 0 { for _, v := range values {
params[name] = formValue if len(v) > 0 {
filtered = append(filtered, v)
}
}
if len(filtered) > 0 {
params[name] = filtered
} }
} }

View File

@ -137,7 +137,7 @@ func TestPatRouter(t *testing.T) {
} }
func TestParseSlice(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) reader := strings.NewReader(body)
r, err := http.NewRequest(http.MethodPost, "http://hello.com/", reader) r, err := http.NewRequest(http.MethodPost, "http://hello.com/", reader)
assert.Nil(t, err) assert.Nil(t, err)
@ -388,7 +388,7 @@ func TestParseQueryRequired(t *testing.T) {
} }
func TestParseOptional(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) assert.Nil(t, err)
router := NewRouter() router := NewRouter()