diff --git a/internal/query/gin_map.go b/internal/query/gin_map.go index 251a23667..df2744219 100644 --- a/internal/query/gin_map.go +++ b/internal/query/gin_map.go @@ -2,19 +2,18 @@ package query import "github.com/gin-gonic/gin" -// QueryNestedMap returns a map for a given query key. +//revive:disable:exported We want to mimic the gin API. + +// ShouldGetQueryNestedMap returns a map from query params. // In contrast to QueryMap it handles nesting in query maps like key[foo][bar]=value. -// -//revive:disable:exported We want to mimic the gin API -func QueryNestedMap(c *gin.Context, key string) (dicts map[string]interface{}) { - dicts, _ = GetQueryNestedMap(c, key) - return +func ShouldGetQueryNestedMap(c *gin.Context) (dicts map[string]interface{}, err error) { + return ShouldGetQueryNestedMapForKey(c, "") } -// 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 GetQueryNestedMap(c *gin.Context, key string) (map[string]interface{}, bool) { +// 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 ShouldGetQueryNestedMapForKey(c *gin.Context, key string) (dicts map[string]interface{}, err error) { q := c.Request.URL.Query() return GetMap(q, key) } diff --git a/internal/query/gin_map_test.go b/internal/query/gin_map_test.go index 6117c3623..c2548174e 100644 --- a/internal/query/gin_map_test.go +++ b/internal/query/gin_map_test.go @@ -10,103 +10,189 @@ import ( "github.com/stretchr/testify/require" ) -func TestContextQueryNestedMap(t *testing.T) { +func TestContextShouldGetQueryNestedMapSuccessfulParsing(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", + "no query params": { + url: "", expectedResult: emptyQueryMap, - exists: false, }, - "searched map key is not a map": { - url: "?mapkey=value", - expectedResult: emptyQueryMap, - exists: false, + "single query param": { + url: "?foo=bar", + expectedResult: map[string]interface{}{ + "foo": "bar", + }, }, - "searched map key is array": { - url: "?mapkey[]=value1&mapkey[]=value2", - expectedResult: emptyQueryMap, - exists: false, + "multiple query param": { + url: "?foo=bar&mapkey=value1", + expectedResult: map[string]interface{}{ + "foo": "bar", + "mapkey": "value1", + }, }, - "searched map key with invalid map access": { - url: "?mapkey[key]nested=value", - expectedResult: emptyQueryMap, - exists: false, + "map query param": { + url: "?mapkey[key]=value", + expectedResult: map[string]interface{}{ + "mapkey": map[string]interface{}{ + "key": "value", + }, + }, }, - "searched map key with valid and invalid map access": { - url: "?mapkey[key]invalidNested=value&mapkey[key][nested]=value1", + "nested map query param": { + url: "?mapkey[key][nested][moreNested]=value", expectedResult: map[string]interface{}{ - "key": map[string]interface{}{ - "nested": "value1", + "mapkey": map[string]interface{}{ + "key": map[string]interface{}{ + "nested": map[string]interface{}{ + "moreNested": "value", + }, + }, }, }, - exists: true, }, - "searched map key with valid before invalid map access": { - url: "?mapkey[key][nested]=value1&mapkey[key]invalidNested=value", + "map query param with explicit arrays accessors ([]) at the value level will return array": { + url: "?mapkey[key][]=value1&mapkey[key][]=value2", expectedResult: map[string]interface{}{ - "key": map[string]interface{}{ - "nested": "value1", + "mapkey": map[string]interface{}{ + "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]interface{}{ + "mapkey": map[string]interface{}{ + "key": "value1", }, }, - exists: true, + }, + "array query param": { + url: "?mapkey[]=value1&mapkey[]=value2", + expectedResult: map[string]interface{}{ + "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 := &gin.Context{ + Request: &http.Request{ + URL: u, + }, + } + + dicts, err := query.ShouldGetQueryNestedMap(c) + require.Equal(t, test.expectedResult, dicts) + require.NoError(t, err) + }) + } +} + +func TestContextShouldGetQueryNestedMapParsingError(t *testing.T) { + tests := map[string]struct { + url string + expectedResult map[string]interface{} + 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", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + u, err := url.Parse(test.url) + require.NoError(t, err) + + c := &gin.Context{ + Request: &http.Request{ + URL: u, + }, + } + + dicts, err := query.ShouldGetQueryNestedMap(c) + require.Nil(t, dicts) + require.ErrorContains(t, err, test.error) + }) + } +} + +func TestContextShouldGetQueryNestedForKeySuccessfulParsing(t *testing.T) { + var emptyQueryMap map[string]interface{} + + tests := map[string]struct { + url string + key string + expectedResult map[string]interface{} + }{ + "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]interface{}{ "key": "value", }, - exists: true, }, "searched map key before other query params": { url: "?mapkey[key]=value&foo=bar", + key: "mapkey", expectedResult: map[string]interface{}{ "key": "value", }, - exists: true, }, "single key in searched map key": { url: "?mapkey[key]=value", + key: "mapkey", expectedResult: map[string]interface{}{ "key": "value", }, - exists: true, }, "multiple keys in searched map key": { url: "?mapkey[key1]=value1&mapkey[key2]=value2&mapkey[key3]=value3", + key: "mapkey", expectedResult: map[string]interface{}{ "key1": "value1", "key2": "value2", "key3": "value3", }, - exists: true, }, "nested key in searched map key": { url: "?mapkey[foo][nested]=value1", + key: "mapkey", 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", + key: "mapkey", 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", + key: "mapkey", expectedResult: map[string]interface{}{ "key1": map[string]interface{}{ "nested": "value1", @@ -115,10 +201,10 @@ func TestContextQueryNestedMap(t *testing.T) { "nested": "value2", }, }, - exists: true, }, "multiple levels of nesting in searched map key": { url: "?mapkey[key][nested][moreNested]=value1", + key: "mapkey", expectedResult: map[string]interface{}{ "key": map[string]interface{}{ "nested": map[string]interface{}{ @@ -126,32 +212,31 @@ func TestContextQueryNestedMap(t *testing.T) { }, }, }, - exists: true, }, "query keys similar to searched map key": { url: "?mapkey[key]=value&mapkeys[key1]=value1&mapkey1=foo", + key: "mapkey", expectedResult: map[string]interface{}{ "key": "value", }, - exists: true, }, "handle explicit arrays accessors ([]) at the value level": { url: "?mapkey[key][]=value1&mapkey[key][]=value2", + key: "mapkey", expectedResult: map[string]interface{}{ "key": []string{"value1", "value2"}, }, - exists: true, }, "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]interface{}{ "key": "value1", }, - exists: true, }, } for name, test := range tests { - t.Run("getQueryMap: "+name, func(t *testing.T) { + t.Run(name, func(t *testing.T) { u, err := url.Parse(test.url) require.NoError(t, err) @@ -161,11 +246,48 @@ func TestContextQueryNestedMap(t *testing.T) { }, } - dicts, exists := query.GetQueryNestedMap(c, "mapkey") + dicts, err := query.ShouldGetQueryNestedMapForKey(c, test.key) require.Equal(t, test.expectedResult, dicts) - require.Equal(t, test.exists, exists) + require.NoError(t, err) }) - t.Run("queryMap: "+name, func(t *testing.T) { + } +} + +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) @@ -175,8 +297,9 @@ func TestContextQueryNestedMap(t *testing.T) { }, } - dicts := query.QueryNestedMap(c, "mapkey") - require.Equal(t, test.expectedResult, dicts) + dicts, err := query.ShouldGetQueryNestedMapForKey(c, test.key) + require.Nil(t, dicts) + require.ErrorContains(t, err, test.error) }) } } diff --git a/internal/query/map.go b/internal/query/map.go index 9a055867c..f03556096 100644 --- a/internal/query/map.go +++ b/internal/query/map.go @@ -1,53 +1,118 @@ package query import ( + "errors" "fmt" "strings" ) // GetMap returns a map, which satisfies conditions. -func GetMap(query map[string][]string, key string) (dicts map[string]interface{}, exists bool) { +func GetMap(query map[string][]string, key string) (dicts map[string]interface{}, err error) { result := make(map[string]interface{}) + getAll := key == "" + var allErrors = make([]error, 0) for qk, value := range query { - if isKey(qk, key) { - path, err := parsePath(qk, key) + kType := getType(qk, key, getAll) + switch kType { + case "filtered_unsupported": + allErrors = append(allErrors, fmt.Errorf("invalid access to map %s", qk)) + continue + case "filtered_map": + fallthrough + case "map": + path, err := parsePath(qk) if err != nil { + allErrors = append(allErrors, err) continue } + if !getAll { + path = path[1:] + } setValueOnPath(result, path, value) - exists = true + case "array": + result[keyWithoutArraySymbol(qk)] = value + case "filtered_rejected": + continue + default: + result[qk] = value[0] } } - if !exists { - return nil, exists + if len(allErrors) > 0 { + return nil, errors.Join(allErrors...) + } else if len(result) == 0 { + return nil, nil } - return result, exists + return result, nil +} +// getType is an internal function to get the type of query key. +func getType(qk string, key string, getAll bool) string { + if getAll { + if isMap(qk) { + return "map" + } else if isArray(qk) { + return "array" + } + return "value" + } + if isFilteredKey(qk, key) { + if isMap(qk) { + return "filtered_map" + } + return "filtered_unsupported" + } else { + return "filtered_rejected" + } } -// isKey is an internal function to check if a k is a map key. -func isKey(k string, key string) bool { +// isFilteredKey is an internal function to check if k is accepted when searching for map with given key. +func isFilteredKey(k string, key string) bool { + return k == key || strings.HasPrefix(k, key+"[") +} + +// isMap is an internal function to check if k is a map query key. +func isMap(k string) bool { i := strings.IndexByte(k, '[') - return i >= 1 && k[0:i] == key + j := strings.IndexByte(k, ']') + return j-i > 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(qk string) string { + return qk[:len(qk)-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, key string) ([]string, error) { - rawPath := strings.TrimPrefix(k, key) - splitted := strings.Split(rawPath, "]") - paths := make([]string, 0) - for i, p := range splitted { - if p == "" { - continue - } +func parsePath(k string) ([]string, error) { + firstKeyEnd := strings.IndexByte(k, '[') + first, rawPath := k[:firstKeyEnd], k[firstKeyEnd:] + + split := strings.Split(rawPath, "]") + if split[len(split)-1] != "" { + return nil, fmt.Errorf("invalid access to map key %s", k) + } + + // -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] if strings.HasPrefix(p, "[") { p = p[1:] } else { return nil, fmt.Errorf("invalid access to map key %s", p) } - if i == 0 && p == "" { - return nil, fmt.Errorf("expect %s to be a map but got array", key) + if p == "" && i != last { + return nil, fmt.Errorf("unsupported array-like access to map key %s", k) } paths = append(paths, p) } @@ -61,7 +126,7 @@ func setValueOnPath(dicts map[string]interface{}, paths []string, value []string currentLevel := dicts for i, p := range paths { if isLast(i, nesting) { - if isArray(p) { + if isArrayOnPath(p) { previousLevel[paths[i-1]] = value } else { currentLevel[p] = value[0] @@ -74,7 +139,8 @@ func setValueOnPath(dicts map[string]interface{}, paths []string, value []string } } -func isArray(p string) bool { +// isArrayOnPath is an internal function to check if the current parsed map path is an array. +func isArrayOnPath(p string) bool { return p == "" }