diff --git a/context.go b/context.go index baa4b0f9c9..e0ac31c4f8 100644 --- a/context.go +++ b/context.go @@ -21,6 +21,7 @@ import ( "github.com/gin-contrib/sse" "github.com/gin-gonic/gin/binding" + "github.com/gin-gonic/gin/internal/query" "github.com/gin-gonic/gin/render" ) @@ -504,6 +505,20 @@ func (c *Context) GetQueryMap(key string) (map[string]string, bool) { return c.get(c.queryCache, key) } +// ShouldGetQueryNestedMap returns a map from query params. +// In contrast to QueryMap it handles nesting in query maps like key[foo][bar]=value. +func (c *Context) ShouldGetQueryNestedMap() (dicts map[string]interface{}, err error) { + return c.ShouldGetQueryNestedMapForKey("") +} + +// ShouldGetQueryNestedMapForKey returns a map from query params for a given query key. +// In contrast to QueryMap it handles nesting in query maps like key[foo][bar]=value. +// Similar to ShouldGetQueryNestedMap but it returns only the map for the given key. +func (c *Context) ShouldGetQueryNestedMapForKey(key string) (dicts map[string]interface{}, err error) { + q := c.Request.URL.Query() + return query.GetMap(q, key) +} + // PostForm returns the specified key from a POST urlencoded form or multipart form // when it exists, otherwise it returns an empty string `("")`. func (c *Context) PostForm(key string) (value string) { diff --git a/context_test.go b/context_test.go index 66190b302e..53d6c5ce6e 100644 --- a/context_test.go +++ b/context_test.go @@ -18,6 +18,7 @@ import ( "net/url" "os" "reflect" + "strconv" "strings" "sync" "testing" @@ -25,6 +26,7 @@ import ( "github.com/gin-contrib/sse" "github.com/gin-gonic/gin/binding" + "github.com/gin-gonic/gin/internal/query" testdata "github.com/gin-gonic/gin/testdata/protoexample" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -574,6 +576,423 @@ func TestContextQueryAndPostForm(t *testing.T) { assert.Empty(t, dicts) } +func TestContextShouldGetQueryNestedMapSuccessfulParsing(t *testing.T) { + var emptyQueryMap map[string]any + veryDeepNesting := "" + currentLv := make(map[string]any) + veryDeepNestingResult := currentLv + for i := 0; i < query.MaxNestedMapDepth; i++ { + currKey := "nested" + strconv.Itoa(i) + veryDeepNesting += "[" + currKey + "]" + if i == query.MaxNestedMapDepth-1 { + currentLv[currKey] = "value" + continue + } + currentLv[currKey] = make(map[string]any) + currentLv = currentLv[currKey].(map[string]any) + } + + tests := map[string]struct { + url string + expectedResult map[string]any + }{ + "no query params": { + url: "", + expectedResult: emptyQueryMap, + }, + "single query param": { + url: "?foo=bar", + expectedResult: map[string]any{ + "foo": "bar", + }, + }, + "empty key and value": { + url: "?=", + expectedResult: map[string]any{ + "": "", + }, + }, + "empty key with some value value": { + url: "?=value", + expectedResult: map[string]any{ + "": "value", + }, + }, + "single key with empty value": { + url: "?key=", + expectedResult: map[string]any{ + "key": "", + }, + }, + "only keys": { + url: "?foo&bar", + expectedResult: map[string]any{ + "foo": "", + "bar": "", + }, + }, + "encoded & sign in value": { + url: "?foo=bar%26baz", + expectedResult: map[string]any{ + "foo": "bar&baz", + }, + }, + "encoded = sign in value": { + url: "?foo=bar%3Dbaz", + expectedResult: map[string]any{ + "foo": "bar=baz", + }, + }, + "multiple query param": { + url: "?foo=bar&mapkey=value1", + expectedResult: map[string]any{ + "foo": "bar", + "mapkey": "value1", + }, + }, + "map query param": { + url: "?mapkey[key]=value", + expectedResult: map[string]any{ + "mapkey": map[string]any{ + "key": "value", + }, + }, + }, + "multiple different value types in map query param": { + url: "?mapkey[key1]=value1&mapkey[key2]=1&mapkey[key3]=true", + expectedResult: map[string]any{ + "mapkey": map[string]any{ + "key1": "value1", + "key2": "1", + "key3": "true", + }, + }, + }, + "multiple different value types in array value of map query param": { + url: "?mapkey[key][]=value1&mapkey[key][]=1&mapkey[key][]=true", + expectedResult: map[string]any{ + "mapkey": map[string]any{ + "key": []string{"value1", "1", "true"}, + }, + }, + }, + "nested map query param": { + url: "?mapkey[key][nested][moreNested]=value", + expectedResult: map[string]any{ + "mapkey": map[string]any{ + "key": map[string]any{ + "nested": map[string]any{ + "moreNested": "value", + }, + }, + }, + }, + }, + "very deep nested map query param": { + url: "?mapkey" + veryDeepNesting + "=value", + expectedResult: map[string]any{ + "mapkey": veryDeepNestingResult, + }, + }, + "map query param with explicit arrays accessors ([]) at the value level will return array": { + url: "?mapkey[key][]=value1&mapkey[key][]=value2", + expectedResult: map[string]any{ + "mapkey": map[string]any{ + "key": []string{"value1", "value2"}, + }, + }, + }, + "map query param with implicit arrays (duplicated key) at the value level will return only first value": { + url: "?mapkey[key]=value1&mapkey[key]=value2", + expectedResult: map[string]any{ + "mapkey": map[string]any{ + "key": "value1", + }, + }, + }, + "array query param": { + url: "?mapkey[]=value1&mapkey[]=value2", + expectedResult: map[string]any{ + "mapkey": []string{"value1", "value2"}, + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + u, err := url.Parse(test.url) + require.NoError(t, err) + + c := &Context{ + Request: &http.Request{ + URL: u, + }, + } + + dicts, err := c.ShouldGetQueryNestedMap() + require.NoError(t, err) + require.Equal(t, test.expectedResult, dicts) + }) + } +} + +func TestContextShouldGetQueryNestedMapParsingError(t *testing.T) { + tooDeepNesting := strings.Repeat("[nested]", query.MaxNestedMapDepth+1) + + tests := map[string]struct { + url string + expectedResult map[string]any + error string + }{ + "searched map key with invalid map access": { + url: "?mapkey[key]nested=value", + error: "invalid access to map key", + }, + "searched map key with array accessor in the middle": { + url: "?mapkey[key][][nested]=value", + error: "unsupported array-like access to map key", + }, + "too deep nesting of the map in query params": { + url: "?mapkey" + tooDeepNesting + "=value", + error: "maximum depth [100] of nesting in map exceeded", + }, + "setting value and nested map at the same level": { + url: "?mapkey[key]=value&mapkey[key][nested]=value1", + error: "trying to set different types at the same key", + }, + "setting array and nested map at the same level": { + url: "?mapkey[key][]=value&mapkey[key][nested]=value1", + error: "trying to set different types at the same key", + }, + "setting nested map and array at the same level": { + url: "?mapkey[key][nested]=value1&mapkey[key][]=value", + error: "trying to set different types at the same key", + }, + "setting array and value at the same level": { + url: "?key[]=value1&key=value2", + error: "trying to set different types at the same key", + }, + "setting value and array at the same level": { + url: "?key=value1&key[]=value2", + error: "trying to set different types at the same key", + }, + "setting array and nested map at same query param": { + url: "?mapkey[]=value1&mapkey[key]=value2", + error: "trying to set different types at the same key", + }, + "setting nested map and array at same query param": { + url: "?mapkey[key]=value2&mapkey[]=value1", + error: "trying to set different types at the same key", + }, + "] without [ in query param": { + url: "?mapkey]=value", + error: "invalid access to map key", + }, + "[ without ] in query param": { + url: "?mapkey[key=value", + error: "invalid access to map key", + }, + "[ without ] in query param with nested key": { + url: "?mapkey[key[nested]=value", + error: "invalid access to map key", + }, + "[[key]] in query param": { + url: "?mapkey[[key]]=value", + error: "invalid access to map key", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + u, err := url.Parse(test.url) + require.NoError(t, err) + + c := &Context{ + Request: &http.Request{ + URL: u, + }, + } + + dicts, err := c.ShouldGetQueryNestedMap() + require.ErrorContains(t, err, test.error) + require.Nil(t, dicts) + }) + } +} + +func TestContextShouldGetQueryNestedForKeySuccessfulParsing(t *testing.T) { + var emptyQueryMap map[string]any + + tests := map[string]struct { + url string + key string + expectedResult map[string]any + }{ + "no searched map key in query string": { + url: "?foo=bar", + key: "mapkey", + expectedResult: emptyQueryMap, + }, + "searched map key after other query params": { + url: "?foo=bar&mapkey[key]=value", + key: "mapkey", + expectedResult: map[string]any{ + "key": "value", + }, + }, + "searched map key before other query params": { + url: "?mapkey[key]=value&foo=bar", + key: "mapkey", + expectedResult: map[string]any{ + "key": "value", + }, + }, + "single key in searched map key": { + url: "?mapkey[key]=value", + key: "mapkey", + expectedResult: map[string]any{ + "key": "value", + }, + }, + "multiple keys in searched map key": { + url: "?mapkey[key1]=value1&mapkey[key2]=value2&mapkey[key3]=value3", + key: "mapkey", + expectedResult: map[string]any{ + "key1": "value1", + "key2": "value2", + "key3": "value3", + }, + }, + "nested key in searched map key": { + url: "?mapkey[foo][nested]=value1", + key: "mapkey", + expectedResult: map[string]any{ + "foo": map[string]any{ + "nested": "value1", + }, + }, + }, + "multiple nested keys in single key of searched map key": { + url: "?mapkey[foo][nested1]=value1&mapkey[foo][nested2]=value2", + key: "mapkey", + expectedResult: map[string]any{ + "foo": map[string]any{ + "nested1": "value1", + "nested2": "value2", + }, + }, + }, + "multiple keys with nested keys of searched map key": { + url: "?mapkey[key1][nested]=value1&mapkey[key2][nested]=value2", + key: "mapkey", + expectedResult: map[string]any{ + "key1": map[string]any{ + "nested": "value1", + }, + "key2": map[string]any{ + "nested": "value2", + }, + }, + }, + "multiple levels of nesting in searched map key": { + url: "?mapkey[key][nested][moreNested]=value1", + key: "mapkey", + expectedResult: map[string]any{ + "key": map[string]any{ + "nested": map[string]any{ + "moreNested": "value1", + }, + }, + }, + }, + "query keys similar to searched map key": { + url: "?mapkey[key]=value&mapkeys[key1]=value1&mapkey1=foo", + key: "mapkey", + expectedResult: map[string]any{ + "key": "value", + }, + }, + "handle explicit arrays accessors ([]) at the value level": { + url: "?mapkey[key][]=value1&mapkey[key][]=value2", + key: "mapkey", + expectedResult: map[string]any{ + "key": []string{"value1", "value2"}, + }, + }, + "implicit arrays (duplicated key) at the value level will return only first value": { + url: "?mapkey[key]=value1&mapkey[key]=value2", + key: "mapkey", + expectedResult: map[string]any{ + "key": "value1", + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + u, err := url.Parse(test.url) + require.NoError(t, err) + + c := &Context{ + Request: &http.Request{ + URL: u, + }, + } + + dicts, err := c.ShouldGetQueryNestedMapForKey(test.key) + require.NoError(t, err) + require.Equal(t, test.expectedResult, dicts) + }) + } +} + +func TestContextShouldGetQueryNestedForKeyParsingError(t *testing.T) { + tests := map[string]struct { + url string + key string + error string + }{ + + "searched map key is value not a map": { + url: "?mapkey=value", + key: "mapkey", + error: "invalid access to map", + }, + "searched map key is array": { + url: "?mapkey[]=value1&mapkey[]=value2", + key: "mapkey", + error: "invalid access to map", + }, + "searched map key with invalid map access": { + url: "?mapkey[key]nested=value", + key: "mapkey", + error: "invalid access to map key", + }, + "searched map key with valid and invalid map access": { + url: "?mapkey[key]invalidNested=value&mapkey[key][nested]=value1", + key: "mapkey", + error: "invalid access to map key", + }, + "searched map key with valid before invalid map access": { + url: "?mapkey[key][nested]=value1&mapkey[key]invalidNested=value", + key: "mapkey", + error: "invalid access to map key", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + u, err := url.Parse(test.url) + require.NoError(t, err) + + c := &Context{ + Request: &http.Request{ + URL: u, + }, + } + dicts, err := c.ShouldGetQueryNestedMapForKey(test.key) + require.ErrorContains(t, err, test.error) + require.Nil(t, dicts) + }) + } +} + func TestContextPostFormMultipart(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) c.Request = createMultipartRequest() diff --git a/docs/doc.md b/docs/doc.md index 5136640929..72b466153d 100644 --- a/docs/doc.md +++ b/docs/doc.md @@ -259,6 +259,128 @@ func main() { ids: map[b:hello a:1234]; names: map[second:tianou first:thinkerou] ``` +### Query string param as nested map + +#### Parse query params to nested map + +```sh +GET /get?name=alice&page[number]=1&page[sort][order]=asc&created[days][]=5&created[days][]=7 HTTP/1.1 +``` + +Notice that: +- client can use standard name=value syntax and this will be mapped to the `key: value` in map +- client can use map access syntax (`key[nested][deepNested]=value`) and this will be mapped to the nested map `key:map[nested:map[deepNested:value]]` +- client can use array syntax even as value of map key (`key[nested][]=value1&key[nested][]=value2`) and this will be mapped to the array `key:map[nested:[value1 value2]]` + +```go +package main + +import ( + "fmt" + "github.com/gin-gonic/gin" +) + +func main() { + router := gin.Default() + + router.GET("/get", func(c *gin.Context) { + + paging, err := c.ShouldGetQueryNestedMap() + if err != nil { + c.JSON(400, gin.H{ "error": err.Error() }) + return + } + + fmt.Printf("query: %v\n", paging) + c.JSON(200, paging) + }) + + router.Run(":8080") +} +``` + +```sh +query: map[created:map[days:[5 7]] name:alice page:map[number:1 sort:map[order:asc]]] +``` + +#### Extract key as nested map + +```sh +GET /get?page[number]=1&page[size]=50&page[sort][by]=id&page[sort][order]=asc HTTP/1.1 +``` + +```go +package main + +import ( + "fmt" + "github.com/gin-gonic/gin" +) + +func main() { + router := gin.Default() + + router.GET("/get", func(c *gin.Context) { + + paging, err := c.ShouldGetQueryNestedMapForKey("page") + if err != nil { + c.JSON(400, gin.H{ "error": err.Error() }) + return + } + + fmt.Printf("paging: %v\n", paging) + c.JSON(200, paging) + }) + + router.Run(":8080") +} +``` + +```sh +paging: map[number:1 size:50 sort:map[by:id order:asc]] +``` + +#### Extract key as nested map with array as values + +It is possible to get the array values from the query string as well. +But the client need to use array syntax in the query string. + +```sh +GET /get?filter[names][]=alice&filter[names][]=bob&filter[status]=new&filter[status]=old HTTP/1.1 +``` + +```go +package main + +import ( + "fmt" + "github.com/gin-gonic/gin" +) + +func main() { + router := gin.Default() + + router.GET("/get", func(c *gin.Context) { + + filters, err := c.ShouldGetQueryNestedMapForKey("filter") + if err != nil { + c.JSON(400, gin.H{ "error": err.Error() }) + return + } + + fmt.Printf("filters: %v\n", filters) + c.JSON(200, filters) + }) + + router.Run(":8080") +} +``` + +```sh +filters: map[names:[alice bob] status:new] +``` +Notice that status has only one value because it is not an explicit array. + ### Upload files #### Single file diff --git a/internal/query/map.go b/internal/query/map.go new file mode 100644 index 0000000000..b145a19917 --- /dev/null +++ b/internal/query/map.go @@ -0,0 +1,214 @@ +package query + +import ( + "errors" + "fmt" + "strings" +) + +// MaxNestedMapDepth is the maximum depth of nesting of single map key in query params. +const MaxNestedMapDepth = 100 + +type queryKeyType int + +const ( + filteredUnsupported queryKeyType = iota + filteredMap + filteredRejected + mapType + arrayType + emptyKeyValue + valueType +) + +// GetMap returns a map, which satisfies conditions. +func GetMap(query map[string][]string, filteredKey string) (map[string]interface{}, error) { + result := make(map[string]interface{}) + getAll := filteredKey == "" + var allErrors = make([]error, 0) + for key, value := range query { + kType := getType(key, filteredKey, getAll) + switch kType { + case filteredUnsupported: + allErrors = append(allErrors, fmt.Errorf("invalid access to map %s", key)) + continue + case filteredMap: + fallthrough + case mapType: + path, err := parsePath(key) + if err != nil { + allErrors = append(allErrors, err) + continue + } + if !getAll { + path = path[1:] + } + err = setValueOnPath(result, path, value) + if err != nil { + allErrors = append(allErrors, err) + continue + } + case arrayType: + err := setValueOnPath(result, []string{keyWithoutArraySymbol(key), ""}, value) + if err != nil { + allErrors = append(allErrors, err) + continue + } + case filteredRejected: + continue + case emptyKeyValue: + result[key] = value[0] + case valueType: + fallthrough + default: + err := setValueOnPath(result, []string{key}, value) + if err != nil { + allErrors = append(allErrors, err) + continue + } + } + } + if len(allErrors) > 0 { + return nil, errors.Join(allErrors...) + } + if len(result) == 0 { + return nil, nil + } + return result, nil +} + +// getType is an internal function to get the type of query key. +func getType(key string, filteredKey string, getAll bool) queryKeyType { + if getAll { + if isMap(key) { + return mapType + } + if isArray(key) { + return arrayType + } + if key == "" { + return emptyKeyValue + } + return valueType + } + if isFilteredKey(key, filteredKey) { + if isMap(key) { + return filteredMap + } + return filteredUnsupported + } + return filteredRejected +} + +// isFilteredKey is an internal function to check if k is accepted when searching for map with given key. +func isFilteredKey(k string, filteredKey string) bool { + return k == filteredKey || strings.HasPrefix(k, filteredKey+"[") +} + +// isMap is an internal function to check if k is a map query key. +func isMap(k string) bool { + i := strings.IndexByte(k, '[') + j := strings.IndexByte(k, ']') + return j-i > 1 || (i >= 0 && j == -1) +} + +// isArray is an internal function to check if k is an array query key. +func isArray(k string) bool { + i := strings.IndexByte(k, '[') + j := strings.IndexByte(k, ']') + return j-i == 1 +} + +// keyWithoutArraySymbol is an internal function to remove array symbol from query key. +func keyWithoutArraySymbol(key string) string { + return key[:len(key)-2] +} + +// parsePath is an internal function to parse key access path. +// For example, key[foo][bar] will be parsed to ["foo", "bar"]. +func parsePath(k string) ([]string, error) { + firstKeyEnd := strings.IndexByte(k, '[') + if firstKeyEnd == -1 { + return nil, fmt.Errorf("invalid access to map key %s", k) + } + first, rawPath := k[:firstKeyEnd], k[firstKeyEnd:] + + split := strings.Split(rawPath, "]") + + // Bear in mind that split of the valid map will always have "" as the last element. + if split[len(split)-1] != "" { + return nil, fmt.Errorf("invalid access to map key %s", k) + } + if len(split)-1 > MaxNestedMapDepth { + return nil, fmt.Errorf("maximum depth [%d] of nesting in map exceeded [%d]", MaxNestedMapDepth, len(split)-1) + } + + // -2 because after split the last element should be empty string. + last := len(split) - 2 + + paths := []string{first} + for i := 0; i <= last; i++ { + p := split[i] + + // this way we can handle both error cases: foo] and [foo[bar + if strings.LastIndex(p, "[") == 0 { + p = p[1:] + } else { + return nil, fmt.Errorf("invalid access to map key %s", p) + } + if p == "" && i != last { + return nil, fmt.Errorf("unsupported array-like access to map key %s", k) + } + paths = append(paths, p) + } + return paths, nil +} + +// setValueOnPath is an internal function to set value a path on dicts. +func setValueOnPath(dicts map[string]interface{}, paths []string, value []string) error { + nesting := len(paths) + previousLevel := dicts + currentLevel := dicts + for i, p := range paths { + if isLast(i, nesting) { + // handle setting value + if isArrayOnPath(p) { + previousLevel[paths[i-1]] = value + } else { + // if there was already a value set, then it is an error to set a different value. + if _, ok := currentLevel[p]; ok { + return fmt.Errorf("trying to set different types at the same key [%s]", p) + } + currentLevel[p] = value[0] + } + } else { + // handle subpath of the map + switch currentLevel[p].(type) { + case map[string]any: + // if there was map, and we have to set array, then it is an error + if isArrayOnPath(paths[i+1]) { + return fmt.Errorf("trying to set different types at the same key [%s]", p) + } + case nil: + // initialize map if it is not set here yet + currentLevel[p] = make(map[string]any) + default: + // if there was different value then a map, then it is an error to set a map here. + return fmt.Errorf("trying to set different types at the same key [%s]", p) + } + previousLevel = currentLevel + currentLevel = currentLevel[p].(map[string]any) + } + } + return nil +} + +// isArrayOnPath is an internal function to check if the current parsed map path is an array. +func isArrayOnPath(p string) bool { + return p == "" +} + +// isLast is an internal function to check if the current level is the last level. +func isLast(i int, nesting int) bool { + return i == nesting-1 +}