diff --git a/iter_test.go b/iter_test.go index d8608c22cd..39039e711d 100644 --- a/iter_test.go +++ b/iter_test.go @@ -165,7 +165,13 @@ func (tq *testQuery) query(*Params, *form.Values) ([]interface{}, ListContainer, return x.v, x.m, x.e } -func collect(it *Iter) ([]interface{}, error) { +type collectable interface { + Next() bool + Current() interface{} + Err() error +} + +func collect(it collectable) ([]interface{}, error) { var g []interface{} for it.Next() { g = append(g, it.Current()) diff --git a/search_iter.go b/search_iter.go new file mode 100644 index 0000000000..cb76b22dc7 --- /dev/null +++ b/search_iter.go @@ -0,0 +1,125 @@ +package stripe + +import ( + "reflect" + + "github.com/stripe/stripe-go/v72/form" +) + +// +// Public constants +// + +// Contains constants for the names of parameters used for pagination in search APIs. +const ( + Page = "page" +) + +// +// Public types +// + +// SearchIter provides a convenient interface +// for iterating over the elements +// returned from paginated search API calls. +// Successive calls to the Next method +// will step through each item in the search results, +// fetching pages of items as needed. +// Iterators are not thread-safe, so they should not be consumed +// across multiple goroutines. +type SearchIter struct { + cur interface{} + err error + formValues *form.Values + searchContainer SearchContainer + searchParams SearchParams + meta *SearchMeta + query SearchQuery + values []interface{} +} + +// Current returns the most recent item +// visited by a call to Next. +func (it *SearchIter) Current() interface{} { + return it.cur +} + +// Err returns the error, if any, +// that caused the SearchIter to stop. +// It must be inspected +// after Next returns false. +func (it *SearchIter) Err() error { + return it.err +} + +// SearchResult returns the current search result container which the iterator is currently using. +// Objects will change as new API calls are made to continue pagination. +func (it *SearchIter) SearchResult() SearchContainer { + return it.searchContainer +} + +// Meta returns the search metadata. +func (it *SearchIter) Meta() *SearchMeta { + return it.meta +} + +// Next advances the SearchIter to the next item in the search results, +// which will then be available +// through the Current method. +// It returns false when the iterator stops +// at the end of the search results. +func (it *SearchIter) Next() bool { + if len(it.values) == 0 && it.meta.HasMore && !it.searchParams.Single { + if it.meta.NextPage != nil { + it.formValues.Set(Page, *it.meta.NextPage) + it.getPage() + } + } + if len(it.values) == 0 { + return false + } + it.cur = it.values[0] + it.values = it.values[1:] + return true +} + +func (it *SearchIter) getPage() { + it.values, it.searchContainer, it.err = it.query(it.searchParams.GetParams(), it.formValues) + it.meta = it.searchContainer.GetSearchMeta() +} + +// SearchQuery is the function used to get search results. +type SearchQuery func(*Params, *form.Values) ([]interface{}, SearchContainer, error) + +// +// Public functions +// + +// GetSearchIter returns a new SearchIter for a given query and its options. +func GetSearchIter(container SearchParamsContainer, query SearchQuery) *SearchIter { + var searchParams *SearchParams + formValues := &form.Values{} + + if container != nil { + reflectValue := reflect.ValueOf(container) + + // See the comment on Call in stripe.go. + if reflectValue.Kind() == reflect.Ptr && !reflectValue.IsNil() { + searchParams = container.GetSearchParams() + form.AppendTo(formValues, container) + } + } + + if searchParams == nil { + searchParams = &SearchParams{} + } + iter := &SearchIter{ + formValues: formValues, + searchParams: *searchParams, + query: query, + } + + iter.getPage() + + return iter +} diff --git a/search_iter_test.go b/search_iter_test.go new file mode 100644 index 0000000000..800e696fea --- /dev/null +++ b/search_iter_test.go @@ -0,0 +1,196 @@ +package stripe + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + assert "github.com/stretchr/testify/require" + "github.com/stripe/stripe-go/v72/form" +) + +var nextPageTestToken = "next_page_test_token" + +func TestSearchIterEmpty(t *testing.T) { + tq := testSearchQuery{{nil, &SearchMeta{}, nil}} + g, gerr := collect(GetSearchIter(nil, tq.query)) + assert.Equal(t, 0, len(tq)) + assert.Equal(t, 0, len(g)) + assert.NoError(t, gerr) +} + +func TestSearchIterEmptyErr(t *testing.T) { + tq := testSearchQuery{{nil, &SearchMeta{}, errTest}} + g, gerr := collect(GetSearchIter(nil, tq.query)) + assert.Equal(t, 0, len(tq)) + assert.Equal(t, 0, len(g)) + assert.Equal(t, errTest, gerr) +} + +func TestSearchIterOne(t *testing.T) { + tq := testSearchQuery{{[]interface{}{1}, &SearchMeta{}, nil}} + want := []interface{}{1} + g, gerr := collect(GetSearchIter(nil, tq.query)) + assert.Equal(t, 0, len(tq)) + assert.Equal(t, want, g) + assert.NoError(t, gerr) +} + +func TestSearchIterOneErr(t *testing.T) { + tq := testSearchQuery{{[]interface{}{1}, &SearchMeta{}, errTest}} + want := []interface{}{1} + g, gerr := collect(GetSearchIter(nil, tq.query)) + assert.Equal(t, 0, len(tq)) + assert.Equal(t, want, g) + assert.Equal(t, errTest, gerr) +} + +func TestSearchIterPage2Empty(t *testing.T) { + tq := testSearchQuery{ + {[]interface{}{&item{"x"}}, &SearchMeta{HasMore: true, URL: "", NextPage: &nextPageTestToken}, nil}, + {nil, &SearchMeta{}, nil}, + } + want := []interface{}{&item{"x"}} + g, gerr := collect(GetSearchIter(nil, tq.query)) + assert.Equal(t, 0, len(tq)) + assert.Equal(t, want, g) + assert.NoError(t, gerr) +} + +func TestSearchIterPage2EmptyErr(t *testing.T) { + tq := testSearchQuery{ + {[]interface{}{&item{"x"}}, &SearchMeta{HasMore: true, URL: "", NextPage: &nextPageTestToken}, nil}, + {nil, &SearchMeta{}, errTest}, + } + want := []interface{}{&item{"x"}} + g, gerr := collect(GetSearchIter(nil, tq.query)) + assert.Equal(t, 0, len(tq)) + assert.Equal(t, want, g) + assert.Equal(t, errTest, gerr) +} + +func TestSearchIterTwoPages(t *testing.T) { + tq := testSearchQuery{ + {[]interface{}{&item{"x"}}, &SearchMeta{HasMore: true, URL: "", NextPage: &nextPageTestToken}, nil}, + {[]interface{}{2}, &SearchMeta{HasMore: false, URL: ""}, nil}, + } + want := []interface{}{&item{"x"}, 2} + g, gerr := collect(GetSearchIter(nil, tq.query)) + assert.Equal(t, 0, len(tq)) + assert.Equal(t, want, g) + assert.NoError(t, gerr) +} + +func TestSearchIterTwoPagesErr(t *testing.T) { + tq := testSearchQuery{ + {[]interface{}{&item{"x"}}, &SearchMeta{HasMore: true, URL: "", NextPage: &nextPageTestToken}, nil}, + {[]interface{}{2}, &SearchMeta{HasMore: false, URL: ""}, errTest}, + } + want := []interface{}{&item{"x"}, 2} + g, gerr := collect(GetSearchIter(nil, tq.query)) + assert.Equal(t, 0, len(tq)) + assert.Equal(t, want, g) + assert.Equal(t, errTest, gerr) +} + +func TestSearchIterListAndMeta(t *testing.T) { + type listType struct { + SearchMeta + } + listMeta := &SearchMeta{HasMore: true, URL: "", NextPage: &nextPageTestToken} + list := &listType{SearchMeta: *listMeta} + + tq := testSearchQuery{{nil, list, nil}} + it := GetSearchIter(nil, tq.query) + assert.Equal(t, list, it.SearchResult()) + assert.Equal(t, listMeta, it.Meta()) +} + +func TestSearchIterMultiplePages(t *testing.T) { + // Create an ephemeral test server so that we can inspect request attributes. + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery == "query=my+query" { + w.Write([]byte(`{"data":[{"id": "1"}, {"id":"2"}], "has_more":true, "next_page":"page2" }`)) + return + } else if r.URL.RawQuery == "query=my+query&page=page2" { + w.Write([]byte(`{"data":[{"id": "3"}, {"id":"4"}], "has_more":false, "next_page":null }`)) + return + } + assert.Fail(t, "shouldn't be hit") + })) + defer ts.Close() + + // Configure the stripe client to use the ephemeral backend. + backend := GetBackendWithConfig(APIBackend, &BackendConfig{ + URL: String(ts.URL), + }) + client := Client{B: backend, Key: Key} + + p := &OrderReturnParams{} + p.SetStripeAccount("acct_123") + + iter := client.Search(&SearchParams{ + Query: "my query", + }) + cnt := 0 + for iter.Next() { + e := iter.Current().(*TestEntity) + cnt += 1 + assert.Equal(t, fmt.Sprint(cnt), e.ID) + } + + assert.Equal(t, 4, cnt) +} + +type testSearchQuery []struct { + v []interface{} + m SearchContainer + e error +} + +func (tq *testSearchQuery) query(*Params, *form.Values) ([]interface{}, SearchContainer, error) { + x := (*tq)[0] + *tq = (*tq)[1:] + return x.v, x.m, x.e +} + +// Client is used to invoke /charges APIs. +type Client struct { + B Backend + Key string +} + +// SearchIter is an iterator for charges. +type TestSearchIter struct { + *SearchIter +} + +// Search returns a search result containing charges. +func (c Client) Search(params *SearchParams) *TestSearchIter { + return &TestSearchIter{ + SearchIter: GetSearchIter(params, func(p *Params, b *form.Values) ([]interface{}, SearchContainer, error) { + list := &TestSearchResult{} + err := c.B.CallRaw(http.MethodGet, "/v1/something/search", c.Key, b, p, list) + + ret := make([]interface{}, len(list.Data)) + for i, v := range list.Data { + ret[i] = v + } + + return ret, list, err + }), + } +} + +type TestEntity struct { + APIResource + // Amount intended to be collected by this payment. A positive integer representing how much to charge in the [smallest currency unit](https://stripe.com/docs/currencies#zero-decimal) (e.g., 100 cents to charge $1.00 or 100 to charge ¥100, a zero-decimal currency). The minimum amount is $0.50 US or [equivalent in charge currency](https://stripe.com/docs/currencies#minimum-and-maximum-charge-amounts). The amount value supports up to eight digits (e.g., a value of 99999999 for a USD charge of $999,999.99). + ID string `json:"id"` +} + +type TestSearchResult struct { + APIResource + SearchMeta + Data []*TestEntity `json:"data"` +} diff --git a/search_params.go b/search_params.go new file mode 100644 index 0000000000..e1790788b2 --- /dev/null +++ b/search_params.go @@ -0,0 +1,97 @@ +package stripe + +import ( + "context" +) + +// +// Public types +// + +// SearchContainer is a general interface for which all search result object structs +// should comply. They achieve this by embedding a SearchMeta struct and +// inheriting its implementation of this interface. +type SearchContainer interface { + GetSearchMeta() *SearchMeta +} + +// SearchMeta is the structure that contains the common properties of the search iterators +type SearchMeta struct { + HasMore bool `json:"has_more"` + NextPage *string `json:"next_page"` + URL string `json:"url"` +} + +// GetSearchMeta returns a SearchMeta struct (itself). It exists because any +// structs that embed SearchMeta will inherit it, and thus implement the +// SearchContainer interface. +func (l *SearchMeta) GetSearchMeta() *SearchMeta { + return l +} + +// SearchParams is the structure that contains the common properties +// of any *SearchParams structure. +type SearchParams struct { + // Context used for request. It may carry deadlines, cancelation signals, + // and other request-scoped values across API boundaries and between + // processes. + // + // Note that a cancelled or timed out context does not provide any + // guarantee whether the operation was or was not completed on Stripe's API + // servers. For certainty, you must either retry with the same idempotency + // key or query the state of the API. + Context context.Context `form:"-"` + + Query string `form:"query"` + Limit *int64 `form:"limit"` + Page *string `form:"page"` + + // Single specifies whether this is a single page iterator. By default, + // listing through an iterator will automatically grab additional pages as + // the query progresses. To change this behavior and just load a single + // page, set this to true. + Single bool `form:"-"` // Not an API parameter + + // StripeAccount may contain the ID of a connected account. By including + // this field, the request is made as if it originated from the connected + // account instead of under the account of the owner of the configured + // Stripe key. + StripeAccount *string `form:"-"` // Passed as header +} + +// GetSearchParams returns a SearchParams struct (itself). It exists because any +// structs that embed SearchParams will inherit it, and thus implement the +// SearchParamsContainer interface. +func (p *SearchParams) GetSearchParams() *SearchParams { + return p +} + +// GetParams returns SearchParams as a Params struct. It exists because any +// structs that embed Params will inherit it, and thus implement the +// ParamsContainer interface. +func (p *SearchParams) GetParams() *Params { + return p.ToParams() +} + +// SetStripeAccount sets a value for the Stripe-Account header. +func (p *SearchParams) SetStripeAccount(val string) { + p.StripeAccount = &val +} + +// ToParams converts a SearchParams to a Params by moving over any fields that +// have valid targets in the new type. This is useful because fields in +// Params can be injected directly into an http.Request while generally +// SearchParams is only used to build a set of parameters. +func (p *SearchParams) ToParams() *Params { + return &Params{ + Context: p.Context, + StripeAccount: p.StripeAccount, + } +} + +// SearchParamsContainer is a general interface for which all search parameter +// structs should comply. They achieve this by embedding a SearchParams struct +// and inheriting its implementation of this interface. +type SearchParamsContainer interface { + GetSearchParams() *SearchParams +}