279 lines
8.2 KiB
JavaScript
279 lines
8.2 KiB
JavaScript
|
'use strict';
|
||
|
|
||
|
const makeRequest = require('./makeRequest');
|
||
|
const utils = require('./utils');
|
||
|
|
||
|
function makeAutoPaginationMethods(self, requestArgs, spec, firstPagePromise) {
|
||
|
const 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;
|
||
|
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, {
|
||
|
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 (
|
||
|
!(
|
||
|
pageResult &&
|
||
|
pageResult.data &&
|
||
|
typeof pageResult.data.length === 'number'
|
||
|
)
|
||
|
) {
|
||
|
throw Error(
|
||
|
'Unexpected: Stripe API response does not have a well-formed `data` array.'
|
||
|
);
|
||
|
}
|
||
|
|
||
|
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 (pageResult.has_more) {
|
||
|
// Reset counter, request next page, and recurse.
|
||
|
i = 0;
|
||
|
pagePromise = getNextPagePromise(pageResult);
|
||
|
return pagePromise.then(iterate);
|
||
|
}
|
||
|
return {value: undefined, done: true};
|
||
|
}
|
||
|
|
||
|
function asyncIteratorNext() {
|
||
|
return memoizedPromise(promiseCache, (resolve, reject) => {
|
||
|
return pagePromise
|
||
|
.then(iterate)
|
||
|
.then(resolve)
|
||
|
.catch(reject);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
const autoPagingEach = makeAutoPagingEach(asyncIteratorNext);
|
||
|
const autoPagingToArray = makeAutoPagingToArray(autoPagingEach);
|
||
|
|
||
|
const autoPaginationMethods = {
|
||
|
autoPagingEach,
|
||
|
autoPagingToArray,
|
||
|
|
||
|
// Async iterator functions:
|
||
|
next: asyncIteratorNext,
|
||
|
return: () => {
|
||
|
// This is required for `break`.
|
||
|
return {};
|
||
|
},
|
||
|
[getAsyncIteratorSymbol()]: () => {
|
||
|
return autoPaginationMethods;
|
||
|
},
|
||
|
};
|
||
|
return autoPaginationMethods;
|
||
|
}
|
||
|
|
||
|
module.exports.makeAutoPaginationMethods = makeAutoPaginationMethods;
|
||
|
|
||
|
/**
|
||
|
* ----------------
|
||
|
* Private Helpers:
|
||
|
* ----------------
|
||
|
*/
|
||
|
|
||
|
function getAsyncIteratorSymbol() {
|
||
|
if (typeof Symbol !== 'undefined' && Symbol.asyncIterator) {
|
||
|
return Symbol.asyncIterator;
|
||
|
}
|
||
|
// Follow the convention from libraries like iterall: https://github.com/leebyron/iterall#asynciterator-1
|
||
|
return '@@asyncIterator';
|
||
|
}
|
||
|
|
||
|
function getDoneCallback(args) {
|
||
|
if (args.length < 2) {
|
||
|
return undefined;
|
||
|
}
|
||
|
const onDone = args[1];
|
||
|
if (typeof onDone !== 'function') {
|
||
|
throw Error(
|
||
|
`The second argument to autoPagingEach, if present, must be a callback function; received ${typeof onDone}`
|
||
|
);
|
||
|
}
|
||
|
return onDone;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* We allow four forms of the `onItem` callback (the middle two being equivalent),
|
||
|
*
|
||
|
* 1. `.autoPagingEach((item) => { doSomething(item); return false; });`
|
||
|
* 2. `.autoPagingEach(async (item) => { await doSomething(item); return false; });`
|
||
|
* 3. `.autoPagingEach((item) => doSomething(item).then(() => false));`
|
||
|
* 4. `.autoPagingEach((item, next) => { doSomething(item); next(false); });`
|
||
|
*
|
||
|
* In addition to standard validation, this helper
|
||
|
* coalesces the former forms into the latter form.
|
||
|
*/
|
||
|
function getItemCallback(args) {
|
||
|
if (args.length === 0) {
|
||
|
return undefined;
|
||
|
}
|
||
|
const onItem = args[0];
|
||
|
if (typeof onItem !== 'function') {
|
||
|
throw Error(
|
||
|
`The first argument to autoPagingEach, if present, must be a callback function; received ${typeof onItem}`
|
||
|
);
|
||
|
}
|
||
|
|
||
|
// 4. `.autoPagingEach((item, next) => { doSomething(item); next(false); });`
|
||
|
if (onItem.length === 2) {
|
||
|
return onItem;
|
||
|
}
|
||
|
|
||
|
if (onItem.length > 2) {
|
||
|
throw Error(
|
||
|
`The \`onItem\` callback function passed to autoPagingEach must accept at most two arguments; got ${onItem}`
|
||
|
);
|
||
|
}
|
||
|
|
||
|
// This magically handles all three of these usecases (the latter two being functionally identical):
|
||
|
// 1. `.autoPagingEach((item) => { doSomething(item); return false; });`
|
||
|
// 2. `.autoPagingEach(async (item) => { await doSomething(item); return false; });`
|
||
|
// 3. `.autoPagingEach((item) => doSomething(item).then(() => false));`
|
||
|
return function _onItem(item, next) {
|
||
|
const shouldContinue = onItem(item);
|
||
|
next(shouldContinue);
|
||
|
};
|
||
|
}
|
||
|
|
||
|
function getLastId(listResult, reverseIteration) {
|
||
|
const lastIdx = reverseIteration ? 0 : listResult.data.length - 1;
|
||
|
const lastItem = listResult.data[lastIdx];
|
||
|
const lastId = lastItem && lastItem.id;
|
||
|
if (!lastId) {
|
||
|
throw Error(
|
||
|
'Unexpected: No `id` found on the last item while auto-paging a list.'
|
||
|
);
|
||
|
}
|
||
|
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, cb) {
|
||
|
if (promiseCache.currentPromise) {
|
||
|
return promiseCache.currentPromise;
|
||
|
}
|
||
|
promiseCache.currentPromise = new Promise(cb).then((ret) => {
|
||
|
promiseCache.currentPromise = undefined;
|
||
|
return ret;
|
||
|
});
|
||
|
return promiseCache.currentPromise;
|
||
|
}
|
||
|
|
||
|
function makeAutoPagingEach(asyncIteratorNext) {
|
||
|
return function autoPagingEach(/* onItem?, onDone? */) {
|
||
|
const args = [].slice.call(arguments);
|
||
|
const onItem = getItemCallback(args);
|
||
|
const onDone = getDoneCallback(args);
|
||
|
if (args.length > 2) {
|
||
|
throw Error(`autoPagingEach takes up to two arguments; received ${args}`);
|
||
|
}
|
||
|
|
||
|
const autoPagePromise = wrapAsyncIteratorWithCallback(
|
||
|
asyncIteratorNext,
|
||
|
onItem
|
||
|
);
|
||
|
return utils.callbackifyPromiseWithTimeout(autoPagePromise, onDone);
|
||
|
};
|
||
|
}
|
||
|
|
||
|
function makeAutoPagingToArray(autoPagingEach) {
|
||
|
return function autoPagingToArray(opts, onDone) {
|
||
|
const limit = opts && opts.limit;
|
||
|
if (!limit) {
|
||
|
throw Error(
|
||
|
'You must pass a `limit` option to autoPagingToArray, e.g., `autoPagingToArray({limit: 1000});`.'
|
||
|
);
|
||
|
}
|
||
|
if (limit > 10000) {
|
||
|
throw Error(
|
||
|
'You cannot specify a limit of more than 10,000 items to fetch in `autoPagingToArray`; use `autoPagingEach` to iterate through longer lists.'
|
||
|
);
|
||
|
}
|
||
|
const promise = new Promise((resolve, reject) => {
|
||
|
const items = [];
|
||
|
autoPagingEach((item) => {
|
||
|
items.push(item);
|
||
|
if (items.length >= limit) {
|
||
|
return false;
|
||
|
}
|
||
|
})
|
||
|
.then(() => {
|
||
|
resolve(items);
|
||
|
})
|
||
|
.catch(reject);
|
||
|
});
|
||
|
return utils.callbackifyPromiseWithTimeout(promise, onDone);
|
||
|
};
|
||
|
}
|
||
|
|
||
|
function wrapAsyncIteratorWithCallback(asyncIteratorNext, onItem) {
|
||
|
return new Promise((resolve, reject) => {
|
||
|
function handleIteration(iterResult) {
|
||
|
if (iterResult.done) {
|
||
|
resolve();
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const item = iterResult.value;
|
||
|
return new Promise((next) => {
|
||
|
// Bit confusing, perhaps; we pass a `resolve` fn
|
||
|
// to the user, so they can decide when and if to continue.
|
||
|
// They can return false, or a promise which resolves to false, to break.
|
||
|
onItem(item, next);
|
||
|
}).then((shouldContinue) => {
|
||
|
if (shouldContinue === false) {
|
||
|
return handleIteration({done: true});
|
||
|
} else {
|
||
|
return asyncIteratorNext().then(handleIteration);
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
asyncIteratorNext()
|
||
|
.then(handleIteration)
|
||
|
.catch(reject);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
function isReverseIteration(requestArgs) {
|
||
|
const args = [].slice.call(requestArgs);
|
||
|
const dataFromArgs = utils.getDataFromArgs(args);
|
||
|
|
||
|
return !!dataFromArgs.ending_before;
|
||
|
}
|