feat(context): methods to get nested map from query string

This commit is contained in:
Damian Orzepowski 2024-08-14 18:28:08 +02:00
parent cc4e11438c
commit 8f79761032
4 changed files with 770 additions and 0 deletions

View File

@ -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) {

View File

@ -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()

View File

@ -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

214
internal/query/map.go Normal file
View File

@ -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
}