Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(SPV-793): add function to parse query params into new search struct #675

Merged
merged 6 commits into from
Aug 26, 2024
7 changes: 5 additions & 2 deletions engine/spverrors/definitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}

Expand Down Expand Up @@ -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"}
Expand All @@ -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"}
Expand Down
39 changes: 39 additions & 0 deletions internal/query/parse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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]

dicts, err := ShouldGetQueryNestedMap(c)
if err != nil {
return nil, err
}

config := mapstructure.DecoderConfig{
DecodeHook: mapstructure.StringToTimeHookFunc(time.RFC3339),
WeaklyTypedInput: true,
Result: &params,
TagName: "json", // Small hax to reuse json tags which we have already defined
}

decoder, err := mapstructure.NewDecoder(&config)
if err != nil {
return nil, spverrors.Wrapf(err, spverrors.ErrCannotParseQueryParams.Error())
}

err = decoder.Decode(dicts)
if err != nil {
return nil, spverrors.Wrapf(err, spverrors.ErrCannotParseQueryParams.Error())
}

return &params, nil
}
124 changes: 124 additions & 0 deletions internal/query/parse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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[ExampleConditionsForTests](c)
require.NoError(t, err)
require.EqualValues(t, test.expectedResult, *params)
})
}
}

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"`
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
}
File renamed without changes.
45 changes: 45 additions & 0 deletions mappings/search.go
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 3 additions & 1 deletion models/filter/access_key_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package filter

// AccessKeyFilter is a struct for handling request parameters for destination search requests
type AccessKeyFilter struct {
ModelFilter `json:",inline"`
// 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.
RevokedRange *TimeRange `json:"revokedRange,omitempty"`
Expand Down
4 changes: 3 additions & 1 deletion models/filter/contact_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package filter

// ContactFilter is a struct for handling request parameters for contact search requests
type ContactFilter struct {
ModelFilter `json:",inline"`
// 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"`
Paymail *string `json:"paymail" example:"alice@example.com"`
Expand Down
4 changes: 3 additions & 1 deletion models/filter/destination_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package filter

// DestinationFilter is a struct for handling request parameters for destination search requests
type DestinationFilter struct {
ModelFilter `json:",inline"`
// 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"`
DraftID *string `json:"draftId,omitempty" example:"b356f7fa00cd3f20cce6c21d704cd13e871d28d714a5ebd0532f5a0e0cde63f7"`
Expand Down
4 changes: 3 additions & 1 deletion models/filter/paymail_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package filter

// AdminPaymailFilter is a struct for handling request parameters for paymail_addresses search requests
type AdminPaymailFilter struct {
ModelFilter `json:",inline"`
// 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"`
XpubID *string `json:"xpubId,omitempty" example:"79f90a6bab0a44402fc64828af820e9465645658aea2d138c5205b88e6dabd00"`
Expand Down
14 changes: 14 additions & 0 deletions models/filter/search_params.go
Original file line number Diff line number Diff line change
@@ -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"`
}
4 changes: 3 additions & 1 deletion models/filter/transaction_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package filter

// TransactionFilter is a struct for handling request parameters for transactions search requests
type TransactionFilter struct {
ModelFilter `json:",inline"`
// 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"`
BlockHeight *uint64 `json:"blockHeight,omitempty" example:"839376"`
Expand Down
5 changes: 4 additions & 1 deletion models/filter/utxo_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package filter

// UtxoFilter is a struct for handling request parameters for utxo search requests
type UtxoFilter struct {
ModelFilter `json:",inline"`

// 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"`
OutputIndex *uint32 `json:"outputIndex,omitempty" example:"0"`
Expand Down
4 changes: 3 additions & 1 deletion models/filter/xpub_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package filter

// XpubFilter is a struct for handling request parameters for utxo search requests
type XpubFilter struct {
ModelFilter `json:",inline"`
// 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"`
CurrentBalance *uint64 `json:"currentBalance,omitempty" example:"1000"`
Expand Down
Loading