diff --git a/src/ngResource/resource.js b/src/ngResource/resource.js index 767d3ba7872c..f0b46e013718 100644 --- a/src/ngResource/resource.js +++ b/src/ngResource/resource.js @@ -1,6 +1,7 @@ 'use strict'; var $resourceMinErr = angular.$$minErr('$resource'); +var hasOwnProperty = Object.prototype.hasOwnProperty; // Helper functions and regex to lookup a dotted path on an object // stopping at undefined/null. The path must be composed of ASCII @@ -63,6 +64,8 @@ function shallowClearAndCopy(src, dst) { * @ngdoc service * @name $resource * @requires $http + * @requires ng.$log + * @requires $q * * @description * A factory which creates a resource object that lets you interact with @@ -107,9 +110,9 @@ function shallowClearAndCopy(src, dst) { * URL `/path/greet?salutation=Hello`. * * If the parameter value is prefixed with `@` then the value for that parameter will be extracted - * from the corresponding property on the `data` object (provided when calling an action method). For - * example, if the `defaultParam` object is `{someParam: '@someProp'}` then the value of `someParam` - * will be `data.someProp`. + * from the corresponding property on the `data` object (provided when calling an action method). + * For example, if the `defaultParam` object is `{someParam: '@someProp'}` then the value of + * `someParam` will be `data.someProp`. * * @param {Object.=} actions Hash with declaration of custom actions that should extend * the default set of resource actions. The declaration should be created in the format of {@link @@ -143,15 +146,23 @@ function shallowClearAndCopy(src, dst) { * `{function(data, headersGetter)|Array.}` – * transform function or an array of such functions. The transform function takes the http * response body and headers and returns its transformed (typically deserialized) version. - * By default, transformResponse will contain one function that checks if the response looks like - * a JSON string and deserializes it using `angular.fromJson`. To prevent this behavior, set - * `transformResponse` to an empty array: `transformResponse: []` + * By default, transformResponse will contain one function that checks if the response looks + * like a JSON string and deserializes it using `angular.fromJson`. To prevent this behavior, + * set `transformResponse` to an empty array: `transformResponse: []` * - **`cache`** – `{boolean|Cache}` – If true, a default $http cache will be used to cache the * GET request, otherwise if a cache instance built with * {@link ng.$cacheFactory $cacheFactory}, this cache will be used for * caching. - * - **`timeout`** – `{number|Promise}` – timeout in milliseconds, or {@link ng.$q promise} that - * should abort the request when resolved. + * - **`timeout`** – `{number}` – timeout in milliseconds.
+ * **Note:** In contrast to {@link ng.$http#usage $http.config}, {@link ng.$q promises} are + * **not** supported in $resource, because the same value has to be re-used for multiple + * requests. If you are looking for a way to cancel requests, you should use the `cancellable` + * option. + * - **`cancellable`** – `{boolean}` – if set to true, the request made by a "non-instance" call + * will be cancelled (if not already completed) by calling `$cancelRequest()` on the call's + * return value. Calling `$cancelRequest()` for a non-cancellable or an already + * completed/cancelled request will have no effect.
+ * **Note:** If a timeout is specified in millisecondes, `cancellable` is ignored. * - **`withCredentials`** - `{boolean}` - whether to set the `withCredentials` flag on the * XHR object. See * [requests with credentials](https://developer.mozilla.org/en/http_access_control#section_5) @@ -163,12 +174,13 @@ function shallowClearAndCopy(src, dst) { * with `http response` object. See {@link ng.$http $http interceptors}. * * @param {Object} options Hash with custom settings that should extend the - * default `$resourceProvider` behavior. The only supported option is - * - * Where: + * default `$resourceProvider` behavior. The supported options are: * * - **`stripTrailingSlashes`** – {boolean} – If true then the trailing * slashes from any calculated URL will be stripped. (Defaults to true.) + * - **`cancellable`** – {boolean} – If true, the request made by a "non-instance" call will be + * cancelled (if not already completed) by calling `$cancelRequest()` on the call's return value. + * This can be overwritten per action. (Defaults to false.) * * @returns {Object} A resource "class" object with methods for the default set of resource actions * optionally extended with custom `actions`. The default set contains these actions: @@ -216,7 +228,7 @@ function shallowClearAndCopy(src, dst) { * Class actions return empty instance (with additional properties below). * Instance actions return promise of the action. * - * The Resource instances and collection have these additional properties: + * The Resource instances and collections have these additional properties: * * - `$promise`: the {@link ng.$q promise} of the original server interaction that created this * instance or collection. @@ -236,6 +248,11 @@ function shallowClearAndCopy(src, dst) { * rejection), `false` before that. Knowing if the Resource has been resolved is useful in * data-binding. * + * The Resource instances and collections have these additional methods: + * + * - `$cancelRequest`: If there is a cancellable, pending request related to the instance or + * collection, calling this method will abort the request. + * * @example * * # Credit card resource @@ -280,6 +297,11 @@ function shallowClearAndCopy(src, dst) { * * Calling these methods invoke `$http` on the `url` template with the given `method`, `params` and * `headers`. + * + * @example + * + * # User resource + * * When the data is returned from the server then the object is an instance of the resource type and * all of the non-GET methods are available with `$` prefix. This allows you to easily support CRUD * operations (create, read, update, delete) on server-side data. @@ -298,10 +320,10 @@ function shallowClearAndCopy(src, dst) { * ```js var User = $resource('/user/:userId', {userId:'@id'}); - User.get({userId:123}, function(u, getResponseHeaders){ - u.abc = true; - u.$save(function(u, putResponseHeaders) { - //u => saved user object + User.get({userId:123}, function(user, getResponseHeaders){ + user.abc = true; + user.$save(function(user, putResponseHeaders) { + //user => saved user object //putResponseHeaders => $http header getter }); }); @@ -316,8 +338,11 @@ function shallowClearAndCopy(src, dst) { $scope.user = user; }); ``` - + * + * @example + * * # Creating a custom 'PUT' request + * * In this example we create a custom method on our resource to make a PUT request * ```js * var app = angular.module('app', ['ngResource', 'ngRoute']); @@ -345,6 +370,34 @@ function shallowClearAndCopy(src, dst) { * // This will PUT /notes/ID with the note object in the request payload * }]); * ``` + * + * @example + * + * # Cancelling requests + * + * If an action's configuration specifies that it is cancellable, you can cancel the request related + * to an instance or collection (as long as it is a result of a "non-instance" call): + * + ```js + // ...defining the `Hotel` resource... + var Hotel = $resource('/api/hotel/:id', {id: '@id'}, { + // Let's make the `query()` method cancellable + query: {method: 'get', isArray: true, cancellable: true} + }); + + // ...somewhere in the PlanVacationController... + ... + this.onDestinationChanged = function onDestinationChanged(destination) { + // We don't care about any pending request for hotels + // in a different destination any more + this.availableHotels.$cancelRequest(); + + // Let's query for hotels in '' + // (calls: /api/hotel?location=) + this.availableHotels = Hotel.query({location: destination}); + }; + ``` + * */ angular.module('ngResource', ['ng']). provider('$resource', function() { @@ -365,7 +418,7 @@ angular.module('ngResource', ['ng']). } }; - this.$get = ['$http', '$q', function($http, $q) { + this.$get = ['$http', '$log', '$q', function($http, $log, $q) { var noop = angular.noop, forEach = angular.forEach, @@ -525,6 +578,22 @@ angular.module('ngResource', ['ng']). forEach(actions, function(action, name) { var hasBody = /^(POST|PUT|PATCH)$/i.test(action.method); + var hasTimeout = hasOwnProperty.call(action, 'timeout'); + if (hasTimeout && !angular.isNumber(action.timeout)) { + $log.debug('ngResource:\n' + + ' Only numeric values are allowed as `timeout`.\n' + + ' Promises are not supported in $resource, because the same value has to ' + + 'be re-used for multiple requests. If you are looking for a way to cancel ' + + 'requests, you should use the `cancellable` option.'); + delete action.timeout; + hasTimeout = false; + } + action.cancellable = hasTimeout ? + false : hasOwnProperty.call(action, 'cancellable') ? + action.cancellable : (options && hasOwnProperty.call(options, 'cancellable')) ? + options.cancellable : + provider.defaults.cancellable; + Resource[name] = function(a1, a2, a3, a4) { var params = {}, data, success, error; @@ -581,6 +650,7 @@ angular.module('ngResource', ['ng']). case 'params': case 'isArray': case 'interceptor': + case 'cancellable': break; case 'timeout': httpConfig[key] = value; @@ -588,14 +658,23 @@ angular.module('ngResource', ['ng']). } }); + if (!isInstanceCall && action.cancellable) { + var deferred = $q.defer(); + httpConfig.timeout = deferred.promise; + value.$cancelRequest = deferred.resolve; + } + if (hasBody) httpConfig.data = data; route.setUrlParams(httpConfig, extend({}, extractParams(data, action.params || {}), params), action.url); - var promise = $http(httpConfig).then(function(response) { + var promise = $http(httpConfig).finally(function() { + if (value.$cancelRequest) value.$cancelRequest = angular.noop; + }).then(function(response) { var data = response.data, - promise = value.$promise; + promise = value.$promise, + cancelRequest = value.$cancelRequest; if (data) { // Need to convert action.isArray to boolean in case it is undefined @@ -625,6 +704,7 @@ angular.module('ngResource', ['ng']). } } + value.$cancelRequest = cancelRequest; value.$resolved = true; response.resource = value; diff --git a/test/ngResource/resourceSpec.js b/test/ngResource/resourceSpec.js index 41a90d2f1601..dd0820dc4b03 100644 --- a/test/ngResource/resourceSpec.js +++ b/test/ngResource/resourceSpec.js @@ -1364,35 +1364,210 @@ describe('resource', function() { /^\[\$resource:badcfg\] Error in resource configuration for action `get`\. Expected response to contain an object but got an array \(Request: GET \/Customer\/123\)/ ); }); +}); + +describe('resource wrt cancelling requests', function() { + var httpSpy; + var $httpBackend; + var $resource; + + beforeEach(module('ngResource', function($provide) { + $provide.decorator('$http', function($delegate) { + httpSpy = jasmine.createSpy('$http').andCallFake($delegate); + return httpSpy; + }); + })); - it('should cancel the request if timeout promise is resolved', function() { - var canceler = $q.defer(); + beforeEach(inject(function(_$httpBackend_, _$resource_) { + $httpBackend = _$httpBackend_; + $resource = _$resource_; + })); - $httpBackend.when('GET', '/CreditCard').respond({data: '123'}); + it('should accept numeric timeouts in actions and pass them to $http', function() { + $httpBackend.whenGET('/CreditCard').respond({}); var CreditCard = $resource('/CreditCard', {}, { - query: { + get: { method: 'GET', - timeout: canceler.promise + timeout: 10000 } }); - CreditCard.query(); + CreditCard.get(); + $httpBackend.flush(); + + expect(httpSpy).toHaveBeenCalledOnce(); + expect(httpSpy.calls[0].args[0].timeout).toBe(10000); + }); + + it('should delete non-numeric timeouts in actions and log a $debug message', + inject(function($log, $q) { + spyOn($log, 'debug'); + $httpBackend.whenGET('/CreditCard').respond({}); + + var CreditCard = $resource('/CreditCard', {}, { + get: { + method: 'GET', + timeout: $q.defer().promise + } + }); + + CreditCard.get(); + $httpBackend.flush(); + + expect(httpSpy).toHaveBeenCalledOnce(); + expect(httpSpy.calls[0].args[0].timeout).toBeUndefined(); + expect($log.debug).toHaveBeenCalledOnceWith('ngResource:\n' + + ' Only numeric values are allowed as `timeout`.\n' + + ' Promises are not supported in $resource, because the same value has to ' + + 'be re-used for multiple requests. If you are looking for a way to cancel ' + + 'requests, you should use the `cancellable` option.'); + }) + ); + + it('should not create a `$cancelRequest` method for instance calls', function() { + var CreditCard = $resource('/CreditCard', {}, { + save1: { + method: 'POST', + cancellable: false + }, + save2: { + method: 'POST', + cancellable: true + } + }); + + var creditCard = new CreditCard(); + + var promise1 = creditCard.$save1(); + expect(promise1.$cancelRequest).toBeUndefined(); + expect(creditCard.$cancelRequest).toBeUndefined(); + + var promise2 = creditCard.$save2(); + expect(promise2.$cancelRequest).toBeUndefined(); + expect(creditCard.$cancelRequest).toBeUndefined(); + }); + + it('should not create a `$cancelRequest` method for non-cancellable calls', function() { + var CreditCard = $resource('/CreditCard', {}, { + get: { + method: 'GET', + cancellable: false + } + }); + + var creditCard = CreditCard.get(); + + expect(creditCard.$cancelRequest).toBeUndefined(); + }); + + it('should also take into account `options.cancellable`', function() { + var options = {cancellable: true}; + var CreditCard = $resource('/CreditCard', {}, { + get1: {method: 'GET', cancellable: false}, + get2: {method: 'GET', cancellable: true}, + get3: {method: 'GET'} + }, options); + + var creditCard1 = CreditCard.get1(); + var creditCard2 = CreditCard.get2(); + var creditCard3 = CreditCard.get3(); - canceler.resolve(); - expect($httpBackend.flush).toThrow(new Error("No pending request to flush !")); + expect(creditCard1.$cancelRequest).toBeUndefined(); + expect(creditCard2.$cancelRequest).toBeDefined(); + expect(creditCard3.$cancelRequest).toBeDefined(); - canceler = $q.defer(); + options = {cancellable: false}; CreditCard = $resource('/CreditCard', {}, { - query: { + get1: {method: 'GET', cancellable: false}, + get2: {method: 'GET', cancellable: true}, + get3: {method: 'GET'} + }, options); + + creditCard1 = CreditCard.get1(); + creditCard2 = CreditCard.get2(); + creditCard3 = CreditCard.get3(); + + expect(creditCard1.$cancelRequest).toBeUndefined(); + expect(creditCard2.$cancelRequest).toBeDefined(); + expect(creditCard3.$cancelRequest).toBeUndefined(); + }); + + it('should not make the request cancellable if there is a timeout', function() { + var CreditCard = $resource('/CreditCard', {}, { + get: { method: 'GET', - timeout: canceler.promise + timeout: 10000, + cancellable: true } }); - CreditCard.query(); + var creditCard = CreditCard.get(); + + expect(creditCard.$cancelRequest).toBeUndefined(); + }); + + it('should cancel the request (if cancellable), when calling `$cancelRequest`', function() { + $httpBackend.whenGET('/CreditCard').respond({}); + + var CreditCard = $resource('/CreditCard', {}, { + get: { + method: 'GET', + cancellable: true + } + }); + + CreditCard.get().$cancelRequest(); + expect($httpBackend.flush).toThrow(new Error('No pending request to flush !')); + + CreditCard.get(); expect($httpBackend.flush).not.toThrow(); }); + it('should reset `$cancelRequest` after the response arrives', function() { + $httpBackend.whenGET('/CreditCard').respond({}); + var CreditCard = $resource('/CreditCard', {}, { + get: { + method: 'GET', + cancellable: true + } + }); + + var creditCard = CreditCard.get(); + + expect(creditCard.$cancelRequest).not.toBe(noop); + + $httpBackend.flush(); + + expect(creditCard.$cancelRequest).toBe(noop); + }); +}); + +describe('resource wrt configuring `cancellable` on the provider', function() { + var $resource; + + beforeEach(module('ngResource', function($resourceProvider) { + $resourceProvider.defaults.cancellable = true; + })); + + beforeEach(inject(function(_$resource_) { + $resource = _$resource_; + })); + + it('should also take into account `$resourceProvider.defaults.cancellable`', function() { + var CreditCard = $resource('/CreditCard', {}, { + get1: {method: 'GET', cancellable: false}, + get2: {method: 'GET', cancellable: true}, + get3: {method: 'GET'} + }); + + var creditCard1 = CreditCard.get1(); + var creditCard2 = CreditCard.get2(); + var creditCard3 = CreditCard.get3(); + + expect(creditCard1.$cancelRequest).toBeUndefined(); + expect(creditCard2.$cancelRequest).toBeDefined(); + expect(creditCard3.$cancelRequest).toBeDefined(); + }); });