diff --git a/src/StripeMethod.ts b/src/StripeMethod.ts index 73e95ba8eb..25d03928ec 100644 --- a/src/StripeMethod.ts +++ b/src/StripeMethod.ts @@ -40,17 +40,10 @@ export function stripeMethod( callback ); - // 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, - spec, - requestPromise - ); - Object.assign(requestPromise, autoPaginationMethods); - } + Object.assign( + requestPromise, + makeAutoPaginationMethods(this, args, spec, requestPromise) + ); return requestPromise; }; diff --git a/src/autoPagination.ts b/src/autoPagination.ts index db81ed41df..72bfa42429 100644 --- a/src/autoPagination.ts +++ b/src/autoPagination.ts @@ -4,82 +4,66 @@ import {callbackifyPromiseWithTimeout, getDataFromArgs} from './utils.js'; type PromiseCache = { currentPromise: Promise | undefined | null; }; -type IterationResult = { - done: boolean; - value?: any; -}; +type IterationResult = + | { + done: false; + value: T; + } + | {done: true; value?: undefined}; type IterationDoneCallback = () => void; -type IterationItemCallback = ( - item: any, +type IterationItemCallback = ( + item: T, next: any ) => void | boolean | Promise; -type ListResult = { - data: Array; - // eslint-disable-next-line camelcase - has_more: boolean; -}; -type AutoPagingEach = ( - onItem: IterationItemCallback, +type AutoPagingEach = ( + onItem: IterationItemCallback, onDone?: IterationDoneCallback ) => Promise; type AutoPagingToArrayOptions = { limit?: number; }; -type AutoPagingToArray = ( +type AutoPagingToArray = ( opts: AutoPagingToArrayOptions, onDone: IterationDoneCallback -) => Promise>; +) => Promise>; -type AutoPaginationMethods = { - autoPagingEach: AutoPagingEach; - autoPagingToArray: AutoPagingToArray; - next: () => Promise; +type AutoPaginationMethods = { + autoPagingEach: AutoPagingEach; + autoPagingToArray: AutoPagingToArray; + next: () => Promise>; return: () => void; }; - -export function makeAutoPaginationMethods( - self: StripeResourceObject, - requestArgs: RequestArgs, - spec: MethodSpec, - firstPagePromise: Promise -): AutoPaginationMethods { - const promiseCache: PromiseCache = {currentPromise: null}; - const reverseIteration = isReverseIteration(requestArgs); - let pagePromise = firstPagePromise; - let i = 0; - - // 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: (pageResult: any) => Promise; - if (spec.methodType === 'search') { - getNextPagePromise = (pageResult): Promise => { - 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 self._makeRequest(requestArgs, spec, { - page: pageResult.next_page, - }); - }; - } else { - getNextPagePromise = (pageResult): Promise => { - const lastId = getLastId(pageResult, reverseIteration); - return self._makeRequest(requestArgs, spec, { - [reverseIteration ? 'ending_before' : 'starting_after']: lastId, - }); - }; +interface IStripeIterator { + next: () => Promise>; +} +type PageResult = { + data: Array; + has_more: boolean; + next_page: string | null; +}; +class StripeIterator implements IStripeIterator { + private index: number; + private pagePromise: Promise>; + private promiseCache: PromiseCache; + protected requestArgs: RequestArgs; + protected spec: MethodSpec; + protected stripeResource: StripeResourceObject; + constructor( + firstPagePromise: Promise>, + requestArgs: RequestArgs, + spec: MethodSpec, + stripeResource: StripeResourceObject + ) { + this.index = 0; + this.pagePromise = firstPagePromise; + this.promiseCache = {currentPromise: null}; + this.requestArgs = requestArgs; + this.spec = spec; + this.stripeResource = stripeResource; } - function iterate( - pageResult: ListResult - ): IterationResult | Promise { + async iterate(pageResult: PageResult): Promise> { if ( !( pageResult && @@ -92,39 +76,117 @@ export function makeAutoPaginationMethods( ); } - if (i < pageResult.data.length) { - const idx = reverseIteration ? pageResult.data.length - 1 - i : i; + const reverseIteration = isReverseIteration(this.requestArgs); + if (this.index < pageResult.data.length) { + const idx = reverseIteration + ? pageResult.data.length - 1 - this.index + : this.index; const value = pageResult.data[idx]; - i += 1; + this.index += 1; return {value, done: false}; } else if (pageResult.has_more) { // Reset counter, request next page, and recurse. - i = 0; - pagePromise = getNextPagePromise(pageResult); - return pagePromise.then(iterate); + this.index = 0; + this.pagePromise = this.getNextPage(pageResult); + const nextPageResult = await this.pagePromise; + return this.iterate(nextPageResult); } + // eslint-disable-next-line no-warning-comments + // TODO (next major) stop returning explicit undefined return {value: undefined, done: true}; } - function asyncIteratorNext(): Promise { - return memoizedPromise(promiseCache, (resolve, reject) => { - return pagePromise - .then(iterate) - .then(resolve) - .catch(reject); + /** @abstract */ + getNextPage(_pageResult: PageResult): Promise> { + throw new Error('Unimplemented'); + } + + private async _next(): Promise> { + return this.iterate(await this.pagePromise); + } + + next(): Promise> { + /** + * If a user calls `.next()` multiple times in parallel, + * return the same result until something has resolved + * to prevent page-turning race conditions. + */ + if (this.promiseCache.currentPromise) { + return this.promiseCache.currentPromise; + } + + const nextPromise = (async (): Promise> => { + const ret = await this._next(); + this.promiseCache.currentPromise = null; + return ret; + })(); + + this.promiseCache.currentPromise = nextPromise; + + return nextPromise; + } +} + +class ListIterator extends StripeIterator { + getNextPage(pageResult: PageResult): Promise> { + const reverseIteration = isReverseIteration(this.requestArgs); + const lastId = getLastId(pageResult, reverseIteration); + return this.stripeResource._makeRequest(this.requestArgs, this.spec, { + [reverseIteration ? 'ending_before' : 'starting_after']: lastId, }); } +} + +class SearchIterator extends StripeIterator { + getNextPage(pageResult: PageResult): Promise> { + 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 this.stripeResource._makeRequest(this.requestArgs, this.spec, { + page: pageResult.next_page, + }); + } +} + +export const makeAutoPaginationMethods = < + TMethodSpec extends MethodSpec, + TItem extends {id: string} +>( + stripeResource: StripeResourceObject, + requestArgs: RequestArgs, + spec: TMethodSpec, + firstPagePromise: Promise> +): AutoPaginationMethods | null => { + if (spec.methodType === 'search') { + return makeAutoPaginationMethodsFromIterator( + new SearchIterator(firstPagePromise, requestArgs, spec, stripeResource) + ); + } + if (spec.methodType === 'list') { + return makeAutoPaginationMethodsFromIterator( + new ListIterator(firstPagePromise, requestArgs, spec, stripeResource) + ); + } + return null; +}; - const autoPagingEach = makeAutoPagingEach(asyncIteratorNext); +const makeAutoPaginationMethodsFromIterator = ( + iterator: IStripeIterator +): AutoPaginationMethods => { + const autoPagingEach = makeAutoPagingEach((...args) => + iterator.next(...args) + ); const autoPagingToArray = makeAutoPagingToArray(autoPagingEach); - const autoPaginationMethods: AutoPaginationMethods = { + const autoPaginationMethods: AutoPaginationMethods = { autoPagingEach, autoPagingToArray, // Async iterator functions: - next: asyncIteratorNext, + next: () => iterator.next(), return: (): any => { // This is required for `break`. return {}; @@ -134,7 +196,7 @@ export function makeAutoPaginationMethods( }, }; return autoPaginationMethods; -} +}; /** * ---------------- @@ -174,7 +236,9 @@ function getDoneCallback(args: Array): IterationDoneCallback | null { * In addition to standard validation, this helper * coalesces the former forms into the latter form. */ -function getItemCallback(args: Array): IterationItemCallback | undefined { +function getItemCallback( + args: Array +): IterationItemCallback | undefined { if (args.length === 0) { return undefined; } @@ -206,7 +270,10 @@ function getItemCallback(args: Array): IterationItemCallback | undefined { }; } -function getLastId(listResult: ListResult, reverseIteration: boolean): string { +function getLastId( + listResult: PageResult, + reverseIteration: boolean +): string { const lastIdx = reverseIteration ? 0 : listResult.data.length - 1; const lastItem = listResult.data[lastIdx]; const lastId = lastItem && lastItem.id; @@ -218,28 +285,9 @@ function getLastId(listResult: ListResult, reverseIteration: boolean): string { return lastId; } -/** - * If a user calls `.next()` multiple times in parallel, - * return the same result until something has resolved - * to prevent page-turning race conditions. - */ -function memoizedPromise( - promiseCache: PromiseCache, - cb: (resolve: (value: T) => void, reject: (reason?: any) => void) => void -): Promise { - if (promiseCache.currentPromise) { - return promiseCache.currentPromise; - } - promiseCache.currentPromise = new Promise(cb).then((ret) => { - promiseCache.currentPromise = undefined; - return ret; - }); - return promiseCache.currentPromise; -} - -function makeAutoPagingEach( - asyncIteratorNext: () => Promise -): AutoPagingEach { +function makeAutoPagingEach( + asyncIteratorNext: () => Promise> +): AutoPagingEach { return function autoPagingEach(/* onItem?, onDone? */): Promise { const args = [].slice.call(arguments); const onItem = getItemCallback(args); @@ -254,12 +302,12 @@ function makeAutoPagingEach( onItem ); return callbackifyPromiseWithTimeout(autoPagePromise, onDone); - } as AutoPagingEach; + } as AutoPagingEach; } -function makeAutoPagingToArray( - autoPagingEach: AutoPagingEach -): AutoPagingToArray { +function makeAutoPagingToArray( + autoPagingEach: AutoPagingEach +): AutoPagingToArray { return function autoPagingToArray( opts, onDone: IterationDoneCallback @@ -293,12 +341,14 @@ function makeAutoPagingToArray( }; } -function wrapAsyncIteratorWithCallback( - asyncIteratorNext: () => Promise, - onItem: IterationItemCallback +function wrapAsyncIteratorWithCallback( + asyncIteratorNext: () => Promise>, + onItem: IterationItemCallback ): Promise { return new Promise((resolve, reject) => { - function handleIteration(iterResult: IterationResult): Promise | void { + function handleIteration( + iterResult: IterationResult + ): Promise | void { if (iterResult.done) { resolve(); return; diff --git a/test/autoPagination.spec.js b/test/autoPagination.spec.js index 5e4103623f..521548c3a6 100644 --- a/test/autoPagination.spec.js +++ b/test/autoPagination.spec.js @@ -9,11 +9,14 @@ const {makeAutoPaginationMethods} = require('../cjs/autoPagination.js'); const expect = require('chai').expect; -describe('auto pagination', function() { - const testCase = ( - mockPaginationFn, - {pages, limit, expectedIds, expectedParamsLog, initialArgs} - ) => { +describe('auto pagination', () => { + const testCase = (mockPaginationFn) => ({ + pages, + limit, + expectedIds, + expectedParamsLog, + initialArgs, + }) => { const {paginator, paramsLog} = mockPaginationFn(pages, initialArgs); return expect( @@ -28,14 +31,15 @@ describe('auto pagination', function() { paramsLog: expectedParamsLog, }); }; - - describe('pagination logic using a mock paginator', () => { - const mockPagination = (pages, initialArgs) => { + describe('V1 list response pagination', () => { + const mockPaginationV1List = (pages, initialArgs) => { let i = 1; const paramsLog = []; const spec = { method: 'GET', fullPath: '/v1/items', + methodType: 'list', + apiMode: 'v1', }; const mockStripe = testUtils.getMockStripe( @@ -65,6 +69,7 @@ describe('auto pagination', function() { ); return {paginator, paramsLog}; }; + const testCaseV1List = testCase(mockPaginationV1List); const ID_PAGES = [ ['cus_1', 'cus_2', 'cus_3'], @@ -83,7 +88,7 @@ describe('auto pagination', function() { describe('callbacks', () => { it('lets you call `next()` to iterate and `next(false)` to break', () => { - const {paginator} = mockPagination(ID_PAGES, {}); + const {paginator} = mockPaginationV1List(ID_PAGES, {}); return expect( new Promise((resolve, reject) => { @@ -111,7 +116,7 @@ describe('auto pagination', function() { }); it('lets you ignore the second arg and `return false` to break', () => { - const {paginator} = mockPagination(ID_PAGES, {}); + const {paginator} = mockPaginationV1List(ID_PAGES, {}); return expect( new Promise((resolve, reject) => { @@ -138,7 +143,7 @@ describe('auto pagination', function() { }); it('lets you ignore the second arg and return a Promise which returns `false` to break', () => { - const {paginator} = mockPagination(ID_PAGES, {}); + const {paginator} = mockPaginationV1List(ID_PAGES, {}); return expect( new Promise((resolve, reject) => { @@ -166,7 +171,7 @@ describe('auto pagination', function() { }); it('can use a promise instead of a callback for onDone', () => { - const {paginator} = mockPagination(ID_PAGES, {}); + const {paginator} = mockPaginationV1List(ID_PAGES, {}); return expect( new Promise((resolve, reject) => { @@ -187,7 +192,7 @@ describe('auto pagination', function() { }); it('handles the end of a list properly when the last page is full', () => { - const {paginator} = mockPagination( + const {paginator} = mockPaginationV1List( [ ['cus_1', 'cus_2'], ['cus_3', 'cus_4'], @@ -213,7 +218,7 @@ describe('auto pagination', function() { }); it('handles the end of a list properly when the last page is not full', () => { - const {paginator} = mockPagination( + const {paginator} = mockPaginationV1List( [ ['cus_1', 'cus_2', 'cus_3'], ['cus_4', 'cus_5', 'cus_6'], @@ -237,7 +242,7 @@ describe('auto pagination', function() { }); it('handles a list which is shorter than the page size properly', () => { - const {paginator} = mockPagination([OBJECT_IDS], { + const {paginator} = mockPaginationV1List([OBJECT_IDS], { limit: TOTAL_OBJECTS + 2, }); @@ -257,7 +262,7 @@ describe('auto pagination', function() { }); it('handles errors after the first page correctly (callback)', () => { - const {paginator} = mockPagination(ID_PAGES, {}); + const {paginator} = mockPaginationV1List(ID_PAGES, {}); return expect( new Promise((resolve, reject) => { @@ -280,7 +285,7 @@ describe('auto pagination', function() { }); it('handles errors after the first page correctly (promise)', () => { - const {paginator} = mockPagination(ID_PAGES, {}); + const {paginator} = mockPaginationV1List(ID_PAGES, {}); return expect( new Promise((resolve, reject) => { @@ -339,7 +344,7 @@ describe('auto pagination', function() { } it('works with `for await` when that feature exists (user break)', () => { - const {paginator} = mockPagination(ID_PAGES, {}); + const {paginator} = mockPaginationV1List(ID_PAGES, {}); return expect( new Promise((resolve, reject) => { @@ -353,7 +358,7 @@ describe('auto pagination', function() { }); it('works with `for await` when that feature exists (end of list)', () => { - const {paginator} = mockPagination(ID_PAGES, {}); + const {paginator} = mockPaginationV1List(ID_PAGES, {}); return expect( new Promise((resolve, reject) => { @@ -367,7 +372,7 @@ describe('auto pagination', function() { }); it('works with `await` and a while loop when await exists', () => { - const {paginator} = mockPagination(ID_PAGES, {}); + const {paginator} = mockPaginationV1List(ID_PAGES, {}); return expect( new Promise((resolve, reject) => { @@ -381,7 +386,7 @@ describe('auto pagination', function() { }); it('returns an empty object from .return() so that `break;` works in for-await', () => { - const {paginator} = mockPagination(ID_PAGES, {}); + const {paginator} = mockPaginationV1List(ID_PAGES, {}); return expect( new Promise((resolve, reject) => { @@ -403,7 +408,7 @@ describe('auto pagination', function() { }); it('works when you call it sequentially', () => { - const {paginator} = mockPagination(ID_PAGES, {}); + const {paginator} = mockPaginationV1List(ID_PAGES, {}); return expect( new Promise((resolve, reject) => { @@ -426,7 +431,7 @@ describe('auto pagination', function() { }); it('gives you the same result each time when you call it multiple times in parallel', () => { - const {paginator} = mockPagination(ID_PAGES, {}); + const {paginator} = mockPaginationV1List(ID_PAGES, {}); expect( new Promise((resolve, reject) => { @@ -476,7 +481,7 @@ describe('auto pagination', function() { describe('autoPagingToArray', () => { it('can go to the end', () => { - const {paginator} = mockPagination(ID_PAGES, {}); + const {paginator} = mockPaginationV1List(ID_PAGES, {}); return expect( new Promise((resolve, reject) => { @@ -490,7 +495,7 @@ describe('auto pagination', function() { }); it('returns a promise of an array', () => { - const {paginator} = mockPagination(ID_PAGES, {}); + const {paginator} = mockPaginationV1List(ID_PAGES, {}); return expect( new Promise((resolve, reject) => { @@ -504,7 +509,7 @@ describe('auto pagination', function() { }); it('accepts an onDone callback, passing an array', () => { - const {paginator} = mockPagination(ID_PAGES, {}); + const {paginator} = mockPaginationV1List(ID_PAGES, {}); return expect( new Promise((resolve, reject) => { @@ -524,7 +529,7 @@ describe('auto pagination', function() { describe('foward pagination', () => { it('paginates forwards through a page', () => { - return testCase(mockPagination, { + return testCaseV1List({ pages: [ [1, 2], [3, 4], @@ -536,7 +541,7 @@ describe('auto pagination', function() { }); it('paginates forwards through un-even sized pages', () => { - return testCase(mockPagination, { + return testCaseV1List({ pages: [[1, 2], [3, 4], [5]], limit: 10, expectedIds: [1, 2, 3, 4, 5], @@ -545,7 +550,7 @@ describe('auto pagination', function() { }); it('respects limit even when paginating', () => { - return testCase(mockPagination, { + return testCaseV1List({ pages: [ [1, 2], [3, 4], @@ -558,7 +563,7 @@ describe('auto pagination', function() { }); it('paginates through multiple full pages', () => { - return testCase(mockPagination, { + return testCaseV1List({ pages: [ [1, 2], [3, 4], @@ -580,7 +585,7 @@ describe('auto pagination', function() { describe('backwards pagination', () => { it('paginates forwards through a page', () => { - return testCase(mockPagination, { + return testCaseV1List({ pages: [ [-2, -1], [-4, -3], @@ -593,7 +598,7 @@ describe('auto pagination', function() { }); it('paginates backwards through un-even sized pages ', () => { - return testCase(mockPagination, { + return testCaseV1List({ pages: [[-2, -1], [-4, -3], [-5]], limit: 5, expectedIds: [-1, -2, -3, -4, -5], @@ -603,7 +608,7 @@ describe('auto pagination', function() { }); it('respects limit', () => { - return testCase(mockPagination, { + return testCaseV1List({ pages: [ [-2, -1], [-4, -3], @@ -618,13 +623,14 @@ describe('auto pagination', function() { }); }); - describe('pagination logic using a mock search paginator', () => { - const mockPagination = (pages, initialArgs) => { + describe('V1 search result pagination', () => { + const mockPaginationV1Search = (pages, initialArgs) => { let i = 1; const paramsLog = []; const spec = { method: 'GET', methodType: 'search', + apiMode: 'v1', }; const addNextPage = (props) => { @@ -668,9 +674,9 @@ describe('auto pagination', function() { ); return {paginator, paramsLog}; }; - + const testCaseV1Search = testCase(mockPaginationV1Search); it('paginates forwards through a page', () => { - return testCase(mockPagination, { + return testCaseV1Search({ pages: [ [1, 2], [3, 4], @@ -682,7 +688,7 @@ describe('auto pagination', function() { }); it('paginates forwards through uneven-sized pages', () => { - return testCase(mockPagination, { + return testCaseV1Search({ pages: [[1, 2], [3, 4], [5]], limit: 10, expectedIds: [1, 2, 3, 4, 5], @@ -691,7 +697,7 @@ describe('auto pagination', function() { }); it('respects limit even when paginating', () => { - return testCase(mockPagination, { + return testCaseV1Search({ pages: [ [1, 2], [3, 4], @@ -704,7 +710,7 @@ describe('auto pagination', function() { }); it('paginates through multiple full pages', () => { - return testCase(mockPagination, { + return testCaseV1Search({ pages: [ [1, 2], [3, 4],