From 256e3f8d0c7d99f9dcaa161c04f611208f1fe858 Mon Sep 17 00:00:00 2001 From: Damian Orzepowski Date: Wed, 14 Aug 2024 18:28:08 +0200 Subject: [PATCH] feat(context): methods to get nested map from query string --- context.go | 16 +++++ context_test.go | 146 ++++++++++++++++++++++++++++++++++++++++++ docs/doc.md | 27 ++++++++ internal/query/map.go | 85 ++++++++++++++++++++++++ 4 files changed, 274 insertions(+) create mode 100644 internal/query/map.go diff --git a/context.go b/context.go index baa4b0f9c9..090bd4a9e1 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,21 @@ func (c *Context) GetQueryMap(key string) (map[string]string, bool) { return c.get(c.queryCache, key) } +// QueryNestedMap returns a map for a given query key. +// In contrast to QueryMap it handles nesting in query maps like key[foo][bar]=value. +func (c *Context) QueryNestedMap(key string) (dicts map[string]interface{}) { + dicts, _ = c.GetQueryNestedMap(key) + return +} + +// GetQueryNestedMap returns a map for a given query key, plus a boolean value +// whether at least one value exists for the given key. +// In contrast to GetQueryMap it handles nesting in query maps like key[foo][bar]=value. +func (c *Context) GetQueryNestedMap(key string) (map[string]interface{}, bool) { + c.initQueryCache() + return query.GetMap(c.queryCache, 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..2577ef2f08 100644 --- a/context_test.go +++ b/context_test.go @@ -574,6 +574,152 @@ func TestContextQueryAndPostForm(t *testing.T) { assert.Empty(t, dicts) } +func TestContextQueryNestedMap(t *testing.T) { + var emptyQueryMap map[string]interface{} + + tests := map[string]struct { + url string + expectedResult map[string]interface{} + exists bool + }{ + "no searched map key in query string": { + url: "?foo=bar", + expectedResult: emptyQueryMap, + exists: false, + }, + "searched map key is not a map": { + url: "?mapkey=value", + expectedResult: emptyQueryMap, + exists: false, + }, + "searched map key is array": { + url: "?mapkey[]=value1&mapkey[]=value2", + expectedResult: emptyQueryMap, + exists: false, + }, + "searched map key with invalid map access": { + url: "?mapkey[key]nested=value", + expectedResult: emptyQueryMap, + exists: false, + }, + "searched map key with valid and invalid map access": { + url: "?mapkey[key]invalidNested=value&mapkey[key][nested]=value1", + expectedResult: map[string]interface{}{ + "key": map[string]interface{}{ + "nested": "value1", + }, + }, + exists: true, + }, + "searched map key after other query params": { + url: "?foo=bar&mapkey[key]=value", + expectedResult: map[string]interface{}{ + "key": "value", + }, + exists: true, + }, + "searched map key before other query params": { + url: "?mapkey[key]=value&foo=bar", + expectedResult: map[string]interface{}{ + "key": "value", + }, + exists: true, + }, + "single key in searched map key": { + url: "?mapkey[key]=value", + expectedResult: map[string]interface{}{ + "key": "value", + }, + exists: true, + }, + "multiple keys in searched map key": { + url: "?mapkey[key1]=value1&mapkey[key2]=value2&mapkey[key3]=value3", + expectedResult: map[string]interface{}{ + "key1": "value1", + "key2": "value2", + "key3": "value3", + }, + exists: true, + }, + "nested key in searched map key": { + url: "?mapkey[foo][nested]=value1", + expectedResult: map[string]interface{}{ + "foo": map[string]interface{}{ + "nested": "value1", + }, + }, + exists: true, + }, + "multiple nested keys in single key of searched map key": { + url: "?mapkey[foo][nested1]=value1&mapkey[foo][nested2]=value2", + expectedResult: map[string]interface{}{ + "foo": map[string]interface{}{ + "nested1": "value1", + "nested2": "value2", + }, + }, + exists: true, + }, + "multiple keys with nested keys of searched map key": { + url: "?mapkey[key1][nested]=value1&mapkey[key2][nested]=value2", + expectedResult: map[string]interface{}{ + "key1": map[string]interface{}{ + "nested": "value1", + }, + "key2": map[string]interface{}{ + "nested": "value2", + }, + }, + exists: true, + }, + "multiple levels of nesting in searched map key": { + url: "?mapkey[key][nested][moreNested]=value1", + expectedResult: map[string]interface{}{ + "key": map[string]interface{}{ + "nested": map[string]interface{}{ + "moreNested": "value1", + }, + }, + }, + exists: true, + }, + "query keys similar to searched map key": { + url: "?mapkey[key]=value&mapkeys[key1]=value1&mapkey1=foo", + expectedResult: map[string]interface{}{ + "key": "value", + }, + exists: true, + }, + } + for name, test := range tests { + t.Run("getQueryMap: "+name, func(t *testing.T) { + u, err := url.Parse(test.url) + require.NoError(t, err) + + c := Context{ + Request: &http.Request{ + URL: u, + }, + } + dicts, exists := c.GetQueryNestedMap("mapkey") + require.Equal(t, test.expectedResult, dicts) + require.Equal(t, test.exists, exists) + }) + t.Run("queryMap: "+name, func(t *testing.T) { + u, err := url.Parse(test.url) + require.NoError(t, err) + + c := Context{ + Request: &http.Request{ + URL: u, + }, + } + dicts := c.QueryNestedMap("mapkey") + require.Equal(t, test.expectedResult, 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..eb155cfdd6 100644 --- a/docs/doc.md +++ b/docs/doc.md @@ -259,6 +259,33 @@ func main() { ids: map[b:hello a:1234]; names: map[second:tianou first:thinkerou] ``` +### Query string param as nested map + +```sh +GET /get?page[number]=1&page[size]=50&page[sort][by]=id&page[sort][order]=asc HTTP/1.1 +``` + +```go +func main() { + router := gin.Default() + + router.GET("/get", func(c *gin.Context) { + + paging := c.QueryNestedMap("page") + + 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]] +``` + + ### Upload files #### Single file diff --git a/internal/query/map.go b/internal/query/map.go new file mode 100644 index 0000000000..b48a5ca55c --- /dev/null +++ b/internal/query/map.go @@ -0,0 +1,85 @@ +package query + +import ( + "fmt" + "strings" +) + +// GetMap returns a map, which satisfies conditions. +func GetMap(query map[string][]string, key string) (dicts map[string]interface{}, exists bool) { + result := make(map[string]interface{}) + for qk, value := range query { + if isKey(qk, key) { + exists = true + path, err := parsePath(qk, key) + if err != nil { + exists = false + continue + } + setValueOnPath(result, path, value) + } + } + if !exists { + return nil, exists + } + return result, exists + +} + +// isKey is an internal function to check if a k is a map key. +func isKey(k string, key string) bool { + i := strings.IndexByte(k, '[') + return i >= 1 && k[0:i] == key +} + +// 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, key string) ([]string, error) { + rawPath := strings.TrimPrefix(k, key) + if rawPath == "" { + return nil, fmt.Errorf("expect %s to be a map but got value", key) + } + splitted := strings.Split(rawPath, "]") + paths := make([]string, 0) + for _, p := range splitted { + if p == "" { + continue + } + if strings.HasPrefix(p, "[") { + p = p[1:] + } else { + return nil, fmt.Errorf("invalid access to map key %s", p) + } + if p == "" { + return nil, fmt.Errorf("expect %s to be a map but got array", key) + } + 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) { + nesting := len(paths) + currentLevel := dicts + for i, p := range paths { + if isLast(i, nesting) { + currentLevel[p] = value[0] + } else { + initNestingIfNotExists(currentLevel, p) + currentLevel = currentLevel[p].(map[string]interface{}) + } + } +} + +// initNestingIfNotExists is an internal function to initialize a nested map if not exists. +func initNestingIfNotExists(currentLevel map[string]interface{}, p string) { + if _, ok := currentLevel[p]; !ok { + currentLevel[p] = make(map[string]interface{}) + } +} + +// 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 +}