Skip to content

Commit 6430701

Browse files
committed
client: add pagination with --all flag (PRINFRA-125)
1 parent 3916f75 commit 6430701

16 files changed

Lines changed: 717 additions & 14 deletions

File tree

cmd/heygen/builder.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ package main
22

33
import (
44
"encoding/json"
5+
"errors"
56
"fmt"
67
"io"
78
"os"
89
"strconv"
910
"strings"
1011

12+
"github.com/heygen-com/heygen-cli/internal/client"
1113
"github.com/heygen-com/heygen-cli/internal/command"
1214
clierrors "github.com/heygen-com/heygen-cli/internal/errors"
1315
"github.com/spf13/cobra"
@@ -41,6 +43,33 @@ func buildCobraCommand(spec *command.Spec, ctx *cmdContext) *cobra.Command {
4143
return err
4244
}
4345

46+
if spec.Paginated {
47+
allPages, _ := cmd.Flags().GetBool("all")
48+
cursorFlagName := cursorFlagForSpec(spec)
49+
cursorSet := cursorFlagName != "" && cmd.Flags().Changed(cursorFlagName)
50+
51+
if allPages && cursorSet {
52+
return clierrors.NewUsage(fmt.Sprintf("--all and --%s are mutually exclusive", cursorFlagName))
53+
}
54+
55+
if allPages {
56+
result, err := ctx.client.ExecuteAll(spec, inv)
57+
if err != nil {
58+
var truncErr *client.ErrPaginationTruncated
59+
if errors.As(err, &truncErr) {
60+
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %s\n", truncErr.Error())
61+
if fmtErr := ctx.formatter.Data(truncErr.Data); fmtErr != nil {
62+
return fmtErr
63+
}
64+
return clierrors.New(truncErr.Error())
65+
}
66+
return err
67+
}
68+
69+
return ctx.formatter.Data(result)
70+
}
71+
}
72+
4473
result, err := ctx.client.Execute(spec, inv)
4574
if err != nil {
4675
return err
@@ -55,6 +84,10 @@ func buildCobraCommand(spec *command.Spec, ctx *cmdContext) *cobra.Command {
5584
registerFlag(cmd, flag)
5685
}
5786

87+
if spec.Paginated {
88+
cmd.Flags().Bool("all", false, "Fetch all pages (returns flat JSON array instead of API envelope)")
89+
}
90+
5891
// Add -d/--data for commands with JSON request bodies
5992
if spec.BodyEncoding == "json" {
6093
cmd.Flags().StringVarP(&rawData, "data", "d", "",
@@ -86,6 +119,15 @@ func buildUseLine(spec *command.Spec) string {
86119
return strings.Join(parts, " ")
87120
}
88121

122+
func cursorFlagForSpec(spec *command.Spec) string {
123+
for _, flag := range spec.Flags {
124+
if flag.JSONName == spec.TokenParam {
125+
return flag.Name
126+
}
127+
}
128+
return ""
129+
}
130+
89131
// registerFlag adds a typed flag to the Cobra command based on the FlagSpec.
90132
func registerFlag(cmd *cobra.Command, flag command.FlagSpec) {
91133
helpText := flag.Help

cmd/heygen/builder_test.go

Lines changed: 193 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"encoding/json"
66
"errors"
7+
"fmt"
78
"io"
89
"net/http"
910
"os"
@@ -18,13 +19,14 @@ import (
1819
// videoListSpec mirrors the hand-written video list command as a Spec,
1920
// proving the generic builder produces identical behavior.
2021
var videoListSpec = &command.Spec{
21-
Group: "video",
22-
Name: "list",
23-
Summary: "List videos",
24-
Endpoint: "/v3/videos",
25-
Method: "GET",
26-
// TokenField/DataField for pagination (used by paginator, not tested here)
22+
Group: "video",
23+
Name: "list",
24+
Summary: "List videos",
25+
Endpoint: "/v3/videos",
26+
Method: "GET",
27+
Paginated: true,
2728
TokenField: "next_token",
29+
TokenParam: "token",
2830
DataField: "data",
2931
Flags: []command.FlagSpec{
3032
{Name: "limit", Type: "int", Source: "query", JSONName: "limit"},
@@ -93,6 +95,174 @@ func TestGenBuilder_VideoList_Flags(t *testing.T) {
9395
}
9496
}
9597

98+
func TestGenBuilder_VideoList_AllPages(t *testing.T) {
99+
var calls int
100+
srv := setupTestServer(t, map[string]testHandler{
101+
"GET /v3/videos": {
102+
StatusCode: 200,
103+
ValidateRequest: func(t *testing.T, r *http.Request) {
104+
t.Helper()
105+
calls++
106+
switch calls {
107+
case 1:
108+
if got := r.URL.Query().Get("token"); got != "" {
109+
t.Fatalf("first page token = %q, want empty", got)
110+
}
111+
case 2:
112+
if got := r.URL.Query().Get("token"); got != "cursor_2" {
113+
t.Fatalf("second page token = %q, want %q", got, "cursor_2")
114+
}
115+
default:
116+
t.Fatalf("unexpected request count %d", calls)
117+
}
118+
},
119+
Body: `{"data":[{"id":"v1"},{"id":"v2"}],"next_token":"cursor_2"}`,
120+
},
121+
})
122+
defer srv.Close()
123+
124+
originalHandler := srv.Config.Handler
125+
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
126+
if r.URL.Query().Get("token") == "cursor_2" {
127+
w.WriteHeader(http.StatusOK)
128+
_, _ = w.Write([]byte(`{"data":[{"id":"v3"}],"next_token":null}`))
129+
return
130+
}
131+
originalHandler.ServeHTTP(w, r)
132+
})
133+
134+
res := runGenCommand(t, srv.URL, "test-key", videoListSpec, "list", "--all")
135+
136+
if res.ExitCode != 0 {
137+
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
138+
}
139+
var parsed []map[string]any
140+
if err := json.Unmarshal([]byte(res.Stdout), &parsed); err != nil {
141+
t.Fatalf("stdout is not valid JSON array: %v\nstdout: %s", err, res.Stdout)
142+
}
143+
if len(parsed) != 3 {
144+
t.Fatalf("len(parsed) = %d, want 3", len(parsed))
145+
}
146+
}
147+
148+
func TestGenBuilder_VideoList_AllPages_SinglePage(t *testing.T) {
149+
srv := setupTestServer(t, map[string]testHandler{
150+
"GET /v3/videos": {
151+
StatusCode: 200,
152+
Body: `{"data":[{"id":"v1"}],"next_token":null}`,
153+
},
154+
})
155+
defer srv.Close()
156+
157+
res := runGenCommand(t, srv.URL, "test-key", videoListSpec, "list", "--all")
158+
159+
if res.ExitCode != 0 {
160+
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
161+
}
162+
var parsed []map[string]any
163+
if err := json.Unmarshal([]byte(res.Stdout), &parsed); err != nil {
164+
t.Fatalf("stdout is not valid JSON array: %v\nstdout: %s", err, res.Stdout)
165+
}
166+
if len(parsed) != 1 {
167+
t.Fatalf("len(parsed) = %d, want 1", len(parsed))
168+
}
169+
}
170+
171+
func TestGenBuilder_VideoList_AllAndTokenConflict(t *testing.T) {
172+
srv := setupTestServer(t, map[string]testHandler{})
173+
defer srv.Close()
174+
175+
res := runGenCommand(t, srv.URL, "test-key", videoListSpec, "list", "--all", "--token", "cursor_abc")
176+
177+
if res.ExitCode != 2 {
178+
t.Fatalf("ExitCode = %d, want 2\nstderr: %s", res.ExitCode, res.Stderr)
179+
}
180+
if !strings.Contains(res.Stderr, "--all and --token are mutually exclusive") {
181+
t.Fatalf("stderr = %s, want conflict message", res.Stderr)
182+
}
183+
}
184+
185+
func TestGenBuilder_VideoList_NoAllFlag_NonPaginated(t *testing.T) {
186+
nonPaginated := &command.Spec{
187+
Group: "video",
188+
Name: "get",
189+
Summary: "Get video",
190+
Endpoint: "/v3/videos/{video_id}",
191+
Method: "GET",
192+
Args: []command.ArgSpec{
193+
{Name: "video-id", Param: "video_id"},
194+
},
195+
Examples: []string{"heygen video get <video-id>"},
196+
}
197+
198+
srv := setupTestServer(t, map[string]testHandler{})
199+
defer srv.Close()
200+
201+
res := runGenCommand(t, srv.URL, "test-key", nonPaginated, "get", "vid_123", "--all")
202+
203+
if res.ExitCode != 2 {
204+
t.Fatalf("ExitCode = %d, want 2\nstderr: %s", res.ExitCode, res.Stderr)
205+
}
206+
if !strings.Contains(res.Stderr, "unknown flag: --all") {
207+
t.Fatalf("stderr = %s, want unknown flag error", res.Stderr)
208+
}
209+
}
210+
211+
func TestGenBuilder_VideoList_AllPages_Truncated(t *testing.T) {
212+
var calls int
213+
srv := setupTestServer(t, map[string]testHandler{
214+
"GET /v3/videos": {
215+
StatusCode: 200,
216+
ValidateRequest: func(t *testing.T, r *http.Request) {
217+
t.Helper()
218+
calls++
219+
},
220+
Body: truncatedPageBody(0, 2500, "cursor_1"),
221+
},
222+
})
223+
defer srv.Close()
224+
225+
originalHandler := srv.Config.Handler
226+
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
227+
token := r.URL.Query().Get("token")
228+
switch token {
229+
case "":
230+
originalHandler.ServeHTTP(w, r)
231+
case "cursor_1":
232+
calls++
233+
w.WriteHeader(http.StatusOK)
234+
_, _ = w.Write([]byte(truncatedPageBody(2500, 2500, "cursor_2")))
235+
case "cursor_2":
236+
calls++
237+
w.WriteHeader(http.StatusOK)
238+
_, _ = w.Write([]byte(truncatedPageBody(5000, 2500, "cursor_3")))
239+
case "cursor_3":
240+
calls++
241+
w.WriteHeader(http.StatusOK)
242+
_, _ = w.Write([]byte(truncatedPageBody(7500, 2500, "cursor_4")))
243+
default:
244+
t.Fatalf("unexpected token %q", token)
245+
}
246+
})
247+
248+
res := runGenCommand(t, srv.URL, "test-key", videoListSpec, "list", "--all")
249+
250+
if res.ExitCode != 1 {
251+
t.Fatalf("ExitCode = %d, want 1\nstderr: %s", res.ExitCode, res.Stderr)
252+
}
253+
if !strings.Contains(res.Stderr, "Warning: pagination stopped at 10000 items") {
254+
t.Fatalf("stderr = %s, want truncation warning", res.Stderr)
255+
}
256+
257+
var parsed []map[string]any
258+
if err := json.Unmarshal([]byte(res.Stdout), &parsed); err != nil {
259+
t.Fatalf("stdout is not valid JSON array: %v\nstdout: %s", err, res.Stdout)
260+
}
261+
if len(parsed) != 10000 {
262+
t.Fatalf("len(parsed) = %d, want 10000", len(parsed))
263+
}
264+
}
265+
96266
func TestGenBuilder_PostWithBodyFlags(t *testing.T) {
97267
var gotBody map[string]any
98268

@@ -437,3 +607,20 @@ func runGeneratedRootCommand(t *testing.T, serverURL, apiKey string, groups map[
437607
ExitCode: exitCode,
438608
}
439609
}
610+
611+
func truncatedPageBody(start, count int, nextToken string) string {
612+
items := make([]map[string]any, 0, count)
613+
for i := 0; i < count; i++ {
614+
items = append(items, map[string]any{"id": fmt.Sprintf("v%d", start+i)})
615+
}
616+
body := map[string]any{
617+
"data": items,
618+
}
619+
if nextToken == "" {
620+
body["next_token"] = nil
621+
} else {
622+
body["next_token"] = nextToken
623+
}
624+
raw, _ := json.Marshal(body)
625+
return string(raw)
626+
}

codegen/grouper.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import (
5353
// → sessions → sub-group, {session_id} → arg, stop → sub-group
5454
// → x-cli-action: true → no terminal verb appended
5555
// → result: heygen video-agent sessions stop <session-id>
56+
//
5657
// GroupDescriptions maps group name → description from the OpenAPI tag.
5758
// Used by the builder for group command help text.
5859
type GroupDescriptions map[string]string
@@ -157,7 +158,7 @@ func buildSpec(
157158
}
158159

159160
// Pagination
160-
_, spec.TokenField, spec.DataField = detectPagination(op)
161+
spec.Paginated, spec.TokenField, spec.TokenParam, spec.DataField = detectPagination(op, pathItem)
161162

162163
// Flags from query params
163164
for _, paramRef := range collectParams(pathItem, op) {
@@ -439,7 +440,7 @@ func isComplexField(s *openapi3.Schema) bool {
439440

440441
// --- Response analysis ---
441442

442-
func detectPagination(op *openapi3.Operation) (hasMore bool, tokenField, dataField string) {
443+
func detectPagination(op *openapi3.Operation, pathItem *openapi3.PathItem) (paginated bool, tokenField, tokenParam, dataField string) {
443444
respSchema := successResponseSchema(op)
444445
if respSchema == nil {
445446
return
@@ -455,11 +456,39 @@ func detectPagination(op *openapi3.Operation) (hasMore bool, tokenField, dataFie
455456
for _, schema := range schemasToCheck {
456457
for _, candidate := range []string{"next_token", "token", "cursor"} {
457458
if _, ok := schema.Properties[candidate]; ok {
458-
return true, candidate, dataField
459+
tokenField = candidate
460+
break
459461
}
460462
}
463+
if tokenField != "" {
464+
break
465+
}
466+
}
467+
if tokenField == "" {
468+
return false, "", "", dataField
469+
}
470+
471+
tokenParam = detectCursorParam(pathItem, op)
472+
if tokenParam == "" {
473+
return false, tokenField, "", dataField
461474
}
462-
return
475+
476+
return true, tokenField, tokenParam, dataField
477+
}
478+
479+
func detectCursorParam(pathItem *openapi3.PathItem, op *openapi3.Operation) string {
480+
params := collectParams(pathItem, op)
481+
for _, paramRef := range params {
482+
param := paramRef.Value
483+
if param == nil || param.In != "query" {
484+
continue
485+
}
486+
switch param.Name {
487+
case "token", "cursor", "page_token":
488+
return param.Name
489+
}
490+
}
491+
return ""
463492
}
464493

465494
func successResponseSchema(op *openapi3.Operation) *openapi3.Schema {

codegen/grouper_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,15 @@ func TestGroupEndpoints_Pagination(t *testing.T) {
156156
if s.Name != "list" {
157157
continue
158158
}
159+
if !s.Paginated {
160+
t.Error("Paginated = false, want true")
161+
}
159162
if s.TokenField != "token" {
160163
t.Errorf("TokenField = %q, want 'token'", s.TokenField)
161164
}
165+
if s.TokenParam != "token" {
166+
t.Errorf("TokenParam = %q, want 'token'", s.TokenParam)
167+
}
162168
if s.DataField != "data" {
163169
t.Errorf("DataField = %q, want 'data'", s.DataField)
164170
}

0 commit comments

Comments
 (0)