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);