handlers/targets: refactor for new pagination design

Refactor the application layer to use the new domain layer
pagination functionality.
This commit is contained in:
Johan Brandhorst-Satzkorn committed 2024-01-05 07:43:20 -08:00
1 parent eca27b00c9
commit 00c4f825c7
2 files changed
+238 -106

No files matched your search

@@ -29,9 +29,9 @@ import (
"github.com/hashicorp/boundary/internal/host/plugin"
"github.com/hashicorp/boundary/internal/host/static"
"github.com/hashicorp/boundary/internal/kms"
"github.com/hashicorp/boundary/internal/listtoken"
"github.com/hashicorp/boundary/internal/pagination"
"github.com/hashicorp/boundary/internal/perms"
"github.com/hashicorp/boundary/internal/refreshtoken"
"github.com/hashicorp/boundary/internal/requests"
"github.com/hashicorp/boundary/internal/server"
"github.com/hashicorp/boundary/internal/session"
@@ -243,67 +243,122 @@ func (s Service) ListTargets(ctx context.Context, req *pbs.ListTargetsRequest) (
if req.GetPageSize() != 0 && uint(req.GetPageSize()) < s.maxPageSize {
pageSize = int(req.GetPageSize())
}
filter, err := handlers.NewFilter(ctx, req.GetFilter())
if err != nil {
return nil, err
var filterItemFn func(ctx context.Context, item target.Target) (bool, error)
switch {
case req.GetFilter() != "":
// Only use a filter if we need to
filter, err := handlers.NewFilter(ctx, req.GetFilter())
if err != nil {
return nil, err
}
// TODO: replace the need for this function with some way to convert the `filter`
// to a domain type. This would allow filtering to happen in the domain, and we could
// remove this callback altogether.
filterItemFn = func(ctx context.Context, item target.Target) (bool, error) {
pbItem, err := toProto(ctx, item, newOutputOpts(ctx, item, authResults, authzScopes)...)
if err != nil {
return false, err
}
filterable, err := subtypes.Filterable(ctx, pbItem)
if err != nil {
return false, err
}
return filter.Match(filterable), nil
}
default:
filterItemFn = func(ctx context.Context, item target.Target) (bool, error) {
return true, nil
}
}
repo, err := s.repoFn(target.WithPermissions(userPerms))
if err != nil {
return nil, err
}
// TODO: replace the need for this function with some way to convert the `filter`
// to a domain type. This would allow filtering to happen in the domain, and we could
// remove this callback altogether.
filterItemFn := func(ctx context.Context, item target.Target) (bool, error) {
pbItem, err := toProto(ctx, item, newOutputOpts(ctx, item, authResults, authzScopes)...)
if err != nil {
return false, err
}
filterable, err := subtypes.Filterable(ctx, pbItem)
if err != nil {
return false, err
}
return filter.Match(filterable), nil
}
grantsHash, err := authResults.GrantsHash(ctx)
if err != nil {
return nil, err
}
var listResp *pagination.ListResponse[target.Target]
if req.GetRefreshToken() == "" {
var sortBy string
if req.GetListToken() == "" {
sortBy = "created_time"
listResp, err = target.List(ctx, grantsHash, pageSize, filterItemFn, repo)
if err != nil {
return nil, err
}
} else {
rt, err := handlers.ParseRefreshToken(ctx, req.GetRefreshToken())
pt, err := handlers.ParseListToken(ctx, req.GetListToken())
if err != nil {
return nil, err
}
// We're doing the conversion from the protobuf types to the
// domain types here rather than in the domain so that the domain
// doesn't need to know about the protobuf types.
domainRefreshToken, err := refreshtoken.New(
ctx,
rt.CreatedTime.AsTime(),
rt.UpdatedTime.AsTime(),
handlers.RefreshTokenResourceToResource(rt.ResourceType),
rt.GrantsHash,
rt.LastItemId,
rt.LastItemUpdatedTime.AsTime(),
)
if err != nil {
return nil, err
}
if err := domainRefreshToken.Validate(ctx, resource.Target, grantsHash); err != nil {
return nil, err
}
listResp, err = target.ListRefresh(ctx, grantsHash, pageSize, filterItemFn, domainRefreshToken, repo)
if err != nil {
return nil, err
switch st := pt.Token.(type) {
case *pbs.ListToken_PaginationToken:
sortBy = "created_time"
listToken, err := listtoken.NewPagination(
ctx,
pt.CreateTime.AsTime(),
handlers.ListTokenResourceToResource(pt.ResourceType),
pt.GrantsHash,
st.PaginationToken.LastItemId,
st.PaginationToken.LastItemCreateTime.AsTime(),
)
if err != nil {
return nil, err
}
if err := listToken.Validate(ctx, resource.Target, grantsHash); err != nil {
return nil, err
}
listResp, err = target.ListPage(ctx, grantsHash, pageSize, filterItemFn, listToken, repo)
if err != nil {
return nil, err
}
case *pbs.ListToken_StartRefreshToken:
sortBy = "updated_time"
listToken, err := listtoken.NewStartRefresh(
ctx,
pt.CreateTime.AsTime(),
handlers.ListTokenResourceToResource(pt.ResourceType),
pt.GrantsHash,
st.StartRefreshToken.PreviousDeletedIdsTime.AsTime(),
st.StartRefreshToken.PreviousPhaseUpperBound.AsTime(),
)
if err != nil {
return nil, err
}
if err := listToken.Validate(ctx, resource.Target, grantsHash); err != nil {
return nil, err
}
listResp, err = target.ListRefresh(ctx, grantsHash, pageSize, filterItemFn, listToken, repo)
if err != nil {
return nil, err
}
case *pbs.ListToken_RefreshToken:
sortBy = "updated_time"
listToken, err := listtoken.NewRefresh(
ctx,
pt.CreateTime.AsTime(),
handlers.ListTokenResourceToResource(pt.ResourceType),
pt.GrantsHash,
st.RefreshToken.PreviousDeletedIdsTime.AsTime(),
st.RefreshToken.PhaseUpperBound.AsTime(),
st.RefreshToken.PhaseLowerBound.AsTime(),
st.RefreshToken.LastItemId,
st.RefreshToken.LastItemUpdateTime.AsTime(),
)
if err != nil {
return nil, err
}
if err := listToken.Validate(ctx, resource.Target, grantsHash); err != nil {
return nil, err
}
listResp, err = target.ListRefreshPage(ctx, grantsHash, pageSize, filterItemFn, listToken, repo)
if err != nil {
return nil, err
}
default:
return nil, handlers.ApiErrorWithCodeAndMessage(codes.InvalidArgument, "unexpected list token subtype: %T", st)
}
}
@@ -324,22 +379,12 @@ func (s Service) ListTargets(ctx context.Context, req *pbs.ListTargetsRequest) (
EstItemCount: uint32(listResp.EstimatedItemCount),
RemovedIds: listResp.DeletedIds,
ResponseType: respType,
SortBy: "updated_time",
SortDir: "asc",
SortBy: sortBy,
SortDir: "desc",
}
if listResp.RefreshToken != nil {
if listResp.RefreshToken.ResourceType != resource.Target {
return nil, errors.New(ctx, errors.Internal, op, "refresh token resource type does not match service resource type")
}
resp.RefreshToken, err = handlers.MarshalRefreshToken(ctx, &pbs.ListRefreshToken{
CreatedTime: timestamppb.New(listResp.RefreshToken.CreatedTime),
UpdatedTime: timestamppb.New(listResp.RefreshToken.UpdatedTime),
ResourceType: pbs.ResourceType_RESOURCE_TYPE_TARGET,
GrantsHash: listResp.RefreshToken.GrantsHash,
LastItemId: listResp.RefreshToken.LastItemId,
LastItemUpdatedTime: timestamppb.New(listResp.RefreshToken.LastItemUpdatedTime),
})
if listResp.ListToken != nil {
resp.ListToken, err = handlers.MarshalListToken(ctx, listResp.ListToken, pbs.ResourceType_RESOURCE_TYPE_TARGET)
if err != nil {
return nil, err
}
@@ -12,6 +12,7 @@ import (
"errors"
"fmt"
"path"
"slices"
"strings"
"sync/atomic"
"testing"
@@ -46,6 +47,7 @@ import (
"github.com/hashicorp/boundary/internal/session"
"github.com/hashicorp/boundary/internal/target"
"github.com/hashicorp/boundary/internal/target/tcp"
"github.com/hashicorp/boundary/internal/target/tcp/store"
"github.com/hashicorp/boundary/internal/types/scope"
credlibpb "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/credentiallibraries"
credpb "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/credentials"
@@ -258,6 +260,8 @@ func TestList(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn, _ := db.TestSetup(t, "postgres")
sqlDB, err := conn.SqlDB(ctx)
require.NoError(t, err)
wrapper := db.TestWrapper(t)
kms := kms.TestKms(t, conn, wrapper)
@@ -333,6 +337,14 @@ func TestList(t *testing.T) {
})
}
// Run analyze to update postgres estimates
_, err = sqlDB.ExecContext(ctx, "analyze")
require.NoError(t, err)
// Reverse slices since response is ordered by created_time descending (newest first)
slices.Reverse(wantTars)
slices.Reverse(totalTars)
cases := []struct {
name string
req *pbs.ListTargetsRequest
@@ -342,7 +354,13 @@ func TestList(t *testing.T) {
{
name: "List Many Targets",
req: &pbs.ListTargetsRequest{ScopeId: proj.GetPublicId()},
res: &pbs.ListTargetsResponse{Items: wantTars},
res: &pbs.ListTargetsResponse{
Items: wantTars,
ResponseType: "complete",
SortBy: "created_time",
SortDir: "desc",
EstItemCount: 5,
},
},
{
name: "List No Targets",
@@ -352,22 +370,44 @@ func TestList(t *testing.T) {
{
name: "List Targets Recursively",
req: &pbs.ListTargetsRequest{ScopeId: scope.Global.String(), Recursive: true},
res: &pbs.ListTargetsResponse{Items: totalTars},
res: &pbs.ListTargetsResponse{
Items: totalTars,
ResponseType: "complete",
SortBy: "created_time",
SortDir: "desc",
EstItemCount: 10,
},
},
{
name: "Paginate listing",
req: &pbs.ListTargetsRequest{ScopeId: scope.Global.String(), Recursive: true, PageSize: 2},
res: &pbs.ListTargetsResponse{Items: totalTars[:2]},
res: &pbs.ListTargetsResponse{
Items: totalTars[:2],
ResponseType: "delta",
SortBy: "created_time",
SortDir: "desc",
EstItemCount: 10,
},
},
{
name: "Filter To Many Targets",
req: &pbs.ListTargetsRequest{ScopeId: scope.Global.String(), Recursive: true, Filter: fmt.Sprintf(`"/item/scope/id"==%q`, proj.GetPublicId())},
res: &pbs.ListTargetsResponse{Items: wantTars},
res: &pbs.ListTargetsResponse{
Items: wantTars,
ResponseType: "complete",
SortBy: "created_time",
SortDir: "desc",
EstItemCount: 5,
},
},
{
name: "Filter To No Targets",
req: &pbs.ListTargetsRequest{ScopeId: proj.GetPublicId(), Filter: `"/item/id"=="doesnt match"`},
res: &pbs.ListTargetsResponse{},
res: &pbs.ListTargetsResponse{
ResponseType: "complete",
SortBy: "created_time",
SortDir: "desc",
},
},
{
name: "Filter Bad Format",
@@ -434,7 +474,12 @@ func TestList(t *testing.T) {
}
func TestListPagination(t *testing.T) {
t.Parallel()
// Set database read timeout to avoid duplicates in response
oldReadTimeout := globals.RefreshReadLookbackDuration
globals.RefreshReadLookbackDuration = 0
t.Cleanup(func() {
globals.RefreshReadLookbackDuration = oldReadTimeout
})
ctx := context.Background()
conn, _ := db.TestSetup(t, "postgres")
sqlDB, err := conn.SqlDB(ctx)
@@ -486,12 +531,13 @@ func TestListPagination(t *testing.T) {
Address: &wrapperspb.StringValue{},
})
}
// Reverse since we read items in descending order (newest first)
slices.Reverse(allTargets)
// Run analyze to update postgres estimates
_, err = sqlDB.ExecContext(ctx, "analyze")
require.NoError(t, err)
// Test without anon user
requestInfo := authpb.RequestInfo{
TokenFormat: uint32(auth.AuthTokenTypeBearer),
PublicId: at.GetPublicId(),
@@ -502,77 +548,74 @@ func TestListPagination(t *testing.T) {
// Start paginating, recursively
req := &pbs.ListTargetsRequest{
ScopeId: "global",
Recursive: true,
Filter: "",
RefreshToken: "",
PageSize: 2,
ScopeId: "global",
Recursive: true,
Filter: "",
ListToken: "",
PageSize: 2,
}
got, err := s.ListTargets(ctx, req)
require.NoError(t, err)
require.Len(t, got.GetItems(), 2)
// Compare without comparing the refresh token
// Compare without comparing the list token
assert.Empty(t,
cmp.Diff(
got,
&pbs.ListTargetsResponse{
Items: allTargets[0:2],
ResponseType: "delta",
RefreshToken: "",
SortBy: "updated_time",
SortDir: "asc",
SortBy: "created_time",
SortDir: "desc",
RemovedIds: nil,
EstItemCount: 10,
},
protocmp.Transform(),
protocmp.IgnoreFields(&pbs.ListTargetsResponse{}, "refresh_token"),
protocmp.IgnoreFields(&pbs.ListTargetsResponse{}, "list_token"),
),
)
// Request second page
req.RefreshToken = got.RefreshToken
req.ListToken = got.ListToken
got, err = s.ListTargets(ctx, req)
require.NoError(t, err)
require.Len(t, got.GetItems(), 2)
// Compare without comparing the refresh token
// Compare without comparing the list token
assert.Empty(t,
cmp.Diff(
got,
&pbs.ListTargetsResponse{
Items: allTargets[2:4],
ResponseType: "delta",
RefreshToken: "",
SortBy: "updated_time",
SortDir: "asc",
SortBy: "created_time",
SortDir: "desc",
RemovedIds: nil,
EstItemCount: 10,
},
protocmp.Transform(),
protocmp.IgnoreFields(&pbs.ListTargetsResponse{}, "refresh_token"),
protocmp.IgnoreFields(&pbs.ListTargetsResponse{}, "list_token"),
),
)
// Request rest of results
req.RefreshToken = got.RefreshToken
req.ListToken = got.ListToken
req.PageSize = 10
got, err = s.ListTargets(ctx, req)
require.NoError(t, err)
require.Len(t, got.GetItems(), 6)
// Compare without comparing the refresh token
// Compare without comparing the list token
assert.Empty(t,
cmp.Diff(
got,
&pbs.ListTargetsResponse{
Items: allTargets[4:],
ResponseType: "complete",
RefreshToken: "",
SortBy: "updated_time",
SortDir: "asc",
SortBy: "created_time",
SortDir: "desc",
RemovedIds: nil,
EstItemCount: 10,
},
protocmp.Transform(),
protocmp.IgnoreFields(&pbs.ListTargetsResponse{}, "refresh_token"),
protocmp.IgnoreFields(&pbs.ListTargetsResponse{}, "list_token"),
),
)
@@ -593,45 +636,91 @@ func TestListPagination(t *testing.T) {
AuthorizedActions: testAuthorizedActions,
Address: &wrapperspb.StringValue{},
}
allTargets = append(allTargets, newTarget)
// Add to the front since it's most recently updated
allTargets = append([]*pb.Target{newTarget}, allTargets...)
// Delete one of the other targets
_, err = repo.DeleteTarget(ctx, allTargets[0].Id)
_, err = repo.DeleteTarget(ctx, allTargets[len(allTargets)-1].Id)
require.NoError(t, err)
deletedTarget := allTargets[0]
allTargets = allTargets[1:]
deletedTarget := allTargets[len(allTargets)-1]
allTargets = allTargets[:len(allTargets)-1]
// Update one of the other targets
allTargets[1].Name = wrapperspb.String("new-name")
allTargets[1].Version = 2
updatedTarget := &tcp.Target{
Target: &store.Target{
PublicId: allTargets[1].Id,
Name: allTargets[1].Name.GetValue(),
ProjectId: allTargets[1].ScopeId,
},
}
tg, _, err := repo.UpdateTarget(ctx, updatedTarget, 1, []string{"name"})
require.NoError(t, err)
allTargets[1].UpdatedTime = tg.GetUpdateTime().GetTimestamp()
allTargets[1].Version = tg.GetVersion()
// Add to the front since it's most recently updated
allTargets = append(
[]*pb.Target{allTargets[1]},
append(
[]*pb.Target{allTargets[0]},
allTargets[2:]...,
)...,
)
// Run analyze to update postgres estimates
_, err = sqlDB.ExecContext(ctx, "analyze")
require.NoError(t, err)
// Request updated results
req.RefreshToken = got.RefreshToken
req.ListToken = got.ListToken
req.PageSize = 1
got, err = s.ListTargets(ctx, req)
require.NoError(t, err)
require.Len(t, got.GetItems(), 1)
// Compare without comparing the refresh token
// Compare without comparing the list token
assert.Empty(t,
cmp.Diff(
got,
&pbs.ListTargetsResponse{
Items: allTargets[len(allTargets)-1:],
ResponseType: "complete",
RefreshToken: "",
Items: []*pb.Target{allTargets[0]},
ResponseType: "delta",
SortBy: "updated_time",
SortDir: "asc",
SortDir: "desc",
// Should contain the deleted target
RemovedIds: []string{deletedTarget.Id},
EstItemCount: 10,
},
protocmp.Transform(),
protocmp.IgnoreFields(&pbs.ListTargetsResponse{}, "refresh_token"),
protocmp.IgnoreFields(&pbs.ListTargetsResponse{}, "list_token"),
),
)
// Get next page
req.ListToken = got.ListToken
got, err = s.ListTargets(ctx, req)
require.NoError(t, err)
require.Len(t, got.GetItems(), 1)
// Compare without comparing the list token
assert.Empty(t,
cmp.Diff(
got,
&pbs.ListTargetsResponse{
Items: []*pb.Target{allTargets[1]},
ResponseType: "complete",
SortBy: "updated_time",
SortDir: "desc",
RemovedIds: nil,
EstItemCount: 10,
},
protocmp.Transform(),
protocmp.IgnoreFields(&pbs.ListTargetsResponse{}, "list_token"),
),
)
// Request new page with filter requiring looping
// to fill the page.
req.RefreshToken = ""
req.ListToken = ""
req.PageSize = 1
req.Filter = fmt.Sprintf(`"/item/id"==%q or "/item/id"==%q`, allTargets[len(allTargets)-2].Id, allTargets[len(allTargets)-1].Id)
got, err = s.ListTargets(ctx, req)
@@ -643,18 +732,17 @@ func TestListPagination(t *testing.T) {
&pbs.ListTargetsResponse{
Items: []*pb.Target{allTargets[len(allTargets)-2]},
ResponseType: "delta",
RefreshToken: "",
SortBy: "updated_time",
SortDir: "asc",
SortBy: "created_time",
SortDir: "desc",
// Should be empty again
RemovedIds: nil,
EstItemCount: 10,
},
protocmp.Transform(),
protocmp.IgnoreFields(&pbs.ListTargetsResponse{}, "refresh_token"),
protocmp.IgnoreFields(&pbs.ListTargetsResponse{}, "list_token"),
),
)
req.RefreshToken = got.RefreshToken
req.ListToken = got.ListToken
// Get the second page
got, err = s.ListTargets(ctx, req)
require.NoError(t, err)
@@ -665,14 +753,13 @@ func TestListPagination(t *testing.T) {
&pbs.ListTargetsResponse{
Items: []*pb.Target{allTargets[len(allTargets)-1]},
ResponseType: "complete",
RefreshToken: "",
SortBy: "updated_time",
SortDir: "asc",
SortBy: "created_time",
SortDir: "desc",
RemovedIds: nil,
EstItemCount: 10,
},
protocmp.Transform(),
protocmp.IgnoreFields(&pbs.ListTargetsResponse{}, "refresh_token"),
protocmp.IgnoreFields(&pbs.ListTargetsResponse{}, "list_token"),
),
)
}