From d215566652239ec8b51910b289fbd8f712974e28 Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Mon, 5 Feb 2024 22:31:46 +0100 Subject: [PATCH 1/4] feat: add AllPages pagination helper --- pagination.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 pagination.go diff --git a/pagination.go b/pagination.go new file mode 100644 index 00000000..0bfb0f56 --- /dev/null +++ b/pagination.go @@ -0,0 +1,34 @@ +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", +// }, +// }) +func AllPages[O, T any](f Paginatable[O, T], opt *O) ([]*T, error) { + all := make([]*T, 0) + nextLink := "" + for { + page, resp, err := f(opt, 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 that is implemented by all functions used to paginated content. +type Paginatable[O, T any] func(*O, ...RequestOptionFunc) ([]*T, *Response, error) -- GitLab From 4eb63f7cbea16df550e0f187d64ebfa6e7bc350c Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Wed, 7 Feb 2024 12:07:48 +0100 Subject: [PATCH 2/4] feat: add experimental iterator --- iterator.go | 41 +++++++++++++++++++++++++++++++++++++++++ pagination.go | 20 ++++++++++++-------- 2 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 iterator.go diff --git a/iterator.go b/iterator.go new file mode 100644 index 00000000..91486476 --- /dev/null +++ b/iterator.go @@ -0,0 +1,41 @@ +//go:build go1.22 && goexperiment.rangefunc +// +build go1.22,goexperiment.rangefunc + +package gitlab + +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: +// +// for user, err := range gitlab.PageIterator(gl.Users.List, 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] { + 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 + } + for _, p := range page { + if !yield(p, nil) { + return + } + } + if resp.NextLink == "" { + break + } + nextLink = resp.NextLink + } + } +} diff --git a/pagination.go b/pagination.go index 0bfb0f56..910afc55 100644 --- a/pagination.go +++ b/pagination.go @@ -6,18 +6,22 @@ package gitlab // // 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", +// mrs, err := gitlab.AllPages( +// gl.MergeRequests.ListMergeRequests, +// &gitlab.ListMergeRequestsOptions{ +// ListOptions: gitlab.ListOptions{ +// PerPage: 100, +// Pagination: "keyset", +// OrderBy: "created_at", +// }, // }, -// }) -func AllPages[O, T any](f Paginatable[O, T], opt *O) ([]*T, error) { +// 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, WithKeysetPaginationParameters(nextLink)) + page, resp, err := f(opt, append(optFunc, WithKeysetPaginationParameters(nextLink))...) if err != nil { return nil, err } -- GitLab From 16265ec452e0d95faf1e1aa1ada2f00a52ce8a56 Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Wed, 14 Feb 2024 23:15:02 +0100 Subject: [PATCH 3/4] feat: add "ForID" variants of pagination --- iterator.go | 8 ++++++++ pagination.go | 17 ++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/iterator.go b/iterator.go index 91486476..64682461 100644 --- a/iterator.go +++ b/iterator.go @@ -39,3 +39,11 @@ 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] { + idFunc := func(opt *O, optFunc ...RequestOptionFunc) ([]*T, *Response, error) { + return f(id, opt, optFunc...) + } + return PageIterator(idFunc, opt, optFunc...) +} diff --git a/pagination.go b/pagination.go index 910afc55..876c605b 100644 --- a/pagination.go +++ b/pagination.go @@ -34,5 +34,20 @@ func AllPages[O, T any](f Paginatable[O, T], opt *O, optFunc ...RequestOptionFun return all, nil } -// Paginatable is the type that is implemented by all functions used to paginated content. +// 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) -- GitLab From 944529b116998257df8819c775ffd62217cfe0da Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Sat, 28 Sep 2024 11:25:06 +0200 Subject: [PATCH 4/4] refactor: remove non-rangefunc variant; add tests --- iterator.go | 59 ++++++++++++++++++++------ iterator_test.go | 105 +++++++++++++++++++++++++++++++++++++++++++++++ pagination.go | 53 ------------------------ 3 files changed, 151 insertions(+), 66 deletions(-) create mode 100644 iterator_test.go delete mode 100644 pagination.go diff --git a/iterator.go b/iterator.go index 64682461..11b24e7a 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 00000000..9703e78d --- /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 876c605b..00000000 --- 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) -- GitLab