From 0aa62ed72f3d5767347387ac81363f1fa0901ab5 Mon Sep 17 00:00:00 2001 From: Sylvester Keil Date: Wed, 12 Dec 2012 13:14:29 +0100 Subject: [PATCH 1/2] feat($http): add request interceptors Request interceptors are promise-based and can be registered analogously to response interceptors. For backwards compatibility, requests are handled exactly as before when no interceptors are registered. When request interceptors have been registered, the interceptors are prependend to the promise chain returned by $http. The interceptor promises will be resolved or rejected before the request is sent out and passed the request's transformed config object; therefore, the interceptors are able to change everything about the request before it is actually sent. This makes Angular play nice with many advanced requirements (e.g., complex authentication or analytics). Because the interceptors use promises, this approach is extremely flexible. For instance, it is possible to make additional requests before sending a requests by injecting new promises into the chain. Closes #929 This commit also adds tests that cover the initial implementation. Since request interceptors utilize promisesi, tests involving the mocked $httpBackend need to make an extra call to $rootScope.$apply for the intercepted requests to register with $httpBackend. --- src/ng/http.js | 104 ++++++++++++++++++++++++++------- test/ng/httpSpec.js | 138 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 219 insertions(+), 23 deletions(-) diff --git a/src/ng/http.js b/src/ng/http.js index 4288e7c17c1e..f77e8d1464e6 100644 --- a/src/ng/http.js +++ b/src/ng/http.js @@ -155,21 +155,15 @@ function $HttpProvider() { xsrfHeaderName: 'X-XSRF-TOKEN' }; - var providerResponseInterceptors = this.responseInterceptors = []; + var providerResponseInterceptors = this.responseInterceptors = [], + providerRequestInterceptors = this.requestInterceptors = []; this.$get = ['$httpBackend', '$browser', '$cacheFactory', '$rootScope', '$q', '$injector', function($httpBackend, $browser, $cacheFactory, $rootScope, $q, $injector) { var defaultCache = $cacheFactory('$http'), - responseInterceptors = []; - - forEach(providerResponseInterceptors, function(interceptor) { - responseInterceptors.push( - isString(interceptor) - ? $injector.get(interceptor) - : $injector.invoke(interceptor) - ); - }); + requestInterceptors = resolveInterceptors(providerRequestInterceptors), + responseInterceptors = resolveInterceptors(providerResponseInterceptors); /** @@ -347,6 +341,30 @@ function $HttpProvider() { * * * + * # Request interceptors + * + * For purposes of global request preprocessing Angular provides request + * interceptors. Request interceptors can be registered analogously to + * response interceptors by adding a service factory to the + * `$httpProvider.requestInterceptors` array. The factory is called and + * injected with dependencies (if specified) and returns the interceptor + * function: this function will be called for every HTTP request and + * passed a promise which will be resolved with the request's `config` + * object. + * + *
+     *   // register the interceptor via an anonymous factory
+     *   $httpProvider.requestInterceptors.push(function(dependency1, dependency2) {
+     *     return function(promise) {
+     *       return function(config) {
+     *         // do something
+     *         return config;
+     *       };
+     *     };
+     *   });
+     * 
+ * + * * # Security Considerations * * When designing web applications, consider security threats from: @@ -520,6 +538,9 @@ function $HttpProvider() { */ function $http(config) { + // make a defensive copy of the config object + config = extend({}, config); + config.method = uppercase(config.method); var xsrfHeader = {}, @@ -532,28 +553,49 @@ function $HttpProvider() { var reqTransformFn = config.transformRequest || defaults.transformRequest, respTransformFn = config.transformResponse || defaults.transformResponse, defHeaders = defaults.headers, - reqHeaders = extend(xsrfHeader, - defHeaders.common, defHeaders[lowercase(config.method)], config.headers), - reqData = transformData(config.data, headersGetter(reqHeaders), reqTransformFn), - promise; + deferred, promise; + + config.headers = extend(xsrfHeader, defHeaders.common, + defHeaders[lowercase(config.method)], config.headers); + + config.data = transformData(config.data, headersGetter(config.headers), reqTransformFn); // strip content-type if data is undefined if (isUndefined(config.data)) { - delete reqHeaders['Content-Type']; + delete config.headers['Content-Type']; } if (isUndefined(config.withCredentials) && !isUndefined(defaults.withCredentials)) { config.withCredentials = defaults.withCredentials; } - // send request - promise = sendReq(config, reqData, reqHeaders); + + // apply request interceptors + if (requestInterceptors.length) { + deferred = $q.defer(); + promise = deferred.promise; + + forEach(requestInterceptors, function(interceptor) { + promise = interceptor(promise); + }); + + // send request after interceptors have finished + promise = promise.then(function(config) { + return sendReq(config); + }); + + deferred.resolve(config); + + } else { + // if there are no request interceptors just send request + promise = sendReq(config); + } // transform future response promise = promise.then(transformResponse, transformResponse); - // apply interceptors + // apply response interceptors forEach(responseInterceptors, function(interceptor) { promise = interceptor(promise); }); @@ -718,12 +760,20 @@ function $HttpProvider() { * !!! ACCESSES CLOSURE VARS: * $httpBackend, defaults, $log, $rootScope, defaultCache, $http.pendingRequests */ - function sendReq(config, reqData, reqHeaders) { + function sendReq(config) { var deferred = $q.defer(), promise = deferred.promise, cache, cachedResp, - url = buildUrl(config.url, config.params); + url = buildUrl(config.url, config.params), + reqHeaders = config.headers, + reqData = config.data; + + // remove data/headers from config object, because ngResource + // tests which do not expect these in the config object after + // the request + delete config.data; + delete config.headers; $http.pendingRequests.push(config); promise.then(removePendingReq, removePendingReq); @@ -756,8 +806,8 @@ 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, - config.withCredentials, config.responseType); + $httpBackend(config.method, url, reqData, done, reqHeaders, + config.timeout, config.withCredentials, config.responseType); } return promise; @@ -807,6 +857,16 @@ function $HttpProvider() { } + function resolveInterceptors(interceptors) { + var list = []; + forEach(interceptors, function(interceptor) { + list.push( + $injector[isString(interceptor) ? 'get' : 'invoke'](interceptor) + ); + }); + return list; + }; + function buildUrl(url, params) { if (!params) return url; var parts = []; diff --git a/test/ng/httpSpec.js b/test/ng/httpSpec.js index 600a6a2ebf8a..ce3198f1c977 100644 --- a/test/ng/httpSpec.js +++ b/test/ng/httpSpec.js @@ -27,7 +27,7 @@ describe('$http', function() { describe('$httpProvider', function() { - describe('interceptors', function() { + describe('response interceptors', function() { it('should default to an empty array', module(function($httpProvider) { expect($httpProvider.responseInterceptors).toEqual([]); @@ -103,6 +103,142 @@ describe('$http', function() { }); + describe('request interceptors', function() { + it('are not defined by default', module(function($httpProvider) { + expect($httpProvider.requestInterceptors).toEqual([]); + })); + + it('are passed a the request config as a promise', function() { + module(function($httpProvider) { + $httpProvider.requestInterceptors.push(function($rootScope) { + return function(promise) { + return promise.then(function(config) { + expect(config.url).toBe('/url'); + expect(config.data).toBe('{"one":"two"}'); + expect(config.headers.foo).toBe('bar'); + return config; + }); + }; + }); + }); + inject(function($http, $httpBackend, $rootScope) { + $httpBackend.expect('POST', '/url').respond(''); + $http({method: 'POST', url: '/url', data: {one: 'two'}, headers: {foo: 'bar'}}); + $rootScope.$apply(); + }); + }); + + it('can manipulate the request', function() { + module(function($httpProvider) { + $httpProvider.requestInterceptors.push(function() { + return function(promise) { + return promise.then(function(config) { + config.url = '/intercepted'; + config.headers.foo = 'intercepted'; + return config; + }); + }; + }); + }); + inject(function($http, $httpBackend, $rootScope) { + $httpBackend.expect('GET', '/intercepted', null, function (headers) { + return headers.foo === 'intercepted'; + }).respond(''); + $http.get('/url'); + $rootScope.$apply(); + }); + }); + + it('rejects the http promise if an interceptor fails', function() { + var reason = new Error('interceptor failed'); + module(function($httpProvider) { + $httpProvider.requestInterceptors.push(function($q) { + return function(promise) { + return $q.reject(reason); + }; + }); + }); + inject(function($http, $httpBackend, $rootScope) { + var success = jasmine.createSpy(), error = jasmine.createSpy(); + $http.get('/url').then(success, error); + $rootScope.$apply(); + expect(success).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith(reason); + $httpBackend.verifyNoOutstandingRequest(); + }); + }); + + it('does not manipulate the passed-in config', function() { + module(function($httpProvider) { + $httpProvider.requestInterceptors.push(function() { + return function(promise) { + return promise.then(function(config) { + config.url = '/intercepted'; + config.headers.foo = 'intercepted'; + return config; + }); + }; + }); + }); + inject(function($http, $httpBackend, $rootScope) { + var config = { method: 'get', url: '/url', headers: { foo: 'bar'} }; + $httpBackend.expect('GET', '/intercepted').respond(''); + $http.get('/url'); + $rootScope.$apply(); + expect(config.method).toBe('get'); + expect(config.url).toBe('/url'); + expect(config.headers.foo).toBe('bar') + }); + }); + + it('supports interceptors defined as services', function() { + module(function($provide, $httpProvider) { + $provide.factory('myInterceptor', function() { + return function(promise) { + return promise.then(function(config) { + config.url = '/intercepted'; + return config; + }); + }; + }); + $httpProvider.requestInterceptors.push('myInterceptor'); + }); + inject(function($http, $httpBackend, $rootScope) { + $httpBackend.expect('POST', '/intercepted').respond(''); + $http.post('/url'); + $rootScope.$apply(); + }); + }); + + it('supports complex interceptors based on promises', function() { + module(function($provide, $httpProvider) { + $provide.factory('myInterceptor', function($q, $rootScope) { + return function(promise) { + var deferred = $q.defer(); + + $rootScope.$apply(function() { + deferred.resolve('/intercepted'); + }); + + return deferred.promise.then(function(intercepted) { + return promise.then(function(config) { + config.url = intercepted; + return config; + }); + }); + }; + }); + $httpProvider.requestInterceptors.push('myInterceptor'); + }); + inject(function($http, $httpBackend, $rootScope) { + $httpBackend.expect('POST', '/intercepted').respond(''); + $http.post('/two'); + $rootScope.$apply(); + }); + }); + }); + + describe('the instance', function() { var $httpBackend, $http, $rootScope; From bf0994b03df59f2c22963c24452941a8cb9aa128 Mon Sep 17 00:00:00 2001 From: Sylvester Keil Date: Tue, 26 Feb 2013 10:22:12 +0100 Subject: [PATCH 2/2] docs($http): improve request interceptor example Fixes the request interceptor code sample and adds examples for intercepting the reuqest's URL, headers, or data. --- src/ng/http.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/ng/http.js b/src/ng/http.js index f77e8d1464e6..ab1ba9aa37a3 100644 --- a/src/ng/http.js +++ b/src/ng/http.js @@ -356,10 +356,20 @@ function $HttpProvider() { * // register the interceptor via an anonymous factory * $httpProvider.requestInterceptors.push(function(dependency1, dependency2) { * return function(promise) { - * return function(config) { - * // do something + * return promise.then(function(config) { + * // do something with the config object + * + * // example: alter the request's URL + * config.url = '/intercepted'; + * + * // example: add an extra HTTP header + * config.headers.custom = 'intercepted'; + * + * // example: modify the request's data + * config.data = { intercepted: true }; + * * return config; - * }; + * }); * }; * }); *