diff --git a/go.list b/go.list index 7ee3f08b34..9cfa8916ab 100644 --- a/go.list +++ b/go.list @@ -26,6 +26,7 @@ github.com/golang/protobuf v1.3.1 github.com/gomodule/redigo v2.0.0+incompatible github.com/google/go-querystring v0.0.0-20160401233042-9235644dd9e5 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 +github.com/gorilla/schema v1.1.0 github.com/graph-gophers/graphql-go v0.0.0-20190225005345-3e8838d4614c github.com/guregu/null v2.1.3-0.20151024101046-79c5bd36b615+incompatible github.com/hashicorp/golang-lru v0.5.0 diff --git a/go.mod b/go.mod index 634b4bba5c..748e4e30a7 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d github.com/gomodule/redigo v2.0.0+incompatible github.com/google/go-querystring v0.0.0-20160401233042-9235644dd9e5 // indirect + github.com/gorilla/schema v1.1.0 github.com/graph-gophers/graphql-go v0.0.0-20190225005345-3e8838d4614c github.com/guregu/null v2.1.3-0.20151024101046-79c5bd36b615+incompatible github.com/hashicorp/golang-lru v0.5.0 // indirect diff --git a/go.sum b/go.sum index a636208c97..9828d2f85d 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/google/go-querystring v0.0.0-20160401233042-9235644dd9e5 h1:oERTZ1buO github.com/google/go-querystring v0.0.0-20160401233042-9235644dd9e5/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY= +github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/graph-gophers/graphql-go v0.0.0-20190225005345-3e8838d4614c h1:YyFUsspLqAt3noyPCLz7EFK/o1LpC1j/6MjU0bSVOQ4= github.com/graph-gophers/graphql-go v0.0.0-20190225005345-3e8838d4614c/go.mod h1:uJhtPXrcJLqyi0H5IuMFh+fgW+8cMMakK3Txrbk/WJE= github.com/guregu/null v2.1.3-0.20151024101046-79c5bd36b615+incompatible h1:SZmF1M6CdAm4MmTPYYTG+x9EC8D3FOxUq9S4D37irQg= diff --git a/services/horizon/internal/actions/helpers.go b/services/horizon/internal/actions/helpers.go index abfddd654d..10d1fcfeee 100644 --- a/services/horizon/internal/actions/helpers.go +++ b/services/horizon/internal/actions/helpers.go @@ -6,12 +6,15 @@ import ( "mime" "net/http" "net/url" + "reflect" "regexp" "strconv" "strings" "unicode/utf8" + "github.com/asaskevich/govalidator" "github.com/go-chi/chi" + "github.com/gorilla/schema" "github.com/stellar/go/amount" "github.com/stellar/go/services/horizon/internal/assets" @@ -711,3 +714,125 @@ func FullURL(ctx context.Context) *url.URL { } return url } + +// Note from chi: it is a good idea to set a Decoder instance as a package +// global, because it caches meta-data about structs, and an instance can be +// shared safely: +var decoder = schema.NewDecoder() + +// GetParams fills a struct with values read from a request's query parameters. +func GetParams(dst interface{}, r *http.Request) error { + query := r.URL.Query() + + // Merge chi's URLParams with URL Query Params. Given + // `/accounts/{account_id}/transactions?foo=bar`, chi's URLParams will + // contain `account_id` and URL Query params will contain `foo`. + if rctx := chi.RouteContext(r.Context()); rctx != nil { + for _, key := range rctx.URLParams.Keys { + val := query.Get(key) + if len(val) > 0 { + return problem.MakeInvalidFieldProblem( + key, + errors.New("The parameter should not be included in the request"), + ) + } + + query.Set(key, rctx.URLParam(key)) + } + } + + decoder.IgnoreUnknownKeys(true) + if err := decoder.Decode(dst, query); err != nil { + return errors.Wrap(err, "error decoding Request query") + } + + if _, err := govalidator.ValidateStruct(dst); err != nil { + field, message := getErrorFieldMessage(err) + err = problem.MakeInvalidFieldProblem( + getSchemaTag(dst, field), + errors.New(message), + ) + + return err + } + + if v, ok := dst.(Validateable); ok { + if err := v.Validate(); err != nil { + return err + } + } + + return nil +} + +func getSchemaTag(params interface{}, field string) string { + v := reflect.ValueOf(params).Elem() + qt := v.Type() + f, _ := qt.FieldByName(field) + return f.Tag.Get("schema") +} + +func validateAssetParams(aType, code, issuer, prefix string) error { + // If asset type is not present but code or issuer are, then there is a + // missing parameter and the request is unprocessable. + if len(aType) == 0 { + if len(code) > 0 || len(issuer) > 0 { + return problem.MakeInvalidFieldProblem( + prefix+"_asset_type", + errors.New("Missing parameter"), + ) + } + + return nil + } + + t, err := assets.Parse(aType) + if err != nil { + return problem.MakeInvalidFieldProblem( + prefix+"_asset_type", + err, + ) + } + + var validLen int + switch t { + case xdr.AssetTypeAssetTypeNative: + // If asset type is native, issuer or code should not be included in the + // request + switch { + case len(code) > 0: + return problem.MakeInvalidFieldProblem( + prefix+"_asset_code", + errors.New("native asset does not have a code"), + ) + case len(issuer) > 0: + return problem.MakeInvalidFieldProblem( + prefix+"_asset_issuer", + errors.New("native asset does not have an issuer"), + ) + } + + return nil + case xdr.AssetTypeAssetTypeCreditAlphanum4: + validLen = len(xdr.AssetAlphaNum4{}.AssetCode) + case xdr.AssetTypeAssetTypeCreditAlphanum12: + validLen = len(xdr.AssetAlphaNum12{}.AssetCode) + } + + codeLen := len(code) + if codeLen == 0 || codeLen > validLen { + return problem.MakeInvalidFieldProblem( + prefix+"_asset_code", + errors.New("Asset code must be 1-12 alphanumeric characters"), + ) + } + + if len(issuer) == 0 { + return problem.MakeInvalidFieldProblem( + prefix+"_asset_issuer", + errors.New("Missing parameter"), + ) + } + + return nil +} diff --git a/services/horizon/internal/actions/helpers_test.go b/services/horizon/internal/actions/helpers_test.go index f6c2c0c128..25e27bbcab 100644 --- a/services/horizon/internal/actions/helpers_test.go +++ b/services/horizon/internal/actions/helpers_test.go @@ -16,6 +16,7 @@ import ( "github.com/stellar/go/services/horizon/internal/test" "github.com/stellar/go/services/horizon/internal/toid" "github.com/stellar/go/support/db" + "github.com/stellar/go/support/errors" "github.com/stellar/go/support/render/problem" "github.com/stellar/go/xdr" ) @@ -570,6 +571,108 @@ func TestFullURL(t *testing.T) { tt.Assert.Equal("http:///foo-bar/blah?limit=2&cursor=123456", url.String()) } +func TestGetParams(t *testing.T) { + tt := test.Start(t) + defer tt.Finish() + + type QueryParams struct { + SellingBuyingAssetQueryParams `valid:"-"` + Account string `schema:"account_id" valid:"accountID"` + } + + account := "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H" + usd := xdr.MustNewCreditAsset("USD", account) + + // Simulate chi's URL params. The following would be equivalent to having a + // chi route like the following `/accounts/{account_id}` + urlParams := map[string]string{ + "account_id": account, + "selling_asset_type": "credit_alphanum4", + "selling_asset_code": "USD", + "selling_asset_issuer": account, + } + + r := makeAction("/transactions?limit=2&cursor=123456&order=desc", urlParams).R + qp := QueryParams{} + err := GetParams(&qp, r) + + tt.Assert.NoError(err) + tt.Assert.Equal(account, qp.Account) + tt.Assert.True(usd.Equals(*qp.Selling())) + + urlParams = map[string]string{ + "account_id": account, + "selling_asset_type": "native", + } + + r = makeAction("/transactions?limit=2&cursor=123456&order=desc", urlParams).R + qp = QueryParams{} + err = GetParams(&qp, r) + + tt.Assert.NoError(err) + native := xdr.MustNewNativeAsset() + tt.Assert.True(native.Equals(*qp.Selling())) + + urlParams = map[string]string{"account_id": "1"} + r = makeAction("/transactions?limit=2&cursor=123456&order=desc", urlParams).R + qp = QueryParams{} + err = GetParams(&qp, r) + + if tt.Assert.IsType(&problem.P{}, err) { + p := err.(*problem.P) + tt.Assert.Equal("bad_request", p.Type) + tt.Assert.Equal("account_id", p.Extras["invalid_field"]) + tt.Assert.Equal( + "Account ID must start with `G` and contain 56 alphanum characters", + p.Extras["reason"], + ) + } + + urlParams = map[string]string{ + "account_id": account, + } + r = makeAction(fmt.Sprintf("/transactions?account_id=%s", account), urlParams).R + err = GetParams(&qp, r) + + tt.Assert.Error(err) + if tt.Assert.IsType(&problem.P{}, err) { + p := err.(*problem.P) + tt.Assert.Equal("bad_request", p.Type) + tt.Assert.Equal("account_id", p.Extras["invalid_field"]) + tt.Assert.Equal( + "The parameter should not be included in the request", + p.Extras["reason"], + ) + } +} + +type ParamsValidator struct { + Account string `schema:"account_id" valid:"required"` +} + +func (pv ParamsValidator) Validate() error { + return problem.MakeInvalidFieldProblem( + "Name", + errors.New("Invalid"), + ) +} + +func TestGetParamsCustomValidator(t *testing.T) { + tt := test.Start(t) + defer tt.Finish() + + urlParams := map[string]string{"account_id": "1"} + r := makeAction("/transactions", urlParams).R + qp := ParamsValidator{} + err := GetParams(&qp, r) + + if tt.Assert.IsType(&problem.P{}, err) { + p := err.(*problem.P) + tt.Assert.Equal("bad_request", p.Type) + tt.Assert.Equal("Name", p.Extras["invalid_field"]) + } +} + func makeTestAction() *Base { return makeAction("/foo-bar/blah?limit=2&cursor=123456", testURLParams()) } diff --git a/services/horizon/internal/actions/offer.go b/services/horizon/internal/actions/offer.go index 5cfb0efa12..ce347adfb8 100644 --- a/services/horizon/internal/actions/offer.go +++ b/services/horizon/internal/actions/offer.go @@ -9,7 +9,6 @@ import ( "github.com/stellar/go/services/horizon/internal/resourceadapter" "github.com/stellar/go/support/errors" "github.com/stellar/go/support/render/hal" - "github.com/stellar/go/xdr" ) // GetOfferByID is the action handler for the /offers/{id} endpoint @@ -53,6 +52,17 @@ func (handler GetOfferByID) GetResource( return offerResponse, nil } +// OffersQuery query struct for offers end-point +type OffersQuery struct { + SellingBuyingAssetQueryParams `valid:"-"` + Seller string `schema:"seller" valid:"accountID,optional"` +} + +// Validate runs custom validations. +func (q OffersQuery) Validate() error { + return q.SellingBuyingAssetQueryParams.Validate() +} + // GetOffersHandler is the action handler for the /offers endpoint type GetOffersHandler struct { } @@ -63,31 +73,22 @@ func (handler GetOffersHandler) GetResourcePage( r *http.Request, ) ([]hal.Pageable, error) { ctx := r.Context() - pq, err := GetPageQuery(r) + qp := OffersQuery{} + err := GetParams(&qp, r) if err != nil { return nil, err } - seller, err := GetString(r, "seller") + pq, err := GetPageQuery(r) if err != nil { return nil, err } - var selling *xdr.Asset - if sellingAsset, found := MaybeGetAsset(r, "selling_"); found { - selling = &sellingAsset - } - - var buying *xdr.Asset - if buyingAsset, found := MaybeGetAsset(r, "buying_"); found { - buying = &buyingAsset - } - query := history.OffersQuery{ PageQuery: pq, - SellerID: seller, - Selling: selling, - Buying: buying, + SellerID: qp.Seller, + Selling: qp.Selling(), + Buying: qp.Buying(), } historyQ, err := historyQFromRequest(r) diff --git a/services/horizon/internal/actions/offer_test.go b/services/horizon/internal/actions/offer_test.go index b56e038d9c..e259635fd1 100644 --- a/services/horizon/internal/actions/offer_test.go +++ b/services/horizon/internal/actions/offer_test.go @@ -241,6 +241,27 @@ func TestGetOffersHandler(t *testing.T) { for _, offer := range offers { tt.Assert.Equal(issuer.Address(), offer.Seller) } + + _, err = handler.GetResourcePage( + httptest.NewRecorder(), + makeRequest( + t, + map[string]string{ + "seller": "GCXEWJ6U4KPGTNTBY5HX4WQ2EEVPWV2QKXEYIQ32IDYIX", + }, + map[string]string{}, + q.Session, + ), + ) + tt.Assert.Error(err) + tt.Assert.IsType(&problem.P{}, err) + p := err.(*problem.P) + tt.Assert.Equal("bad_request", p.Type) + tt.Assert.Equal("seller", p.Extras["invalid_field"]) + tt.Assert.Equal( + "Account ID must start with `G` and contain 56 alphanum characters", + p.Extras["reason"], + ) }) t.Run("Filter by selling asset", func(t *testing.T) { diff --git a/services/horizon/internal/actions/query_params.go b/services/horizon/internal/actions/query_params.go new file mode 100644 index 0000000000..099ddea2db --- /dev/null +++ b/services/horizon/internal/actions/query_params.go @@ -0,0 +1,67 @@ +package actions + +import ( + "github.com/stellar/go/xdr" +) + +// SellingBuyingAssetQueryParams query struct for end-points requiring a selling +// and buying asset +type SellingBuyingAssetQueryParams struct { + SellingAssetType string `schema:"selling_asset_type" valid:"assetType,optional"` + SellingAssetIssuer string `schema:"selling_asset_issuer" valid:"accountID,optional"` + SellingAssetCode string `schema:"selling_asset_code" valid:"-"` + BuyingAssetType string `schema:"buying_asset_type" valid:"assetType,optional"` + BuyingAssetIssuer string `schema:"buying_asset_issuer" valid:"accountID,optional"` + BuyingAssetCode string `schema:"buying_asset_code" valid:"-"` +} + +// Validate runs custom validations buying and selling +func (q SellingBuyingAssetQueryParams) Validate() error { + err := validateAssetParams(q.SellingAssetType, q.SellingAssetCode, q.SellingAssetIssuer, "selling") + if err != nil { + return err + } + err = validateAssetParams(q.BuyingAssetType, q.BuyingAssetCode, q.BuyingAssetIssuer, "buying") + if err != nil { + return err + } + return nil +} + +// Selling returns an xdr.Asset representing the selling side of the offer. +func (q SellingBuyingAssetQueryParams) Selling() *xdr.Asset { + if len(q.SellingAssetType) == 0 { + return nil + } + + selling, err := xdr.BuildAsset( + q.SellingAssetType, + q.SellingAssetIssuer, + q.SellingAssetCode, + ) + + if err != nil { + panic(err) + } + + return &selling +} + +// Buying returns an *xdr.Asset representing the buying side of the offer. +func (q SellingBuyingAssetQueryParams) Buying() *xdr.Asset { + if len(q.BuyingAssetType) == 0 { + return nil + } + + buying, err := xdr.BuildAsset( + q.BuyingAssetType, + q.BuyingAssetIssuer, + q.BuyingAssetCode, + ) + + if err != nil { + panic(err) + } + + return &buying +} diff --git a/services/horizon/internal/actions/query_params_test.go b/services/horizon/internal/actions/query_params_test.go new file mode 100644 index 0000000000..c111fe3a76 --- /dev/null +++ b/services/horizon/internal/actions/query_params_test.go @@ -0,0 +1,211 @@ +package actions + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/stellar/go/support/render/problem" +) + +func TestSellingBuyingAssetQueryParams(t *testing.T) { + testCases := []struct { + desc string + urlParams map[string]string + expectedInvalidField string + expectedErr string + }{ + { + desc: "Invalid selling_asset_type", + urlParams: map[string]string{ + "selling_asset_type": "invalid", + }, + expectedInvalidField: "selling_asset_type", + expectedErr: "Asset type must be native, credit_alphanum4 or credit_alphanum12", + }, + { + desc: "Invalid buying_asset_type", + urlParams: map[string]string{ + "buying_asset_type": "invalid", + }, + expectedInvalidField: "buying_asset_type", + expectedErr: "Asset type must be native, credit_alphanum4 or credit_alphanum12", + }, { + desc: "Invalid selling_asset_code for credit_alphanum4", + urlParams: map[string]string{ + "selling_asset_type": "credit_alphanum4", + "selling_asset_code": "invalid", + "selling_asset_issuer": "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", + }, + expectedInvalidField: "selling_asset_code", + expectedErr: "Asset code must be 1-12 alphanumeric characters", + }, { + desc: "Invalid buying_asset_code for credit_alphanum4", + urlParams: map[string]string{ + "buying_asset_type": "credit_alphanum4", + "buying_asset_code": "invalid", + }, + expectedInvalidField: "buying_asset_code", + expectedErr: "Asset code must be 1-12 alphanumeric characters", + }, { + desc: "Empty selling_asset_code for credit_alphanum4", + urlParams: map[string]string{ + "selling_asset_type": "credit_alphanum4", + "selling_asset_code": "", + }, + expectedInvalidField: "selling_asset_code", + expectedErr: "Asset code must be 1-12 alphanumeric characters", + }, { + desc: "Empty buying_asset_code for credit_alphanum4", + urlParams: map[string]string{ + "buying_asset_type": "credit_alphanum4", + "buying_asset_code": "", + }, + expectedInvalidField: "buying_asset_code", + expectedErr: "Asset code must be 1-12 alphanumeric characters", + }, { + desc: "Empty selling_asset_code for credit_alphanum12", + urlParams: map[string]string{ + "selling_asset_type": "credit_alphanum12", + "selling_asset_code": "", + }, + expectedInvalidField: "selling_asset_code", + expectedErr: "Asset code must be 1-12 alphanumeric characters", + }, { + desc: "Empty buying_asset_code for credit_alphanum12", + urlParams: map[string]string{ + "buying_asset_type": "credit_alphanum12", + "buying_asset_code": "", + }, + expectedInvalidField: "buying_asset_code", + expectedErr: "Asset code must be 1-12 alphanumeric characters", + }, { + desc: "Invalid selling_asset_code for credit_alphanum12", + urlParams: map[string]string{ + "selling_asset_type": "credit_alphanum12", + "selling_asset_code": "OHLOOOOOOOOOONG", + }, + expectedInvalidField: "selling_asset_code", + expectedErr: "Asset code must be 1-12 alphanumeric characters", + }, { + desc: "Invalid buying_asset_code for credit_alphanum12", + urlParams: map[string]string{ + "buying_asset_type": "credit_alphanum12", + "buying_asset_code": "OHLOOOOOOOOOONG", + }, + expectedInvalidField: "buying_asset_code", + expectedErr: "Asset code must be 1-12 alphanumeric characters", + }, { + desc: "Invalid selling_asset_issuer", + urlParams: map[string]string{ + "selling_asset_issuer": "GFOOO", + }, + expectedInvalidField: "selling_asset_issuer", + expectedErr: "Account ID must start with `G` and contain 56 alphanum characters", + }, { + desc: "Invalid buying_asset_issuer", + urlParams: map[string]string{ + "buying_asset_issuer": "GFOOO", + }, + expectedInvalidField: "buying_asset_issuer", + expectedErr: "Account ID must start with `G` and contain 56 alphanum characters", + }, { + desc: "Missing selling_asset_type", + urlParams: map[string]string{ + "selling_asset_code": "OHLOOOOOOOOOONG", + "selling_asset_issuer": "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", + }, + expectedInvalidField: "selling_asset_type", + expectedErr: "Missing parameter", + }, { + desc: "Missing buying_asset_type", + urlParams: map[string]string{ + "buying_asset_code": "OHLOOOOOOOOOONG", + "buying_asset_issuer": "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", + }, + expectedInvalidField: "buying_asset_type", + expectedErr: "Missing parameter", + }, { + desc: "Missing selling_asset_issuer", + urlParams: map[string]string{ + "selling_asset_type": "credit_alphanum4", + "selling_asset_code": "USD", + }, + expectedInvalidField: "selling_asset_issuer", + expectedErr: "Missing parameter", + }, { + desc: "Missing buying_asset_issuer", + urlParams: map[string]string{ + "buying_asset_type": "credit_alphanum4", + "buying_asset_code": "USD", + }, + expectedInvalidField: "buying_asset_issuer", + expectedErr: "Missing parameter", + }, { + desc: "Native with issued asset info: buying_asset_issuer", + urlParams: map[string]string{ + "buying_asset_type": "native", + "buying_asset_issuer": "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", + }, + expectedInvalidField: "buying_asset_issuer", + expectedErr: "native asset does not have an issuer", + }, { + desc: "Native with issued asset info: buying_asset_code", + urlParams: map[string]string{ + "buying_asset_type": "native", + "buying_asset_code": "USD", + }, + expectedInvalidField: "buying_asset_code", + expectedErr: "native asset does not have a code", + }, { + desc: "Native with issued asset info: selling_asset_issuer", + urlParams: map[string]string{ + "selling_asset_type": "native", + "selling_asset_issuer": "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", + }, + expectedInvalidField: "selling_asset_issuer", + expectedErr: "native asset does not have an issuer", + }, { + desc: "Native with issued asset info: selling_asset_code", + urlParams: map[string]string{ + "selling_asset_type": "native", + "selling_asset_code": "USD", + }, + expectedInvalidField: "selling_asset_code", + expectedErr: "native asset does not have a code", + }, { + desc: "Valid parameters", + urlParams: map[string]string{ + "buying_asset_type": "credit_alphanum4", + "buying_asset_code": "USD", + "buying_asset_issuer": "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", + "selling_asset_type": "credit_alphanum4", + "selling_asset_code": "EUR", + "selling_asset_issuer": "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", + }, + }, + } + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + tt := assert.New(t) + r := makeAction("/", tc.urlParams).R + qp := SellingBuyingAssetQueryParams{} + err := GetParams(&qp, r) + + if len(tc.expectedInvalidField) == 0 { + tt.NoError(err) + } else { + if tt.IsType(&problem.P{}, err) { + p := err.(*problem.P) + tt.Equal("bad_request", p.Type) + tt.Equal(tc.expectedInvalidField, p.Extras["invalid_field"]) + tt.Equal( + tc.expectedErr, + p.Extras["reason"], + ) + } + } + + }) + } +} diff --git a/services/horizon/internal/actions/validators.go b/services/horizon/internal/actions/validators.go new file mode 100644 index 0000000000..f393927148 --- /dev/null +++ b/services/horizon/internal/actions/validators.go @@ -0,0 +1,64 @@ +package actions + +import ( + "github.com/asaskevich/govalidator" + + "github.com/stellar/go/services/horizon/internal/assets" + "github.com/stellar/go/xdr" +) + +// Validateable allow structs to define their own custom validations. +type Validateable interface { + Validate() error +} + +func init() { + govalidator.TagMap["accountID"] = govalidator.Validator(isAccountID) + govalidator.TagMap["assetType"] = govalidator.Validator(isAssetType) +} + +var customTagsErrorMessages = map[string]string{ + "accountID": "Account ID must start with `G` and contain 56 alphanum characters", + "assetType": "Asset type must be native, credit_alphanum4 or credit_alphanum12", +} + +func getErrorFieldMessage(err error) (string, string) { + var field, message string + + switch err := err.(type) { + case govalidator.Error: + field = err.Name + validator := err.Validator + m, ok := customTagsErrorMessages[validator] + // Give priority to inline custom error messages. + // CustomErrorMessageExists when the validator is defined like: + // `validatorName~custom message` + if !ok || err.CustomErrorMessageExists { + m = err.Err.Error() + } + message = m + case govalidator.Errors: + for _, item := range err.Errors() { + field, message = getErrorFieldMessage(item) + break + } + } + + return field, message +} + +func isAssetType(str string) bool { + if _, err := assets.Parse(str); err != nil { + return false + } + + return true +} + +func isAccountID(str string) bool { + if _, err := xdr.AddressToAccountId(str); err != nil { + return false + } + + return true +} diff --git a/services/horizon/internal/actions/validators_test.go b/services/horizon/internal/actions/validators_test.go new file mode 100644 index 0000000000..570bcf77d7 --- /dev/null +++ b/services/horizon/internal/actions/validators_test.go @@ -0,0 +1,100 @@ +package actions + +import ( + "testing" + + "github.com/asaskevich/govalidator" + "github.com/stretchr/testify/assert" +) + +func TestAssetTypeValidator(t *testing.T) { + type Query struct { + AssetType string `valid:"assetType,optional"` + } + + for _, testCase := range []struct { + assetType string + valid bool + }{ + { + "native", + true, + }, + { + "credit_alphanum4", + true, + }, + { + "credit_alphanum12", + true, + }, + { + "", + true, + }, + { + "stellar_asset_type", + false, + }, + } { + t.Run(testCase.assetType, func(t *testing.T) { + tt := assert.New(t) + + q := Query{ + AssetType: testCase.assetType, + } + + result, err := govalidator.ValidateStruct(q) + if testCase.valid { + tt.NoError(err) + tt.True(result) + } else { + tt.Equal("AssetType: stellar_asset_type does not validate as assetType", err.Error()) + } + }) + } +} + +func TestAccountIDValidator(t *testing.T) { + type Query struct { + Account string `valid:"accountID,optional"` + } + + for _, testCase := range []struct { + name string + value string + expectedError string + }{ + { + "invalid stellar address", + "FON4WOTCFSASG3J6SGLLQZURDDUVNBQANAHEQJ3PBNDZ74X63UZWQPZW", + "Account: FON4WOTCFSASG3J6SGLLQZURDDUVNBQANAHEQJ3PBNDZ74X63UZWQPZW does not validate as accountID", + }, + { + "valid stellar address", + "GAN4WOTCFSASG3J6SGLLQZURDDUVNBQANAHEQJ3PBNDZ74X63UZWQPZW", + "", + }, + { + "empty stellar address should not be validated", + "", + "", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + tt := assert.New(t) + + q := Query{ + Account: testCase.value, + } + + result, err := govalidator.ValidateStruct(q) + if testCase.expectedError == "" { + tt.NoError(err) + tt.True(result) + } else { + tt.Equal(testCase.expectedError, err.Error()) + } + }) + } +} diff --git a/xdr/account_id_test.go b/xdr/account_id_test.go index 0b845e6a66..ad3a7a011b 100644 --- a/xdr/account_id_test.go +++ b/xdr/account_id_test.go @@ -1,10 +1,10 @@ package xdr_test import ( - . "github.com/stellar/go/xdr" - . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + + . "github.com/stellar/go/xdr" ) var _ = Describe("xdr.AccountId#Address()", func() { @@ -47,3 +47,17 @@ var _ = Describe("xdr.AccountId#LedgerKey()", func() { Expect(packed.Equals(aid)).To(BeTrue()) }) }) + +var _ = Describe("xdr.AddressToAccountID()", func() { + It("works", func() { + address := "GCR22L3WS7TP72S4Z27YTO6JIQYDJK2KLS2TQNHK6Y7XYPA3AGT3X4FH" + accountID, err := AddressToAccountId(address) + + Expect(accountID.Address()).To(Equal("GCR22L3WS7TP72S4Z27YTO6JIQYDJK2KLS2TQNHK6Y7XYPA3AGT3X4FH")) + Expect(err).ShouldNot(HaveOccurred()) + + _, err = AddressToAccountId("GCR22L3") + + Expect(err).Should(HaveOccurred()) + }) +}) diff --git a/xdr/asset.go b/xdr/asset.go index d11f060879..3439069bb9 100644 --- a/xdr/asset.go +++ b/xdr/asset.go @@ -17,6 +17,13 @@ var AssetTypeToString = map[AssetType]string{ AssetTypeAssetTypeCreditAlphanum12: "credit_alphanum12", } +// StringToAssetType maps an strings to its xdr.AssetType representation +var StringToAssetType = map[string]AssetType{ + "native": AssetTypeAssetTypeNative, + "credit_alphanum4": AssetTypeAssetTypeCreditAlphanum4, + "credit_alphanum12": AssetTypeAssetTypeCreditAlphanum12, +} + // MustNewNativeAsset returns a new native asset, panicking if it can't. func MustNewNativeAsset() Asset { a := Asset{} @@ -39,6 +46,39 @@ func MustNewCreditAsset(code string, issuer string) Asset { return a } +// BuildAsset creates a new asset from a given `assetType`, `code`, and `issuer`. +// +// Valid assetTypes are: +// - `native` +// - `credit_alphanum4` +// - `credit_alphanum12` +func BuildAsset(assetType, issuer, code string) (Asset, error) { + t, ok := StringToAssetType[assetType] + + if !ok { + return Asset{}, errors.New("invalid asset type: was not one of 'native', 'credit_alphanum4', 'credit_alphanum12'") + } + + var asset Asset + switch t { + case AssetTypeAssetTypeNative: + if err := asset.SetNative(); err != nil { + return Asset{}, err + } + default: + issuerAccountID := AccountId{} + if err := issuerAccountID.SetAddress(issuer); err != nil { + return Asset{}, err + } + + if err := asset.SetCredit(code, issuerAccountID); err != nil { + return Asset{}, err + } + } + + return asset, nil +} + // SetCredit overwrites `a` with a credit asset using `code` and `issuer`. The // asset type (CreditAlphanum4 or CreditAlphanum12) is chosen automatically // based upon the length of `code`. diff --git a/xdr/asset_test.go b/xdr/asset_test.go index 1f4c59ce70..2d8f630522 100644 --- a/xdr/asset_test.go +++ b/xdr/asset_test.go @@ -5,8 +5,9 @@ import ( . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" - . "github.com/stellar/go/xdr" "github.com/stretchr/testify/assert" + + . "github.com/stellar/go/xdr" ) var _ = Describe("xdr.Asset#Extract()", func() { @@ -228,3 +229,48 @@ func TestToAllowTrustOpAsset_Error(t *testing.T) { _, err := a.ToAllowTrustOpAsset("") assert.EqualError(t, err, "Asset code length is invalid") } + +func TestBuildAsset(t *testing.T) { + testCases := []struct { + assetType string + code string + issuer string + valid bool + }{ + { + assetType: "native", + valid: true, + }, + { + assetType: "credit_alphanum4", + code: "USD", + issuer: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", + valid: true, + }, + { + assetType: "credit_alphanum12", + code: "SPOOON", + issuer: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", + valid: true, + }, + { + assetType: "invalid", + }, + } + for _, tc := range testCases { + t.Run(tc.assetType, func(t *testing.T) { + asset, err := BuildAsset(tc.assetType, tc.issuer, tc.code) + + if tc.valid { + assert.NoError(t, err) + var assetType, code, issuer string + asset.Extract(&assetType, &code, &issuer) + assert.Equal(t, tc.assetType, assetType) + assert.Equal(t, tc.code, code) + assert.Equal(t, tc.issuer, issuer) + } else { + assert.Error(t, err) + } + }) + } +}