diff --git a/.travis.yml b/.travis.yml index 71c72ccf53..9d4506dfb0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,6 @@ language: node_js +node_js: + - node email: on_failure: change on_success: never diff --git a/README.md b/README.md index 6c42a02716..c32533835d 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,12 @@ These are the available config options for making requests. Only the `url` is re proxy: { host: '127.0.0.1', port: 9000 - } + }, + + // `cancelToken` specifies a cancel token that can be used to cancel the request + // (see Cancellation section below for details) + cancelToken: new CancelToken(function (cancel) { + }) } ``` @@ -457,6 +462,49 @@ axios.get('/user/12345', { }) ``` +## Cancellation + +You can cancel a request using a *cancel token*. + +> The axios cancel token API is based on the [cancelable promises proposal](https://github.com/tc39/proposal-cancelable-promises), which is currently at Stage 1. + +You can create a cancel token using the `CancelToken.source` factory as shown below: + +```js +var CancelToken = axios.CancelToken; +var source = CancelToken.source(); + +axios.get('/user/12345', { + cancelToken: source.token +}).catch(function(thrown) { + if (axios.isCancel(thrown)) { + console.log('Request canceled', thrown.message); + } else { + // handle error + } +}); + +// cancel the request (the message parameter is optional) +source.cancel('Operation canceled by the user.'); +``` + +You can also create a cancel token by passing an executor function to the `CancelToken` constructor: + +```js +var CancelToken = axios.CancelToken; +var cancel; + +axios.get('/user/12345', { + cancelToken: new CancelToken(function executor(c) { + // An executor function receives a cancel function as a parameter + cancel = c; + }) +}); + +// cancel the request +cancel(); +``` + ## Semver Until axios reaches a `1.0` release, breaking changes will be released with a new minor version. For example `0.5.1`, and `0.5.4` will have the same API, but `0.6.0` will have breaking changes. diff --git a/axios.d.ts b/axios.d.ts index d6721754ee..03c7f1bfe2 100644 --- a/axios.d.ts +++ b/axios.d.ts @@ -41,6 +41,7 @@ export interface AxiosRequestConfig { httpAgent?: any; httpsAgent?: any; proxy?: AxiosProxyConfig; + cancelToken?: CancelToken; } export interface AxiosResponse { @@ -66,6 +67,34 @@ export interface Promise { export interface AxiosPromise extends Promise { } +export interface CancelStatic { + new (message?: string): Cancel; +} + +export interface Cancel { + message: string; +} + +export interface Canceler { + (message?: string): void; +} + +export interface CancelTokenStatic { + new (executor: (cancel: Canceler) => void): CancelToken; + source(): CancelTokenSource; +} + +export interface CancelToken { + promise: Promise; + reason?: Cancel; + throwIfRequested(): void; +} + +export interface CancelTokenSource { + token: CancelToken; + cancel: Canceler; +} + export interface AxiosInterceptorManager { use(onFulfilled: (value: V) => V | Promise, onRejected?: (error: any) => any): number; eject(id: number): void; @@ -90,6 +119,9 @@ export interface AxiosStatic extends AxiosInstance { (config: AxiosRequestConfig): AxiosPromise; (url: string, config?: AxiosRequestConfig): AxiosPromise; create(config?: AxiosRequestConfig): AxiosInstance; + Cancel: CancelStatic; + CancelToken: CancelTokenStatic; + isCancel(value: any): boolean; all(values: (T | Promise)[]): Promise; spread(callback: (...args: T[]) => R): (array: T[]) => R; } diff --git a/lib/adapters/http.js b/lib/adapters/http.js index 4d76a01655..95e4e8cb5c 100644 --- a/lib/adapters/http.js +++ b/lib/adapters/http.js @@ -185,6 +185,15 @@ module.exports = function httpAdapter(config) { }, config.timeout); } + if (config.cancelToken) { + // Handle cancellation + config.cancelToken.promise.then(function onCanceled(cancel) { + req.abort(); + reject(cancel); + aborted = true; + }); + } + // Send the request if (utils.isStream(data)) { data.pipe(req); diff --git a/lib/adapters/xhr.js b/lib/adapters/xhr.js index b0035d7f47..1fb890bbb1 100644 --- a/lib/adapters/xhr.js +++ b/lib/adapters/xhr.js @@ -153,6 +153,15 @@ module.exports = function xhrAdapter(config) { request.upload.addEventListener('progress', config.onUploadProgress); } + if (config.cancelToken) { + // Handle cancellation + config.cancelToken.promise.then(function onCanceled(cancel) { + request.abort(); + reject(cancel); + // Clean up request + request = null; + }); + } if (requestData === undefined) { requestData = null; diff --git a/lib/axios.js b/lib/axios.js index 8937b80291..aa741f0004 100644 --- a/lib/axios.js +++ b/lib/axios.js @@ -34,6 +34,11 @@ axios.create = function create(defaultConfig) { return createInstance(defaultConfig); }; +// Expose Cancel & CancelToken +axios.Cancel = require('./cancel/Cancel'); +axios.CancelToken = require('./cancel/CancelToken'); +axios.isCancel = require('./cancel/isCancel'); + // Expose all/spread axios.all = function all(promises) { return Promise.all(promises); diff --git a/lib/cancel/Cancel.js b/lib/cancel/Cancel.js new file mode 100644 index 0000000000..e0de4003f9 --- /dev/null +++ b/lib/cancel/Cancel.js @@ -0,0 +1,19 @@ +'use strict'; + +/** + * A `Cancel` is an object that is thrown when an operation is canceled. + * + * @class + * @param {string=} message The message. + */ +function Cancel(message) { + this.message = message; +} + +Cancel.prototype.toString = function toString() { + return 'Cancel' + (this.message ? ': ' + this.message : ''); +}; + +Cancel.prototype.__CANCEL__ = true; + +module.exports = Cancel; diff --git a/lib/cancel/CancelToken.js b/lib/cancel/CancelToken.js new file mode 100644 index 0000000000..6b46e66625 --- /dev/null +++ b/lib/cancel/CancelToken.js @@ -0,0 +1,57 @@ +'use strict'; + +var Cancel = require('./Cancel'); + +/** + * A `CancelToken` is an object that can be used to request cancellation of an operation. + * + * @class + * @param {Function} executor The executor function. + */ +function CancelToken(executor) { + if (typeof executor !== 'function') { + throw new TypeError('executor must be a function.'); + } + + var resolvePromise; + this.promise = new Promise(function promiseExecutor(resolve) { + resolvePromise = resolve; + }); + + var token = this; + executor(function cancel(message) { + if (token.reason) { + // Cancellation has already been requested + return; + } + + token.reason = new Cancel(message); + resolvePromise(token.reason); + }); +} + +/** + * Throws a `Cancel` if cancellation has been requested. + */ +CancelToken.prototype.throwIfRequested = function throwIfRequested() { + if (this.reason) { + throw this.reason; + } +}; + +/** + * Returns an object that contains a new `CancelToken` and a function that, when called, + * cancels the `CancelToken`. + */ +CancelToken.source = function source() { + var cancel; + var token = new CancelToken(function executor(c) { + cancel = c; + }); + return { + token: token, + cancel: cancel + }; +}; + +module.exports = CancelToken; diff --git a/lib/cancel/isCancel.js b/lib/cancel/isCancel.js new file mode 100644 index 0000000000..051f3ae4c5 --- /dev/null +++ b/lib/cancel/isCancel.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = function isCancel(value) { + return !!(value && value.__CANCEL__); +}; diff --git a/lib/core/dispatchRequest.js b/lib/core/dispatchRequest.js index c8f6cd2b95..b5111017d1 100644 --- a/lib/core/dispatchRequest.js +++ b/lib/core/dispatchRequest.js @@ -2,8 +2,18 @@ var utils = require('./../utils'); var transformData = require('./transformData'); +var isCancel = require('../cancel/isCancel'); var defaults = require('../defaults'); +/** + * Throws a `Cancel` if cancellation has been requested. + */ +function throwIfCancellationRequested(config) { + if (config.cancelToken) { + config.cancelToken.throwIfRequested(); + } +} + /** * Dispatch a request to the server using the configured adapter. * @@ -11,6 +21,8 @@ var defaults = require('../defaults'); * @returns {Promise} The Promise to be fulfilled */ module.exports = function dispatchRequest(config) { + throwIfCancellationRequested(config); + // Ensure headers exist config.headers = config.headers || {}; @@ -38,6 +50,8 @@ module.exports = function dispatchRequest(config) { var adapter = config.adapter || defaults.adapter; return adapter(config).then(function onAdapterResolution(response) { + throwIfCancellationRequested(config); + // Transform response data response.data = transformData( response.data, @@ -46,16 +60,20 @@ module.exports = function dispatchRequest(config) { ); return response; - }, function onAdapterRejection(error) { - // Transform response data - if (error && error.response) { - error.response.data = transformData( - error.response.data, - error.response.headers, - config.transformResponse - ); + }, function onAdapterRejection(reason) { + if (!isCancel(reason)) { + throwIfCancellationRequested(config); + + // Transform response data + if (reason && reason.response) { + reason.response.data = transformData( + reason.response.data, + reason.response.headers, + config.transformResponse + ); + } } - return Promise.reject(error); + return Promise.reject(reason); }); }; diff --git a/test/specs/api.spec.js b/test/specs/api.spec.js index 937f124595..e177d07abb 100644 --- a/test/specs/api.spec.js +++ b/test/specs/api.spec.js @@ -34,6 +34,12 @@ describe('static api', function () { it('should have factory method', function () { expect(typeof axios.create).toEqual('function'); }); + + it('should have Cancel, CancelToken, and isCancel properties', function () { + expect(typeof axios.Cancel).toEqual('function'); + expect(typeof axios.CancelToken).toEqual('function'); + expect(typeof axios.isCancel).toEqual('function'); + }); }); describe('instance api', function () { diff --git a/test/specs/cancel.spec.js b/test/specs/cancel.spec.js new file mode 100644 index 0000000000..d02d8e3e61 --- /dev/null +++ b/test/specs/cancel.spec.js @@ -0,0 +1,66 @@ +var Cancel = axios.Cancel; +var CancelToken = axios.CancelToken; + +describe('cancel', function() { + beforeEach(function() { + jasmine.Ajax.install(); + }); + + afterEach(function() { + jasmine.Ajax.uninstall(); + }); + + describe('when called before sending request', function() { + it('rejects Promise with a Cancel object', function (done) { + var source = CancelToken.source(); + source.cancel('Operation has been canceled.'); + axios.get('/foo', { + cancelToken: source.token + }).catch(function (thrown) { + expect(thrown).toEqual(jasmine.any(Cancel)); + expect(thrown.message).toBe('Operation has been canceled.'); + done(); + }); + }); + }); + + describe('when called after request has been sent', function() { + it('rejects Promise with a Cancel object', function (done) { + var source = CancelToken.source(); + axios.get('/foo/bar', { + cancelToken: source.token + }).catch(function (thrown) { + expect(thrown).toEqual(jasmine.any(Cancel)); + expect(thrown.message).toBe('Operation has been canceled.'); + done(); + }); + + getAjaxRequest().then(function (request) { + // call cancel() when the request has been sent, but a response has not been received + source.cancel('Operation has been canceled.'); + request.respondWith({ + status: 200, + responseText: 'OK' + }); + }); + }); + + it('calls abort on request object', function (done) { + var source = CancelToken.source(); + var request; + axios.get('/foo/bar', { + cancelToken: source.token + }).catch(function() { + // jasmine-ajax sets statusText to 'abort' when request.abort() is called + expect(request.statusText).toBe('abort'); + done(); + }); + + getAjaxRequest().then(function (req) { + // call cancel() when the request has been sent, but a response has not been received + source.cancel(); + request = req; + }); + }); + }); +}); diff --git a/test/specs/cancel/Cancel.spec.js b/test/specs/cancel/Cancel.spec.js new file mode 100644 index 0000000000..0e0de805bf --- /dev/null +++ b/test/specs/cancel/Cancel.spec.js @@ -0,0 +1,15 @@ +var Cancel = require('../../../lib/cancel/Cancel'); + +describe('Cancel', function() { + describe('toString', function() { + it('returns correct result when message is not specified', function() { + var cancel = new Cancel(); + expect(cancel.toString()).toBe('Cancel'); + }); + + it('returns correct result when message is specified', function() { + var cancel = new Cancel('Operation has been canceled.'); + expect(cancel.toString()).toBe('Cancel: Operation has been canceled.'); + }); + }); +}); diff --git a/test/specs/cancel/CancelToken.spec.js b/test/specs/cancel/CancelToken.spec.js new file mode 100644 index 0000000000..dd72327174 --- /dev/null +++ b/test/specs/cancel/CancelToken.spec.js @@ -0,0 +1,87 @@ +var CancelToken = require('../../../lib/cancel/CancelToken'); +var Cancel = require('../../../lib/cancel/Cancel'); + +describe('CancelToken', function() { + describe('constructor', function() { + it('throws when executor is not specified', function() { + expect(function() { + new CancelToken(); + }).toThrowError(TypeError, 'executor must be a function.'); + }); + + it('throws when executor is not a function', function() { + expect(function() { + new CancelToken(123); + }).toThrowError(TypeError, 'executor must be a function.'); + }); + }); + + describe('reason', function() { + it('returns a Cancel if cancellation has been requested', function() { + var cancel; + var token = new CancelToken(function(c) { + cancel = c; + }); + cancel('Operation has been canceled.'); + expect(token.reason).toEqual(jasmine.any(Cancel)); + expect(token.reason.message).toBe('Operation has been canceled.'); + }); + + it('returns undefined if cancellation has not been requested', function() { + var token = new CancelToken(function() {}); + expect(token.reason).toBeUndefined(); + }); + }); + + describe('promise', function() { + it('returns a Promise that resolves when cancellation is requested', function(done) { + var cancel; + var token = new CancelToken(function(c) { + cancel = c; + }); + token.promise.then(function onFulfilled(value) { + expect(value).toEqual(jasmine.any(Cancel)); + expect(value.message).toBe('Operation has been canceled.'); + done(); + }); + cancel('Operation has been canceled.'); + }); + }); + + describe('throwIfRequested', function() { + it('throws if cancellation has been requested', function() { + // Note: we cannot use expect.toThrowError here as Cancel does not inherit from Error + var cancel; + var token = new CancelToken(function(c) { + cancel = c; + }); + cancel('Operation has been canceled.'); + try { + token.throwIfRequested(); + fail('Expected throwIfRequested to throw.'); + } catch (thrown) { + if (!(thrown instanceof Cancel)) { + fail('Expected throwIfRequested to throw a Cancel, but it threw ' + thrown + '.'); + } + expect(thrown.message).toBe('Operation has been canceled.'); + } + }); + + it('does not throw if cancellation has not been requested', function() { + var token = new CancelToken(function() {}); + token.throwIfRequested(); + }); + }); + + describe('source', function() { + it('returns an object containing token and cancel function', function() { + var source = CancelToken.source(); + expect(source.token).toEqual(jasmine.any(CancelToken)); + expect(source.cancel).toEqual(jasmine.any(Function)); + expect(source.token.reason).toBeUndefined(); + source.cancel('Operation has been canceled.'); + expect(source.token.reason).toEqual(jasmine.any(Cancel)); + expect(source.token.reason.message).toBe('Operation has been canceled.'); + }); + }); +}); diff --git a/test/specs/cancel/isCancel.spec.js b/test/specs/cancel/isCancel.spec.js new file mode 100644 index 0000000000..e6be40dac9 --- /dev/null +++ b/test/specs/cancel/isCancel.spec.js @@ -0,0 +1,12 @@ +var isCancel = require('../../../lib/cancel/isCancel'); +var Cancel = require('../../../lib/cancel/Cancel'); + +describe('isCancel', function() { + it('returns true if value is a Cancel', function() { + expect(isCancel(new Cancel())).toBe(true); + }); + + it('returns false if value is not a Cancel', function() { + expect(isCancel({ foo: 'bar' })).toBe(false); + }); +}); diff --git a/test/specs/instance.spec.js b/test/specs/instance.spec.js index 4fac13dff4..fe395f314b 100644 --- a/test/specs/instance.spec.js +++ b/test/specs/instance.spec.js @@ -11,7 +11,15 @@ describe('instance', function () { var instance = axios.create(); for (var prop in axios) { - if (['Axios', 'create', 'all', 'spread', 'default'].indexOf(prop) > -1) { + if ([ + 'Axios', + 'create', + 'Cancel', + 'CancelToken', + 'isCancel', + 'all', + 'spread', + 'default'].indexOf(prop) > -1) { continue; } expect(typeof instance[prop]).toBe(typeof axios[prop]); diff --git a/test/typescript/axios.ts b/test/typescript/axios.ts index 9f25f1b02f..d3d319843f 100644 --- a/test/typescript/axios.ts +++ b/test/typescript/axios.ts @@ -1,4 +1,15 @@ -import axios, { AxiosRequestConfig, AxiosResponse, AxiosError, AxiosInstance, AxiosAdapter } from '../../'; +import axios, { + AxiosRequestConfig, + AxiosResponse, + AxiosError, + AxiosInstance, + AxiosAdapter, + Cancel, + CancelToken, + CancelTokenSource, + Canceler +} from '../../'; + import { Promise } from 'es6-promise'; const config: AxiosRequestConfig = { @@ -30,7 +41,8 @@ const config: AxiosRequestConfig = { proxy: { host: '127.0.0.1', port: 9000 - } + }, + cancelToken: new axios.CancelToken((cancel: Canceler) => {}) }; const handleResponse = (response: AxiosResponse) => { @@ -210,3 +222,18 @@ axios.get('/user') axios.get('/user') .catch((error: any) => Promise.resolve('foo')) .then((value: string) => {}); + +// Cancellation + +const source: CancelTokenSource = axios.CancelToken.source(); + +axios.get('/user', { + cancelToken: source.token +}).catch((thrown: AxiosError | Cancel) => { + if (axios.isCancel(thrown)) { + const cancel: Cancel = thrown; + console.log(cancel.message); + } +}); + +source.cancel('Operation has been canceled.'); diff --git a/test/unit/adapters/http.js b/test/unit/adapters/http.js index ba59558e8e..1e41a63ca9 100644 --- a/test/unit/adapters/http.js +++ b/test/unit/adapters/http.js @@ -320,5 +320,21 @@ module.exports = { }); }); }); + }, + + testCancel: function(test) { + var source = axios.CancelToken.source(); + server = http.createServer(function (req, res) { + // call cancel() when the request has been sent, but a response has not been received + source.cancel('Operation has been canceled.'); + }).listen(4444, function() { + axios.get('http://localhost:4444/', { + cancelToken: source.token + }).catch(function (thrown) { + test.ok(thrown instanceof axios.Cancel, 'Promise must be rejected with a Cancel obejct'); + test.equal(thrown.message, 'Operation has been canceled.'); + test.done(); + }); + }); } };