Skip to content

Commit

Permalink
Preliminary implementation of autoPagingEach
Browse files Browse the repository at this point in the history
  • Loading branch information
rattrayalex-stripe committed Sep 11, 2018
1 parent 5109070 commit 7efc7ea
Show file tree
Hide file tree
Showing 9 changed files with 312 additions and 1 deletion.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.node10.js
1 change: 1 addition & 0 deletions lib/StripeMethod.basic.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module.exports = {

list: stripeMethod({
method: 'GET',
autoPageable: true,
}),

retrieve: stripeMethod({
Expand Down
5 changes: 5 additions & 0 deletions lib/StripeMethod.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

var makeRequest = require('./makeRequest');
var autoPagingEach = require('./autoPagingEach');

/**
* Create an API method from the declared spec.
Expand All @@ -16,6 +17,7 @@ var makeRequest = require('./makeRequest');
* Usefully for applying transforms to data on a per-method basis.
*/
function stripeMethod(spec) {
var autoPageable = spec.autoPageable || false;

return function() {
var self = this;
Expand All @@ -25,6 +27,9 @@ function stripeMethod(spec) {

var requestPromise = makeRequest(self, args, spec, {});

if (autoPageable) {
requestPromise.autoPagingEach = autoPagingEach(self, args, spec, requestPromise);
}

return self.wrapTimeout(requestPromise, callback);
};
Expand Down
153 changes: 153 additions & 0 deletions lib/autoPagingEach.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
'use strict';

var makeRequest = require('./makeRequest');

function getItemCallback(args) {
if (args.length === 0) {
return undefined;
}
var onItem = args[0];
if (typeof onItem !== 'function') {
throw Error('The first argument to autoPagingEach, if present, must be a callback function; receieved ' + typeof onItem);
}

// `.autoPagingEach((item, next) => { doSomething(item); next(); });`
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);
}

// API compat; turn this:
// .autoPagingEach((item) => { doSomething(item); return false; });
// into this:
// .autoPagingEach((item) => { doSomething(item); next(false); });
return function(item, next) {
var shouldContinue = onItem(item);
next(shouldContinue);
};
}

function getDoneCallback(args) {
if (args.length < 2) {
return undefined;
}
var onDone = args[1];
if (typeof onDone !== 'function') {
throw Error('The second argument to autoPagingEach, if present, must be a callback function; receieved ' + typeof onDone);
}
return onDone;
}

function wrapAsyncIteratorWithCallback(asyncIteratorNext, onItem) {
return new Promise(function(resolve, reject) {
function handleIteration(iterResult) {
if (iterResult.done) {
resolve();
return;
}

var item = iterResult.value;
return new Promise(function(next) {
// Bit confusing, perhaps; we pass a `resolve` fn
// to the user, so they can decide when and if to continue.
onItem(item, next);
}).then(function(shouldContinue) {
if (shouldContinue === false) {
return handleIteration({done: true});
} else {
return asyncIteratorNext().then(handleIteration);
}
});
}

asyncIteratorNext().then(handleIteration).catch(reject);
});
}

function autoPagingEach(self, requestArgs, spec, firstPagePromise) {
return function autoPagingEach(/* onItem?, onDone? */) {
var args = [].slice.call(arguments);
var onItem = getItemCallback(args);
var onDone = getDoneCallback(args);
if (args.length > 2) {
throw Error('autoPagingEach takes up to two arguments; received:', args);
}

function requestNextPage(listResult) {
var lastIdx = listResult.data.length - 1;
var lastItem = listResult.data[lastIdx];
var lastId = lastItem && lastItem.id;
if (!lastId) {
throw Error('Unexpected: No `id` found on the last item while auto-paging a list.');
}
return makeRequest(self, requestArgs, spec, {starting_after: lastId});
}

// If a user calls `.next()` multiple times in parallel,
// return the same result until something has resolved
// to prevent page-turning race conditions.
var currentPromise;
function memoizedPromise(cb) {
if (currentPromise) {
return currentPromise;
}
currentPromise = new Promise(cb).then(function(ret) {
currentPromise = undefined;
return ret;
});
return currentPromise;
}

// Iterator state.
var listPromise = firstPagePromise;
var i = 0;

function iterate(listResult) {
if (i < listResult.data.length) {
var value = listResult.data[i];
i += 1;
return {value: value, done: false};
} else if (listResult.has_more) {
// Reset counter, request next page, and recurse.
i = 0;
listPromise = requestNextPage(listResult);
return listPromise.then(iterate);
}
return {done: true};
}

function asyncIteratorNext() {
return memoizedPromise(function(resolve, reject) {
return listPromise
.then(iterate)
.catch(reject)
.then(resolve);
});
}

// Bifurcate API for those using callbacks vs. those using async iterators.
if (onItem) {
var autoPagePromise = wrapAsyncIteratorWithCallback(asyncIteratorNext, onItem);
return self.wrapTimeout(autoPagePromise, onDone);
} else {
var iterator = {
next: asyncIteratorNext,
return: function() {
// This is required for `break`.
return {};
},
}
if (typeof Symbol !== 'undefined' && Symbol.asyncIterator) {
iterator[Symbol.asyncIterator] = function() {
return iterator;
}
}
return iterator;
}
};
}

module.exports = autoPagingEach;
1 change: 1 addition & 0 deletions lib/resources/Accounts.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ module.exports = StripeResource.extend({
list: stripeMethod({
method: 'GET',
path: 'accounts',
autoPageable: true,
}),

update: stripeMethod({
Expand Down
1 change: 1 addition & 0 deletions lib/resources/UsageRecordSummaries.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ module.exports = StripeResource.extend({
method: 'GET',
path: '{subscriptionItem}/usage_record_summaries',
urlParams: ['subscriptionItem'],
autoPageable: true,
}),
});
137 changes: 137 additions & 0 deletions test/autoPagination.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
'use strict';

/* eslint-disable callback-return */

var testUtils = require('./testUtils');
var stripe = require('../lib/stripe')(
testUtils.getUserStripeKey(),
'latest'
);

var expect = require('chai').expect;

var LIMIT = 7;

describe('auto pagination', function() {
this.timeout(20000);

var realCustomerIds;
before(function() {
return new Promise(function(resolve) {
stripe.customers.list({limit: LIMIT}).then(function(customers) {
realCustomerIds = customers.data.map(function(item) {
return item.id;
});
resolve();
});
});
});

describe('callbacks', function() {
it('lets you call `next()` to iterate and `next(false)` to break', function() {
return expect(new Promise(function(resolve, reject) {
var customerIds = [];
function onCustomer(customer, next) {
customerIds.push(customer.id);
if (customerIds.length >= LIMIT) {
next(false);
} else {
next();
}
}
function onDone(err) {
resolve(customerIds);
}

stripe.customers.list({limit: 3}).autoPagingEach(onCustomer, onDone);
})).to.eventually.deep.equal(realCustomerIds);
});

it('lets you ignore the second arg and `return false` to break', function() {
return expect(new Promise(function(resolve, reject) {
var customerIds = [];
function onCustomer(customer) {
customerIds.push(customer.id);
if (customerIds.length >= LIMIT) {
return false;
}
}
function onDone(err) {
resolve(customerIds);
}

stripe.customers.list({limit: 3}).autoPagingEach(onCustomer, onDone);
})).to.eventually.deep.equal(realCustomerIds);
});
});

describe('async iterators', function() {
// `for await` throws a syntax error everywhere but node 10,
// so we must conditionally require it.
if (typeof Symbol !== 'undefined' && Symbol.asyncIterator) {
var forAwaitUntil = require('./forAwait.node10').forAwaitUntil;

it('works with `for await` when that feature exists', function() {
return expect(new Promise(function(resolve, reject) {
forAwaitUntil(stripe.customers.list({limit: 3}).autoPagingEach(), LIMIT).then(function(customers) {
resolve(customers.map(function(customer) { return customer.id; }));
});
})).to.eventually.deep.equal(realCustomerIds);
});
}

it('works when you call it sequentially', function() {
return expect(new Promise(function(resolve, reject) {
var iter = stripe.customers.list({limit: 3}).autoPagingEach();

var customerIds = [];
function handleIter(result) {
customerIds.push(result.value.id);
if (customerIds.length < 7) {
return iter.next().then(handleIter);
}
}
iter.next().then(handleIter).then(function() {
resolve(customerIds);
});
})).to.eventually.deep.equal(realCustomerIds);
});

it('gives you the same result each time when you call it multiple times in parallel', function() {
return expect(new Promise(function(resolve, reject) {
var iter = stripe.customers.list({limit: 3}).autoPagingEach();

var customerIds = []
function handleIter(result) {
customerIds.push(result.value.id);
}

Promise.all([
iter.next().then(handleIter),
iter.next().then(handleIter).then(function() {
return Promise.all([
iter.next().then(handleIter),
iter.next().then(handleIter),
])
}).then(function() {
return Promise.all([
iter.next().then(handleIter),
iter.next().then(handleIter),
])
}).then(function() {
return Promise.all([
iter.next().then(handleIter),
iter.next().then(handleIter),
])
})
]).then(function() {
resolve(customerIds);
});
})).to.eventually.deep.equal(realCustomerIds.slice(0, 4).reduce(function(acc, x) {
acc.push(x);
acc.push(x);
return acc;
}, []));
});
});
});
12 changes: 12 additions & 0 deletions test/forAwait.node10.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use strict';

module.exports.forAwaitUntil = async function forAwaitUntil(iterator, limit) {
const items = [];
for await (const item of iterator) {
items.push(item);
if (items.length >= limit) {
break;
}
}
return items;
}
2 changes: 1 addition & 1 deletion test/mocha.opts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
--bail
--recursive
**/*.spec.js

0 comments on commit 7efc7ea

Please sign in to comment.