diff --git a/iterator.go b/iterator.go index 646824615..11b24e7a0 100644 --- a/iterator.go +++ b/iterator.go @@ -1,5 +1,5 @@ -//go:build go1.22 && goexperiment.rangefunc -// +build go1.22,goexperiment.rangefunc +//go:build go1.23 +// +build go1.23 package gitlab @@ -7,25 +7,52 @@ import ( "iter" ) -// PageIterator is an EXPERIMENTAL iterator as defined in the "rangefunc" experiment for go 1.22. -// See https://go.dev/wiki/RangefuncExperiment for more details. -// -// It can be used as: +// 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.PageIterator(gl.Users.List, nil) { +// for user, err := range gitlab.AllPages(gl.Users.ListUsers, nil) { // if err != nil { // // handle error // } // // process individual user // } -func PageIterator[O, T any](f Paginatable[O, T], opt *O, optFunc ...RequestOptionFunc) iter.Seq2[*T, error] { +// +// 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 { - yield(nil, err) - return + if !yield(nil, err) { + return + } + continue } for _, p := range page { if !yield(p, nil) { @@ -40,10 +67,16 @@ func PageIterator[O, T any](f Paginatable[O, T], opt *O, optFunc ...RequestOptio } } -// PageIteratorForID is similar to [PageIterator] but for paginated resources that require a parent ID (e.g. tags of a project). -func PageIteratorForID[O, T any](id any, f PaginatableForID[O, T], opt *O, optFunc ...RequestOptionFunc) iter.Seq2[*T, error] { +// 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 PageIterator(idFunc, opt, optFunc...) + return AllPages(idFunc, opt, optFunc...) } diff --git a/iterator_test.go b/iterator_test.go new file mode 100644 index 000000000..9703e78d6 --- /dev/null +++ b/iterator_test.go @@ -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...)) + }) + } +} diff --git a/pagination.go b/pagination.go deleted file mode 100644 index 876c605b7..000000000 --- a/pagination.go +++ /dev/null @@ -1,53 +0,0 @@ -package gitlab - -// AllPages can be used to fetch all pages of a paginated resource, e.g.: (assuming gl is a gitlab client instance) -// -// allUsers, err := gitlab.AllPages(gl.Users.List, nil) -// -// It is also possible to specify additional pagination parameters: -// -// mrs, err := gitlab.AllPages( -// gl.MergeRequests.ListMergeRequests, -// &gitlab.ListMergeRequestsOptions{ -// ListOptions: gitlab.ListOptions{ -// PerPage: 100, -// Pagination: "keyset", -// OrderBy: "created_at", -// }, -// }, -// gitlab.WithContext(ctx), -// ) -func AllPages[O, T any](f Paginatable[O, T], opt *O, optFunc ...RequestOptionFunc) ([]*T, error) { - all := make([]*T, 0) - nextLink := "" - for { - page, resp, err := f(opt, append(optFunc, WithKeysetPaginationParameters(nextLink))...) - if err != nil { - return nil, err - } - all = append(all, page...) - if resp.NextLink == "" { - break - } - nextLink = resp.NextLink - } - return all, nil -} - -// 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) - -// 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) ([]*T, error) { - idFunc := func(opt *O, optFunc ...RequestOptionFunc) ([]*T, *Response, error) { - return f(id, opt, optFunc...) - } - return AllPages(idFunc, opt, optFunc...) -} - -// 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)