From 60af61afdf8cdc98883cfa3d22fb89ecf6261761 Mon Sep 17 00:00:00 2001 From: Damian Orzepowski Date: Thu, 22 Aug 2024 11:56:23 +0200 Subject: [PATCH 1/5] feat(SPV-793): handle arrays in query string --- internal/query/gin_map_test.go | 14 ++++++++++++++ internal/query/map.go | 19 +++++++++++++------ models/filter/access_key_filter.go | 3 ++- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/internal/query/gin_map_test.go b/internal/query/gin_map_test.go index 7e580892..6117c362 100644 --- a/internal/query/gin_map_test.go +++ b/internal/query/gin_map_test.go @@ -135,6 +135,20 @@ func TestContextQueryNestedMap(t *testing.T) { }, exists: true, }, + "handle explicit arrays accessors ([]) at the value level": { + url: "?mapkey[key][]=value1&mapkey[key][]=value2", + 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", + expectedResult: map[string]interface{}{ + "key": "value1", + }, + exists: true, + }, } for name, test := range tests { t.Run("getQueryMap: "+name, func(t *testing.T) { diff --git a/internal/query/map.go b/internal/query/map.go index ea1a3bd4..9a055867 100644 --- a/internal/query/map.go +++ b/internal/query/map.go @@ -35,12 +35,9 @@ func isKey(k string, key string) bool { // 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 { + for i, p := range splitted { if p == "" { continue } @@ -49,7 +46,7 @@ func parsePath(k string, key string) ([]string, error) { } else { return nil, fmt.Errorf("invalid access to map key %s", p) } - if p == "" { + if i == 0 && p == "" { return nil, fmt.Errorf("expect %s to be a map but got array", key) } paths = append(paths, p) @@ -60,17 +57,27 @@ func parsePath(k string, key string) ([]string, error) { // 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) + previousLevel := dicts currentLevel := dicts for i, p := range paths { if isLast(i, nesting) { - currentLevel[p] = value[0] + if isArray(p) { + previousLevel[paths[i-1]] = value + } else { + currentLevel[p] = value[0] + } } else { initNestingIfNotExists(currentLevel, p) + previousLevel = currentLevel currentLevel = currentLevel[p].(map[string]interface{}) } } } +func isArray(p string) bool { + return p == "" +} + // 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 { diff --git a/models/filter/access_key_filter.go b/models/filter/access_key_filter.go index 93380093..a20e7b0d 100644 --- a/models/filter/access_key_filter.go +++ b/models/filter/access_key_filter.go @@ -5,7 +5,8 @@ type AccessKeyFilter struct { ModelFilter `json:",inline"` // RevokedRange specifies the time range when a record was revoked. - RevokedRange *TimeRange `json:"revokedRange,omitempty"` + RevokedRange *TimeRange `form:"revokedRange" json:"revokedRange,omitempty"` + Test bool `form:"test" json:"test,omitempty"` } // ToDbConditions converts filter fields to the datastore conditions using gorm naming strategy From 8b853de6be73b8cd9c5858ffe3639b823ae15bbf Mon Sep 17 00:00:00 2001 From: Damian Orzepowski Date: Fri, 23 Aug 2024 13:50:41 +0200 Subject: [PATCH 2/5] feat(SPV-793): replace QueryNestedMap with new function which handle whole query params and returns errors --- internal/query/gin_map.go | 19 ++- internal/query/gin_map_test.go | 211 +++++++++++++++++++++++------ internal/query/map.go | 109 ++++++++++++--- models/filter/access_key_filter.go | 3 +- 4 files changed, 264 insertions(+), 78 deletions(-) diff --git a/internal/query/gin_map.go b/internal/query/gin_map.go index 251a2366..df274421 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 6117c362..c2548174 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 9a055867..ae521b74 100644 --- a/internal/query/map.go +++ b/internal/query/map.go @@ -1,53 +1,117 @@ 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" + } + 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 +125,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 +138,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 == "" } diff --git a/models/filter/access_key_filter.go b/models/filter/access_key_filter.go index a20e7b0d..93380093 100644 --- a/models/filter/access_key_filter.go +++ b/models/filter/access_key_filter.go @@ -5,8 +5,7 @@ type AccessKeyFilter struct { ModelFilter `json:",inline"` // RevokedRange specifies the time range when a record was revoked. - RevokedRange *TimeRange `form:"revokedRange" json:"revokedRange,omitempty"` - Test bool `form:"test" json:"test,omitempty"` + RevokedRange *TimeRange `json:"revokedRange,omitempty"` } // ToDbConditions converts filter fields to the datastore conditions using gorm naming strategy From 10f94a4b336f958b084ddad0ba0a2236d55728cb Mon Sep 17 00:00:00 2001 From: Damian Orzepowski Date: Fri, 23 Aug 2024 16:57:29 +0200 Subject: [PATCH 3/5] feat(SPV-793): add function that allows parsing query params into new search params struct. --- internal/query/parse.go | 37 ++++++++ internal/query/parse_test.go | 122 ++++++++++++++++++++++++++ mappings/{filter.go => filter_old.go} | 0 mappings/search.go | 45 ++++++++++ models/filter/access_key_filter.go | 2 +- models/filter/contact_filter.go | 2 +- models/filter/destination_filter.go | 2 +- models/filter/paymail_filter.go | 2 +- models/filter/search_params.go | 14 +++ models/filter/transaction_filter.go | 2 +- models/filter/utxo_filter.go | 2 +- models/filter/xpub_filter.go | 2 +- 12 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 internal/query/parse.go create mode 100644 internal/query/parse_test.go rename mappings/{filter.go => filter_old.go} (100%) create mode 100644 mappings/search.go create mode 100644 models/filter/search_params.go diff --git a/internal/query/parse.go b/internal/query/parse.go new file mode 100644 index 00000000..fb6bdb3b --- /dev/null +++ b/internal/query/parse.go @@ -0,0 +1,37 @@ +package query + +import ( + "time" + + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/gin-gonic/gin" + "github.com/mitchellh/mapstructure" +) + +func ParseSearchParams[T any](c *gin.Context, _ T) (*filter.SearchParams[T], error) { + var params filter.SearchParams[T] + + dicts, err := ShouldGetQueryNestedMap(c) + if err != nil { + return nil, err + } + + config := mapstructure.DecoderConfig{ + DecodeHook: mapstructure.StringToTimeHookFunc(time.RFC3339), + WeaklyTypedInput: true, + Result: ¶ms, + TagName: "json", // Small hax to reuse json tags which we have already defined + } + + decoder, err := mapstructure.NewDecoder(&config) + if err != nil { + return nil, err + } + + err = decoder.Decode(dicts) + if err != nil { + return nil, err + } + + return ¶ms, nil +} diff --git a/internal/query/parse_test.go b/internal/query/parse_test.go new file mode 100644 index 00000000..ac08200d --- /dev/null +++ b/internal/query/parse_test.go @@ -0,0 +1,122 @@ +package query + +import ( + "net/http" + "net/url" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestParseSearchParamsSuccessfully(t *testing.T) { + tests := map[string]struct { + url string + expectedResult filter.SearchParams[ExampleConditionsForTests] + }{ + "empty query": { + url: "", + expectedResult: filter.SearchParams[ExampleConditionsForTests]{}, + }, + "query page": { + url: "?page=2&size=200&order=asc&sortBy=id", + expectedResult: filter.SearchParams[ExampleConditionsForTests]{ + Page: filter.Page{ + Number: 2, + Size: 200, + Order: "asc", + SortBy: "id", + }, + }, + }, + "query conditions model filter": { + url: "?includeDeleted=true&createdRange[from]=2021-01-01T00:00:00Z&createdRange[to]=2021-01-02T00:00:00Z&updatedRange[from]=2021-02-01T00:00:00Z&updatedRange[to]=2021-02-02T00:00:00Z", + expectedResult: filter.SearchParams[ExampleConditionsForTests]{ + Conditions: ExampleConditionsForTests{ + ModelFilter: filter.ModelFilter{ + IncludeDeleted: ptr(true), + CreatedRange: &filter.TimeRange{ + From: ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + To: ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + UpdatedRange: &filter.TimeRange{ + From: ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + }, + }, + "query conditions without nested structs": { + url: "?xBoolean=true&xString=some%20string&xInt=5", + expectedResult: filter.SearchParams[ExampleConditionsForTests]{ + Conditions: ExampleConditionsForTests{ + XBoolean: ptr(true), + XString: ptr("some string"), + XInt: ptr(5), + }, + }, + }, + "query conditions nested struct": { + url: "?nested[isNested]=true&nested[name]=some%20name&nested[number]=10", + expectedResult: filter.SearchParams[ExampleConditionsForTests]{ + Conditions: ExampleConditionsForTests{ + Nested: &ExampleNestedConditionsForTests{ + IsNested: ptr(true), + Name: ptr("some name"), + Number: ptr(10), + }, + }, + }, + }, + "query metadata": { + url: "?metadata[key]=value1&metadata[key2][nested]=value2&metadata[key3][nested][]=value3&metadata[key3][nested][]=value4", + expectedResult: filter.SearchParams[ExampleConditionsForTests]{ + Metadata: map[string]interface{}{ + "key": "value1", + "key2": map[string]interface{}{ + "nested": "value2", + }, + "key3": map[string]interface{}{ + "nested": []string{"value3", "value4"}, + }, + }, + }, + }, + } + 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, + }, + } + + params, err := ParseSearchParams(c, ExampleConditionsForTests{}) + require.NoError(t, err) + require.EqualValues(t, test.expectedResult, *params) + }) + } +} + +type ExampleConditionsForTests struct { + filter.ModelFilter `json:",inline,squash"` + XBoolean *bool `json:"xBoolean,omitempty"` + XString *string `json:"xString,omitempty"` + XInt *int `json:"xInt,omitempty"` + Nested *ExampleNestedConditionsForTests `json:"nested,omitempty"` +} +type ExampleNestedConditionsForTests struct { + IsNested *bool `json:"isNested,omitempty"` + Name *string `json:"name,omitempty"` + Number *int `json:"number,omitempty"` +} + +func ptr[T any](value T) *T { + return &value +} diff --git a/mappings/filter.go b/mappings/filter_old.go similarity index 100% rename from mappings/filter.go rename to mappings/filter_old.go diff --git a/mappings/search.go b/mappings/search.go new file mode 100644 index 00000000..728087c4 --- /dev/null +++ b/mappings/search.go @@ -0,0 +1,45 @@ +package mappings + +import ( + "github.com/bitcoin-sv/spv-wallet/engine/datastore" + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +const ( + defaultPage = 1 + defaultPageSize = 50 + defaultSortBy = "created_at" + defaultOrder = "desc" +) + +// MapToDbQueryParams converts filter.QueryParams from models to matching datastore.QueryParams +func MapToDbQueryParams(model *filter.Page) *datastore.QueryParams { + if model == nil { + return &datastore.QueryParams{ + Page: defaultPage, + PageSize: defaultPageSize, + OrderByField: defaultOrder, + SortDirection: defaultOrder, + } + } + return &datastore.QueryParams{ + Page: getNumberOrDefault(model.Number, defaultPage), + PageSize: getNumberOrDefault(model.Size, defaultPageSize), + OrderByField: getStringOrDefalut(model.SortBy, defaultSortBy), + SortDirection: getStringOrDefalut(model.Order, defaultOrder), + } +} + +func getNumberOrDefault(value int, defaultValue int) int { + if value == 0 { + return defaultValue + } + return value +} + +func getStringOrDefalut(value string, defaultValue string) string { + if value == "" { + return defaultValue + } + return value +} diff --git a/models/filter/access_key_filter.go b/models/filter/access_key_filter.go index 93380093..f2fdc295 100644 --- a/models/filter/access_key_filter.go +++ b/models/filter/access_key_filter.go @@ -2,7 +2,7 @@ package filter // AccessKeyFilter is a struct for handling request parameters for destination search requests type AccessKeyFilter struct { - ModelFilter `json:",inline"` + ModelFilter `json:",inline,squash"` // RevokedRange specifies the time range when a record was revoked. RevokedRange *TimeRange `json:"revokedRange,omitempty"` diff --git a/models/filter/contact_filter.go b/models/filter/contact_filter.go index 003bd2b1..4418b1a7 100644 --- a/models/filter/contact_filter.go +++ b/models/filter/contact_filter.go @@ -2,7 +2,7 @@ package filter // ContactFilter is a struct for handling request parameters for contact search requests type ContactFilter struct { - ModelFilter `json:",inline"` + ModelFilter `json:",inline,squash"` ID *string `json:"id" example:"ffdbe74e-0700-4710-aac5-611a1f877c7f"` FullName *string `json:"fullName" example:"Alice"` Paymail *string `json:"paymail" example:"alice@example.com"` diff --git a/models/filter/destination_filter.go b/models/filter/destination_filter.go index 4af01789..e6f47aa9 100644 --- a/models/filter/destination_filter.go +++ b/models/filter/destination_filter.go @@ -2,7 +2,7 @@ package filter // DestinationFilter is a struct for handling request parameters for destination search requests type DestinationFilter struct { - ModelFilter `json:",inline"` + ModelFilter `json:",inline,squash"` LockingScript *string `json:"lockingScript,omitempty" example:"76a9147b05764a97f3b4b981471492aa703b188e45979b88ac"` Address *string `json:"address,omitempty" example:"1CDUf7CKu8ocTTkhcYUbq75t14Ft168K65"` DraftID *string `json:"draftId,omitempty" example:"b356f7fa00cd3f20cce6c21d704cd13e871d28d714a5ebd0532f5a0e0cde63f7"` diff --git a/models/filter/paymail_filter.go b/models/filter/paymail_filter.go index 094ecbaa..8ea8da3f 100644 --- a/models/filter/paymail_filter.go +++ b/models/filter/paymail_filter.go @@ -2,7 +2,7 @@ package filter // AdminPaymailFilter is a struct for handling request parameters for paymail_addresses search requests type AdminPaymailFilter struct { - ModelFilter `json:",inline"` + ModelFilter `json:",inline,squash"` ID *string `json:"id,omitempty" example:"ffb86c103d17d87c15aaf080aab6be5415c9fa885309a79b04c9910e39f2b542"` XpubID *string `json:"xpubId,omitempty" example:"79f90a6bab0a44402fc64828af820e9465645658aea2d138c5205b88e6dabd00"` diff --git a/models/filter/search_params.go b/models/filter/search_params.go new file mode 100644 index 00000000..c123bd8c --- /dev/null +++ b/models/filter/search_params.go @@ -0,0 +1,14 @@ +package filter + +type Page struct { + Number int `json:"page,omitempty"` + Size int `json:"size,omitempty"` + Order string `json:"order,omitempty"` + SortBy string `json:"sortBy,omitempty"` +} + +type SearchParams[T any] struct { + Page Page `json:"paging,squash"` + Conditions T `json:"conditions,squash"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} diff --git a/models/filter/transaction_filter.go b/models/filter/transaction_filter.go index 9422d954..8fc2861d 100644 --- a/models/filter/transaction_filter.go +++ b/models/filter/transaction_filter.go @@ -2,7 +2,7 @@ package filter // TransactionFilter is a struct for handling request parameters for transactions search requests type TransactionFilter struct { - ModelFilter `json:",inline"` + ModelFilter `json:",inline,squash"` Hex *string `json:"hex,omitempty"` BlockHash *string `json:"blockHash,omitempty" example:"0000000000000000031928c28075a82d7a00c2c90b489d1d66dc0afa3f8d26f8"` BlockHeight *uint64 `json:"blockHeight,omitempty" example:"839376"` diff --git a/models/filter/utxo_filter.go b/models/filter/utxo_filter.go index f953ee9f..dd2a9588 100644 --- a/models/filter/utxo_filter.go +++ b/models/filter/utxo_filter.go @@ -2,7 +2,7 @@ package filter // UtxoFilter is a struct for handling request parameters for utxo search requests type UtxoFilter struct { - ModelFilter `json:",inline"` + ModelFilter `json:",inline,squash"` TransactionID *string `json:"transactionId,omitempty" example:"5e17858ea0ca4155827754ba82bdcfcce108d5bb5b47fbb3aa54bd14540683c6"` OutputIndex *uint32 `json:"outputIndex,omitempty" example:"0"` diff --git a/models/filter/xpub_filter.go b/models/filter/xpub_filter.go index a2575038..fdec760f 100644 --- a/models/filter/xpub_filter.go +++ b/models/filter/xpub_filter.go @@ -2,7 +2,7 @@ package filter // XpubFilter is a struct for handling request parameters for utxo search requests type XpubFilter struct { - ModelFilter `json:",inline"` + ModelFilter `json:",inline,squash"` ID *string `json:"id,omitempty" example:"00b953624f78004a4c727cd28557475d5233c15f17aef545106639f4d71b712d"` CurrentBalance *uint64 `json:"currentBalance,omitempty" example:"1000"` From ac2d1844dc1ac2e6c3e1386ca6e022af8afcffb0 Mon Sep 17 00:00:00 2001 From: Damian Orzepowski Date: Mon, 26 Aug 2024 12:55:49 +0200 Subject: [PATCH 4/5] refactor(SPV-793): use generic type call to function instead of providing typed parameter. --- internal/query/parse.go | 2 +- internal/query/parse_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/query/parse.go b/internal/query/parse.go index fb6bdb3b..bd35dc72 100644 --- a/internal/query/parse.go +++ b/internal/query/parse.go @@ -8,7 +8,7 @@ import ( "github.com/mitchellh/mapstructure" ) -func ParseSearchParams[T any](c *gin.Context, _ T) (*filter.SearchParams[T], error) { +func ParseSearchParams[T any](c *gin.Context) (*filter.SearchParams[T], error) { var params filter.SearchParams[T] dicts, err := ShouldGetQueryNestedMap(c) diff --git a/internal/query/parse_test.go b/internal/query/parse_test.go index ac08200d..bd1ef949 100644 --- a/internal/query/parse_test.go +++ b/internal/query/parse_test.go @@ -97,7 +97,7 @@ func TestParseSearchParamsSuccessfully(t *testing.T) { }, } - params, err := ParseSearchParams(c, ExampleConditionsForTests{}) + params, err := ParseSearchParams[ExampleConditionsForTests](c) require.NoError(t, err) require.EqualValues(t, test.expectedResult, *params) }) From 1acd5c896eda5b15be1b56080d4b957a28f6b165 Mon Sep 17 00:00:00 2001 From: Damian Orzepowski Date: Mon, 26 Aug 2024 15:04:02 +0200 Subject: [PATCH 5/5] chore(SPV-793): fix linter issues. --- engine/spverrors/definitions.go | 7 +++++-- internal/query/parse.go | 6 ++++-- internal/query/parse_test.go | 2 ++ models/filter/access_key_filter.go | 2 ++ models/filter/contact_filter.go | 2 ++ models/filter/destination_filter.go | 2 ++ models/filter/paymail_filter.go | 2 ++ models/filter/transaction_filter.go | 2 ++ models/filter/utxo_filter.go | 3 +++ models/filter/xpub_filter.go | 2 ++ 10 files changed, 26 insertions(+), 4 deletions(-) diff --git a/engine/spverrors/definitions.go b/engine/spverrors/definitions.go index 3653ecd6..9da018fe 100644 --- a/engine/spverrors/definitions.go +++ b/engine/spverrors/definitions.go @@ -60,6 +60,9 @@ var ErrValidateXPub = models.SPVError{Message: "error validation xpub", StatusCo // ErrCannotBindRequest is when request body cannot be bind into struct var ErrCannotBindRequest = models.SPVError{Message: "cannot bind request body", StatusCode: 400, Code: "error-bind-body-invalid"} +// ErrCannotParseQueryParams is when query params cannot be parsed into expected struct. +var ErrCannotParseQueryParams = models.SPVError{Message: "cannot parse request query params", StatusCode: 400, Code: "error-query-params-invalid"} + // ErrInvalidConditions is when request has invalid conditions var ErrInvalidConditions = models.SPVError{Message: "invalid conditions", StatusCode: 400, Code: "error-bind-conditions-invalid"} @@ -335,7 +338,7 @@ var ErrMissingClient = models.SPVError{Message: "client is missing from model, c // ErrDatastoreRequired is when a datastore function is called without a datastore present var ErrDatastoreRequired = models.SPVError{Message: "datastore is required", StatusCode: 500, Code: "error-datastore-required"} -//////////////////////////////////// NOTIFICATION ERRORS +// ////////////////////////////////// NOTIFICATION ERRORS // ErrWebhookSubscriptionFailed is when webhook subscription failed var ErrWebhookSubscriptionFailed = models.SPVError{Message: "webhook subscription failed", StatusCode: 500, Code: "error-webhook-subscription-failed"} @@ -349,7 +352,7 @@ var ErrWebhookSubscriptionNotFound = models.SPVError{Message: "webhook subscript // ErrNotificationsDisabled happens when the notifications are not enabled in the config var ErrNotificationsDisabled = models.SPVError{Message: "notifications are disabled", StatusCode: 404, Code: "error-notifications-disabled"} -//////////////////////////////////// ROUTES ERRORS +// ////////////////////////////////// ROUTES ERRORS // ErrRouteNotFound is when route is not found var ErrRouteNotFound = models.SPVError{Message: "route not found", StatusCode: 404, Code: "error-route-not-found"} diff --git a/internal/query/parse.go b/internal/query/parse.go index bd35dc72..c78a07bb 100644 --- a/internal/query/parse.go +++ b/internal/query/parse.go @@ -3,11 +3,13 @@ package query import ( "time" + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" "github.com/bitcoin-sv/spv-wallet/models/filter" "github.com/gin-gonic/gin" "github.com/mitchellh/mapstructure" ) +// ParseSearchParams parses search params from the query string into a SearchParams struct with conditions of a given type. func ParseSearchParams[T any](c *gin.Context) (*filter.SearchParams[T], error) { var params filter.SearchParams[T] @@ -25,12 +27,12 @@ func ParseSearchParams[T any](c *gin.Context) (*filter.SearchParams[T], error) { decoder, err := mapstructure.NewDecoder(&config) if err != nil { - return nil, err + return nil, spverrors.Wrapf(err, spverrors.ErrCannotParseQueryParams.Error()) } err = decoder.Decode(dicts) if err != nil { - return nil, err + return nil, spverrors.Wrapf(err, spverrors.ErrCannotParseQueryParams.Error()) } return ¶ms, nil diff --git a/internal/query/parse_test.go b/internal/query/parse_test.go index bd1ef949..f8327fdc 100644 --- a/internal/query/parse_test.go +++ b/internal/query/parse_test.go @@ -105,6 +105,8 @@ func TestParseSearchParamsSuccessfully(t *testing.T) { } type ExampleConditionsForTests struct { + // ModelFilter is a struct for handling typical request parameters for search requests + //nolint:staticcheck // SA5008 - We want to reuse json tags also to mapstructure. filter.ModelFilter `json:",inline,squash"` XBoolean *bool `json:"xBoolean,omitempty"` XString *string `json:"xString,omitempty"` diff --git a/models/filter/access_key_filter.go b/models/filter/access_key_filter.go index f2fdc295..3e060900 100644 --- a/models/filter/access_key_filter.go +++ b/models/filter/access_key_filter.go @@ -2,6 +2,8 @@ package filter // AccessKeyFilter is a struct for handling request parameters for destination search requests type AccessKeyFilter struct { + // ModelFilter is a struct for handling typical request parameters for search requests + //lint:ignore SA5008 We want to reuse json tags also to mapstructure. ModelFilter `json:",inline,squash"` // RevokedRange specifies the time range when a record was revoked. diff --git a/models/filter/contact_filter.go b/models/filter/contact_filter.go index 4418b1a7..72020cba 100644 --- a/models/filter/contact_filter.go +++ b/models/filter/contact_filter.go @@ -2,6 +2,8 @@ package filter // ContactFilter is a struct for handling request parameters for contact search requests type ContactFilter struct { + // ModelFilter is a struct for handling typical request parameters for search requests + //lint:ignore SA5008 We want to reuse json tags also to mapstructure. ModelFilter `json:",inline,squash"` ID *string `json:"id" example:"ffdbe74e-0700-4710-aac5-611a1f877c7f"` FullName *string `json:"fullName" example:"Alice"` diff --git a/models/filter/destination_filter.go b/models/filter/destination_filter.go index e6f47aa9..0790430d 100644 --- a/models/filter/destination_filter.go +++ b/models/filter/destination_filter.go @@ -2,6 +2,8 @@ package filter // DestinationFilter is a struct for handling request parameters for destination search requests type DestinationFilter struct { + // ModelFilter is a struct for handling typical request parameters for search requests + //lint:ignore SA5008 We want to reuse json tags also to mapstructure. ModelFilter `json:",inline,squash"` LockingScript *string `json:"lockingScript,omitempty" example:"76a9147b05764a97f3b4b981471492aa703b188e45979b88ac"` Address *string `json:"address,omitempty" example:"1CDUf7CKu8ocTTkhcYUbq75t14Ft168K65"` diff --git a/models/filter/paymail_filter.go b/models/filter/paymail_filter.go index 8ea8da3f..290b9618 100644 --- a/models/filter/paymail_filter.go +++ b/models/filter/paymail_filter.go @@ -2,6 +2,8 @@ package filter // AdminPaymailFilter is a struct for handling request parameters for paymail_addresses search requests type AdminPaymailFilter struct { + // ModelFilter is a struct for handling typical request parameters for search requests + //lint:ignore SA5008 We want to reuse json tags also to mapstructure. ModelFilter `json:",inline,squash"` ID *string `json:"id,omitempty" example:"ffb86c103d17d87c15aaf080aab6be5415c9fa885309a79b04c9910e39f2b542"` diff --git a/models/filter/transaction_filter.go b/models/filter/transaction_filter.go index 8fc2861d..504e6395 100644 --- a/models/filter/transaction_filter.go +++ b/models/filter/transaction_filter.go @@ -2,6 +2,8 @@ package filter // TransactionFilter is a struct for handling request parameters for transactions search requests type TransactionFilter struct { + // ModelFilter is a struct for handling typical request parameters for search requests + //lint:ignore SA5008 We want to reuse json tags also to mapstructure. ModelFilter `json:",inline,squash"` Hex *string `json:"hex,omitempty"` BlockHash *string `json:"blockHash,omitempty" example:"0000000000000000031928c28075a82d7a00c2c90b489d1d66dc0afa3f8d26f8"` diff --git a/models/filter/utxo_filter.go b/models/filter/utxo_filter.go index dd2a9588..24619c27 100644 --- a/models/filter/utxo_filter.go +++ b/models/filter/utxo_filter.go @@ -2,6 +2,9 @@ package filter // UtxoFilter is a struct for handling request parameters for utxo search requests type UtxoFilter struct { + + // ModelFilter is a struct for handling typical request parameters for search requests + //lint:ignore SA5008 We want to reuse json tags also to mapstructure. ModelFilter `json:",inline,squash"` TransactionID *string `json:"transactionId,omitempty" example:"5e17858ea0ca4155827754ba82bdcfcce108d5bb5b47fbb3aa54bd14540683c6"` diff --git a/models/filter/xpub_filter.go b/models/filter/xpub_filter.go index fdec760f..7ff07bbf 100644 --- a/models/filter/xpub_filter.go +++ b/models/filter/xpub_filter.go @@ -2,6 +2,8 @@ package filter // XpubFilter is a struct for handling request parameters for utxo search requests type XpubFilter struct { + // ModelFilter is a struct for handling typical request parameters for search requests + //lint:ignore SA5008 We want to reuse json tags also to mapstructure. ModelFilter `json:",inline,squash"` ID *string `json:"id,omitempty" example:"00b953624f78004a4c727cd28557475d5233c15f17aef545106639f4d71b712d"`