From 141c88b4bc1c7a71ad31eef335d7c3a4933a2113 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Sun, 29 Dec 2013 01:50:09 +0100 Subject: [PATCH] feat($resource): Make stripping of trailing slashes configurable. First, this now uses a flat object configuration, similar to `$httpBackend`. This should make configuring this provider much more familiar. This adds a fourth optional argument to the `$resource()` constructor, supporting overriding global `$resourceProvider` configuration. Now, both of these ways of configuring this is supported: app.config(function($resourceProvider) { $resourceProvider.defaults.stripTrailingSlashes = false; }); or per instance: var CreditCard = $resource('/some/:url/', ..., ..., { stripTrailingSlashes: false }); --- src/ngResource/resource.js | 527 +++++++++++++++++--------------- test/ngResource/resourceSpec.js | 55 +++- 2 files changed, 333 insertions(+), 249 deletions(-) diff --git a/src/ngResource/resource.js b/src/ngResource/resource.js index 8874bbeca484..8f57ed6b8524 100644 --- a/src/ngResource/resource.js +++ b/src/ngResource/resource.js @@ -74,6 +74,18 @@ function shallowClearAndCopy(src, dst) { * * Requires the {@link ngResource `ngResource`} module to be installed. * + * By default, trailing slashes will be stripped from the calculated URLs, + * which can pose problems with server backends that do not expect that + * behavior. This can be disabled by configuring the `$resourceProvider` like + * this: + * + *
+      app.config(['$resourceProvider', function ($resourceProvider) {
+        // Don't strip trailing slashes from calculated URLs
+        $resourceProvider.defaults.stripTrailingSlashes = false;
+      }]);
+   
+ * * @param {string} url A parametrized URL template with parameters prefixed by `:` as in * `/user/:username`. If you are using a URL with a port number (e.g. * `http://example.com:8080/api`), it will be respected. @@ -141,6 +153,14 @@ function shallowClearAndCopy(src, dst) { * - **`interceptor`** - `{Object=}` - The interceptor object has two optional methods - * `response` and `responseError`. Both `response` and `responseError` interceptors get called * 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: + * + * - **`stripTrailingSlashes`** – {boolean} – If true then the trailing +* slashes from any calculated URL will be stripped. (Defaults to true.) * * @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: @@ -303,284 +323,299 @@ function shallowClearAndCopy(src, dst) { * */ angular.module('ngResource', ['ng']). - factory('$resource', ['$http', '$q', function($http, $q) { - - var DEFAULT_ACTIONS = { - 'get': {method:'GET'}, - 'save': {method:'POST'}, - 'query': {method:'GET', isArray:true}, - 'remove': {method:'DELETE'}, - 'delete': {method:'DELETE'} + provider('$resource', function () { + var provider = this; + + this.defaults = { + // Strip slashes by default + stripTrailingSlashes: true, + + // Default actions configuration + actions: { + 'get': {method:'GET'}, + 'save': {method:'POST'}, + 'query': {method:'GET', isArray:true}, + 'remove': {method:'DELETE'}, + 'delete': {method:'DELETE'} + } }; - var noop = angular.noop, - forEach = angular.forEach, - extend = angular.extend, - copy = angular.copy, - isFunction = angular.isFunction; - - /** - * We need our custom method because encodeURIComponent is too aggressive and doesn't follow - * http://www.ietf.org/rfc/rfc3986.txt with regards to the character set (pchar) allowed in path - * segments: - * segment = *pchar - * pchar = unreserved / pct-encoded / sub-delims / ":" / "@" - * pct-encoded = "%" HEXDIG HEXDIG - * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" - * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" - * / "*" / "+" / "," / ";" / "=" - */ - function encodeUriSegment(val) { - return encodeUriQuery(val, true). - replace(/%26/gi, '&'). - replace(/%3D/gi, '='). - replace(/%2B/gi, '+'); - } + this.$get = ['$http', '$q', function($http, $q) { + + var noop = angular.noop, + forEach = angular.forEach, + extend = angular.extend, + copy = angular.copy, + isFunction = angular.isFunction; + + /** + * We need our custom method because encodeURIComponent is too aggressive and doesn't follow + * http://www.ietf.org/rfc/rfc3986.txt with regards to the character set + * (pchar) allowed in path segments: + * segment = *pchar + * pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + * pct-encoded = "%" HEXDIG HEXDIG + * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" + * / "*" / "+" / "," / ";" / "=" + */ + function encodeUriSegment(val) { + return encodeUriQuery(val, true). + replace(/%26/gi, '&'). + replace(/%3D/gi, '='). + replace(/%2B/gi, '+'); + } - /** - * This method is intended for encoding *key* or *value* parts of query component. We need a - * custom method because encodeURIComponent is too aggressive and encodes stuff that doesn't - * have to be encoded per http://tools.ietf.org/html/rfc3986: - * query = *( pchar / "/" / "?" ) - * pchar = unreserved / pct-encoded / sub-delims / ":" / "@" - * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" - * pct-encoded = "%" HEXDIG HEXDIG - * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" - * / "*" / "+" / "," / ";" / "=" - */ - function encodeUriQuery(val, pctEncodeSpaces) { - return encodeURIComponent(val). - replace(/%40/gi, '@'). - replace(/%3A/gi, ':'). - replace(/%24/g, '$'). - replace(/%2C/gi, ','). - replace(/%20/g, (pctEncodeSpaces ? '%20' : '+')); - } - function Route(template, defaults) { - this.template = template; - this.defaults = defaults || {}; - this.urlParams = {}; - } + /** + * This method is intended for encoding *key* or *value* parts of query component. We need a + * custom method because encodeURIComponent is too aggressive and encodes stuff that doesn't + * have to be encoded per http://tools.ietf.org/html/rfc3986: + * query = *( pchar / "/" / "?" ) + * pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + * pct-encoded = "%" HEXDIG HEXDIG + * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" + * / "*" / "+" / "," / ";" / "=" + */ + function encodeUriQuery(val, pctEncodeSpaces) { + return encodeURIComponent(val). + replace(/%40/gi, '@'). + replace(/%3A/gi, ':'). + replace(/%24/g, '$'). + replace(/%2C/gi, ','). + replace(/%20/g, (pctEncodeSpaces ? '%20' : '+')); + } - Route.prototype = { - setUrlParams: function(config, params, actionUrl) { - var self = this, - url = actionUrl || self.template, - val, - encodedVal; - - var urlParams = self.urlParams = {}; - forEach(url.split(/\W/), function(param){ - if (param === 'hasOwnProperty') { - throw $resourceMinErr('badname', "hasOwnProperty is not a valid parameter name."); - } - if (!(new RegExp("^\\d+$").test(param)) && param && - (new RegExp("(^|[^\\\\]):" + param + "(\\W|$)").test(url))) { - urlParams[param] = true; - } - }); - url = url.replace(/\\:/g, ':'); - - params = params || {}; - forEach(self.urlParams, function(_, urlParam){ - val = params.hasOwnProperty(urlParam) ? params[urlParam] : self.defaults[urlParam]; - if (angular.isDefined(val) && val !== null) { - encodedVal = encodeUriSegment(val); - url = url.replace(new RegExp(":" + urlParam + "(\\W|$)", "g"), encodedVal + "$1"); - } else { - url = url.replace(new RegExp("(\/?):" + urlParam + "(\\W|$)", "g"), function(match, - leadingSlashes, tail) { - if (tail.charAt(0) == '/') { - return tail; - } else { - return leadingSlashes + tail; - } - }); + function Route(template, defaults) { + this.template = template; + this.defaults = extend({}, provider.defaults, defaults); + this.urlParams = {}; + } + + Route.prototype = { + setUrlParams: function(config, params, actionUrl) { + var self = this, + url = actionUrl || self.template, + val, + encodedVal; + + var urlParams = self.urlParams = {}; + forEach(url.split(/\W/), function(param){ + if (param === 'hasOwnProperty') { + throw $resourceMinErr('badname', "hasOwnProperty is not a valid parameter name."); + } + if (!(new RegExp("^\\d+$").test(param)) && param && + (new RegExp("(^|[^\\\\]):" + param + "(\\W|$)").test(url))) { + urlParams[param] = true; + } + }); + url = url.replace(/\\:/g, ':'); + + params = params || {}; + forEach(self.urlParams, function(_, urlParam){ + val = params.hasOwnProperty(urlParam) ? params[urlParam] : self.defaults[urlParam]; + if (angular.isDefined(val) && val !== null) { + encodedVal = encodeUriSegment(val); + url = url.replace(new RegExp(":" + urlParam + "(\\W|$)", "g"), encodedVal + "$1"); + } else { + url = url.replace(new RegExp("(\/?):" + urlParam + "(\\W|$)", "g"), function(match, + leadingSlashes, tail) { + if (tail.charAt(0) == '/') { + return tail; + } else { + return leadingSlashes + tail; + } + }); + } + }); + + // strip trailing slashes and set the url (unless this behavior is specifically disabled) + if (self.defaults.stripTrailingSlashes) { + url = url.replace(/\/+$/, '') || '/'; } - }); - // strip trailing slashes and set the url - url = url.replace(/\/+$/, '') || '/'; - // then replace collapse `/.` if found in the last URL path segment before the query - // E.g. `http://url.com/id./format?q=x` becomes `http://url.com/id.format?q=x` - url = url.replace(/\/\.(?=\w+($|\?))/, '.'); - // replace escaped `/\.` with `/.` - config.url = url.replace(/\/\\\./, '/.'); + // then replace collapse `/.` if found in the last URL path segment before the query + // E.g. `http://url.com/id./format?q=x` becomes `http://url.com/id.format?q=x` + url = url.replace(/\/\.(?=\w+($|\?))/, '.'); + // replace escaped `/\.` with `/.` + config.url = url.replace(/\/\\\./, '/.'); - // set params - delegate param encoding to $http - forEach(params, function(value, key){ - if (!self.urlParams[key]) { - config.params = config.params || {}; - config.params[key] = value; - } - }); - } - }; + // set params - delegate param encoding to $http + forEach(params, function(value, key){ + if (!self.urlParams[key]) { + config.params = config.params || {}; + config.params[key] = value; + } + }); + } + }; - function resourceFactory(url, paramDefaults, actions) { - var route = new Route(url); + function resourceFactory(url, paramDefaults, actions, options) { + var route = new Route(url, options); - actions = extend({}, DEFAULT_ACTIONS, actions); + actions = extend({}, provider.defaults.actions, actions); - function extractParams(data, actionParams){ - var ids = {}; - actionParams = extend({}, paramDefaults, actionParams); - forEach(actionParams, function(value, key){ - if (isFunction(value)) { value = value(); } - ids[key] = value && value.charAt && value.charAt(0) == '@' ? - lookupDottedPath(data, value.substr(1)) : value; - }); - return ids; - } + function extractParams(data, actionParams){ + var ids = {}; + actionParams = extend({}, paramDefaults, actionParams); + forEach(actionParams, function(value, key){ + if (isFunction(value)) { value = value(); } + ids[key] = value && value.charAt && value.charAt(0) == '@' ? + lookupDottedPath(data, value.substr(1)) : value; + }); + return ids; + } - function defaultResponseInterceptor(response) { - return response.resource; - } + function defaultResponseInterceptor(response) { + return response.resource; + } - function Resource(value){ - shallowClearAndCopy(value || {}, this); - } + function Resource(value){ + shallowClearAndCopy(value || {}, this); + } - forEach(actions, function(action, name) { - var hasBody = /^(POST|PUT|PATCH)$/i.test(action.method); - - Resource[name] = function(a1, a2, a3, a4) { - var params = {}, data, success, error; - - /* jshint -W086 */ /* (purposefully fall through case statements) */ - switch(arguments.length) { - case 4: - error = a4; - success = a3; - //fallthrough - case 3: - case 2: - if (isFunction(a2)) { - if (isFunction(a1)) { - success = a1; - error = a2; - break; - } + forEach(actions, function(action, name) { + var hasBody = /^(POST|PUT|PATCH)$/i.test(action.method); - success = a2; - error = a3; - //fallthrough - } else { - params = a1; - data = a2; + Resource[name] = function(a1, a2, a3, a4) { + var params = {}, data, success, error; + + /* jshint -W086 */ /* (purposefully fall through case statements) */ + switch(arguments.length) { + case 4: + error = a4; success = a3; + //fallthrough + case 3: + case 2: + if (isFunction(a2)) { + if (isFunction(a1)) { + success = a1; + error = a2; + break; + } + + success = a2; + error = a3; + //fallthrough + } else { + params = a1; + data = a2; + success = a3; + break; + } + case 1: + if (isFunction(a1)) success = a1; + else if (hasBody) data = a1; + else params = a1; break; + case 0: break; + default: + throw $resourceMinErr('badargs', + "Expected up to 4 arguments [params, data, success, error], got {0} arguments", + arguments.length); } - case 1: - if (isFunction(a1)) success = a1; - else if (hasBody) data = a1; - else params = a1; - break; - case 0: break; - default: - throw $resourceMinErr('badargs', - "Expected up to 4 arguments [params, data, success, error], got {0} arguments", - arguments.length); - } - /* jshint +W086 */ /* (purposefully fall through case statements) */ - - var isInstanceCall = this instanceof Resource; - var value = isInstanceCall ? data : (action.isArray ? [] : new Resource(data)); - var httpConfig = {}; - var responseInterceptor = action.interceptor && action.interceptor.response || - defaultResponseInterceptor; - var responseErrorInterceptor = action.interceptor && action.interceptor.responseError || - undefined; - - forEach(action, function(value, key) { - if (key != 'params' && key != 'isArray' && key != 'interceptor') { - httpConfig[key] = copy(value); - } - }); - - if (hasBody) httpConfig.data = data; - route.setUrlParams(httpConfig, - extend({}, extractParams(data, action.params || {}), params), - action.url); - - var promise = $http(httpConfig).then(function(response) { - var data = response.data, - promise = value.$promise; - - if (data) { - // Need to convert action.isArray to boolean in case it is undefined - // jshint -W018 - if (angular.isArray(data) !== (!!action.isArray)) { - throw $resourceMinErr('badcfg', 'Error in resource configuration. Expected ' + - 'response to contain an {0} but got an {1}', - action.isArray?'array':'object', angular.isArray(data)?'array':'object'); + /* jshint +W086 */ /* (purposefully fall through case statements) */ + + var isInstanceCall = this instanceof Resource; + var value = isInstanceCall ? data : (action.isArray ? [] : new Resource(data)); + var httpConfig = {}; + var responseInterceptor = action.interceptor && action.interceptor.response || + defaultResponseInterceptor; + var responseErrorInterceptor = action.interceptor && action.interceptor.responseError || + undefined; + + forEach(action, function(value, key) { + if (key != 'params' && key != 'isArray' && key != 'interceptor') { + httpConfig[key] = copy(value); } - // jshint +W018 - if (action.isArray) { - value.length = 0; - forEach(data, function(item) { - value.push(new Resource(item)); - }); - } else { - shallowClearAndCopy(data, value); - value.$promise = promise; + }); + + if (hasBody) httpConfig.data = data; + route.setUrlParams(httpConfig, + extend({}, extractParams(data, action.params || {}), params), + action.url); + + var promise = $http(httpConfig).then(function(response) { + var data = response.data, + promise = value.$promise; + + if (data) { + // Need to convert action.isArray to boolean in case it is undefined + // jshint -W018 + if (angular.isArray(data) !== (!!action.isArray)) { + throw $resourceMinErr('badcfg', 'Error in resource configuration. Expected ' + + 'response to contain an {0} but got an {1}', + action.isArray?'array':'object', angular.isArray(data)?'array':'object'); + } + // jshint +W018 + if (action.isArray) { + value.length = 0; + forEach(data, function(item) { + value.push(new Resource(item)); + }); + } else { + shallowClearAndCopy(data, value); + value.$promise = promise; + } } - } - value.$resolved = true; + value.$resolved = true; - response.resource = value; + response.resource = value; - return response; - }, function(response) { - value.$resolved = true; + return response; + }, function(response) { + value.$resolved = true; - (error||noop)(response); + (error||noop)(response); - return $q.reject(response); - }); + return $q.reject(response); + }); - promise = promise.then( - function(response) { - var value = responseInterceptor(response); - (success||noop)(value, response.headers); - return value; - }, - responseErrorInterceptor); - - if (!isInstanceCall) { - // we are creating instance / collection - // - set the initial promise - // - return the instance / collection - value.$promise = promise; - value.$resolved = false; - - return value; - } + promise = promise.then( + function(response) { + var value = responseInterceptor(response); + (success||noop)(value, response.headers); + return value; + }, + responseErrorInterceptor); + + if (!isInstanceCall) { + // we are creating instance / collection + // - set the initial promise + // - return the instance / collection + value.$promise = promise; + value.$resolved = false; + + return value; + } - // instance call - return promise; - }; + // instance call + return promise; + }; - Resource.prototype['$' + name] = function(params, success, error) { - if (isFunction(params)) { - error = success; success = params; params = {}; - } - var result = Resource[name].call(this, params, this, success, error); - return result.$promise || result; + Resource.prototype['$' + name] = function(params, success, error) { + if (isFunction(params)) { + error = success; success = params; params = {}; + } + var result = Resource[name].call(this, params, this, success, error); + return result.$promise || result; + }; + }); + + Resource.bind = function(additionalParamDefaults){ + return resourceFactory(url, extend({}, paramDefaults, additionalParamDefaults), actions); }; - }); - Resource.bind = function(additionalParamDefaults){ - return resourceFactory(url, extend({}, paramDefaults, additionalParamDefaults), actions); - }; + return Resource; + } - return Resource; - } + return resourceFactory; + }]; - return resourceFactory; - }]); + }); diff --git a/test/ngResource/resourceSpec.js b/test/ngResource/resourceSpec.js index b23f0ca42bd7..486341607d74 100644 --- a/test/ngResource/resourceSpec.js +++ b/test/ngResource/resourceSpec.js @@ -1,9 +1,14 @@ 'use strict'; describe("resource", function() { - var $resource, CreditCard, callback, $httpBackend; + var $resource, CreditCard, callback, $httpBackend, resourceProvider; beforeEach(module('ngResource')); + + beforeEach(module(function ($resourceProvider) { + resourceProvider = $resourceProvider; + })); + beforeEach(inject(function($injector) { $httpBackend = $injector.get('$httpBackend'); $resource = $injector.get('$resource'); @@ -183,7 +188,6 @@ describe("resource", function() { R.get({a: 'doh!@foo', bar: 'baz#1'}); }); - it('should not encode @ in url params', function() { //encodeURIComponent is too agressive and doesn't follow http://www.ietf.org/rfc/rfc3986.txt //with regards to the character set (pchar) allowed in path segments @@ -195,7 +199,6 @@ describe("resource", function() { R.get({a: 'doh@fo o', ':bar': '$baz@1', '!do&h': 'g=a h'}); }); - it('should encode array params', function() { var R = $resource('/Path/:a'); $httpBackend.expect('GET', '/Path/doh&foo?bar=baz1&bar=baz2').respond('{}'); @@ -208,6 +211,52 @@ describe("resource", function() { R.get({a: 'null'}); }); + + it('should implicitly strip trailing slashes from URLs by default', function() { + var R = $resource('http://localhost:8080/Path/:a/'); + + $httpBackend.expect('GET', 'http://localhost:8080/Path/foo').respond(); + R.get({a: 'foo'}); + }); + + it('should support explicitly stripping trailing slashes from URLs', function() { + var R = $resource('http://localhost:8080/Path/:a/', {}, {}, {stripTrailingSlashes: true}); + + $httpBackend.expect('GET', 'http://localhost:8080/Path/foo').respond(); + R.get({a: 'foo'}); + }); + + it('should support explicitly keeping trailing slashes in URLs', function() { + var R = $resource('http://localhost:8080/Path/:a/', {}, {}, {stripTrailingSlashes: false}); + + $httpBackend.expect('GET', 'http://localhost:8080/Path/foo/').respond(); + R.get({a: 'foo'}); + }); + + it('should support provider-level configuration to strip trailing slashes in URLs', function() { + // Set the new behavior for all new resources created by overriding the + // provider configuration + resourceProvider.defaults.stripTrailingSlashes = false; + + var R = $resource('http://localhost:8080/Path/:a/'); + + $httpBackend.expect('GET', 'http://localhost:8080/Path/foo/').respond(); + R.get({a: 'foo'}); + }); + + it('should support overriding provider default trailing-slash stripping configuration', function() { + // Set the new behavior for all new resources created by overriding the + // provider configuration + resourceProvider.defaults.stripTrailingSlashes = false; + + // Specific instances of $resource can still override the provider's default + var R = $resource('http://localhost:8080/Path/:a/', {}, {}, {stripTrailingSlashes: true}); + + $httpBackend.expect('GET', 'http://localhost:8080/Path/foo').respond(); + R.get({a: 'foo'}); + }); + + it('should allow relative paths in resource url', function () { var R = $resource(':relativePath'); $httpBackend.expect('GET', 'data.json').respond('{}');