parasails
Version:
Lightweight structures for apps with more than one page.
1,045 lines (935 loc) • 109 kB
JavaScript
/**
* cloud.js
* (high-level AJAX library)
*
* > This is now part of `parasails`. It was branched from the old "Cloud SDK"
* > library at its v1.0.1 -- but from that point on, its versioning has been
* > tied to the version of parasails it's bundled in. (All future development
* > of Cloud SDK will be as part of parasails.)
*
* Copyright (c) 2014-present, Mike McNeil
* MIT License
*
* - https://twitter.com/mikermcneil
* - https://sailsjs.com/about
* - https://sailsjs.com/support
* - https://www.npmjs.com/package/parasails
*
* ---------------------------------------------------------------------------------------------
* ## Basic Usage
*
* Step 1:
*
* ```
* Cloud.setup({ doSomething: 'POST /api/v1/somethings/:id/do' });
* ```
* ^^Note that this can also be compiled automatically from your Sails app's routes using a script.
*
* Step 2:
*
* ```
* var result = await Cloud.doSomething(8);
* ```
*
* Or:
* ```
* var result = await Cloud.doSomething.with({id: 8, foo: ['bar', 'baz']});
* ```
* ---------------------------------------------------------------------------------------------
*/
(function(factory, exposeUMD){
exposeUMD(this, factory);
})(function (_, io, $, SAILS_LOCALS, location, File, FileList, FormData){
// ██████╗ ██████╗ ██╗██╗ ██╗ █████╗ ████████╗███████╗
// ██╔══██╗██╔══██╗██║██║ ██║██╔══██╗╚══██╔══╝██╔════╝
// ██████╔╝██████╔╝██║██║ ██║███████║ ██║ █████╗
// ██╔═══╝ ██╔══██╗██║╚██╗ ██╔╝██╔══██║ ██║ ██╔══╝
// ██║ ██║ ██║██║ ╚████╔╝ ██║ ██║ ██║ ███████╗
// ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝
//
// ██╗ ██╗████████╗██╗██╗ ███████╗
// ██║ ██║╚══██╔══╝██║██║ ██╔════╝
// ██║ ██║ ██║ ██║██║ ███████╗
// ██║ ██║ ██║ ██║██║ ╚════██║
// ╚██████╔╝ ██║ ██║███████╗███████║
// ╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝
// Module utilities (private)
/**
* @param {Ref} that
*
* @throws {Error} If that is not a File instance, a FileList instance, an
* array of File instances, a special File wrapper, or an
* array of special File wrappers. (Note that, if an array is
* provided, this function will only return true if the array
* consists of ≥1 item.)
*/
function _representsOneOrMoreFiles(that) {
// FUTURE: add support for Blobs
return (
_.isObject(that) &&
(
(File? that instanceof File : false)||
(FileList? that instanceof FileList : false)||
(_.isArray(that) && that.length > 0 && _.all(that, function(item) { return File? _.isObject(item) && item instanceof File : false; }))||
(File? _.isObject(that) && _.isObject(that.file) && that.file instanceof File : false)||
(_.isArray(that) && that.length > 0 && _.all(that, function(item) { return File? _.isObject(item) && _.isObject(item.file) && item.file instanceof File : false; }))
)
);
}//ƒ
/**
* @param {String} negotiationRule
*
* @throws {Error} If rule is invalid or absent
*/
function _verifyErrorNegotiationRule(negotiationRule) {
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// FUTURE: add support for parley/flaverr/bluebird/lodash-style dictionary negotiation rules
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
if (_.isNumber(negotiationRule) && Math.floor(negotiationRule) === negotiationRule) {
if (negotiationRule > 599 || negotiationRule < 0) {
throw new Error('Invalid error negotiation rule: `'+negotiationRule+'`. If a status code is provided, it must be between zero and 599.');
}
}
else if (_.isString(negotiationRule) && negotiationRule) {
// Ok, we'll assume it's fine
}
else {
var suffix = '';
if (negotiationRule === undefined || _.isFunction(negotiationRule)) {
suffix = ' Looking to tolerate or intercept **EVERY** error? This usually isn\'t a good idea, because, just like some try/catch usage patterns, it could mean swallowing errors unexpectedly, which can make debugging a nightmare.';
}
throw new Error('Invalid error negotiation rule: `'+negotiationRule+'`. Please pass in a valid intercept rule string. An intercept rule is either (A) the name of an exit or (B) a whole number representing the status code like `404` or `200`.'+suffix);
}
}
// ███████╗██╗ ██╗██████╗ ██████╗ ██████╗ ████████╗███████╗
// ██╔════╝╚██╗██╔╝██╔══██╗██╔═══██╗██╔══██╗╚══██╔══╝██╔════╝
// █████╗ ╚███╔╝ ██████╔╝██║ ██║██████╔╝ ██║ ███████╗
// ██╔══╝ ██╔██╗ ██╔═══╝ ██║ ██║██╔══██╗ ██║ ╚════██║
// ███████╗██╔╝ ██╗██║ ╚██████╔╝██║ ██║ ██║ ███████║
// ╚══════╝╚═╝ ╚═╝╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝
// Module exports:
/**
* Cloud (SDK)
*
* After setup, this dictionary will have a method for each declared endpoint.
* Each key will be a function which sends an HTTP or socket request to a
* particular endpoint.
*
* ### Setup
*
* ```
* Cloud.setup({
* apiBaseUrl: 'https://example.com',
* usageOpts: {
* arginStyle: 'serial'
* },
* methods: {
* doSomething: 'PUT /api/v1/projects/:id',
* // ...
* }
* });
* ```
*
* > Note that you should avoid having an endpoint method named "setup", for obvious reasons.
* > (Technically, it should work anyway though. But yeah, no reason to tempt the fates.)
*
* ### Basic Usage
*
* ```
* var user = await Cloud.findOneUser(3);
* ```
*
* ```
* var user = await Cloud.findOneUser.with({ id: 3 });
* ```
*
* ```
* Cloud.doSomething.with({
* someParam: ['things', 3235, null, true, false, {}, []]
* someOtherParam: 2523,
* etc: 'more things'
* }).exec(function (err, responseBody, responseObjLikeJqXHR) {
* if (err) {
* // ...
* return;
* }
*
* // ...
* });
* ```
*
* ### Negotiating Errors
* ```
* Cloud.signup.with({...})
* .switch({
* error: function (err) { ... },
* usernameAlreadyInUse: function (recommendedAlternativeUsernames) { ... },
* emailAddressAlreadyInUse: function () { ... },
* success: function () { ... }
* });
* ```
*
* ### Using WebSockets
* ```
* Cloud.doSomething.with({...})
* .protocol('jQuery')
* .exec(...);
* ```
*
* ```
* Cloud.doSomething.with({...})
* .protocol('io.socket')
* .exec(...);
* ```
*
* ##### Providing a particular jQuery or SailsSocket instance
*
* ```
* Cloud.doSomething.with({...})
* .protocol(io.socket)
* .exec(...);
* ```
*
* ```
* Cloud.doSomething.with({...})
* .protocol($)
* .exec(...);
* ```
*
* ### Using Custom Headers
* ```
* Cloud.doSomething.with({...})
* .headers({
* 'X-Auth': 'whatever'
* })
* .exec(...);
* ```
*
* ### CSRF Protection
*
* It `SAILS_LOCALS._csrf` is defined, then it will be sent
* as the "x-csrf-token" header for all Cloud.* requests, automatically.
*
*/
var Cloud = {};
// FUTURE: Cloud.getUrlFor()
// (similar to https://sailsjs.com/documentation/reference/application/sails-get-url-for)
// (but would def need to provide a way of providing values for URL pattern variables like `:id`)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// FUTURE: finish this when time allows (might be better to have it work by attaching dedicated
// nav methods rather than a generic nav method though)
// ```
// // A mapping of names of view actions to URL
// // > provided to `.setup()`, for use in .navigate()
// var _navigableUrlsByViewActionName;
//
//
// /**
// * Cloud.navigate()
// *
// * Call this function to navigate to a different web page.
// * (Be sure and call it *before* trying to use any of the endpoint methods!)
// *
// * @param {String} destination
// * A URL or the name of a view action.
// */
// Cloud.navigate = function(destination) {
// var doesBeginWithSlash = _.isString(destination) && destination.match(/^\//);
// var doesBeginWithHttp = _.isString(destination) && destination.match(/^http/);
// var isProbablyTheNameOfAViewAction = _.isString(destination) && destination.match(/^view/);
// if (!_.isString(destination) || !(doesBeginWithSlash || doesBeginWithHttp || isProbablyTheNameOfAViewAction)) {
// throw new Error('Bad usage: Cloud.navigate() should be called with a URL or the name of a view action.');
// }
// if (!_navigableUrlsByViewActionName) {
// throw new Error('Cannot navigate to a view action because Cloud.setup() has not been called yet-- please do that first (or if that\'s not possible, just navigate directly to the URL)');
// }
// };
// ```
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
/**
* Cloud.setup()
*
* Call this function once, when the page loads.
* (Be sure and call it *before* trying to use any of the endpoint methods!)
*
* @param {Dictionary} options
* @required {Dictionary} methods
* @optional {Dictionary} links
* @optional {Dictionary} apiBaseUrl
*/
Cloud.setup = function(options) {
options = options || {};
if (!_.isObject(options.methods) || _.isArray(options.methods) || _.isFunction(options.methods)) {
throw new Error('Cannot .setup() Cloud SDK: `methods` must be provided as a dictionary of addresses and definitions.');
}//•
// Determine the proper API base URL
if (!options.apiBaseUrl) {
if (location) {
options.apiBaseUrl = location.protocol+'//'+location.hostname+(location.port ? ':'+location.port: '');
}
else {
throw new Error('Cannot .setup() Cloud SDK: Since a location cannot be determined, `apiBaseUrl` must be provided as a string (e.g. "https://example.com").');
}
}//fi
// Apply the base URL for the benefit of WebSockets (if relevant):
if (io) {
io.sails.url = options.apiBaseUrl;
}//fi
// The name of the default protocol.
var DEFAULT_PROTOCOL_NAME = 'jQuery';
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// FUTURE: finish this when time allows (would be better to have it work by attaching dedicated
// nav methods rather than a generic nav method though)
// ```
// // Save a reference to the mapping of navigable URLs by view action name (if provided).
// _navigableUrlsByViewActionName = options.links || {};
// ```
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
if (options.methods.on) {
throw new Error('Cannot .setup() Cloud SDK: `.on()` is reserved. It cannot be used as the name for a method.');
}
if (options.methods.off) {
throw new Error('Cannot .setup() Cloud SDK: `.off()` is reserved. It cannot be used as the name for a method.');
}
// Interpret methods
var methods = _.reduce(options.methods, function(memo, appLevelSdkEndpointDef, methodName) {
if (methodName === 'setup') {
console.warn('"setup" is a confusing name for a cloud action (it conflicts with a built-in feature of this SDK itself). Would "initialize()" work instead? (Continuing this time...)');
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// FUTURE: finish this when time allows (would be better to have it work by attaching dedicated
// nav methods rather than a generic nav method though)
// ```
// if (methodName === 'navigate') {
// console.warn('"navigate" is a confusing name for a cloud action (it conflicts with a built-in feature of this SDK itself). Would "travel()" work instead? (Continuing this time...)');
// }
// ```
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Validate the endpoint definition.
////////////////////////////////////////////////////////////////////////////////////////////////
var _verbToCheck;
var _urlToCheck;
if (typeof appLevelSdkEndpointDef === 'function') {
// We can't really check functions, so we just let it through.
}
else {
if (appLevelSdkEndpointDef && typeof appLevelSdkEndpointDef === 'object') {
// Must have `verb` and `url` properties.
_verbToCheck = appLevelSdkEndpointDef.verb;
_urlToCheck = appLevelSdkEndpointDef.url;
}
else if (typeof appLevelSdkEndpointDef === 'string') {
// Must be able to parse `verb` and `url`.
_verbToCheck = appLevelSdkEndpointDef.replace(/^\s*([^\/\s]+)\s*\/.*$/, '$1');
_urlToCheck = appLevelSdkEndpointDef.replace(/^\s*[^\/\s]+\s*\/(.*)$/, '/$1');
}
else {
throw new Error('CloudSDK endpoint (`'+methodName+'`) is invalid: Endpoints should be defined as either (1) a string like "GET /foo", (2) a dictionary containing a `verb` and a `url`, or (3) a function that returns a dictionary like that.');
}
// --•
// `verb` must be valid.
if (typeof _verbToCheck !== 'string' || _verbToCheck === '') {
throw new Error('CloudSDK endpoint (`'+methodName+'`) is invalid: An endpoint\'s `verb` should be defined as a non-empty string.');
}
// `url` must be valid.
if (typeof _urlToCheck !== 'string' || _urlToCheck === '') {
throw new Error('CloudSDK endpoint (`'+methodName+'`) is invalid: An endpoint\'s `url` should be defined as a non-empty string.');
}
}
// Build the actual method that will be called at runtime:
////////////////////////////////////////////////////////////////////////
var _helpCallCloudMethod = function (argins) {
//+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++//
// There are 3 ways to define an SDK wrapper for a cloud endpoint.
//+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++//
var requestInfo = {
// • the HTTP verb (aka HTTP "method" -- we're just using "verb" for clarity)
verb: undefined,
// • the path part of the URL
url: undefined,
// • a dictionary of request data
// (depending on the circumstances, these params will be encoded directly
// into either the url path, the querystring, or the request body)
params: undefined,
// • a dictionary of custom request headers
headers: undefined,
// • the protocol name (e.g. "jQuery" or "io.socket")
protocolName: undefined,
// • the protocol instance (e.g. actual reference to `$` or `io.socket`)
protocolInstance: undefined,
// • an array of conditional lifecycle instructions from userland .intercept() / .tolerate() calls, if any are configured
lifecycleInstructions: [],
};
// ██████╗ ██╗ ██╗██╗██╗ ██████╗ ██████╗ ███████╗███████╗███████╗██████╗ ██████╗ ███████╗██████╗
// ██╔══██╗██║ ██║██║██║ ██╔══██╗ ██╔══██╗██╔════╝██╔════╝██╔════╝██╔══██╗██╔══██╗██╔════╝██╔══██╗
// ██████╔╝██║ ██║██║██║ ██║ ██║ ██║ ██║█████╗ █████╗ █████╗ ██████╔╝██████╔╝█████╗ ██║ ██║
// ██╔══██╗██║ ██║██║██║ ██║ ██║ ██║ ██║██╔══╝ ██╔══╝ ██╔══╝ ██╔══██╗██╔══██╗██╔══╝ ██║ ██║
// ██████╔╝╚██████╔╝██║███████╗██████╔╝ ██████╔╝███████╗██║ ███████╗██║ ██║██║ ██║███████╗██████╔╝
// ╚═════╝ ╚═════╝ ╚═╝╚══════╝╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═════╝
//
// ██████╗ ██████╗ ██╗███████╗ ██████╗████████╗
// ██╔═══██╗██╔══██╗ ██║██╔════╝██╔════╝╚══██╔══╝
// ██║ ██║██████╔╝ ██║█████╗ ██║ ██║
// ██║ ██║██╔══██╗██ ██║██╔══╝ ██║ ██║
// ╚██████╔╝██████╔╝╚█████╔╝███████╗╚██████╗ ██║
// ╚═════╝ ╚═════╝ ╚════╝ ╚══════╝ ╚═════╝ ╚═╝
//
// Used for avoiding accidentally creating multiple promises when
// using .then() or .catch().
var _promise;
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// FUTURE: add support for omens so we get better stack traces, particularly
// when running this in a Node.js environment.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Return a dictionary of functions (to allow for "deferred object" usage.)
var deferred = {
// Allow request headers to be configured.
/////////////////////////////////////////////////////////////////////////////
headers: function (_customRequestHeaders){
if (!_.isObject(_customRequestHeaders)) {
throw new Error('Invalid request headers: Must be specified as a dictionary, where each key has a string value.');
}
requestInfo.headers = _.extend(requestInfo.headers||{}, _customRequestHeaders);
return deferred;
},
// Allow the protocol to be configured on a per-request basis.
/////////////////////////////////////////////////////////////////////////////
protocol: function (_protocolNameOrInstance){
if (typeof _protocolNameOrInstance === 'string') {
switch (_protocolNameOrInstance) {
case 'jQuery':
requestInfo.protocolName = 'jQuery';
if ($ === undefined) {
throw new Error('Could not access jQuery: `$` is undefined.');
}
else {
requestInfo.protocolInstance = $;
}
break;
case 'io.socket':
requestInfo.protocolName = 'io.socket';
if (typeof io === 'undefined') {
throw new Error('Could not access `io.socket`: `io` is undefined.');
}
else if (typeof io !== 'function') {
throw new Error('Could not access `io.socket`: `io` is invalid:' + io);
}
else if (typeof io.socket === 'undefined') {
throw new Error('Could not access `io.socket`: `io` does not have a `socket` property. Make sure `sails.io.js` is being injected in a <script> tag!');
}
else {
requestInfo.protocolInstance = io.socket;
}
break;
default:
throw new Error('Unrecognized protocol: `'+_protocolNameOrInstance+'`. Use "jQuery" or "io.socket".');
}
}
else if (_.isObject(_protocolNameOrInstance) || _.isFunction(_protocolNameOrInstance)) {
if (_protocolNameOrInstance.name === 'jQuery') {
requestInfo.protocolName = 'jQuery';
requestInfo.protocolInstance = _protocolNameOrInstance;
}
else if (_protocolNameOrInstance.constructor.name === 'SailsSocket') {
requestInfo.protocolName = 'io.socket';
requestInfo.protocolInstance = _protocolNameOrInstance;
}
else if (_protocolNameOrInstance.toString() === '[Package: machinepack-http]' || _protocolNameOrInstance.toString() === '[Package: sails.helpers.http]') {
requestInfo.protocolName = 'machinepack-http';
requestInfo.protocolInstance = _protocolNameOrInstance;
}
// FUTURE: maybe native browser "fetch"?
// FUTURE: maybe native Node "http"?
else {
throw new Error('Unrecognized instance provided to `.protocol()`: `'+_protocolNameOrInstance+'`');
}
}
else {
throw new Error('Unrecognized protocol: `'+_protocolNameOrInstance+'`. Use "jQuery" or "io.socket".');
}
return deferred;
},//</ implementation of `.protocol()`>
// Allow intercepting the response before resolution/rejection occurs.
// (This is basically an "after receiving response" lifecycle callback.)
/////////////////////////////////////////////////////////////////////////////
intercept: function (negotiationRule, handler) {
_verifyErrorNegotiationRule(negotiationRule);
if (!_.isFunction(handler)) {
throw new Error('Invalid 2nd argument to `.intercept()`. Expecting a handler function.');
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// FUTURE: Add a best-effort check to make sure there is no pre-existing rule
// that matches this one (i.e. already previously registered using .tolerate()
// or .intercept())
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
requestInfo.lifecycleInstructions.push({
type: 'intercept',
rule: negotiationRule,
handler: handler
});
return deferred;
},
// Allow explicitly tolerating certain kinds of responses before resolution/rejection occurs.
// (This causes control flow convergence by using `.intercept()` + throwing a special value)
/////////////////////////////////////////////////////////////////////////////
tolerate: function (_negotiationRuleMaybe, _handlerMaybe) {
var handler;
var negotiationRule;
if (_handlerMaybe === undefined && _.isFunction(_negotiationRuleMaybe)) {
handler = _negotiationRuleMaybe;
}
else {
negotiationRule = _negotiationRuleMaybe;
handler = _handlerMaybe;
}
if (negotiationRule !== undefined) {
_verifyErrorNegotiationRule(negotiationRule);
}
if (handler !== undefined && !_.isFunction(handler)) {
throw new Error('Invalid 2nd argument. to `.tolerate()`. Expecting a handler function.');
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// FUTURE: Add a best-effort check to make sure there is no pre-existing rule
// that matches this one (i.e. already previously registered using .tolerate()
// or .intercept())
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
requestInfo.lifecycleInstructions.push({
type: 'tolerate',
rule: negotiationRule,
handler: handler?
handler
:
function(){ return; }
});
return deferred;
},
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Looking for the EarlyReturnSignal stuff?
//
// See https://stackoverflow.com/a/43402123/486547 and specifically also
// https://stackoverflow.com/questions/29499582/how-to-properly-break-out-of-a-promise-chain#comment80446341_43402123
// (there may be a way to do this more elegantly without requiring the calling
// code environment to be aware of our special Errors-- but it's not worth it
// as-is. Too much black magic!)
//
// > More notes & background leading up to this:
// > https://gist.github.com/mikermcneil/c1bc2d57f5bedae810295e5ed8c5f935
// >
// > (Also check out the commit history of the original `caviar` repo.)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
then: function (){
// console.log('in implementation of `then()`...');
var promise = deferred.toPromise();
// console.log('obj:',promise);
return promise.then.apply(promise, arguments);
},
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// FUTURE: use parley for all this instead, if we can find a way to keep it
// from being too enormous when browserified
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
toPromise: function (){
if (typeof Promise === 'undefined') { throw new Error('Cannot use this approach: `Promise` constructor not available in current environment.'); }
if (_promise) {
// console.log('using catched promise...');
return _promise;
}
// console.log('instantiating new promise!');
_promise = new Promise(function(resolve, reject){// eslint-disable-line no-undef
try {
deferred.exec(function(err, resultMaybe) {
if (err){
// console.log('calling reject..');
return reject(err);
}
// console.log('calling resolve..');
return resolve(resultMaybe);
});//_∏_
} catch (err) {
// console.log('EXEC THREW ERROR!',err);
// console.log('CALLED REJECT IN NATIVE CATCH BLOCK!');
reject(err);
}
});//_∏_
return _promise;
},
// Allow the AJAX request to actually be sent.
/////////////////////////////////////////////////////////////////////////////
exec: function (exitCallbacks){
if (exitCallbacks) {
if (!_.isObject(exitCallbacks) && !_.isFunction(exitCallbacks)) {
throw new Error('If specified, the argument passed to `.exec()` must be a dictionary containing a `success` and `error` callback. Alternatively, you can use a Node.js-style callback.');
}
else if (_.isObject(exitCallbacks) && exitCallbacks.success && !_.isFunction(exitCallbacks.success)) {
throw new Error('If specified, `success` callback must be a function.');
}
else if (_.isObject(exitCallbacks) && exitCallbacks.error && !_.isFunction(exitCallbacks.error)) {
throw new Error('If specified, `error` callback must be a function.');
}
}
// Just in case, build an error instance beforehand.
// (This ensures it has a good stack trace.)
var errorInstance = new Error('Endpoint (`'+methodName+'`) responded with an error (or the request failed).');
// Give the error a special `name` property to ease negotiation
// (vs. other unrelated things like typos in argins)
errorInstance.name = 'CloudError';
// If present, use CSRF token from `SAILS_LOCALS` as the `x-csrf-token`
// request header for all non-GET requests.
// (Unless of course there's another x-csrf-token header already specified.)
if (_.isObject(SAILS_LOCALS) && typeof SAILS_LOCALS._csrf !== 'undefined') {
if (_.isUndefined(requestInfo.headers)) {
requestInfo.headers = {};
}// >-
if (!requestInfo.headers['x-csrf-token']) {
requestInfo.headers['x-csrf-token'] = SAILS_LOCALS._csrf;
}
}//fi
// Finally, use the appropriate protocol to actually send the request and
// send back the response to the code that called this `Cloud.*()` method.
(function _makeAjaxCallWithAppropriateProtocol(proceed){
// First, tease apart text params and file params.
var textParamsByFieldName = requestInfo.params;
// Check for file uploads.
//
// If `FormData` constructor is available, check to see if any
// of the param values are File/FileList instances, or arrays of
// File instances, or special File wrappers, or arrays of special
// File wrappers. If they are, then remove them from a shallow
// clone of the params dictionary, and set them up separately.
// (The files will be attached to the request _after_ the text
// parameters.)
var uploadsByFieldName = {};
if (FormData && textParamsByFieldName) {
textParamsByFieldName = _.extend({}, textParamsByFieldName);
_.each(textParamsByFieldName, function(value, fieldName){
if (_representsOneOrMoreFiles(value)) {
uploadsByFieldName[fieldName] = value;
delete textParamsByFieldName[fieldName];
}
});//∞
}//fi
// Don't allow file uploads for GET requests,
// or if the FormData constructor is somehow missing.
if (_.keys(uploadsByFieldName).length > 0) {
if (requestInfo.verb.match(/get/i)) {
throw new Error(
'Detected File or FileList instance(s) provided for parameter(s): '+
_.keys(uploadsByFieldName)+'\n'+
'But this is a nullipotent ('+requestInfo.verb.toUpperCase()+') '+
'request, which does not support file uploads.'
);
}//•
if (!FormData) {
throw new Error(
'Detected File or FileList instance(s) provided for parameter(s): '+
_.keys(uploadsByFieldName)+'\n'+
'But the native FormData constructor does not exist!'
);
}
}//fi
switch (requestInfo.protocolName) {
// ▄▄███▄▄· █████╗ ██╗ █████╗ ██╗ ██╗ ██╗██╗
// ██╔════╝ ██╔══██╗ ██║██╔══██╗╚██╗██╔╝██╔╝╚██╗
// ███████╗ ███████║ ██║███████║ ╚███╔╝ ██║ ██║
// ╚════██║ ██╔══██║██ ██║██╔══██║ ██╔██╗ ██║ ██║
// ███████║██╗██║ ██║╚█████╔╝██║ ██║██╔╝ ██╗╚██╗██╔╝
// ╚═▀▀▀══╝╚═╝╚═╝ ╚═╝ ╚════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝╚═╝
case 'jQuery': return (function _doAjaxWithJQuery(){
var thisJQuery = requestInfo.protocolInstance;
// Build options for $.ajax().
var ajaxOpts = {
url: requestInfo.url,
method: requestInfo.verb
};
// If GET request, encode params in querystring.
if (requestInfo.verb.match(/get/i)) {
ajaxOpts.data = textParamsByFieldName;
}
// Else if there are files, attach them properly,
// alongside the other stuff in the form -- either
// in the body or as querystring parameters, depending
// on what kind of data they are.
//
// > Note that we include text params **FIRST**,
// > in order to support order-aware body parsers
// > that rely on pessimistic upstream awareness,
// > optimizing uploads and preventing DDoS attacks.
// >
// > Also note that we skip text params and file fields w/
// > `undefined` values for consistency w/ Sails conventions.
//
// > Finally, one last thing to consider:
// > If a value is NOT something that needs special encoding
// > to accurately capture its meaning and data type (e.g. if
// > it is a string), then we simply attach it to the body as
// > form data. But otherwise, we have to do something fancy
// > to get it to be losslessly encoded for use in backend code.
else if (_.keys(uploadsByFieldName).length > 0){
ajaxOpts.processData = false;
ajaxOpts.contentType = false;
ajaxOpts.data = new FormData();
_.each(textParamsByFieldName, function(value, fieldName){
if (value === undefined) { return; }//•
if (_.isString(value)) {
ajaxOpts.data.append(fieldName, value);
} else {
// Use the "X-JSON-MPU-Params" header to signal to the
// server that this text param is encoded as stringified
// JSON, even though the request's content type would
// suggest otherwise (because it's multipart/form-data
// in order to handle file uploads).
//
// > This is "the new way" of solving this problem.
// > For more info about "the old way" of "solving" this
// > that didn't really work for everything (i.e. doing
// > a recursive dive over the value and attempting to
// > losslessly encode it in the URL query string), see:
// > https://github.com/mikermcneil/parasails/commit/28732b1ed55eb4697de4bf4c559f0319cf773041
requestInfo.headers = requestInfo.headers||{};
if (requestInfo.headers['X-JSON-MPU-Params']) {
requestInfo.headers['X-JSON-MPU-Params'] += ','+fieldName;
} else {
requestInfo.headers['X-JSON-MPU-Params'] = fieldName;
}
// FUTURE: do a deep-crawl to sanitize prior to stringification (as alluded to below) -- i.e. to strip undefined array items, etc
var stringifiedValue;
try {
stringifiedValue = JSON.stringify(value);
} catch (unusedErr) {
var errMsgPrefix = 'Could not encode value provided for '+fieldName+' because the value is (or contains) ';
var errMsgSuffix = '. In a request that contains one or more file uploads, any additional text parameter values need to be encoded in such a way that they can be losslessly parsed by the Sails framework.\n [?] Unsure? Reach out at https://sailsjs.com/support';
throw new Error(errMsgPrefix+'data that cannot be stringified as JSON (usually, this means it contains circular references-- i.e. its properties or array items are actually references to itself, or each other)'+errMsgSuffix);
}
ajaxOpts.data.append(fieldName, stringifiedValue);
}
});//∞
_.each(uploadsByFieldName, function(fileOrFileList, fieldName){
if (fileOrFileList === undefined) { return; }
if (!_representsOneOrMoreFiles(fileOrFileList)) {
throw new Error('Cannot upload as "'+fieldName+'" because the provided value is not a File instance, an array of File instances, a dictionary like `{file: someFileInstance, name: \'filename-override.png\'}`, or an array of such wrapper dictionaries. Instead, got: '+fileOrFileList+'\n\nNote that this can sometimes occur due to problems with code minification (e.g. uglify configuration).');
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// FUTURE: throw usage error if wrapper (i.e. with `.file`) has a `.name`, override,
// but it isn't a valid string (i.e. truthy, decent chars, & not too long)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
if (_.isArray(fileOrFileList) || (_.isObject(fileOrFileList) && _.isObject(fileOrFileList.constructor) && fileOrFileList.constructor.name === 'FileList')) {
for (var i = 0; i < fileOrFileList.length; i++) {
if (fileOrFileList[i] instanceof File) {
ajaxOpts.data.append(fieldName, fileOrFileList[i], fileOrFileList[i].name);
} else {
ajaxOpts.data.append(fieldName, fileOrFileList[i].file, fileOrFileList[i].name||fileOrFileList[i].file.name);
}
}//∞
}
else {
if (fileOrFileList instanceof File) {
ajaxOpts.data.append(fieldName, fileOrFileList, fileOrFileList.name);
} else {
ajaxOpts.data.append(fieldName, fileOrFileList.file, fileOrFileList.name||fileOrFileList.file.name);
}
}
});//∞
}
// Otherwise, attach params as a JSON-encoded request body.
else {
// If any of our text params are arrays, then before stringifying,
// make a shallow clone and strip out any `undefined` values
// that exist as items at the top level of the array. (This
// prevents them from automatically being changed into `null`
// by JSON.stringify().)
// > (This behavior is a breaking change that was introduced
// > in parasails@0.9.0)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// > FUTURE: Instead, do a deep crawl and mimic the behavior of RTTC:
// > https://github.com/node-machine/rttc/blob/8a84191dc786e872a6c28b24566539573b2a2c4d/lib/helpers/rebuild-recursive.js#L77-L90
// > ^^That'll take care of several other common edge cases that
// > are handled in a kinda strange way by JSON.stringify(),
// > including `NaN`, etc.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
var sanitizedTPBFN = _.mapValues(textParamsByFieldName, function(value) {
var sanitizedValue;
if (_.isArray(value)) {
sanitizedValue = _.clone(value);
_.remove(sanitizedValue, function(item){
return item === undefined;
});//∞
} else {
sanitizedValue = value;
}
return sanitizedValue;
});
ajaxOpts.data = JSON.stringify(sanitizedTPBFN);
ajaxOpts.processData = false;
ajaxOpts.contentType = 'application/json; charset=UTF-8';
}
// Attach headers so they'll be included in our $.ajax() call.
if (requestInfo.headers !== undefined) {
ajaxOpts.headers = requestInfo.headers;
}
// Dealing with jqXHR:
//
// To get status code:
// console.log(jqXHR.statusCode);
//
// To get header(s):
// console.log(jqXHR.getResponseHeader('foo'));
// - or -
// console.log(jqXHR.getAllResponseHeaders());
// ^^^ but this one gives it to you as a string.
// ^^
// WARNING: if using a cross-domain request w/ CORS, this (^^^^^)
// header grabbing may not work properly on some versions of firefox. More details:
// http://stackoverflow.com/questions/5614735/jqxhr-getallresponseheaders-wont-return-all-headers
thisJQuery.ajax(_.extend(ajaxOpts, {
error: function (jqXHR) {
return proceed(undefined, {
body: jqXHR.responseJSON === undefined ? jqXHR.responseText : jqXHR.responseJSON,
statusCode: jqXHR.status,
headers: _.reduce(jqXHR.getAllResponseHeaders().split(/\n/), function (memo, pair) {
var splitPair = pair.split(/:/);
var headerName = splitPair[0];
if (headerName === '') { return memo; }
// Note that we trim leading AND trailing whitespace.
var headerVal = splitPair.slice(1).join('').replace(/^\s*/, '').replace(/\s*$/, '');
memo[headerName] = headerVal;
// Also add an alias using the all-lowercased version of the header name
// (if it's different)
var allLowercaseHeaderName = headerName.toLowerCase();
if (allLowercaseHeaderName !== headerName) {
memo[allLowercaseHeaderName] = headerVal;
}
return memo;
}, {})
});
},
success: function (unused0, unused1, jqXHR) {
return proceed(undefined, {
body: jqXHR.responseJSON === undefined ? jqXHR.responseText : jqXHR.responseJSON,
statusCode: jqXHR.status,
headers: _.reduce(jqXHR.getAllResponseHeaders().split(/\n/), function (memo, pair) {
var splitPair = pair.split(/:/);
var headerName = splitPair[0];
if (headerName === '') { return memo; }
// Note that we trim leading AND trailing whitespace.
var headerVal = splitPair.slice(1).join('').replace(/^\s*/, '').replace(/\s*$/, '');
memo[headerName] = headerVal;
// Also add an alias using the all-lowercased version of the header name
// (if it's different)
var allLowercaseHeaderName = headerName.toLowerCase();
if (allLowercaseHeaderName !== headerName) {
memo[allLowercaseHeaderName] = headerVal;
}
return memo;
}, {})
});
}
}));//</ thisJQuery.ajax + _.extend() >
})();//</self-calling function :: _doAjaxWithJQuery>
// ██╗ ██████╗ ███████╗ ██████╗ ██████╗██╗ ██╗███████╗████████╗ ██╗██╗
// ██║██╔═══██╗ ██╔════╝██╔═══██╗██╔════╝██║ ██╔╝██╔════╝╚══██╔══╝▄ ██╗▄██╔╝╚██╗
// ██║██║ ██║ ███████╗██║ ██║██║ █████╔╝ █████╗ ██║ ████╗██║ ██║
// ██║██║ ██║ ╚════██║██║ ██║██║ ██╔═██╗ ██╔══╝ ██║ ▀╚██╔▀██║ ██║
// ██║╚██████╔╝██╗███████║╚██████╔╝╚██████╗██║ ██╗███████╗ ██║██╗ ╚═╝ ╚██╗██╔╝
// ╚═╝ ╚═════╝ ╚═╝╚══════╝ ╚═════╝ ╚═════╝╚═╝ ╚═╝╚══════╝ ╚═╝╚═╝ ╚═╝╚═╝
//
case 'io.socket': return (function _doAjaxWithSocket(){
var socket = requestInfo.protocolInstance;
// Check to be sure that none of the parameter values are
// attempted file uploads.
if (File && requestInfo.params) {
_.each(requestInfo.params, function(value, fieldName){
if (_representsOneOrMoreFiles(value)) {
throw new Error('Detected File-like data provided for the "'+fieldName+'" parameter -- but file uploads are not currently supported using WebSockets / Socket.io. Please call this method using a different request protocol (e.g. `protocol: \'jQuery\'`)');
}
});
}//fi
// Determine if the socket has been disconnected, or if it
// has NEVER BEEN connected and is not CURRENTLY TRYING to
// connect.
var disconnectedOrWasNeverConnectedAndUnlikelyToTry =
// =>
// If the socket is connected, cool, no problem.
!socket.isConnected() &&
// =>
// If the socket is at least _attempting_ to connect, we'll go ahead
// and let it try to do it's thing (i.e. queue and replay)
!socket.isConnecting() &&
// =>
// If the socket hasn't even had the _chance_ to begin connecting
// (because the one-tick auto-connect timer hasn't fired yet),
// then we'll give it that chance.
!socket.mightBeAboutToAutoConnect();
// If none of the above were true, then emulate a normal
// offline AJAX response from jQuery.
if (disconnectedOrWasNeverConnectedAndUnlikelyToTry) {
return proceed(undefined, {
body: null,
statusCode: 0,
headers: {}
});
}
// Otherwise the socket is either connected, in the process of connecting,
// or in an indeterminate state where it has _never_ connected but _might_
// still connect (see above for details).
//
// In any of these cases, thanks largely to queuing, it is safe to continue
// onwards, and to send the request!
socket.request({
method: requestInfo.verb,
url: requestInfo.url,
data: requestInfo.params,
headers: requestInfo.headers
}, function (unused, jwres) {
return proceed(undefined, {
body: jwres.body,
statusCode: jwres.statusCode,
headers: jwres.headers
});
});//</ socket.request() >
})();//</self-calling function :: _doAjaxWithSocket>
// ███╗ ███╗██████╗ ██╗ ██╗████████╗████████╗██████╗
// ████╗ ████║██╔══██╗ ██║ ██║╚══██╔══╝╚══██╔══╝██╔══██╗
// ██╔████╔██║██████╔╝█████╗███████║ ██║ ██║ ██████╔╝
// ██║╚██╔╝██║██╔═══╝ ╚════╝██╔══██║ ██║ ██║ ██╔═══╝
// ██║ ╚═╝ ██║██║ ██║ ██║ ██║ ██║ ██║
// ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝
case 'machinepack-http': return (function _doAjaxWithMpHttp(){
// If there are request parameters, check to be sure
// that none of the parameter values are File instances.
if (requestInfo.params) {
_.each(requestInfo.params, function(value, fieldName){
if (_representsOneOrMoreFiles(value)) {
throw new Error('Detected File-like data provided for the "'+fieldName+'" parameter -- but file uploads are not currently supported in Cloud SDK when using "machinepack-http". Please call this method using a different request protocol.');
}
});//∞
}//fi
var mpHttpOpts = {
url: requestInfo.url,
method: requestInfo.verb
};
// If GET request, encode params in querystring.
if (requestInfo.verb.match(/get/i)) {
mpHttpOpts.qs = textParamsByFieldName;
}
// Otherwise, attach params as the request body.
// (it will be JSON-encoded automatically by default)
else {
mpHttpOpts.body = textParamsByFieldName;
}
if (typeof requestInfo.headers !== 'undefined') {
mpHttpOpts.headers = requestInfo.headers;
}
requestInfo.protocolInstance.sendHttpRequest.with(mpHttpOpts)
.switch({
error: function (err) {
return proceed(err);
},
requestFailed: function(err) {
return proceed(undefined, {
body: err.message,
statusCode: 0,
headers: {}
});
},
non200Response: function(serverResponse) {
return proceed(undefined, serverResponse);
},