From 62201c506d177f81aeecdb42287c0cde21e0987e Mon Sep 17 00:00:00 2001 From: Chinmay Kulkarni Date: Sat, 17 Oct 2026 17:14:05 +0000 Subject: [PATCH] Follow next_page_token in SearchEntitlements The request catalog search-entitlements endpoint is starting to return paginated results. SearchEntitlements issued a single request with page_size 100 and ignored next_page_token, so a client talking to a paginated server would silently truncate results to the first page. Loop on next_page_token, passing it back on each request until it is empty, and accumulate the results. Expanded objects are referenced by index into each response's own expanded array, so each page is resolved against its own expanded objects before the results are appended. Context cancellation is checked between pages. Co-authored-by: c1-squire-dev[bot] Co-Authored-By: Claude Fable 5.1 --- pkg/client/entitlement.go | 75 ++++++++++++++++-------- pkg/client/entitlement_test.go | 102 +++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 25 deletions(-) create mode 100644 pkg/client/entitlement_test.go diff --git a/pkg/client/entitlement.go b/pkg/client/entitlement.go index a23c63e2..1f08ac72 100644 --- a/pkg/client/entitlement.go +++ b/pkg/client/entitlement.go @@ -98,35 +98,61 @@ func (e *ExpandableEntitlementWithBindings) SetPath(pathname string, value int) } func (c *client) SearchEntitlements(ctx context.Context, filter *SearchEntitlementsFilter) ([]*EntitlementWithBindings, error) { - // TODO(morgabra) Pagination // TODO(morgabra) Should we abstract the OpenAPI objects from the rest of cone? Kinda... no? But they aren't typed... - req := shared.RequestCatalogSearchServiceSearchEntitlementsRequest{ - EntitlementAlias: stringPtr(filter.EntitlementAlias), - GrantedStatus: filter.GrantedStatus.ToPointer(), - PageSize: intPtr(100), - PageToken: nil, - Query: stringPtr(filter.Query), - AppDisplayName: stringPtr(filter.AppDisplayName), - IncludeDeleted: &filter.IncludeDeleted, - ExpandMask: &filter.AppEntitlementExpandMask, - } - resp, err := c.sdk.RequestCatalogSearch.SearchEntitlements(ctx, &req) - if err != nil { - return nil, err - } + var rv []*EntitlementWithBindings + var pageToken *string + for page := 0; ; page++ { + if err := ctx.Err(); err != nil { + return nil, err + } - if err := NewHTTPError(resp.RawResponse); err != nil { - return nil, err - } + req := shared.RequestCatalogSearchServiceSearchEntitlementsRequest{ + EntitlementAlias: stringPtr(filter.EntitlementAlias), + GrantedStatus: filter.GrantedStatus.ToPointer(), + PageSize: intPtr(100), + PageToken: pageToken, + Query: stringPtr(filter.Query), + AppDisplayName: stringPtr(filter.AppDisplayName), + IncludeDeleted: &filter.IncludeDeleted, + ExpandMask: &filter.AppEntitlementExpandMask, + } + resp, err := c.sdk.RequestCatalogSearch.SearchEntitlements(ctx, &req) + if err != nil { + return nil, err + } - list := resp.RequestCatalogSearchServiceSearchEntitlementsResponse.List - if list == nil { - return nil, errors.New("search-entitlements: list is nil") + if err := NewHTTPError(resp.RawResponse); err != nil { + return nil, err + } + + body := resp.RequestCatalogSearchServiceSearchEntitlementsResponse + if body == nil || (body.List == nil && page == 0) { + return nil, errors.New("search-entitlements: list is nil") + } + + // Expanded paths (e.g. "$.expanded[0]") are indexes into this response's own + // Expanded slice, so each page is resolved against its own expanded objects + // before the results are accumulated. + pageResults, err := convertSearchEntitlementsPage(body) + if err != nil { + return nil, err + } + rv = append(rv, pageResults...) + + if body.NextPageToken == nil || *body.NextPageToken == "" { + break + } + pageToken = body.NextPageToken } + return rv, nil +} + +func convertSearchEntitlementsPage(body *shared.RequestCatalogSearchServiceSearchEntitlementsResponse) ([]*EntitlementWithBindings, error) { + list := body.List // Unmarshal the expanded fields - expanded := make([]any, 0, len(resp.RequestCatalogSearchServiceSearchEntitlementsResponse.Expanded)) - for _, x := range resp.RequestCatalogSearchServiceSearchEntitlementsResponse.Expanded { + expanded := make([]any, 0, len(body.Expanded)) + for _, x := range body.Expanded { x := x converted, err := UnmarshalAnyType[shared.RequestCatalogSearchServiceSearchEntitlementsResponseExpanded](&x) if err != nil { @@ -147,10 +173,9 @@ func (c *client) SearchEntitlements(ctx context.Context, filter *SearchEntitleme } // Populate the expandable objects with the indexes of related objects - err = ExpandableReponse[*ExpandableEntitlementWithBindings]{ + err := ExpandableReponse[*ExpandableEntitlementWithBindings]{ List: expandableList, }.PopulateExpandedIndexes() - if err != nil { return nil, err } diff --git a/pkg/client/entitlement_test.go b/pkg/client/entitlement_test.go new file mode 100644 index 00000000..1e988e4f --- /dev/null +++ b/pkg/client/entitlement_test.go @@ -0,0 +1,102 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/conductorone/conductorone-sdk-go/pkg/models/shared" +) + +// searchPage builds a search response page whose single entitlement points at +// its own expanded[0] app, mirroring the server's per-response indexing. +func searchPage(entID, appID, nextToken string) map[string]any { + page := map[string]any{ + "list": []any{ + map[string]any{ + "entitlement": map[string]any{ + "appEntitlement": map[string]any{"id": entID, "appId": appID}, + "appPath": "$.expanded[0]", + }, + }, + }, + "expanded": []any{ + map[string]any{"@type": atTypeApp, "id": appID, "displayName": "App " + appID}, + }, + } + if nextToken != "" { + page["nextPageToken"] = nextToken + } + return page +} + +func TestSearchEntitlementsFollowsPageToken(t *testing.T) { + var tokens []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req shared.RequestCatalogSearchServiceSearchEntitlementsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("Decode() unexpected error: %v", err) + } + if req.PageSize == nil || *req.PageSize != 100 { + t.Errorf("page size = %v, want 100", req.PageSize) + } + tokens = append(tokens, StringFromPtr(req.PageToken)) + + var page map[string]any + switch StringFromPtr(req.PageToken) { + case "": + page = searchPage("e1", "a1", "t1") + case "t1": + page = searchPage("e2", "a2", "t2") + case "t2": + page = searchPage("e3", "a3", "") + default: + t.Errorf("unexpected page token %q", StringFromPtr(req.PageToken)) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(page) + })) + defer server.Close() + + c := newPaperSecretTestClient(server.URL, server.Client()) + got, err := c.SearchEntitlements(context.Background(), &SearchEntitlementsFilter{}) + if err != nil { + t.Fatalf("SearchEntitlements() unexpected error: %v", err) + } + + if want := []string{"", "t1", "t2"}; fmt.Sprint(tokens) != fmt.Sprint(want) { + t.Fatalf("page tokens = %v, want %v", tokens, want) + } + if len(got) != 3 { + t.Fatalf("got %d entitlements, want 3", len(got)) + } + for i, id := range []string{"1", "2", "3"} { + if gotID := StringFromPtr(got[i].Entitlement.ID); gotID != "e"+id { + t.Errorf("entitlement[%d] id = %q, want e%s", i, gotID, id) + } + // Every page uses expanded[0]; each entitlement must resolve to its own page's app. + app := GetExpanded[shared.App](got[i], ExpandedApp) + if app == nil || StringFromPtr(app.ID) != "a"+id { + t.Errorf("entitlement[%d] expanded app = %v, want a%s", i, app, id) + } + } +} + +func TestSearchEntitlementsHonorsContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // Always advertise another page; cancel after the first response. + cancel() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(searchPage("e", "a", "next")) + })) + defer server.Close() + + c := newPaperSecretTestClient(server.URL, server.Client()) + if _, err := c.SearchEntitlements(ctx, &SearchEntitlementsFilter{}); err == nil { + t.Fatal("SearchEntitlements() error = nil, want context error") + } +}