Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

allow json callback customisation #14795

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions angularFiles.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var angularFiles = {
'src/ng/httpBackend.js',
'src/ng/interpolate.js',
'src/ng/interval.js',
'src/ng/jsonpCallbacks.js',
'src/ng/locale.js',
'src/ng/location.js',
'src/ng/log.js',
Expand Down
2 changes: 2 additions & 0 deletions src/AngularPublic.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
$HttpParamSerializerJQLikeProvider,
$HttpBackendProvider,
$xhrFactoryProvider,
$jsonpCallbacksProvider,
$LocationProvider,
$LogProvider,
$ParseProvider,
Expand Down Expand Up @@ -239,6 +240,7 @@ function publishExternalAPI(angular) {
$httpParamSerializerJQLike: $HttpParamSerializerJQLikeProvider,
$httpBackend: $HttpBackendProvider,
$xhrFactory: $xhrFactoryProvider,
$jsonpCallbacks: $jsonpCallbacksProvider,
$location: $LocationProvider,
$log: $LogProvider,
$parse: $ParseProvider,
Expand Down
2 changes: 2 additions & 0 deletions src/ng/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,8 @@ function $HttpProvider() {
*
* @description
* Shortcut method to perform `JSONP` request.
* If you would like to customise where and how the callbacks are stored then try overriding
* or decorating the {@link jsonpCallbacks} service.
*
* @param {string} url Relative or absolute URL specifying the destination of the request.
* The name of the callback should be the string `JSON_CALLBACK`.
Expand Down
26 changes: 11 additions & 15 deletions src/ng/httpBackend.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function $xhrFactoryProvider() {
/**
* @ngdoc service
* @name $httpBackend
* @requires $window
* @requires $jsonpCallbacks
* @requires $document
* @requires $xhrFactory
*
Expand All @@ -47,8 +47,8 @@ function $xhrFactoryProvider() {
* $httpBackend} which can be trained with responses.
*/
function $HttpBackendProvider() {
this.$get = ['$browser', '$window', '$document', '$xhrFactory', function($browser, $window, $document, $xhrFactory) {
return createHttpBackend($browser, $xhrFactory, $browser.defer, $window.angular.callbacks, $document[0]);
this.$get = ['$browser', '$jsonpCallbacks', '$document', '$xhrFactory', function($browser, $jsonpCallbacks, $document, $xhrFactory) {
return createHttpBackend($browser, $xhrFactory, $browser.defer, $jsonpCallbacks, $document[0]);
}];
}

Expand All @@ -59,16 +59,11 @@ function createHttpBackend($browser, createXhr, $browserDefer, callbacks, rawDoc
url = url || $browser.url();

if (lowercase(method) === 'jsonp') {
var callbackId = '_' + (callbacks.counter++).toString(36);
callbacks[callbackId] = function(data) {
callbacks[callbackId].data = data;
callbacks[callbackId].called = true;
};

var jsonpDone = jsonpReq(url.replace('JSON_CALLBACK', 'angular.callbacks.' + callbackId),
callbackId, function(status, text) {
completeRequest(callback, status, callbacks[callbackId].data, "", text);
callbacks[callbackId] = noop;
var callbackPath = callbacks.createCallback(url);
var jsonpDone = jsonpReq(url, callbackPath, function(status, text) {
var response = (status === 200) && callbacks.getResponse(callbackPath);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: I would add a note that status is set in jsonpReq() and it can be either -1, 404 or 200.
The first time I saw this, I thought: "What if it is in the (200, 300) range?"

completeRequest(callback, status, response, "", text);
callbacks.removeCallback(callbackPath);
});
} else {

Expand Down Expand Up @@ -170,7 +165,8 @@ function createHttpBackend($browser, createXhr, $browserDefer, callbacks, rawDoc
}
};

function jsonpReq(url, callbackId, done) {
function jsonpReq(url, callbackPath, done) {
url = url.replace('JSON_CALLBACK', callbackPath);
// we can't use jQuery/jqLite here because jQuery does crazy stuff with script elements, e.g.:
// - fetches local scripts via XHR and evals them
// - adds and immediately removes script elements from the document
Expand All @@ -188,7 +184,7 @@ function createHttpBackend($browser, createXhr, $browserDefer, callbacks, rawDoc
var text = "unknown";

if (event) {
if (event.type === "load" && !callbacks[callbackId].called) {
if (event.type === "load" && !callbacks.wasCalled(callbackPath)) {
event = { type: "error" };
}
text = event.type;
Expand Down
83 changes: 83 additions & 0 deletions src/ng/jsonpCallbacks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
'use strict';

/**
* @ngdoc service
* @name $jsonpCallbacks
* @requires $window
* @description
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we are consistent with all services, but could add a @requires $window.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

* This service handles the lifecycle of callbacks to handle JSONP requests.
* Override this service if you wish to customise where the callbacks are stored and
* how they vary compared to the requested url.
*/
var $jsonpCallbacksProvider = function() {
this.$get = ['$window', function($window) {
var counter = 0;
$window.angular.callbacks = {};
var callbackMap = {};

function createCallback(callbackId) {
var callback = function(data) {
callback.data = data;
callback.called = true;
};
callback.id = callbackId;
return callback;
}

return {
/**
* @ngdoc method
* @name $jsonpCallbacks#createCallback
* @param {string} url the url of the JSONP request
* @returns {string} the callback path to send to the server as part of the JSONP request
* @description
* {@link $httpBackend} calls this method to create a callback and get hold of the path to the callback
* to pass to the server, which will be used to call the callback with its payload in the JSONP response.
*/
createCallback: function(url) {
var callbackId = '_' + (counter++).toString(36);
var callbackPath = 'angular.callbacks.' + callbackId;
var callback = createCallback(callbackId);
callbackMap[callbackPath] = $window.angular.callbacks[callbackId] = callback;
return callbackPath;
},
/**
* @ngdoc method
* @name $jsonpCallbacks#wasCalled
* @param {string} callbackPath the path to the callback that was sent in the JSONP request
* @returns {boolean} whether the callback has been called, as a result of the JSONP response
* @description
* {@link $httpBackend} calls this method to find out whether the JSONP response actually called the
* callback that was passed in the request.
*/
wasCalled: function(callbackPath) {
return callbackMap[callbackPath].called;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are deleting the entries, do we need to safeguard against the callbackPath property not existing on callbackMap ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the way that I have written it in $httpBackend this should never happen.
If it does then we have a programming error somewhere, which should trigger an error so that we can know to fix it.

},
/**
* @ngdoc method
* @name $jsonpCallbacks#getResponse
* @param {string} callbackPath the path to the callback that was sent in the JSONP request
* @returns {*} the data received from the response via the registered callback
* @description
* {@link $httpBackend} calls this method to get hold of the data that was provided to the callback
* in the JSONP response.
*/
getResponse: function(callbackPath) {
return callbackMap[callbackPath].data;
},
/**
* @ngdoc method
* @name $jsonpCallbacks#removeCallback
* @param {string} callbackPath the path to the callback that was sent in the JSONP request
* @description
* {@link $httpBackend} calls this method to remove the callback after the JSONP request has
* completed or timed-out.
*/
removeCallback: function(callbackPath) {
var callback = callbackMap[callbackPath];
delete $window.angular.callbacks[callback.id];
delete callbackMap[callbackPath];
}
};
}];
};
46 changes: 35 additions & 11 deletions test/ng/httpBackendSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@

describe('$httpBackend', function() {

var $backend, $browser, callbacks,
var $backend, $browser, $jsonpCallbacks,
xhr, fakeDocument, callback;


beforeEach(inject(function($injector) {
callbacks = {counter: 0};

$browser = $injector.get('$browser');

fakeDocument = {
$$scripts: [],
createElement: jasmine.createSpy('createElement').and.callFake(function() {
Expand All @@ -28,7 +28,27 @@ describe('$httpBackend', function() {
})
}
};
$backend = createHttpBackend($browser, createMockXhr, $browser.defer, callbacks, fakeDocument);

$jsonpCallbacks = {
createCallback: function(url) {
$jsonpCallbacks[url] = function(data) {
$jsonpCallbacks[url].called = true;
$jsonpCallbacks[url].data = data;
};
return url;
},
wasCalled: function(callbackPath) {
return $jsonpCallbacks[callbackPath].called;
},
getResponse: function(callbackPath) {
return $jsonpCallbacks[callbackPath].data;
},
removeCallback: function(callbackPath) {
delete $jsonpCallbacks[callbackPath];
}
};

$backend = createHttpBackend($browser, createMockXhr, $browser.defer, $jsonpCallbacks, fakeDocument);
callback = jasmine.createSpy('done');
}));

Expand Down Expand Up @@ -235,7 +255,7 @@ describe('$httpBackend', function() {

it('should call $xhrFactory with method and url', function() {
var mockXhrFactory = jasmine.createSpy('mockXhrFactory').and.callFake(createMockXhr);
$backend = createHttpBackend($browser, mockXhrFactory, $browser.defer, callbacks, fakeDocument);
$backend = createHttpBackend($browser, mockXhrFactory, $browser.defer, $jsonpCallbacks, fakeDocument);
$backend('GET', '/some-url', 'some-data', noop);
expect(mockXhrFactory).toHaveBeenCalledWith('GET', '/some-url');
});
Expand Down Expand Up @@ -294,7 +314,7 @@ describe('$httpBackend', function() {

describe('JSONP', function() {

var SCRIPT_URL = /([^\?]*)\?cb=angular\.callbacks\.(.*)/;
var SCRIPT_URL = /([^\?]*)\?cb=(.*)/;


it('should add script tag for JSONP request', function() {
Expand All @@ -310,25 +330,27 @@ describe('$httpBackend', function() {
url = script.src.match(SCRIPT_URL);

expect(url[1]).toBe('http://example.org/path');
callbacks[url[2]]('some-data');
$jsonpCallbacks[url[2]]('some-data');
browserTrigger(script, "load");

expect(callback).toHaveBeenCalledOnce();
});


it('should clean up the callback and remove the script', function() {
spyOn($jsonpCallbacks, 'removeCallback').and.callThrough();

$backend('JSONP', 'http://example.org/path?cb=JSON_CALLBACK', null, callback);
expect(fakeDocument.$$scripts.length).toBe(1);


var script = fakeDocument.$$scripts.shift(),
callbackId = script.src.match(SCRIPT_URL)[2];

callbacks[callbackId]('some-data');
$jsonpCallbacks[callbackId]('some-data');
browserTrigger(script, "load");

expect(callbacks[callbackId]).toBe(angular.noop);
expect($jsonpCallbacks.removeCallback).toHaveBeenCalledOnceWith(callbackId);
expect(fakeDocument.body.removeChild).toHaveBeenCalledOnceWith(script);
});

Expand All @@ -343,7 +365,9 @@ describe('$httpBackend', function() {
});


it('should abort request on timeout and replace callback with noop', function() {
it('should abort request on timeout and remove JSONP callback', function() {
spyOn($jsonpCallbacks, 'removeCallback').and.callThrough();

callback.and.callFake(function(status, response) {
expect(status).toBe(-1);
});
Expand All @@ -359,7 +383,7 @@ describe('$httpBackend', function() {
expect(fakeDocument.$$scripts.length).toBe(0);
expect(callback).toHaveBeenCalledOnce();

expect(callbacks[callbackId]).toBe(angular.noop);
expect($jsonpCallbacks.removeCallback).toHaveBeenCalledOnceWith(callbackId);
});


Expand Down
73 changes: 73 additions & 0 deletions test/ng/jsonpCallbacksSpec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use strict';

describe('$jsonpCallbacks', function() {

beforeEach(module(function($provide) {
// mock out the $window object
$provide.value('$window', { angular: {} });
}));

describe('createCallback(url)', function() {

it('should return a new unique path to a callback function on each call', inject(function($jsonpCallbacks) {
var path = $jsonpCallbacks.createCallback('http://some.dummy.com/jsonp/request');
expect(path).toEqual('angular.callbacks._0');

path = $jsonpCallbacks.createCallback('http://some.dummy.com/jsonp/request');
expect(path).toEqual('angular.callbacks._1');

path = $jsonpCallbacks.createCallback('http://some.dummy.com/jsonp/request');
expect(path).toEqual('angular.callbacks._2');

path = $jsonpCallbacks.createCallback('http://some.dummy.com/jsonp/request');
expect(path).toEqual('angular.callbacks._3');
}));

it('should add a callback method to the $window.angular.callbacks collection on each call', inject(function($window, $jsonpCallbacks) {
$jsonpCallbacks.createCallback('http://some.dummy.com/jsonp/request');
expect($window.angular.callbacks._0).toEqual(jasmine.any(Function));

$jsonpCallbacks.createCallback('http://some.dummy.com/jsonp/request');
expect($window.angular.callbacks._1).toEqual(jasmine.any(Function));

$jsonpCallbacks.createCallback('http://some.dummy.com/jsonp/request');
expect($window.angular.callbacks._2).toEqual(jasmine.any(Function));

$jsonpCallbacks.createCallback('http://some.dummy.com/jsonp/request');
expect($window.angular.callbacks._3).toEqual(jasmine.any(Function));
}));
});


describe('wasCalled(callbackPath)', function() {

it('should return true once the callback has been called', inject(function($window, $jsonpCallbacks) {
var path = $jsonpCallbacks.createCallback('http://some.dummy.com/jsonp/request');
expect($jsonpCallbacks.wasCalled(path)).toBeFalsy();
var response = {};
$window.angular.callbacks._0(response);
expect($jsonpCallbacks.wasCalled(path)).toBeTruthy();
}));
});


describe('getResponse(callbackPath)', function() {

it('should retrieve the data from when the callback was called', inject(function($window, $jsonpCallbacks) {
var path = $jsonpCallbacks.createCallback('http://some.dummy.com/jsonp/request');
var response = {};
$window.angular.callbacks._0(response);
var result = $jsonpCallbacks.getResponse(path);
expect(result).toBe(response);
}));
});

describe('removeCallback(calbackPath)', function() {

it('should remove the callback', inject(function($window, $jsonpCallbacks) {
var path = $jsonpCallbacks.createCallback('http://some.dummy.com/jsonp/request');
$jsonpCallbacks.removeCallback(path);
expect($window.angular.callbacks._0).toBeUndefined();
}));
});
});