diff --git a/lib/concurrent/Future.js b/lib/concurrent/Future.js new file mode 100644 index 0000000..7b0297e --- /dev/null +++ b/lib/concurrent/Future.js @@ -0,0 +1,121 @@ +/** + * @callback Future~resolver + * + * @param {Function} fulfill + * @param {Function} reject + */ + +/** + * This is a very standard promise interface implementation, but with ability + * to be externally completed or cancelled by `#resolve()` and `#reject()` + * methods. + * + * It is named after Java's CompletableFuture. + * + * @class + * @template T + * + * @param {Future~resolver} [resolver] Standard promise resolver + */ +function Future (resolver) { + var self = this + var fulfiller + var rejector + var container = new Promise(function (resolve, reject) { + if (resolver) { + resolver(resolve, reject) + } + fulfiller = resolve + rejector = reject + }) + + /** + * Rejects current instance with provided value + * + * @param {*} [value] + * @returns {Future.} Current instance + */ + this.reject = function (value) { + rejector(value) + return self + } + + /** + * Resolves (fulfills) current instance with provided value + * + * @param {*} [value] + * @returns {Future.} Current instance + */ + this.resolve = function (value) { + fulfiller(value) + return self + } + + this.fulfill = this.resolve + + /** + * Standard then-implementation + * + * @param {Function} [fulfillmentHandler] + * @param {Function} [rejectionHandler] + * @returns {Future.} + */ + this.then = function (fulfillmentHandler, rejectionHandler) { + return Future.wrap(container.then(fulfillmentHandler, rejectionHandler)) + } +} + +/** + * Returns promise that awaits all passed promises. + * + * @param {Array.|Thenable<*>>} promises + * @returns {Future.<*>} + */ +Future.all = function (promises) { + return Future.wrap(Promise.all(promises)) +} + +/** + * Returns result of first resolved promise, be it fulfillment or rejection + * + * @param {Array.|Thenable<*>>} promises + * @returns {Future.<*>} + */ +Future.race = function (promises) { + return Future.wrap(Promise.race(promises)) +} + +/** + * Returns resolved promise. + * + * @param {*} value + * @returns {Future.<*>} + */ +Future.resolve = function (value) { + return new Future().resolve(value) +} + +/** + * Returns rejected promise. + * + * @param {*} value + * @returns {Future.<*>} + */ +Future.reject = function (value) { + return new Future().reject(value) +} + +/** + * Wraps given promise in a Future, giving user code an option + * to reject/resolve it. + * + * @param {Promise.<*>|Thenable.<*>} promise + * @returns {Future.<*>} + */ +Future.wrap = function (promise) { + return new Future(promise.then.bind(promise)) +} + +module.exports = { + Future: Future +} diff --git a/lib/concurrent/index.js b/lib/concurrent/index.js new file mode 100644 index 0000000..a04b8a7 --- /dev/null +++ b/lib/concurrent/index.js @@ -0,0 +1,3 @@ +module.exports = { + Future: require('./Future').Future +} diff --git a/lib/index.js b/lib/index.js index faea535..10d0eb2 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,6 +1,7 @@ var exports = { Http: require('./http'), - Logger: require('./logger') + Logger: require('./logger'), + Concurrent: require('./concurrent') } /** @deprecated */ exports.http = exports.Http diff --git a/test/suites/integration/concurrent/Future.spec.js b/test/suites/integration/concurrent/Future.spec.js new file mode 100644 index 0000000..f1c8f29 --- /dev/null +++ b/test/suites/integration/concurrent/Future.spec.js @@ -0,0 +1,214 @@ +/* eslint-env mocha */ +/* eslint-disable no-unused-expressions */ + +var Future = require('../../../../lib').Concurrent.Future +var Sinon = require('sinon') +var Chai = require('chai') +var expect = Chai.expect + +Chai.use(require('chai-as-promised')) + +var branchStopper = function () { + throw new Error('Unexpected branch execution') +} + +describe('Integration', function () { + describe('/concurrent', function () { + describe('/Future.js', function () { + describe('Future', function () { + describe('< new', function () { + it('does not require arguments', function () { + expect(new Future()).to.be.instanceOf(Future) + }) + + it('accepts resolver function', function () { + var value = 12 + var resolver = function (resolve) { + resolve(value) + } + return expect(new Future(resolver)).to.eventually.equal(value) + }) + }) + + describe('#resolve', function () { + it('resolves externally', function () { + var future = new Future() + var value = 12 + return expect(future.resolve(value)).to.eventually.equal(value) + }) + + it('doesn\'t resolve twice', function () { + var future = new Future() + var value = 12 + future.resolve(value) + future.resolve([value]) + return expect(future).to.eventually.equal(value) + }) + }) + + describe('#reject', function () { + it('rejects externally', function () { + var future = new Future() + var error = new Error() + future.reject(error) + return expect(future).to.eventually.be.rejectedWith(error) + }) + + it('doesn\'t reject twice', function () { + var future = new Future() + var error = new Error() + future.reject(error) + future.reject(new Error()) + return expect(future).to.eventually.be.rejectedWith(error) + }) + }) + + describe('#then', function () { + it('composes new Future', function () { + var future = new Future() + var nextFuture = future.then() + expect(nextFuture).to.be.instanceOf(Future) + expect(nextFuture).not.to.equal(future) + }) + + it('invokes resolution handler when fulfilled', function () { + var future = new Future() + var handler = Sinon.stub() + var nextFuture = future.then(handler) + var value = {value: 12} + future.resolve(value) + return nextFuture.then(function () { + expect(handler.calledOnce).to.be.true + expect(handler.getCall(0).args[0]).to.equal(value) + }) + }) + + it('invokes resolution handler even if set after fulfillment', function () { + var future = new Future() + var value = {value: 12} + future.resolve(value) + var handler = Sinon.stub() + var nextFuture = future.then(handler) + return nextFuture.then(function () { + expect(handler.calledOnce).to.be.true + expect(handler.getCall(0).args[0]).to.equal(value) + }) + }) + + it('invokes rejection handler when rejected', function () { + var future = new Future() + var handler = Sinon.stub() + var nextFuture = future.then(null, handler) + var value = {value: 12} + future.reject(value) + return nextFuture.then(function () { + expect(handler.calledOnce).to.be.true + expect(handler.getCall(0).args[0]).to.equal(value) + }) + }) + + it('invokes rejection handler even if set after rejection', function () { + var future = new Future() + var value = {value: 12} + future.reject(value) + var handler = Sinon.stub() + var nextFuture = future.then(null, handler) + return nextFuture.then(function () { + expect(handler.calledOnce).to.be.true + expect(handler.getCall(0).args[0]).to.equal(value) + }) + }) + }) + + describe('.resolve', function () { + it('returns resolved promise', function () { + var value = {x: 12} + return expect(Future.resolve(value)).to.eventually.equal(value) + }) + }) + + describe('.reject', function () { + it('returns rejected promise', function () { + var value = {x: 12} + var future = Future.reject(value) + return expect(future).to.eventually.be.rejectedWith(value) + }) + }) + + describe('.wrap', function () { + it('wraps Promise with a Future', function () { + var promise = new Promise(function () {}) + return expect(Future.wrap(promise)).to.be.instanceOf(Future) + }) + + it('catches resolved Promise value', function () { + var value = {x: 12} + var promise = new Promise(function (resolve) { + resolve(value) + }) + var future = Future.wrap(promise) + promise + .then(function () { + future.resolve([value]) + }) + return expect(future).to.eventually.equal(value) + }) + + it('catches rejected Promise value', function () { + var value = {x: 12} + var promise = new Promise(function (resolve, reject) { + reject(value) + }) + var future = Future.wrap(promise) + promise + .then(branchStopper, function () { + future.reject([value]) + }) + return expect(future).to.eventually.be.rejectedWith(value) + }) + }) + + describe('.all', function () { + it('returns resolved promise if nothing is passed', function () { + return expect(Future.all([])) + }) + + it('awaits all promises and returns their results as array', function () { + var promises = [new Future(), new Future(), new Future()] + var target = Future.all(promises) + var expectation = [] + for (var i = 0; i < promises.length; i++) { + promises[i].resolve(i) + expectation.push(i) + } + return expect(target).to.eventually.deep.eq(expectation) + }) + }) + + describe('.race', function () { + it('doesn\'t resolve if nothing passed', function () { + var value = {x: 12} + var result = new Future() + Future.race([]).then(function () { + result.reject() + }) + setTimeout(function () { + result.resolve(value) + }, 1) + return expect(result).to.eventually.equal(value) + }) + + it('resolves with first value of the first resolved promise', function () { + var value = {x: 12} + var first = new Future() + var second = new Future() + var result = Future.race([first, second]) + first.resolve(value) + second.reject() + return expect(result).to.eventually.equal(value) + }) + }) + }) + }) + }) +})