Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add AllPages pagination helper #1875

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions iterator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//go:build go1.23
// +build go1.23

package gitlab

import (
"iter"
)

// Paginatable is the type implemented by list functions that return paginated
// content (e.g. [UsersService.ListUsers]).
// It works for top-level entities (e.g. users). See [PaginatableForID] for
// entities that require a parent ID (e.g. tags).
type Paginatable[O, T any] func(*O, ...RequestOptionFunc) ([]*T, *Response, error)

// AllPages is a [iter.Seq2] iterator to be used with any paginated resource.
// E.g. [UsersService.ListUsers]
//
// for user, err := range gitlab.AllPages(gl.Users.ListUsers, nil) {
// if err != nil {
// // handle error
// }
// // process individual user
// }
//
// It is also possible to specify additional pagination parameters:
//
// for mr, err := range gitlab.AllPages(
// gl.MergeRequests.ListMergeRequests,
// &gitlab.ListMergeRequestsOptions{
// ListOptions: gitlab.ListOptions{
// PerPage: 100,
// Pagination: "keyset",
// OrderBy: "created_at",
// },
// },
// gitlab.WithContext(ctx),
// ) {
// // ...
// }
//
// Errors while fetching pages are returned as the second value of the iterator.
// It is the responsibility of the caller to handle them appropriately, e.g. by
// breaking the loop. The iteration will otherwise continue indefinitely,
// retrying to retrieve the erroring page on each iteration.
func AllPages[O, T any](f Paginatable[O, T], opt *O, optFunc ...RequestOptionFunc) iter.Seq2[*T, error] {
return func(yield func(*T, error) bool) {
nextLink := ""
for {
page, resp, err := f(opt, append(optFunc, WithKeysetPaginationParameters(nextLink))...)
if err != nil {
if !yield(nil, err) {
return
}
continue
}
for _, p := range page {
if !yield(p, nil) {
return
}
}
if resp.NextLink == "" {
break
}
nextLink = resp.NextLink
}
}
}

// PaginatableForID is the type implemented by list functions that return
// paginated content for sub-entities (e.g. [TagsService.ListTags]).
// See also [Paginatable] for top-level entities (e.g. users).
type PaginatableForID[O, T any] func(any, *O, ...RequestOptionFunc) ([]*T, *Response, error)

// AllPagesForID is similar to [AllPages] but for paginated resources that
// require a parent ID (e.g. tags of a project).
func AllPagesForID[O, T any](id any, f PaginatableForID[O, T], opt *O, optFunc ...RequestOptionFunc) iter.Seq2[*T, error] {
idFunc := func(opt *O, optFunc ...RequestOptionFunc) ([]*T, *Response, error) {
return f(id, opt, optFunc...)
}
return AllPages(idFunc, opt, optFunc...)
}
105 changes: 105 additions & 0 deletions iterator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//go:build go1.23
// +build go1.23

package gitlab

import (
"errors"
"iter"
"testing"

"github.com/stretchr/testify/assert"
)

func TestAllPages(t *testing.T) {
type foo struct{ string }
type listFooOpt struct{}

type iteration struct {
foo *foo
err error
}

sentinelError := errors.New("sentinel error")

// assertSeq is a helper function to assert the sequence of iterations.
// It is necessary because the iteration may be endless (e.g. in the error
// case).
assertSeq := func(t *testing.T, expected []iteration, actual iter.Seq2[*foo, error]) {
t.Helper()
i := 0
for actualFoo, actualErr := range actual {
if i >= len(expected) {
t.Errorf("unexpected iteration: %v, %v", actualFoo, actualErr)
break
}
assert.Equal(t, expected[i].foo, actualFoo)
assert.Equal(t, expected[i].err, actualErr)
i++
}

if i < len(expected) {
t.Errorf("expected %d more iterations", len(expected)-i)
}
}

type args struct {
f Paginatable[listFooOpt, foo]
opt *listFooOpt
optFunc []RequestOptionFunc
}
tests := []struct {
name string
args args
want []iteration
}{
{
name: "empty",
args: args{
f: func() Paginatable[listFooOpt, foo] {
return func(*listFooOpt, ...RequestOptionFunc) ([]*foo, *Response, error) {
return []*foo{}, &Response{}, nil
}
}(),
},
want: []iteration{},
},
{
name: "single element, no errors",
args: args{
f: func() Paginatable[listFooOpt, foo] {
return func(*listFooOpt, ...RequestOptionFunc) ([]*foo, *Response, error) {
return []*foo{{"foo"}}, &Response{}, nil
}
}(),
},
want: []iteration{
{foo: &foo{"foo"}, err: nil},
},
},
{
name: "one error than success",
args: args{
f: func() Paginatable[listFooOpt, foo] {
called := false
return func(*listFooOpt, ...RequestOptionFunc) ([]*foo, *Response, error) {
if !called {
called = true
return []*foo{}, &Response{}, sentinelError
}
return []*foo{{"foo"}}, &Response{}, nil
}
}(),
},
want: []iteration{
{foo: nil, err: sentinelError},
{foo: &foo{"foo"}, err: nil},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assertSeq(t, tt.want, AllPages(tt.args.f, tt.args.opt, tt.args.optFunc...))
})
}
}