diff --git a/src/ng/compile.js b/src/ng/compile.js index 6606dc6c6586..68a3dca1e841 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -1018,10 +1018,10 @@ function $CompileProvider($provide) { while(linkQueue.length) { - var controller = linkQueue.pop(), - linkRootElement = linkQueue.pop(), - beforeTemplateLinkNode = linkQueue.pop(), - scope = linkQueue.pop(), + var scope = linkQueue.shift(), + beforeTemplateLinkNode = linkQueue.shift(), + linkRootElement = linkQueue.shift(), + controller = linkQueue.shift(), linkNode = compileNode; if (beforeTemplateLinkNode !== beforeTemplateCompileNode) { diff --git a/src/ng/http.js b/src/ng/http.js index e4d695c460a0..d54e8bd31599 100644 --- a/src/ng/http.js +++ b/src/ng/http.js @@ -155,20 +155,52 @@ function $HttpProvider() { xsrfHeaderName: 'X-XSRF-TOKEN' }; - var providerResponseInterceptors = this.responseInterceptors = []; + /** + * Are order by request. I.E. they are applied in the same order as + * array on request, but revers order on response. + */ + var interceptorFactories = this.interceptors = []; + /** + * For historical reasons, response interceptors ordered by the order in which + * they are applied to response. (This is in revers to interceptorFactories) + */ + var responseInterceptorFactories = this.responseInterceptors = []; this.$get = ['$httpBackend', '$browser', '$cacheFactory', '$rootScope', '$q', '$injector', function($httpBackend, $browser, $cacheFactory, $rootScope, $q, $injector) { - var defaultCache = $cacheFactory('$http'), - responseInterceptors = []; + var defaultCache = $cacheFactory('$http'); - forEach(providerResponseInterceptors, function(interceptor) { - responseInterceptors.push( - isString(interceptor) - ? $injector.get(interceptor) - : $injector.invoke(interceptor) - ); + /** + * Interceptors stored in reverse order. Inner interceptors before outer interceptors. + * The reversal is needed so that we can build up the interception chain around the + * server request. + */ + var reversedInterceptors = []; + + forEach(interceptorFactories, function(interceptorFactory) { + reversedInterceptors.unshift(isString(interceptorFactory) + ? $injector.get(interceptorFactory) : $injector.invoke(interceptorFactory)); + }); + + forEach(responseInterceptorFactories, function(interceptorFactory, index) { + var responseFn = isString(interceptorFactory) + ? $injector.get(interceptorFactory) + : $injector.invoke(interceptorFactory); + + /** + * Response interceptors go before "around" interceptors (no real reason, just + * had to pick one.) But they are already revesed, so we can't use unshift, hence + * the splice. + */ + reversedInterceptors.splice(index, 0, { + response: function(response) { + return responseFn($q.when(response)); + }, + responseError: function(response) { + return responseFn($q.reject(response)); + } + }); }); @@ -310,7 +342,90 @@ function $HttpProvider() { * To skip it, set configuration property `cache` to `false`. * * - * # Response interceptors + * # Interceptors + * + * Before you start creating interceptors, be sure to understand the + * {@link ng.$q $q and deferred/promise APIs}. + * + * For purposes of global error handling, authentication or any kind of synchronous or + * asynchronous pre-processing of request or postprocessing of responses, it is desirable to be + * able to intercept requests before they are handed to the server and + * responses before they are handed over to the application code that + * initiated these requests. The interceptors leverage the {@link ng.$q + * promise APIs} to fulfil this need for both synchronous and asynchronous pre-processing. + * + * The interceptors are service factories that are registered with the $httpProvider by + * adding them to the `$httpProvider.interceptors` array. The factory is called and + * injected with dependencies (if specified) and returns the interceptor. + * + * There are two kinds of interceptors (and two kinds of rejection interceptors): + * + * * `request`: interceptors get called with http `config` object. The function is free to modify + * the `config` or create a new one. The function needs to return the `config` directly or as a + * promise. + * * `requestError`: interceptor gets called when a previous interceptor threw an error or resolved + * with a rejection. + * * `response`: interceptors get called with http `response` object. The function is free to modify + * the `response` or create a new one. The function needs to return the `response` directly or as a + * promise. + * * `responseError`: interceptor gets called when a previous interceptor threw an error or resolved + * with a rejection. + * + * + *
+     *   // register the interceptor as a service
+     *   $provide.factory('myHttpInterceptor', function($q, dependency1, dependency2) {
+     *     return {
+     *       // optional method
+     *       'request': function(config) {
+     *         // do something on success
+     *         return config || $q.when(config);
+     *       },
+     *
+     *       // optional method
+     *      'requestError': function(rejection) {
+     *         // do something on error
+     *         if (canRecover(rejection)) {
+     *           return responseOrNewPromise
+     *         }
+     *         return $q.reject(rejection);
+     *       },
+     *
+     *
+     *
+     *       // optional method
+     *       'response': function(response) {
+     *         // do something on success
+     *         return response || $q.when(response);
+     *       },
+     *
+     *       // optional method
+     *      'responseError': function(rejection) {
+     *         // do something on error
+     *         if (canRecover(rejection)) {
+     *           return responseOrNewPromise
+     *         }
+     *         return $q.reject(rejection);
+     *       };
+     *     }
+     *   });
+     *
+     *   $httpProvider.interceptors.push('myHttpInterceptor');
+     *
+     *
+     *   // register the interceptor via an anonymous factory
+     *   $httpProvider.interceptors.push(function($q, dependency1, dependency2) {
+     *     return {
+     *      'request': function(config) {
+     *          // same as above
+     *       },
+     *       'response': function(response) {
+     *          // same as above
+     *       }
+     *   });
+     * 
+ * + * # Response interceptors (DEPRECATED) * * Before you start creating interceptors, be sure to understand the * {@link ng.$q $q and deferred/promise APIs}. @@ -526,45 +641,66 @@ function $HttpProvider() { */ - function $http(config) { + function $http(requestConfig) { + var config = { + transformRequest: defaults.transformRequest, + transformResponse: defaults.transformResponse + }; + var headers = {}; + + extend(config, requestConfig); + config.headers = headers; config.method = uppercase(config.method); - var xsrfHeader = {}, - xsrfCookieName = config.xsrfCookieName || defaults.xsrfCookieName, - xsrfHeaderName = config.xsrfHeaderName || defaults.xsrfHeaderName, - xsrfToken = isSameDomain(config.url, $browser.url()) ? - $browser.cookies()[xsrfCookieName] : undefined; - xsrfHeader[xsrfHeaderName] = xsrfToken; - - 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; - - // strip content-type if data is undefined - if (isUndefined(config.data)) { - delete reqHeaders['Content-Type']; - } + extend(headers, + defaults.headers.common, + defaults.headers[lowercase(config.method)], + requestConfig.headers); - if (isUndefined(config.withCredentials) && !isUndefined(defaults.withCredentials)) { - config.withCredentials = defaults.withCredentials; + var xsrfValue = isSameDomain(config.url, $browser.url()) + ? $browser.cookies()[config.xsrfCookieName || defaults.xsrfCookieName] + : undefined; + if (xsrfValue) { + headers[(config.xsrfHeaderName || defaults.xsrfHeaderName)] = xsrfValue; } - // send request - promise = sendReq(config, reqData, reqHeaders); + var serverRequest = function(config) { + var reqData = transformData(config.data, headersGetter(headers), config.transformRequest); - // transform future response - promise = promise.then(transformResponse, transformResponse); + // strip content-type if data is undefined + if (isUndefined(config.data)) { + delete headers['Content-Type']; + } + + if (isUndefined(config.withCredentials) && !isUndefined(defaults.withCredentials)) { + config.withCredentials = defaults.withCredentials; + } + + // send request + return sendReq(config, reqData, headers).then(transformResponse, transformResponse); + }; + + var chain = [serverRequest, undefined]; + var promise = $q.when(config); // apply interceptors - forEach(responseInterceptors, function(interceptor) { - promise = interceptor(promise); + forEach(reversedInterceptors, function(interceptor) { + if (interceptor.request || interceptor.requestError) { + chain.unshift(interceptor.request, interceptor.requestError); + } + if (interceptor.response || interceptor.responseError) { + chain.push(interceptor.response, interceptor.responseError); + } }); + while(chain.length) { + var thenFn = chain.shift(); + var rejectFn = chain.shift(); + + promise = promise.then(thenFn, rejectFn); + }; + promise.success = function(fn) { promise.then(function(response) { fn(response.data, response.status, response.headers, config); @@ -584,7 +720,7 @@ function $HttpProvider() { function transformResponse(response) { // make a copy since the response must be cacheable var resp = extend({}, response, { - data: transformData(response.data, response.headers, respTransformFn) + data: transformData(response.data, response.headers, config.transformResponse) }); return (isSuccess(response.status)) ? resp diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index 8c91e62801d3..3980a391add4 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -826,7 +826,7 @@ angular.mock.dump = function(object) { */ angular.mock.$HttpBackendProvider = function() { - this.$get = [createHttpBackendMock]; + this.$get = ['$rootScope', createHttpBackendMock]; }; /** @@ -843,7 +843,7 @@ angular.mock.$HttpBackendProvider = function() { * @param {Object=} $browser Auto-flushing enabled if specified * @return {Object} Instance of $httpBackend mock */ -function createHttpBackendMock($delegate, $browser) { +function createHttpBackendMock($rootScope, $delegate, $browser) { var definitions = [], expectations = [], responses = [], @@ -1173,6 +1173,7 @@ function createHttpBackendMock($delegate, $browser) { * is called an exception is thrown (as this typically a sign of programming error). */ $httpBackend.flush = function(count) { + $rootScope.$digest(); if (!responses.length) throw Error('No pending request to flush !'); if (angular.isDefined(count)) { @@ -1205,6 +1206,7 @@ function createHttpBackendMock($delegate, $browser) { * */ $httpBackend.verifyNoOutstandingExpectation = function() { + $rootScope.$digest(); if (expectations.length) { throw Error('Unsatisfied requests: ' + expectations.join(', ')); } @@ -1606,7 +1608,7 @@ angular.module('ngMockE2E', ['ng']).config(function($provide) { * control how a matched request is handled. */ angular.mock.e2e = {}; -angular.mock.e2e.$httpBackendDecorator = ['$delegate', '$browser', createHttpBackendMock]; +angular.mock.e2e.$httpBackendDecorator = ['$rootScope', '$delegate', '$browser', createHttpBackendMock]; angular.mock.clearDataCache = function() { diff --git a/test/ng/directive/ngIncludeSpec.js b/test/ng/directive/ngIncludeSpec.js index 7c94a70ec5ba..dce803b52c31 100644 --- a/test/ng/directive/ngIncludeSpec.js +++ b/test/ng/directive/ngIncludeSpec.js @@ -178,25 +178,23 @@ describe('ngInclude', function() { it('should discard pending xhr callbacks if a new template is requested before the current ' + 'finished loading', inject(function($rootScope, $compile, $httpBackend) { element = jqLite(""); - var log = []; + var log = {}; $rootScope.templateUrl = 'myUrl1'; $rootScope.logger = function(msg) { - log.push(msg); + log[msg] = true; } $compile(element)($rootScope); - expect(log.join('; ')).toEqual(''); + expect(log).toEqual({}); $httpBackend.expect('GET', 'myUrl1').respond('
{{logger("url1")}}
'); $rootScope.$digest(); - expect(log.join('; ')).toEqual(''); + expect(log).toEqual({}); $rootScope.templateUrl = 'myUrl2'; $httpBackend.expect('GET', 'myUrl2').respond('
{{logger("url2")}}
'); - $rootScope.$digest(); $httpBackend.flush(); // now that we have two requests pending, flush! - expect(log.join('; ')).toEqual('url2; url2'); // it's here twice because we go through at - // least two digest cycles + expect(log).toEqual({ url2 : true }); })); diff --git a/test/ng/httpSpec.js b/test/ng/httpSpec.js index 2dd141925789..e6d1cf4fb0f8 100644 --- a/test/ng/httpSpec.js +++ b/test/ng/httpSpec.js @@ -12,7 +12,7 @@ describe('$http', function() { $exceptionHandlerProvider.mode('log'); })); - afterEach(inject(function($exceptionHandler, $httpBackend) { + afterEach(inject(function($exceptionHandler, $httpBackend, $rootScope) { forEach($exceptionHandler.errors, function(e) { dump('Unhandled exception: ', e) }); @@ -21,13 +21,150 @@ describe('$http', function() { throw 'Unhandled exceptions trapped in $exceptionHandler!'; } + $rootScope.$digest(); $httpBackend.verifyNoOutstandingExpectation(); })); describe('$httpProvider', function() { - describe('interceptors', function() { + it('should accept injected rejected response interceptor', function() { + var wasCalled = false; + module(function($httpProvider, $provide) { + $httpProvider.responseInterceptors.push('injectedInterceptor'); + $provide.factory('injectedInterceptor', ['$q', function($q) { + return function(promise) { + return promise.then(null, function authInterceptor(response) { + wasCalled = true; + expect(response.status).toEqual(401); + return $q.reject(response); + }); + }; + }]); + }); + inject(function($http, $httpBackend) { + $httpBackend.expect('GET', '/url').respond(401); + $http({method: 'GET', url: '/url'}); + $httpBackend.flush(); + expect(wasCalled).toEqual(true); + }); + }); + + + it('should chain request, requestReject, response and responseReject interceptors', function() { + module(function($httpProvider) { + var savedConfig, savedResponse; + $httpProvider.interceptors.push(function($q) { + return { + request: function(config) { + config.url += '/1'; + savedConfig = config; + return $q.reject('/2'); + } + }; + }); + $httpProvider.interceptors.push(function($q) { + return { + requestError: function(error) { + savedConfig.url += error; + return $q.when(savedConfig); + } + }; + }); + $httpProvider.interceptors.push(function() { + return { + responseError: function(rejection) { + savedResponse.data += rejection; + return savedResponse; + } + }; + }); + $httpProvider.interceptors.push(function($q) { + return { + response: function(response) { + response.data += ':1'; + savedResponse = response + return $q.reject(':2'); + } + }; + }); + }); + inject(function($http, $httpBackend, $rootScope) { + var response; + $httpBackend.expect('GET', '/url/1/2').respond('response'); + $http({method: 'GET', url: '/url'}).then(function(r) { + response = r; + }); + $rootScope.$apply(); + $httpBackend.flush(); + expect(response.data).toEqual('response:1:2'); + }); + }); + + + it('should verify order of execution', function() { + module(function($httpProvider) { + $httpProvider.interceptors.push(function($q) { + return { + request: function(config) { + config.url += '/outer'; + return config; + }, + response: function(response) { + response.data = '{' + response.data + '} outer'; + return response; + } + }; + }); + $httpProvider.interceptors.push(function($q) { + return { + request: function(config) { + config.url += '/inner'; + return config; + }, + response: function(response) { + response.data = '{' + response.data + '} inner'; + return response; + } + }; + }); + $httpProvider.responseInterceptors.push(function($q) { + return function(promise) { + var defer = $q.defer(); + + promise.then(function(response) { + response.data = '[' + response.data + '] legacy-1'; + defer.resolve(response); + }); + return defer.promise; + }; + }); + $httpProvider.responseInterceptors.push(function($q) { + return function(promise) { + var defer = $q.defer(); + + promise.then(function(response) { + response.data = '[' + response.data + '] legacy-2'; + defer.resolve(response); + }); + return defer.promise; + }; + }); + }); + inject(function($http, $httpBackend) { + var response; + $httpBackend.expect('GET', '/url/outer/inner').respond('response'); + $http({method: 'GET', url: '/url'}).then(function(r) { + response = r; + }); + $httpBackend.flush(); + expect(response.data).toEqual('{{[[response] legacy-1] legacy-2} inner} outer'); + }); + }); + }); + + + describe('response interceptors', function() { it('should default to an empty array', module(function($httpProvider) { expect($httpProvider.responseInterceptors).toEqual([]); @@ -44,7 +181,7 @@ describe('$http', function() { data: response.data + '?', status: 209, headers: response.headers, - config: response.config + request: response.config }); return deferred.promise; }); @@ -100,6 +237,136 @@ describe('$http', function() { }); }); }); + + + describe('request interceptors', function() { + it('should pass request config as a promise', function() { + var run = false; + module(function($httpProvider) { + $httpProvider.interceptors.push(function() { + return { + request: function(config) { + expect(config.url).toEqual('/url'); + expect(config.data).toEqual({one: "two"}); + expect(config.headers.foo).toEqual('bar'); + run = true; + 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(); + expect(run).toEqual(true); + }); + }); + + it('should allow manipulation of request', function() { + module(function($httpProvider) { + $httpProvider.interceptors.push(function() { + return { + request: 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('should reject the http promise if an interceptor fails', function() { + var reason = new Error('interceptor failed'); + module(function($httpProvider) { + $httpProvider.interceptors.push(function($q) { + return { + request: 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); + }); + }); + + it('should not manipulate the passed-in config', function() { + module(function($httpProvider) { + $httpProvider.interceptors.push(function() { + return { + request: 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).toEqual('get'); + expect(config.url).toEqual('/url'); + expect(config.headers.foo).toEqual('bar') + }); + }); + + it('should support interceptors defined as services', function() { + module(function($provide, $httpProvider) { + $provide.factory('myInterceptor', function() { + return { + request: function(config) { + config.url = '/intercepted'; + return config; + } + }; + }); + $httpProvider.interceptors.push('myInterceptor'); + }); + inject(function($http, $httpBackend, $rootScope) { + $httpBackend.expect('POST', '/intercepted').respond(''); + $http.post('/url'); + $rootScope.$apply(); + }); + }); + + it('should support complex interceptors based on promises', function() { + module(function($provide, $httpProvider) { + $provide.factory('myInterceptor', function($q, $rootScope) { + return { + request: function(config) { + return $q.when('/intercepted').then(function(intercepted) { + config.url = intercepted; + return config; + }); + } + }; + }); + $httpProvider.interceptors.push('myInterceptor'); + }); + inject(function($http, $httpBackend, $rootScope) { + $httpBackend.expect('POST', '/intercepted').respond(''); + $http.post('/two'); + $rootScope.$apply(); + }); + }); + }); }); @@ -938,7 +1205,7 @@ describe('$http', function() { $http({method: 'GET', url: '/url'}); // Notice no cache given in config. $httpBackend.flush(); - // Second should be served from cache, without sending request to server. + // Second should be served from cache, without sending request to server. $http({method: 'get', url: '/url'}).success(callback); $rootScope.$digest(); @@ -1004,6 +1271,7 @@ describe('$http', function() { expect($http.pendingRequests.length).toBe(0); $http({method: 'get', url: '/some'}); + $rootScope.$digest(); expect($http.pendingRequests.length).toBe(1); $httpBackend.flush(); @@ -1016,13 +1284,16 @@ describe('$http', function() { $http({method: 'get', url: '/cached', cache: true}); $http({method: 'get', url: '/cached', cache: true}); + $rootScope.$digest(); expect($http.pendingRequests.length).toBe(2); $httpBackend.flush(); expect($http.pendingRequests.length).toBe(0); $http({method: 'get', url: '/cached', cache: true}); - expect($http.pendingRequests.length).toBe(1); + spyOn($http.pendingRequests, 'push').andCallThrough(); + $rootScope.$digest(); + expect($http.pendingRequests.push).toHaveBeenCalledOnce(); $rootScope.$apply(); expect($http.pendingRequests.length).toBe(0); @@ -1035,6 +1306,7 @@ describe('$http', function() { expect($http.pendingRequests.length).toBe(0); }); + $rootScope.$digest(); expect($http.pendingRequests.length).toBe(1); $httpBackend.flush(); }); @@ -1071,10 +1343,11 @@ describe('$http', function() { $provide.value('$httpBackend', $httpBackend); }); - inject(function($http) { + inject(function($http, $rootScope) { $http({ method: 'GET', url: 'some.html', timeout: 12345, withCredentials: true, responseType: 'json' }); + $rootScope.$digest(); expect($httpBackend).toHaveBeenCalledOnce(); }); @@ -1093,11 +1366,12 @@ describe('$http', function() { $provide.value('$httpBackend', $httpBackend); }); - inject(function($http) { + inject(function($http, $rootScope) { $http.defaults.withCredentials = true; $http({ method: 'GET', url: 'some.html', timeout: 12345, responseType: 'json' }); + $rootScope.$digest(); expect($httpBackend).toHaveBeenCalledOnce(); }); diff --git a/test/ngResource/resourceSpec.js b/test/ngResource/resourceSpec.js index 1112473937bc..225f96a165ba 100644 --- a/test/ngResource/resourceSpec.js +++ b/test/ngResource/resourceSpec.js @@ -468,12 +468,9 @@ describe("resource", function() { var response = callback.mostRecentCall.args[0]; - expect(response).toEqualData({ - data: {id: 123, number: '9876'}, - status: 200, - config: {method: 'GET', data: undefined, url: '/CreditCard/123'}, - resource: {id: 123, number: '9876', $resolved: true} - }); + expect(response.data).toEqual({id: 123, number: '9876'}); + expect(response.status).toEqual(200); + expect(response.resource).toEqualData({id: 123, number: '9876', $resolved: true}); expect(typeof response.resource.$save).toBe('function'); }); @@ -516,11 +513,8 @@ describe("resource", function() { var response = callback.mostRecentCall.args[0]; - expect(response).toEqualData({ - data : 'resource not found', - status : 404, - config : { method : 'GET', data : undefined, url : '/CreditCard/123' } - }); + expect(response.data).toEqual('resource not found'); + expect(response.status).toEqual(404); }); @@ -564,12 +558,9 @@ describe("resource", function() { var response = callback.mostRecentCall.args[0]; - expect(response).toEqualData({ - data: [{id: 1}, {id :2}], - status: 200, - config: {method: 'GET', data: undefined, url: '/CreditCard', params: {key: 'value'}}, - resource: [ { id : 1 }, { id : 2 } ] - }); + expect(response.data).toEqual([{id: 1}, {id :2}]); + expect(response.status).toEqual(200); + expect(response.resource).toEqualData([ { id : 1 }, { id : 2 } ]); expect(typeof response.resource[0].$save).toBe('function'); expect(typeof response.resource[1].$save).toBe('function'); }); @@ -613,11 +604,8 @@ describe("resource", function() { var response = callback.mostRecentCall.args[0]; - expect(response).toEqualData({ - data : 'resource not found', - status : 404, - config : { method : 'GET', data : undefined, url : '/CreditCard', params: {key: 'value'}} - }); + expect(response.data).toEqual('resource not found'); + expect(response.status).toEqual(404); });