feat: support form array in three notations (#4498)

Signed-off-by: kevin <wanjunfeng@gmail.com>
This commit is contained in:
Kevin Wan
2024-12-23 00:56:20 +08:00
committed by GitHub
parent 2159d112c3
commit 1d9159ea39
6 changed files with 452 additions and 81 deletions

View File

@@ -88,6 +88,36 @@ func TestParseFormArray(t *testing.T) {
}
})
t.Run("slice with empty", func(t *testing.T) {
var v struct {
Name []string `form:"name,optional"`
}
r, err := http.NewRequest(
http.MethodGet,
"/a",
http.NoBody)
assert.NoError(t, err)
if assert.NoError(t, Parse(r, &v)) {
assert.ElementsMatch(t, []string{}, v.Name)
}
})
t.Run("slice with empty", func(t *testing.T) {
var v struct {
Name []string `form:"name,optional"`
}
r, err := http.NewRequest(
http.MethodGet,
"/a?name=",
http.NoBody)
assert.NoError(t, err)
if assert.NoError(t, Parse(r, &v)) {
assert.ElementsMatch(t, []string{""}, v.Name)
}
})
t.Run("slice with empty and non-empty", func(t *testing.T) {
var v struct {
Name []string `form:"name"`
@@ -99,7 +129,67 @@ func TestParseFormArray(t *testing.T) {
http.NoBody)
assert.NoError(t, err)
if assert.NoError(t, Parse(r, &v)) {
assert.ElementsMatch(t, []string{"1"}, v.Name)
assert.ElementsMatch(t, []string{"", "1"}, v.Name)
}
})
t.Run("slice with one value on array format", func(t *testing.T) {
var v struct {
Names []string `form:"names"`
}
r, err := http.NewRequest(
http.MethodGet,
"/a?names=1,2,3",
http.NoBody)
assert.NoError(t, err)
if assert.NoError(t, Parse(r, &v)) {
assert.ElementsMatch(t, []string{"1", "2", "3"}, v.Names)
}
})
t.Run("slice with one value on combined array format", func(t *testing.T) {
var v struct {
Names []string `form:"names"`
}
r, err := http.NewRequest(
http.MethodGet,
"/a?names=[1,2,3]&names=4",
http.NoBody)
assert.NoError(t, err)
if assert.NoError(t, Parse(r, &v)) {
assert.ElementsMatch(t, []string{"[1,2,3]", "4"}, v.Names)
}
})
t.Run("slice with one value on integer array format", func(t *testing.T) {
var v struct {
Numbers []int `form:"numbers"`
}
r, err := http.NewRequest(
http.MethodGet,
"/a?numbers=1,2,3",
http.NoBody)
assert.NoError(t, err)
if assert.NoError(t, Parse(r, &v)) {
assert.ElementsMatch(t, []int{1, 2, 3}, v.Numbers)
}
})
t.Run("slice with one value on array format brackets", func(t *testing.T) {
var v struct {
Names []string `form:"names"`
}
r, err := http.NewRequest(
http.MethodGet,
"/a?names[]=1&names[]=2&names[]=3",
http.NoBody)
assert.NoError(t, err)
if assert.NoError(t, Parse(r, &v)) {
assert.ElementsMatch(t, []string{"1", "2", "3"}, v.Names)
}
})
}
@@ -528,6 +618,26 @@ func TestCustomUnmarshalerStructRequest(t *testing.T) {
assert.Equal(t, "hello", v.Foo.Name)
}
func TestParseJsonStringRequest(t *testing.T) {
type GoodsInfo struct {
Sku int64 `json:"sku,optional"`
}
type GetReq struct {
GoodsList []*GoodsInfo `json:"goods_list"`
}
input := `{"goods_list":"[{\"sku\":11},{\"sku\":22}]"}`
r := httptest.NewRequest(http.MethodPost, "/a", strings.NewReader(input))
r.Header.Set(ContentType, JsonContentType)
var v GetReq
assert.NotPanics(t, func() {
assert.NoError(t, Parse(r, &v))
assert.Equal(t, 2, len(v.GoodsList))
assert.ElementsMatch(t, []int64{11, 22}, []int64{v.GoodsList[0].Sku, v.GoodsList[1].Sku})
})
}
func BenchmarkParseRaw(b *testing.B) {
r, err := http.NewRequest(http.MethodGet, "http://hello.com/a?name=hello&age=18&percent=3.4", http.NoBody)
if err != nil {

View File

@@ -2,12 +2,23 @@ package httpx
import (
"errors"
"fmt"
"net/http"
"strings"
)
const xForwardedFor = "X-Forwarded-For"
const (
xForwardedFor = "X-Forwarded-For"
arraySuffix = "[]"
// most servers and clients have a limit of 8192 bytes (8 KB)
// one parameter at least take 4 chars, for example `?a=b&c=d`
maxFormParamCount = 2048
)
// GetFormValues returns the form values.
// GetFormValues returns the form values supporting three array notation formats:
// 1. Standard notation: /api?names=alice&names=bob
// 2. Comma notation: /api?names=alice,bob
// 3. Bracket notation: /api?names[]=alice&names[]=bob
func GetFormValues(r *http.Request) (map[string]any, error) {
if err := r.ParseForm(); err != nil {
return nil, err
@@ -19,16 +30,23 @@ func GetFormValues(r *http.Request) (map[string]any, error) {
}
}
var n int
params := make(map[string]any, len(r.Form))
for name, values := range r.Form {
filtered := make([]string, 0, len(values))
for _, v := range values {
if len(v) > 0 {
if n < maxFormParamCount {
filtered = append(filtered, v)
n++
} else {
return nil, fmt.Errorf("too many form values, error: %s", r.Form.Encode())
}
}
if len(filtered) > 0 {
if strings.HasSuffix(name, arraySuffix) {
name = name[:len(name)-2]
}
params[name] = filtered
}
}

View File

@@ -1,7 +1,9 @@
package httpx
import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
@@ -23,3 +25,23 @@ func TestGetRemoteAddrNoHeader(t *testing.T) {
assert.True(t, len(GetRemoteAddr(r)) == 0)
}
func TestGetFormValues_TooManyValues(t *testing.T) {
form := url.Values{}
// Add more values than the limit
for i := 0; i < maxFormParamCount+10; i++ {
form.Add("param", fmt.Sprintf("value%d", i))
}
// Create a new request with the form data
req, err := http.NewRequest("POST", "/test", strings.NewReader(form.Encode()))
assert.NoError(t, err)
// Set the content type for form data
req.Header.Set(ContentType, "application/x-www-form-urlencoded")
_, err = GetFormValues(req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "too many form values")
}

View File

@@ -516,28 +516,55 @@ func TestParsePtrInRequestEmpty(t *testing.T) {
}
func TestParseQueryOptional(t *testing.T) {
r, err := http.NewRequest(http.MethodGet, "http://hello.com/kevin/2017?nickname=whatever&zipcode=", nil)
assert.Nil(t, err)
t.Run("optional with string", func(t *testing.T) {
r, err := http.NewRequest(http.MethodGet, "http://hello.com/kevin/2017?nickname=whatever&zipcode=", nil)
assert.Nil(t, err)
router := NewRouter()
err = router.Handle(http.MethodGet, "/:name/:year", http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
v := struct {
Nickname string `form:"nickname"`
Zipcode int64 `form:"zipcode,optional"`
}{}
router := NewRouter()
err = router.Handle(http.MethodGet, "/:name/:year", http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
v := struct {
Nickname string `form:"nickname"`
Zipcode string `form:"zipcode,optional"`
}{}
err = httpx.Parse(r, &v)
assert.Nil(t, err)
_, err = io.WriteString(w, fmt.Sprintf("%s:%d", v.Nickname, v.Zipcode))
assert.Nil(t, err)
}))
assert.Nil(t, err)
err = httpx.Parse(r, &v)
assert.Nil(t, err)
_, err = io.WriteString(w, fmt.Sprintf("%s:%s", v.Nickname, v.Zipcode))
assert.Nil(t, err)
}))
assert.Nil(t, err)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, r)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, r)
assert.Equal(t, "whatever:0", rr.Body.String())
assert.Equal(t, "whatever:", rr.Body.String())
})
t.Run("optional with int", func(t *testing.T) {
r, err := http.NewRequest(http.MethodGet, "http://hello.com/kevin/2017?nickname=whatever", nil)
assert.Nil(t, err)
router := NewRouter()
err = router.Handle(http.MethodGet, "/:name/:year", http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
v := struct {
Nickname string `form:"nickname"`
Zipcode int `form:"zipcode,optional"`
}{}
err = httpx.Parse(r, &v)
assert.Nil(t, err)
_, err = io.WriteString(w, fmt.Sprintf("%s:%d", v.Nickname, v.Zipcode))
assert.Nil(t, err)
}))
assert.Nil(t, err)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, r)
assert.Equal(t, "whatever:0", rr.Body.String())
})
}
func TestParse(t *testing.T) {