diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index d04ebed107..e42352d605 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -5515,6 +5515,113 @@ paths: description: Success default: description: "" + /namespaces/{ns}/tokens/accounts/{key}: + get: + description: 'TODO: Description' + operationId: getTokenAccountPools + parameters: + - description: 'TODO: Description' + in: path + name: ns + required: true + schema: + example: default + type: string + - description: 'TODO: Description' + in: path + name: key + required: true + schema: + type: string + - description: Server-side request timeout (millseconds, or set a custom suffix + like 10s) + in: header + name: Request-Timeout + schema: + default: 120s + type: string + - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' + in: query + name: balance + schema: + type: string + - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' + in: query + name: connector + schema: + type: string + - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' + in: query + name: key + schema: + type: string + - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' + in: query + name: namespace + schema: + type: string + - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' + in: query + name: pool + schema: + type: string + - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' + in: query + name: tokenindex + schema: + type: string + - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' + in: query + name: updated + schema: + type: string + - description: Sort field. For multi-field sort use comma separated values (or + multiple query values) with '-' prefix for descending + in: query + name: sort + schema: + type: string + - description: Ascending sort order (overrides all fields in a multi-field sort) + in: query + name: ascending + schema: + type: string + - description: Descending sort order (overrides all fields in a multi-field + sort) + in: query + name: descending + schema: + type: string + - description: 'The number of records to skip (max: 1,000). Unsuitable for bulk + operations' + in: query + name: skip + schema: + type: string + - description: 'The maximum number of records to return (max: 1,000)' + in: query + name: limit + schema: + example: "25" + type: string + - description: Return a total count as well as items (adds extra database processing) + in: query + name: count + schema: + type: string + responses: + "200": + content: + application/json: + schema: + items: + properties: + pool: {} + type: object + type: array + description: Success + default: + description: "" /namespaces/{ns}/tokens/balances: get: description: 'TODO: Description' diff --git a/internal/apiserver/route_get_token_account_pools.go b/internal/apiserver/route_get_token_account_pools.go new file mode 100644 index 0000000000..4df72e6fe6 --- /dev/null +++ b/internal/apiserver/route_get_token_account_pools.go @@ -0,0 +1,46 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package apiserver + +import ( + "net/http" + + "github.com/hyperledger/firefly/internal/config" + "github.com/hyperledger/firefly/internal/i18n" + "github.com/hyperledger/firefly/internal/oapispec" + "github.com/hyperledger/firefly/pkg/database" + "github.com/hyperledger/firefly/pkg/fftypes" +) + +var getTokenAccountPools = &oapispec.Route{ + Name: "getTokenAccountPools", + Path: "namespaces/{ns}/tokens/accounts/{key}", + Method: http.MethodGet, + PathParams: []*oapispec.PathParam{ + {Name: "ns", ExampleFromConf: config.NamespacesDefault, Description: i18n.MsgTBD}, + {Name: "key", Description: i18n.MsgTBD}, + }, + QueryParams: nil, + FilterFactory: database.TokenBalanceQueryFactory, + Description: i18n.MsgTBD, + JSONInputValue: nil, + JSONOutputValue: func() interface{} { return []*fftypes.TokenAccountPool{} }, + JSONOutputCodes: []int{http.StatusOK}, + JSONHandler: func(r *oapispec.APIRequest) (output interface{}, err error) { + return filterResult(r.Or.Assets().GetTokenAccountPools(r.Ctx, r.PP["ns"], r.PP["key"], r.Filter)) + }, +} diff --git a/internal/apiserver/route_get_token_account_pools_test.go b/internal/apiserver/route_get_token_account_pools_test.go new file mode 100644 index 0000000000..e77e693c18 --- /dev/null +++ b/internal/apiserver/route_get_token_account_pools_test.go @@ -0,0 +1,42 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package apiserver + +import ( + "net/http/httptest" + "testing" + + "github.com/hyperledger/firefly/mocks/assetmocks" + "github.com/hyperledger/firefly/pkg/fftypes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestGetTokenAccountPools(t *testing.T) { + o, r := newTestAPIServer() + mam := &assetmocks.Manager{} + o.On("Assets").Return(mam) + req := httptest.NewRequest("GET", "/api/v1/namespaces/ns1/tokens/accounts/0x1", nil) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + res := httptest.NewRecorder() + + mam.On("GetTokenAccountPools", mock.Anything, "ns1", "0x1", mock.Anything). + Return([]*fftypes.TokenAccountPool{}, nil, nil) + r.ServeHTTP(res, req) + + assert.Equal(t, 200, res.Result().StatusCode) +} diff --git a/internal/apiserver/routes.go b/internal/apiserver/routes.go index 80314345c9..eec6498dda 100644 --- a/internal/apiserver/routes.go +++ b/internal/apiserver/routes.go @@ -86,6 +86,7 @@ var routes = []*oapispec.Route{ getTokenBalances, getTokenAccounts, getTokenAccountsByPool, + getTokenAccountPools, getTokenTransfers, getTokenTransfersByPool, getTokenTransferByID, diff --git a/internal/assets/manager.go b/internal/assets/manager.go index bd77caca33..b5a6a4e9fa 100644 --- a/internal/assets/manager.go +++ b/internal/assets/manager.go @@ -43,6 +43,7 @@ type Manager interface { GetTokenBalances(ctx context.Context, ns string, filter database.AndFilter) ([]*fftypes.TokenBalance, *database.FilterResult, error) GetTokenAccounts(ctx context.Context, ns string, filter database.AndFilter) ([]*fftypes.TokenAccount, *database.FilterResult, error) + GetTokenAccountPools(ctx context.Context, ns, key string, filter database.AndFilter) ([]*fftypes.TokenAccountPool, *database.FilterResult, error) GetTokenTransfers(ctx context.Context, ns string, filter database.AndFilter) ([]*fftypes.TokenTransfer, *database.FilterResult, error) GetTokenTransferByID(ctx context.Context, ns, id string) (*fftypes.TokenTransfer, error) @@ -135,6 +136,10 @@ func (am *assetManager) GetTokenAccounts(ctx context.Context, ns string, filter return am.database.GetTokenAccounts(ctx, am.scopeNS(ns, filter)) } +func (am *assetManager) GetTokenAccountPools(ctx context.Context, ns, key string, filter database.AndFilter) ([]*fftypes.TokenAccountPool, *database.FilterResult, error) { + return am.database.GetTokenAccountPools(ctx, key, am.scopeNS(ns, filter)) +} + func (am *assetManager) GetTokenConnectors(ctx context.Context, ns string) ([]*fftypes.TokenConnector, error) { if err := fftypes.ValidateFFNameField(ctx, ns, "namespace"); err != nil { return nil, err diff --git a/internal/assets/manager_test.go b/internal/assets/manager_test.go index 0dc79c0d58..2dde3dc10f 100644 --- a/internal/assets/manager_test.go +++ b/internal/assets/manager_test.go @@ -119,6 +119,18 @@ func TestGetTokenAccounts(t *testing.T) { assert.NoError(t, err) } +func TestGetTokenAccountPools(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + + mdi := am.database.(*databasemocks.Plugin) + fb := database.TokenBalanceQueryFactory.NewFilter(context.Background()) + f := fb.And() + mdi.On("GetTokenAccountPools", context.Background(), "0x1", f).Return([]*fftypes.TokenAccountPool{}, nil, nil) + _, _, err := am.GetTokenAccountPools(context.Background(), "ns1", "0x1", f) + assert.NoError(t, err) +} + func TestGetTokenConnectors(t *testing.T) { am, cancel := newTestAssets(t) defer cancel() diff --git a/internal/database/sqlcommon/tokenbalance_sql.go b/internal/database/sqlcommon/tokenbalance_sql.go index 57a66d22fe..b9cd08132d 100644 --- a/internal/database/sqlcommon/tokenbalance_sql.go +++ b/internal/database/sqlcommon/tokenbalance_sql.go @@ -193,7 +193,9 @@ func (s *SQLCommon) GetTokenBalances(ctx context.Context, filter database.Filter } func (s *SQLCommon) GetTokenAccounts(ctx context.Context, filter database.Filter) ([]*fftypes.TokenAccount, *database.FilterResult, error) { - query, fop, fi, err := s.filterSelect(ctx, "", sq.Select("key").Distinct().From("tokenbalance"), filter, tokenBalanceFilterFieldMap, []interface{}{"seq"}) + query, fop, fi, err := s.filterSelect(ctx, "", + sq.Select("key").Distinct().From("tokenbalance"), + filter, tokenBalanceFilterFieldMap, []interface{}{"seq"}) if err != nil { return nil, nil, err } @@ -207,8 +209,7 @@ func (s *SQLCommon) GetTokenAccounts(ctx context.Context, filter database.Filter var accounts []*fftypes.TokenAccount for rows.Next() { var account fftypes.TokenAccount - err := rows.Scan(&account.Key) - if err != nil { + if err := rows.Scan(&account.Key); err != nil { return nil, nil, i18n.WrapError(ctx, err, i18n.MsgDBReadErr, "tokenbalance") } accounts = append(accounts, &account) @@ -216,3 +217,29 @@ func (s *SQLCommon) GetTokenAccounts(ctx context.Context, filter database.Filter return accounts, s.queryRes(ctx, tx, "tokenbalance", fop, fi), err } + +func (s *SQLCommon) GetTokenAccountPools(ctx context.Context, key string, filter database.Filter) ([]*fftypes.TokenAccountPool, *database.FilterResult, error) { + query, fop, fi, err := s.filterSelect(ctx, "", + sq.Select("pool_id").Distinct().From("tokenbalance").Where(sq.Eq{"key": key}), + filter, tokenBalanceFilterFieldMap, []interface{}{"seq"}) + if err != nil { + return nil, nil, err + } + + rows, tx, err := s.query(ctx, query) + if err != nil { + return nil, nil, err + } + defer rows.Close() + + var pools []*fftypes.TokenAccountPool + for rows.Next() { + var pool fftypes.TokenAccountPool + if err := rows.Scan(&pool.Pool); err != nil { + return nil, nil, i18n.WrapError(ctx, err, i18n.MsgDBReadErr, "tokenbalance") + } + pools = append(pools, &pool) + } + + return pools, s.queryRes(ctx, tx, "tokenbalance", fop, fi), err +} diff --git a/internal/database/sqlcommon/tokenbalance_sql_test.go b/internal/database/sqlcommon/tokenbalance_sql_test.go index 1d9a04f1bd..7c56f315ac 100644 --- a/internal/database/sqlcommon/tokenbalance_sql_test.go +++ b/internal/database/sqlcommon/tokenbalance_sql_test.go @@ -112,13 +112,21 @@ func TestTokenBalanceE2EWithDB(t *testing.T) { assert.Equal(t, string(balanceJson), string(balanceReadJson)) // Query the list of unique accounts - fb2 := database.TokenBalanceQueryFactory.NewFilter(ctx) - accounts, fr, err := s.GetTokenAccounts(ctx, fb2.And().Count(true)) + accounts, _, err := s.GetTokenAccounts(ctx, fb.And()) assert.NoError(t, err) - assert.Equal(t, int64(2), *fr.TotalCount) assert.Equal(t, 2, len(accounts)) assert.Equal(t, "0x1", accounts[0].Key) assert.Equal(t, "0x0", accounts[1].Key) + + // Query the pools for each account + pools, _, err := s.GetTokenAccountPools(ctx, "0x0", fb.And()) + assert.NoError(t, err) + assert.Equal(t, 1, len(pools)) + assert.Equal(t, *transfer.Pool, *pools[0].Pool) + pools, _, err = s.GetTokenAccountPools(ctx, "0x1", fb.And()) + assert.NoError(t, err) + assert.Equal(t, 1, len(pools)) + assert.Equal(t, *transfer.Pool, *pools[0].Pool) } func TestUpdateTokenBalancesFailBegin(t *testing.T) { @@ -248,3 +256,28 @@ func TestGetTokenAccountsScanFail(t *testing.T) { assert.Regexp(t, "FF10121", err) assert.NoError(t, mock.ExpectationsWereMet()) } + +func TestGetTokenAccountPoolsQueryFail(t *testing.T) { + s, mock := newMockProvider().init() + mock.ExpectQuery("SELECT .*").WillReturnError(fmt.Errorf("pop")) + f := database.TokenBalanceQueryFactory.NewFilter(context.Background()).And() + _, _, err := s.GetTokenAccountPools(context.Background(), "0x1", f) + assert.Regexp(t, "FF10115", err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestGetTokenAccountPoolsBuildQueryFail(t *testing.T) { + s, _ := newMockProvider().init() + f := database.TokenBalanceQueryFactory.NewFilter(context.Background()).Eq("pool", map[bool]bool{true: false}) + _, _, err := s.GetTokenAccountPools(context.Background(), "0x1", f) + assert.Regexp(t, "FF10149.*pool", err) +} + +func TestGetTokenAccountPoolsScanFail(t *testing.T) { + s, mock := newMockProvider().init() + mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{"key", "bad"}).AddRow("too many", "columns")) + f := database.TokenBalanceQueryFactory.NewFilter(context.Background()).And() + _, _, err := s.GetTokenAccountPools(context.Background(), "0x1", f) + assert.Regexp(t, "FF10121", err) + assert.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/mocks/assetmocks/manager.go b/mocks/assetmocks/manager.go index bf358a919f..845323844d 100644 --- a/mocks/assetmocks/manager.go +++ b/mocks/assetmocks/manager.go @@ -112,6 +112,38 @@ func (_m *Manager) CreateTokenPoolByType(ctx context.Context, ns string, connect return r0, r1 } +// GetTokenAccountPools provides a mock function with given fields: ctx, ns, key, filter +func (_m *Manager) GetTokenAccountPools(ctx context.Context, ns string, key string, filter database.AndFilter) ([]*fftypes.TokenAccountPool, *database.FilterResult, error) { + ret := _m.Called(ctx, ns, key, filter) + + var r0 []*fftypes.TokenAccountPool + if rf, ok := ret.Get(0).(func(context.Context, string, string, database.AndFilter) []*fftypes.TokenAccountPool); ok { + r0 = rf(ctx, ns, key, filter) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*fftypes.TokenAccountPool) + } + } + + var r1 *database.FilterResult + if rf, ok := ret.Get(1).(func(context.Context, string, string, database.AndFilter) *database.FilterResult); ok { + r1 = rf(ctx, ns, key, filter) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*database.FilterResult) + } + } + + var r2 error + if rf, ok := ret.Get(2).(func(context.Context, string, string, database.AndFilter) error); ok { + r2 = rf(ctx, ns, key, filter) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + // GetTokenAccounts provides a mock function with given fields: ctx, ns, filter func (_m *Manager) GetTokenAccounts(ctx context.Context, ns string, filter database.AndFilter) ([]*fftypes.TokenAccount, *database.FilterResult, error) { ret := _m.Called(ctx, ns, filter) diff --git a/mocks/databasemocks/plugin.go b/mocks/databasemocks/plugin.go index 0432770325..ab23f31801 100644 --- a/mocks/databasemocks/plugin.go +++ b/mocks/databasemocks/plugin.go @@ -1293,6 +1293,38 @@ func (_m *Plugin) GetSubscriptions(ctx context.Context, filter database.Filter) return r0, r1, r2 } +// GetTokenAccountPools provides a mock function with given fields: ctx, key, filter +func (_m *Plugin) GetTokenAccountPools(ctx context.Context, key string, filter database.Filter) ([]*fftypes.TokenAccountPool, *database.FilterResult, error) { + ret := _m.Called(ctx, key, filter) + + var r0 []*fftypes.TokenAccountPool + if rf, ok := ret.Get(0).(func(context.Context, string, database.Filter) []*fftypes.TokenAccountPool); ok { + r0 = rf(ctx, key, filter) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*fftypes.TokenAccountPool) + } + } + + var r1 *database.FilterResult + if rf, ok := ret.Get(1).(func(context.Context, string, database.Filter) *database.FilterResult); ok { + r1 = rf(ctx, key, filter) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*database.FilterResult) + } + } + + var r2 error + if rf, ok := ret.Get(2).(func(context.Context, string, database.Filter) error); ok { + r2 = rf(ctx, key, filter) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + // GetTokenAccounts provides a mock function with given fields: ctx, filter func (_m *Plugin) GetTokenAccounts(ctx context.Context, filter database.Filter) ([]*fftypes.TokenAccount, *database.FilterResult, error) { ret := _m.Called(ctx, filter) diff --git a/pkg/database/plugin.go b/pkg/database/plugin.go index 123dd5a922..7c96f9465d 100644 --- a/pkg/database/plugin.go +++ b/pkg/database/plugin.go @@ -375,6 +375,9 @@ type iTokenBalanceCollection interface { // GetTokenAccounts - Get token accounts (all distinct addresses that have a balance) GetTokenAccounts(ctx context.Context, filter Filter) ([]*fftypes.TokenAccount, *FilterResult, error) + + // GetTokenAccountPools - Get the list of pools referenced by a given account + GetTokenAccountPools(ctx context.Context, key string, filter Filter) ([]*fftypes.TokenAccountPool, *FilterResult, error) } type iTokenTransferCollection interface { diff --git a/pkg/fftypes/tokenbalance.go b/pkg/fftypes/tokenbalance.go index b6dd549ab6..d3f997c230 100644 --- a/pkg/fftypes/tokenbalance.go +++ b/pkg/fftypes/tokenbalance.go @@ -34,8 +34,11 @@ func (t *TokenBalance) Identifier() string { return TokenBalanceIdentifier(t.Pool, t.TokenIndex, t.Key) } -// Currently this type is just a filtered view of TokenBalance. -// If more fields/aggregation become needed, this may need its own table in the database. +// Currently these types are just filtered views of TokenBalance. +// If more fields/aggregation become needed, they might merit a new table in the database. type TokenAccount struct { Key string `json:"key,omitempty"` } +type TokenAccountPool struct { + Pool *UUID `json:"pool,omitempty"` +}