diff --git a/src/ng/http.js b/src/ng/http.js index 97d2a00792a9..cfd4ee178204 100644 --- a/src/ng/http.js +++ b/src/ng/http.js @@ -162,7 +162,7 @@ function $HttpProvider() { * # General usage * The `$http` service is a function which takes a single argument — a configuration object — * that is used to generate an http request and returns a {@link ng.$q promise} - * with two $http specific methods: `success` and `error`. + * with three $http specific methods: `success`, `error`, and `abort`. * *
* $http({method: 'GET', url: '/someUrl'}).
@@ -390,12 +390,13 @@ function $HttpProvider() {
* requests with credentials} for more information.
*
* @returns {HttpPromise} Returns a {@link ng.$q promise} object with the
- * standard `then` method and two http specific methods: `success` and `error`. The `then`
- * method takes two arguments a success and an error callback which will be called with a
- * response object. The `success` and `error` methods take a single argument - a function that
- * will be called when the request succeeds or fails respectively. The arguments passed into
- * these functions are destructured representation of the response object passed into the
- * `then` method. The response object has these properties:
+ * standard `then` method and three http specific methods: `success`, `error`, and `abort`.
+ * The `then` method takes two arguments a success and an error callback which will be called
+ * with a response object. The `abort` method will cancel a pending request, causing it to
+ * fail, and return true or false if the abort succeeded. The `success` and `error` methods
+ * take a single argument - a function that will be called when the request succeeds or fails
+ * respectively. The arguments passed into these functions are destructured representation of
+ * the response object passed into the `then` method. The response object has these properties:
*
* - **data** – `{string|Object}` – The response body transformed with the transform functions.
* - **status** – `{number}` – HTTP status code of the response.
@@ -486,7 +487,7 @@ function $HttpProvider() {
reqHeaders = extend({'X-XSRF-TOKEN': $browser.cookies()['XSRF-TOKEN']},
defHeaders.common, defHeaders[lowercase(config.method)], config.headers),
reqData = transformData(config.data, headersGetter(reqHeaders), reqTransformFn),
- promise;
+ promise, abortFn;
// strip content-type if data is undefined
if (isUndefined(config.data)) {
@@ -496,13 +497,17 @@ function $HttpProvider() {
// send request
promise = sendReq(config, reqData, reqHeaders);
+ // save a reference to the abort function
+ abortFn = promise.abort;
// transform future response
promise = promise.then(transformResponse, transformResponse);
+ promise.abort = abortFn;
// apply interceptors
forEach(responseInterceptors, function(interceptor) {
promise = interceptor(promise);
+ promise.abort = abortFn;
});
promise.success = function(fn) {
@@ -668,6 +673,9 @@ function $HttpProvider() {
function sendReq(config, reqData, reqHeaders) {
var deferred = $q.defer(),
promise = deferred.promise,
+ aborted = false,
+ abortFn,
+ complete,
cache,
cachedResp,
url = buildUrl(config.url, config.params);
@@ -694,6 +702,8 @@ function $HttpProvider() {
} else {
resolvePromise(cachedResp, 200, {});
}
+ promise = promise.then(checkAbortedReq);
+ abortFn = noop;
}
} else {
// put the promise for the non-transformed response into cache as a placeholder
@@ -703,10 +713,18 @@ function $HttpProvider() {
// if we won't have the response in cache, send the request to the backend
if (!cachedResp) {
- $httpBackend(config.method, url, reqData, done, reqHeaders, config.timeout,
+ abortFn = $httpBackend(config.method, url, reqData, done, reqHeaders, config.timeout,
config.withCredentials);
}
+ promise.abort = function() {
+ if (isFunction(abortFn) && !complete) {
+ aborted = true;
+ abortFn();
+ }
+ return aborted;
+ }
+
return promise;
@@ -714,7 +732,7 @@ function $HttpProvider() {
* Callback registered to $httpBackend():
* - caches the response if desired
* - resolves the raw $http promise
- * - calls $apply
+ * - calls $apply if called asynchronously
*/
function done(status, response, headersString) {
if (cache) {
@@ -727,7 +745,9 @@ function $HttpProvider() {
}
resolvePromise(response, status, headersString);
- $rootScope.$apply();
+ if (!$rootScope.$$phase) {
+ $rootScope.$apply();
+ }
}
@@ -747,7 +767,20 @@ function $HttpProvider() {
}
+ /**
+ * Reject a cached response that has been aborted.
+ */
+ function checkAbortedReq(response) {
+ if (aborted) {
+ extend(response, {data: null, status: 0});
+ return $q.reject(response);
+ }
+ return response;
+ }
+
+
function removePendingReq() {
+ complete = true;
var idx = indexOf($http.pendingRequests, config);
if (idx !== -1) $http.pendingRequests.splice(idx, 1);
}
diff --git a/src/ng/httpBackend.js b/src/ng/httpBackend.js
index 775d921cab5c..6c7cbc3f4f8e 100644
--- a/src/ng/httpBackend.js
+++ b/src/ng/httpBackend.js
@@ -52,14 +52,17 @@ function createHttpBackend($browser, XHR, $browserDefer, callbacks, rawDocument,
delete callbacks[callbackId];
});
} else {
- var xhr = new XHR();
+ var status, xhr = new XHR(),
+ abortRequest = function() {
+ status = -1;
+ xhr.abort();
+ };
+
xhr.open(method, url, true);
forEach(headers, function(value, key) {
if (value) xhr.setRequestHeader(key, value);
});
- var status;
-
// In IE6 and 7, this might be called synchronously when xhr.send below is called and the
// response is in the cache. the promise api will ensure that to the app code the api is
// always async
@@ -99,11 +102,10 @@ function createHttpBackend($browser, XHR, $browserDefer, callbacks, rawDocument,
xhr.send(post || '');
if (timeout > 0) {
- $browserDefer(function() {
- status = -1;
- xhr.abort();
- }, timeout);
+ $browserDefer(abortRequest, timeout);
}
+
+ return abortRequest;
}
diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js
index 1877e9802c6f..91c15574d1a5 100644
--- a/src/ngMock/angular-mocks.js
+++ b/src/ngMock/angular-mocks.js
@@ -841,8 +841,7 @@ angular.mock.$HttpBackendProvider = function() {
function createHttpBackendMock($delegate, $browser) {
var definitions = [],
expectations = [],
- responses = [],
- responsesPush = angular.bind(responses, responses.push);
+ responses = [];
function createResponse(status, data, headers) {
if (angular.isFunction(status)) return status;
@@ -858,7 +857,9 @@ function createHttpBackendMock($delegate, $browser) {
function $httpBackend(method, url, data, callback, headers) {
var xhr = new MockXhr(),
expectation = expectations[0],
- wasExpected = false;
+ wasExpected = false,
+ aborted = false,
+ jsonp = (method.toLowerCase() == 'jsonp');
function prettyPrint(data) {
return (angular.isString(data) || angular.isFunction(data) || data instanceof RegExp)
@@ -866,6 +867,30 @@ function createHttpBackendMock($delegate, $browser) {
: angular.toJson(data);
}
+ function createResponse(request) {
+ return function() {
+ var response = request.response(method, url, data, headers);
+ xhr.$$respHeaders = response[2];
+ callback(response[0], response[1], xhr.getAllResponseHeaders());
+ }
+ }
+
+ function createAbort(response) {
+ return function() {
+ var index = indexOf(responses, response);
+ if (index >=0) {
+ responses.splice(index, 1);
+ abortFn();
+ return aborted = true;
+ }
+ return aborted;
+ };
+ }
+
+ function abortFn() {
+ callback(-1, null, null);
+ }
+
if (expectation && expectation.match(method, url)) {
if (!expectation.matchData(data))
throw Error('Expected ' + expectation + ' with different data\n' +
@@ -879,12 +904,9 @@ function createHttpBackendMock($delegate, $browser) {
expectations.shift();
if (expectation.response) {
- responses.push(function() {
- var response = expectation.response(method, url, data, headers);
- xhr.$$respHeaders = response[2];
- callback(response[0], response[1], xhr.getAllResponseHeaders());
- });
- return;
+ var expectationResponse = createResponse(expectation);
+ responses.push(expectationResponse);
+ return jsonp ? undefined : createAbort(expectationResponse);
}
wasExpected = true;
}
@@ -894,15 +916,23 @@ function createHttpBackendMock($delegate, $browser) {
if (definition.match(method, url, data, headers || {})) {
if (definition.response) {
// if $browser specified, we do auto flush all requests
- ($browser ? $browser.defer : responsesPush)(function() {
- var response = definition.response(method, url, data, headers);
- xhr.$$respHeaders = response[2];
- callback(response[0], response[1], xhr.getAllResponseHeaders());
- });
+ var definitionResponse = createResponse(definition);
+ if ($browser) {
+ var deferId = $browser.defer(definitionResponse);
+ return jsonp ? undefined : function() {
+ if($browser.defer.cancel(deferId)) {
+ abortFn();
+ return aborted = true;
+ }
+ return aborted;
+ };
+ } else {
+ responses.push(definitionResponse);
+ return jsonp ? undefined : createAbort(definitionResponse);
+ }
} else if (definition.passThrough) {
- $delegate(method, url, data, callback, headers);
+ return $delegate(method, url, data, callback, headers);
} else throw Error('No response defined !');
- return;
}
}
throw wasExpected ?
@@ -1259,6 +1289,15 @@ function createHttpBackendMock($delegate, $browser) {
}
}
+function indexOf(array, obj) {
+ if (array.indexOf) return array.indexOf(obj);
+
+ for ( var i = 0; i < array.length; i++) {
+ if (obj === array[i]) return i;
+ }
+ return -1;
+}
+
function MockHttpExpectation(method, url, data, headers) {
this.data = data;
diff --git a/test/ng/httpBackendSpec.js b/test/ng/httpBackendSpec.js
index 563b624ca279..f028cc1b1c6a 100644
--- a/test/ng/httpBackendSpec.js
+++ b/test/ng/httpBackendSpec.js
@@ -81,6 +81,48 @@ describe('$httpBackend', function() {
});
+ it('should return an abort function', function() {
+ callback.andCallFake(function(status, response) {
+ expect(status).toBe(-1);
+ });
+
+ var abort = $backend('GET', '/url', null, callback);
+ xhr = MockXhr.$$lastInstance;
+ spyOn(xhr, 'abort');
+
+ expect(typeof abort).toBe('function');
+
+ abort();
+ expect(xhr.abort).toHaveBeenCalledOnce();
+
+ xhr.status = 200;
+ xhr.readyState = 4;
+ xhr.onreadystatechange();
+ expect(callback).toHaveBeenCalledOnce();
+ });
+
+
+ it('should not abort a completed request', function() {
+ callback.andCallFake(function(status, response) {
+ expect(status).toBe(200);
+ });
+
+ var abort = $backend('GET', '/url', null, callback);
+ xhr = MockXhr.$$lastInstance;
+ spyOn(xhr, 'abort');
+
+ expect(typeof abort).toBe('function');
+
+ xhr.status = 200;
+ xhr.readyState = 4;
+ xhr.onreadystatechange();
+
+ abort();
+ expect(xhr.abort).toHaveBeenCalledOnce();
+ expect(callback).toHaveBeenCalledOnce();
+ });
+
+
it('should abort request on timeout', function() {
callback.andCallFake(function(status, response) {
expect(status).toBe(-1);
@@ -221,6 +263,11 @@ describe('$httpBackend', function() {
});
+ it('should respond undefined', function() {
+ expect($backend('JSONP', '/url')).toBeUndefined();
+ });
+
+
// TODO(vojta): test whether it fires "async-start"
// TODO(vojta): test whether it fires "async-end" on both success and error
});
diff --git a/test/ng/httpSpec.js b/test/ng/httpSpec.js
index bb4de3c1a5e2..9ecc8be3c6a8 100644
--- a/test/ng/httpSpec.js
+++ b/test/ng/httpSpec.js
@@ -572,6 +572,15 @@ describe('$http', function() {
$exceptionHandler.errors = [];
}));
+
+
+ it('should not $apply if already in an $apply phase', function() {
+ $rootScope.$apply(function() {
+ $httpBackend.expect('GET').respond(200);
+ var promise = $http({method: 'GET', url: '/some'});
+ expect(promise.abort()).toBe(true);
+ });
+ });
});
@@ -978,4 +987,79 @@ describe('$http', function() {
$httpBackend.verifyNoOutstandingExpectation = noop;
});
+
+
+ it('should abort pending requests', inject(function($httpBackend, $http) {
+ $httpBackend.expect('GET', 'some.html').respond(200);
+ var promise = $http({method: 'GET', url: 'some.html'});
+ var successFn = jasmine.createSpy();
+ promise.success(successFn);
+ promise.error(function(data, status, headers) {
+ expect(data).toBeNull();
+ expect(status).toBe(0);
+ expect(headers()).toEqual({});
+ callback();
+ });
+ var aborted = promise.abort();
+ expect(function() {
+ $httpBackend.flush();
+ }).toThrow('No pending request to flush !');
+ expect(aborted).toBe(true);
+ expect(successFn).not.toHaveBeenCalled();
+ expect(callback).toHaveBeenCalledOnce();
+ }));
+
+
+ it('should not abort resolved requests', inject(function($httpBackend, $http) {
+ $httpBackend.expect('GET', 'some.html').respond(200);
+ var promise = $http({method: 'GET', url: 'some.html'});
+ var errorFn = jasmine.createSpy();
+ promise.error(errorFn);
+ promise.success(function(data, status, headers) {
+ expect(data).toBeUndefined();
+ expect(status).toBe(200);
+ expect(headers()).toEqual({});
+ callback();
+ });
+ $httpBackend.flush();
+ var aborted = promise.abort();
+ expect(function() {
+ $httpBackend.flush();
+ }).toThrow('No pending request to flush !');
+ expect(aborted).toBe(false);
+ expect(errorFn).not.toHaveBeenCalled();
+ expect(callback).toHaveBeenCalledOnce();
+ }));
+
+
+ it('should reject aborted cache requests', inject(function($cacheFactory, $http, $rootScope) {
+ var successFn = jasmine.createSpy('successFn');
+ var rejectFn = jasmine.createSpy('rejectFn');
+ var cache = $cacheFactory();
+ cache.put('/alreadyCachedURL', 'content');
+ var promise = $http.get('/alreadyCachedURL', {cache: cache});
+ promise.then(successFn, rejectFn);
+ expect(promise.abort()).toBe(true);
+ $rootScope.$digest();
+ expect(successFn).not.toHaveBeenCalled();
+ expect(rejectFn).toHaveBeenCalledOnce();
+ $rootScope.$digest();
+ expect(promise.abort()).toBe(true);
+ }));
+
+
+ it('should not reject resolved cache requests', inject(function($cacheFactory, $http, $rootScope) {
+ var successFn = jasmine.createSpy('successFn');
+ var rejectFn = jasmine.createSpy('rejectFn');
+ var cache = $cacheFactory();
+ cache.put('/alreadyCachedURL', 'content');
+ var promise = $http.get('/alreadyCachedURL', {cache: cache});
+ promise.then(successFn, rejectFn);
+ $rootScope.$digest();
+ expect(promise.abort()).toBe(false);
+ expect(successFn).toHaveBeenCalledOnce();
+ expect(rejectFn).not.toHaveBeenCalled();
+ $rootScope.$digest();
+ expect(promise.abort()).toBe(false);
+ }));
});
diff --git a/test/ngMock/angular-mocksSpec.js b/test/ngMock/angular-mocksSpec.js
index 5992846d0439..702b0d1d138c 100644
--- a/test/ngMock/angular-mocksSpec.js
+++ b/test/ngMock/angular-mocksSpec.js
@@ -790,6 +790,37 @@ describe('ngMock', function() {
});
+ it('should return an abort function', function() {
+ hb.when('GET', '/url').respond(200, '', {});
+
+ var abortFn = hb('GET', '/url', null, callback);
+ expect(abortFn()).toBe(true);
+ expect(abortFn()).toBe(true);
+
+ expect(function() {
+ hb.flush();
+ }).toThrow('No pending request to flush !');
+ expect(callback).toHaveBeenCalledOnceWith(-1, null, null);
+ hb.verifyNoOutstandingRequest();
+ });
+
+
+ it('should not abort a completed request', function() {
+ hb.when('GET', '/url').respond(200, '', {});
+
+ var abortFn = hb('GET', '/url', null, callback);
+ hb.flush();
+ expect(abortFn()).toBe(false);
+ expect(abortFn()).toBe(false);
+
+ expect(function() {
+ hb.flush();
+ }).toThrow('No pending request to flush !');
+ expect(callback).toHaveBeenCalledOnceWith(200, '', '');
+ hb.verifyNoOutstandingRequest();
+ });
+
+
it('should respond undefined when JSONP method', function() {
hb.when('JSONP', '/url1').respond(200);
hb.expect('JSONP', '/url2').respond(200);