diff --git a/lib/StripeMethod.js b/lib/StripeMethod.js index 4c6c544dd2..78018da23a 100644 --- a/lib/StripeMethod.js +++ b/lib/StripeMethod.js @@ -39,7 +39,9 @@ function stripeMethod(spec) { callback ); - if (spec.methodType === 'list') { + // Please note `spec.methodType === 'search'` is beta functionality and this + // interface is subject to change/removal at any time. + if (spec.methodType === 'list' || spec.methodType === 'search') { const autoPaginationMethods = makeAutoPaginationMethods( this, args, diff --git a/lib/autoPagination.js b/lib/autoPagination.js index 067079ef29..7304a0bc5d 100644 --- a/lib/autoPagination.js +++ b/lib/autoPagination.js @@ -6,15 +6,43 @@ const utils = require('./utils'); function makeAutoPaginationMethods(self, requestArgs, spec, firstPagePromise) { const promiseCache = {currentPromise: null}; const reverseIteration = isReverseIteration(requestArgs); - let listPromise = firstPagePromise; + let pagePromise = firstPagePromise; let i = 0; - function iterate(listResult) { + // Search and List methods iterate differently. + // Search relies on a `next_page` token and can only iterate in one direction. + // List relies on either an `ending_before` or `starting_after` field with + // an item ID to paginate and is bi-directional. + // + // Please note: spec.methodType === 'search' is beta functionality and is + // subject to change/removal at any time. + let getNextPagePromise; + if (spec.methodType === 'search') { + getNextPagePromise = (pageResult) => { + if (!pageResult.next_page) { + throw Error( + 'Unexpected: Stripe API response does not have a well-formed `next_page` field, but `has_more` was true.' + ); + } + return makeRequest(self, requestArgs, spec, { + next_page: pageResult.next_page, + }); + }; + } else { + getNextPagePromise = (pageResult) => { + const lastId = getLastId(pageResult, reverseIteration); + return makeRequest(self, requestArgs, spec, { + [reverseIteration ? 'ending_before' : 'starting_after']: lastId, + }); + }; + } + + function iterate(pageResult) { if ( !( - listResult && - listResult.data && - typeof listResult.data.length === 'number' + pageResult && + pageResult.data && + typeof pageResult.data.length === 'number' ) ) { throw Error( @@ -22,26 +50,24 @@ function makeAutoPaginationMethods(self, requestArgs, spec, firstPagePromise) { ); } - if (i < listResult.data.length) { - const idx = reverseIteration ? listResult.data.length - 1 - i : i; - const value = listResult.data[idx]; + if (i < pageResult.data.length) { + const idx = reverseIteration ? pageResult.data.length - 1 - i : i; + const value = pageResult.data[idx]; i += 1; + return {value, done: false}; - } else if (listResult.has_more) { + } else if (pageResult.has_more) { // Reset counter, request next page, and recurse. i = 0; - const lastId = getLastId(listResult, reverseIteration); - listPromise = makeRequest(self, requestArgs, spec, { - [reverseIteration ? 'ending_before' : 'starting_after']: lastId, - }); - return listPromise.then(iterate); + pagePromise = getNextPagePromise(pageResult); + return pagePromise.then(iterate); } return {value: undefined, done: true}; } function asyncIteratorNext() { return memoizedPromise(promiseCache, (resolve, reject) => { - return listPromise + return pagePromise .then(iterate) .then(resolve) .catch(reject); diff --git a/test/autoPagination.spec.js b/test/autoPagination.spec.js index f87b93ffb6..6a8171c4ad 100644 --- a/test/autoPagination.spec.js +++ b/test/autoPagination.spec.js @@ -701,4 +701,115 @@ describe('auto pagination', function() { }); }); }); + + describe('pagination logic using a mock search paginator', () => { + const mockPagination = (pages, initialArgs) => { + let i = 1; + const paramsLog = []; + const spec = { + method: 'GET', + methodType: 'search', + }; + + const addNextPage = (props) => { + let nextPageProperties = {}; + if (props.has_more) { + nextPageProperties = { + next_page: `${props.data[props.data.length - 1]}-encoded`, + }; + } + return {...props, ...nextPageProperties}; + }; + + const paginator = makeAutoPaginationMethods( + { + createResourcePathWithSymbols: () => {}, + createFullPath: () => {}, + _request: (_1, _2, path, _4, _5, _6, callback) => { + paramsLog.push(path); + + callback( + null, + Promise.resolve( + addNextPage({ + data: pages[i].map((id) => ({id})), + has_more: i < pages.length - 1, + }) + ) + ); + i += 1; + }, + }, + initialArgs || {}, + spec, + Promise.resolve( + addNextPage({ + data: pages[0].map((id) => ({id})), + has_more: pages.length > 1, + }) + ) + ); + return {paginator, paramsLog}; + }; + + const testCase = ({ + pages, + limit, + expectedIds, + expectedParamsLog, + initialArgs, + }) => { + const {paginator, paramsLog} = mockPagination(pages, initialArgs); + expect( + paginator.autoPagingToArray({limit}).then((result) => ({ + ids: result.map((x) => x.id), + paramsLog, + })) + ).to.eventually.deep.equal({ + ids: expectedIds, + paramsLog: expectedParamsLog, + }); + }; + + it('paginates forwards as expected', () => { + testCase({ + pages: [ + [1, 2], + [3, 4], + ], + limit: 5, + expectedIds: [1, 2, 3, 4], + expectedParamsLog: ['?next_page=2-encoded'], + }); + + testCase({ + pages: [[1, 2], [3, 4], [5]], + limit: 5, + expectedIds: [1, 2, 3, 4, 5], + expectedParamsLog: ['?next_page=2-encoded', '?next_page=4-encoded'], + }); + + testCase({ + pages: [ + [1, 2], + [3, 4], + [5, 6], + ], + limit: 5, + expectedIds: [1, 2, 3, 4, 5], + expectedParamsLog: ['?next_page=2-encoded', '?next_page=4-encoded'], + }); + + testCase({ + pages: [ + [1, 2], + [3, 4], + [5, 6], + ], + limit: 6, + expectedIds: [1, 2, 3, 4, 5, 6], + expectedParamsLog: ['?next_page=2-encoded', '?next_page=4-encoded'], + }); + }); + }); }); diff --git a/types/lib.d.ts b/types/lib.d.ts index cb090ab5df..49eca6e267 100644 --- a/types/lib.d.ts +++ b/types/lib.d.ts @@ -18,7 +18,9 @@ declare module 'stripe' { method: string; path?: string; fullPath?: string; - methodType?: 'list'; + // Please note, methodType === 'search' is beta functionality and is subject to + // change/removal at any time. + methodType?: 'list' | 'search'; }): (...args: any[]) => object; //eslint-disable-line @typescript-eslint/no-explicit-any static BASIC_METHODS: { create( @@ -202,6 +204,46 @@ declare module 'stripe' { autoPagingToArray(opts: {limit: number}): Promise>; } + /** + * A container for paginated lists of search results. + * The array of objects is on the `.data` property, + * and `.has_more` indicates whether there are additional objects beyond the end of this list. + * The `.next_page` field can be used to paginate forwards. + * + * Please note, ApiSearchResult is beta functionality and is subject to change/removal + * at any time. + */ + export interface ApiSearchResult { + object: 'search_result'; + + data: Array; + + /** + * True if this list has another page of items after this one that can be fetched. + */ + has_more: boolean; + + /** + * The URL where this list can be accessed. + */ + url: string; + + /** + * The page token to use to get the next page of results. If `has_more` is + * true, this will be set. + */ + next_page?: string; + } + export interface ApiSearchResultPromise + extends Promise>>, + AsyncIterableIterator { + autoPagingEach( + handler: (item: T) => boolean | void | Promise + ): Promise; + + autoPagingToArray(opts: {limit: number}): Promise>; + } + export type StripeStreamResponse = NodeJS.ReadableStream; /** diff --git a/types/test/typescriptTest.ts b/types/test/typescriptTest.ts index 9c763be32f..8cf4f97879 100644 --- a/types/test/typescriptTest.ts +++ b/types/test/typescriptTest.ts @@ -184,6 +184,11 @@ Stripe.StripeResource.extend({ method: 'create', fullPath: '/v1/full/path', }), + search: Stripe.StripeResource.method({ + method: 'create', + fullPath: 'foo', + methodType: 'search', + }), }); const maxBufferedRequestMetrics: number =