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