Skip to content

Commit afd169e

Browse files
authored
feat: Add native Go 1.23 iterator support for cursor-based pagination (#3965)
1 parent 5126e84 commit afd169e

4 files changed

Lines changed: 1573 additions & 2224 deletions

File tree

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ Users who have worked with protocol buffers should find this pattern familiar.
328328
### Pagination ###
329329

330330
All requests for resource collections (repos, pull requests, issues, etc.)
331-
support pagination. Pagination options are described in the
331+
support pagination. Pagination options using page numbers are described in the
332332
`github.ListOptions` struct and passed to the list methods directly or as an
333333
embedded type of a more specific list options struct (for example
334334
`github.PullRequestListOptions`). Pages information is available via the
@@ -355,12 +355,19 @@ for {
355355
}
356356
```
357357

358+
Pagination options using string cursors are described in the `github.ListCursorOptions`
359+
struct and passed to the list methods directly or as an
360+
embedded type of a more specific list cursor options struct (for example
361+
`github.ListGlobalSecurityAdvisoriesOptions`). Similarly, cursor and pages information
362+
is available via the `github.Response` struct.
363+
358364
#### Iterators ####
359365

360366
Go v1.23 introduces the new `iter` package.
361367

362368
The new `github/gen-iterators.go` file auto-generates "*Iter" methods in `github/github-iterators.go`
363-
for all methods that support page number iteration (using the `NextPage` field in each response).
369+
for all methods that support page number iteration (using the `NextPage` field in each response)
370+
or string cursor iteration (using the `Cursor` field in each response).
364371
To handle rate limiting issues, make sure to use a rate-limiting transport.
365372
(See [Rate Limiting](/#rate-limiting) above for more details.)
366373
To use these methods, simply create an iterator and then range over it, for example:

github/gen-iterators.go

Lines changed: 78 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -101,24 +101,25 @@ type structDef struct {
101101
}
102102

103103
type method struct {
104-
RecvType string
105-
RecvVar string
106-
ClientField string
107-
MethodName string
108-
IterMethod string
109-
Args string
110-
CallArgs string
111-
TestCallArgs string
112-
ZeroArgs string
113-
ReturnType string
114-
OptsType string
115-
OptsName string
116-
OptsIsPtr bool
117-
UseListOptions bool
118-
UsePage bool
119-
TestJSON1 string
120-
TestJSON2 string
121-
TestJSON3 string
104+
RecvType string
105+
RecvVar string
106+
ClientField string
107+
MethodName string
108+
IterMethod string
109+
Args string
110+
CallArgs string
111+
TestCallArgs string
112+
ZeroArgs string
113+
ReturnType string
114+
OptsType string
115+
OptsName string
116+
OptsIsPtr bool
117+
UseListCursorOptions bool
118+
UseListOptions bool
119+
UsePage bool
120+
TestJSON1 string
121+
TestJSON2 string
122+
TestJSON3 string
122123
}
123124

124125
// customTestJSON maps method names to the JSON response they expect in tests.
@@ -164,16 +165,24 @@ func (t *templateData) processStructs(f *ast.File) {
164165
}
165166
}
166167

168+
func (t *templateData) hasListCursorOptions(structName string) bool {
169+
return t.hasOptions(structName, "ListCursorOptions")
170+
}
171+
167172
func (t *templateData) hasListOptions(structName string) bool {
173+
return t.hasOptions(structName, "ListOptions")
174+
}
175+
176+
func (t *templateData) hasOptions(structName, optionsType string) bool {
168177
sd, ok := t.Structs[structName]
169178
if !ok {
170179
return false
171180
}
172181
for _, embed := range sd.Embeds {
173-
if embed == "ListOptions" {
182+
if embed == optionsType {
174183
return true
175184
}
176-
if t.hasListOptions(embed) {
185+
if t.hasOptions(embed, optionsType) {
177186
return true
178187
}
179188
}
@@ -290,11 +299,12 @@ func (t *templateData) processMethods(f *ast.File) error {
290299
continue
291300
}
292301

302+
useListCursorOptions := t.hasListCursorOptions(optsType)
293303
useListOptions := t.hasListOptions(optsType)
294304
usePage := t.hasIntPage(optsType)
295305

296-
if !useListOptions && !usePage {
297-
logf("Skipping %s.%s: opts %s does not have ListOptions or Page int", recvType, fd.Name.Name, optsType)
306+
if !useListCursorOptions && !useListOptions && !usePage {
307+
logf("Skipping %s.%s: opts %s does not have ListCursorOptions, ListOptions, or Page int", recvType, fd.Name.Name, optsType)
298308
continue
299309
}
300310

@@ -316,24 +326,25 @@ func (t *templateData) processMethods(f *ast.File) error {
316326
testJSON3 := strings.ReplaceAll(testJSON, "[]", "[{},{}]") // Call 2 - return 2 items
317327

318328
m := &method{
319-
RecvType: recType,
320-
RecvVar: recvVar,
321-
ClientField: clientField,
322-
MethodName: fd.Name.Name,
323-
IterMethod: fd.Name.Name + "Iter",
324-
Args: strings.Join(args, ", "),
325-
CallArgs: strings.Join(callArgs, ", "),
326-
TestCallArgs: strings.Join(testCallArgs, ", "),
327-
ZeroArgs: strings.Join(zeroArgs, ", "),
328-
ReturnType: eltType,
329-
OptsType: optsType,
330-
OptsName: optsName,
331-
OptsIsPtr: optsIsPtr,
332-
UseListOptions: useListOptions,
333-
UsePage: usePage,
334-
TestJSON1: testJSON1,
335-
TestJSON2: testJSON2,
336-
TestJSON3: testJSON3,
329+
RecvType: recType,
330+
RecvVar: recvVar,
331+
ClientField: clientField,
332+
MethodName: fd.Name.Name,
333+
IterMethod: fd.Name.Name + "Iter",
334+
Args: strings.Join(args, ", "),
335+
CallArgs: strings.Join(callArgs, ", "),
336+
TestCallArgs: strings.Join(testCallArgs, ", "),
337+
ZeroArgs: strings.Join(zeroArgs, ", "),
338+
ReturnType: eltType,
339+
OptsType: optsType,
340+
OptsName: optsName,
341+
OptsIsPtr: optsIsPtr,
342+
UseListCursorOptions: useListCursorOptions,
343+
UseListOptions: useListOptions,
344+
UsePage: usePage,
345+
TestJSON1: testJSON1,
346+
TestJSON2: testJSON2,
347+
TestJSON3: testJSON3,
337348
}
338349
t.Methods = append(t.Methods, m)
339350
}
@@ -432,12 +443,26 @@ func ({{.RecvVar}} *{{.RecvType}}) {{.IterMethod}}({{.Args}}) iter.Seq2[{{.Retur
432443
}
433444
}
434445
446+
{{if and .UseListCursorOptions .UseListOptions}}
447+
if resp.Cursor == "" && resp.NextPage == 0 {
448+
break
449+
}
450+
{{.OptsName}}.ListCursorOptions.Cursor = resp.Cursor
451+
{{.OptsName}}.ListOptions.Page = resp.NextPage
452+
{{else if .UseListCursorOptions}}
453+
if resp.Cursor == "" {
454+
break
455+
}
456+
{{.OptsName}}.ListCursorOptions.Cursor = resp.Cursor
457+
{{else if .UseListOptions}}
435458
if resp.NextPage == 0 {
436459
break
437460
}
438-
{{if .UseListOptions}}
439461
{{.OptsName}}.ListOptions.Page = resp.NextPage
440462
{{else}}
463+
if resp.NextPage == 0 {
464+
break
465+
}
441466
{{.OptsName}}.Page = resp.NextPage
442467
{{end -}}
443468
}
@@ -464,20 +489,23 @@ func Test{{.RecvType}}_{{.IterMethod}}(t *testing.T) {
464489
callNum++
465490
switch callNum {
466491
case 1:
492+
{{- if .UseListCursorOptions}}
493+
w.Header().Set("Link", ` + "`" + `<https://api.github.com/?cursor=yo>; rel="next"` + "`" + `)
494+
{{else}}
467495
w.Header().Set("Link", ` + "`" + `<https://api.github.com/?page=1>; rel="next"` + "`" + `)
468-
fmt.Fprint(w, ` + "`" + `{{.TestJSON1}}` + "`" + `) // Call 1 below: return 3 items, NextPage=1, no errors
496+
{{end -}}
497+
fmt.Fprint(w, ` + "`" + `{{.TestJSON1}}` + "`" + `)
469498
case 2:
470-
fmt.Fprint(w, ` + "`" + `{{.TestJSON2}}` + "`" + `) // still Call 1 below: return 4 more items, no next page, no errors
499+
fmt.Fprint(w, ` + "`" + `{{.TestJSON2}}` + "`" + `)
471500
case 3:
472-
fmt.Fprint(w, ` + "`" + `{{.TestJSON3}}` + "`" + `) // Call 2 below: return 2 items, no next page, no errors
501+
fmt.Fprint(w, ` + "`" + `{{.TestJSON3}}` + "`" + `)
473502
case 4:
474-
w.WriteHeader(http.StatusNotFound) // Call 3 below: endpoint returns an error
503+
w.WriteHeader(http.StatusNotFound)
475504
case 5:
476-
fmt.Fprint(w, ` + "`" + `{{.TestJSON3}}` + "`" + `) // Call 4 below: return 2 items, no next page, no errors
505+
fmt.Fprint(w, ` + "`" + `{{.TestJSON3}}` + "`" + `)
477506
}
478507
})
479508
480-
// Call 1: iterator using zero values
481509
iter := client.{{.ClientField}}.{{.IterMethod}}({{.ZeroArgs}})
482510
var gotItems int
483511
for _, err := range iter {
@@ -490,10 +518,9 @@ func Test{{.RecvType}}_{{.IterMethod}}(t *testing.T) {
490518
t.Errorf("client.{{.ClientField}}.{{.IterMethod}} call 1 got %v items; want %v", gotItems, want)
491519
}
492520
493-
// Call 2: iterator using non-nil opts
494521
{{.OptsName}} := &{{.OptsType}}{}
495522
iter = client.{{.ClientField}}.{{.IterMethod}}({{.TestCallArgs}})
496-
gotItems = 0 // reset
523+
gotItems = 0
497524
for _, err := range iter {
498525
gotItems++
499526
if err != nil {
@@ -504,9 +531,8 @@ func Test{{.RecvType}}_{{.IterMethod}}(t *testing.T) {
504531
t.Errorf("client.{{.ClientField}}.{{.IterMethod}} call 2 got %v items; want %v", gotItems, want)
505532
}
506533
507-
// Call 3: iterator returns an error
508534
iter = client.{{.ClientField}}.{{.IterMethod}}({{.ZeroArgs}})
509-
gotItems = 0 // reset
535+
gotItems = 0
510536
for _, err := range iter {
511537
gotItems++
512538
if err == nil {
@@ -517,16 +543,13 @@ func Test{{.RecvType}}_{{.IterMethod}}(t *testing.T) {
517543
t.Errorf("client.{{.ClientField}}.{{.IterMethod}} call 3 got %v items; want 1 (an error)", gotItems)
518544
}
519545
520-
// Call 4: iterator returns false
521546
iter = client.{{.ClientField}}.{{.IterMethod}}({{.ZeroArgs}})
522-
gotItems = 0 // reset
547+
gotItems = 0
523548
iter(func(item {{.ReturnType}}, err error) bool {
524549
gotItems++
525550
if err != nil {
526551
t.Errorf("Unexpected error: %v", err)
527552
}
528-
// Force the iterator to hit:
529-
// if !yield(item, nil) { return }
530553
return false
531554
})
532555
if gotItems != 1 {

0 commit comments

Comments
 (0)