Source: core/CoreXhr.js

/**
 * Copyright (C) 2005-2016 Alfresco Software Limited.
 *
 * This file is part of Alfresco
 *
 * Alfresco is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Alfresco is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
 */

/**
 * This module should be mixed into any widget or service that needs to make XHR calls to REST APIs on
 * either the client or the Alfresco Repository.
 * 
 * @module alfresco/core/CoreXhr
 * @mixinSafe
 * @author Dave Draper
 */
define(["dojo/_base/declare",
        "alfresco/core/Core",
        "alfresco/core/topics",
        "service/constants/Default",
        "webscripts/defaults",
        "dijit/registry",
        "dojo/topic",
        "dojo/_base/array",
        "dojo/_base/lang",
        "dojo/dom-construct",
        "dojox/uuid/generateRandomUuid",
        "dojo/request/xhr",
        "dojo/json",
        "dojo/date/stamp",
        "dojo/cookie"],
        function(declare, Core, topics, AlfConstants, webScriptDefaults, registry, pubSub, array, 
                 lang, domConstruct, uuid, xhr, JSON, stamp, dojoCookie) {

   return declare([Core], {

      /**
       * Indicates whether or not to call the JavaScript encodeURI function on URLs before they
       * are passed to [serviceXhr]{@link module:alfresc/core/CoreXhr#serviceXhr}. This defaults
       * to true but can be overridden if required.
       *
       * @instance
       * @type {boolean}
       * @default
       */
      encodeURIs: false,

      /**
       * Should a cache busting parameter be added to the URL?
       *
       * @instance
       * @type {boolean}
       * @default
       */
      preventCache: false,

      /**
       * Ensures that the csrfProperties are retrieved from the Alfresco constants provided by Surf.
       *
       * @instance
       * @param {object} args The constructor arguments.
       * @listens module:alfresco/core/topics#STOP_XHR_REQUEST
       */
      constructor: function(args){
         lang.mixin(this, args);
         this.csrfProperties = AlfConstants.CSRF_POLICY.properties || {};
         this.serviceRequests = {};

         this.alfSubscribe(topics.STOP_XHR_REQUEST, lang.hitch(this, this.onStopRequest));
      },

      /**
       * This function can be used to clean up JSON responses to remove any superfluous whitespace characters and
       * remove any trailing commas in arrays/objects. This function is particularly handy since Dojo can be very
       * fussy about JSON.
       *
       * @instance
       * @param {string} input
       * @returns {string} A cleaned up JSON response.
       */
      cleanupJSONResponse: function alfresco_core_CoreXhr__cleanupJSONResponse(input) {
         var r = input;
         if (typeof input === "string")
         {
            r = input.replace(/,}/g, "}");
         }
         return r;
      },

      /**
       * This method handles XHR requests. As well as providing default callback handlers for the success, failure and
       * progress responses it also performs some additional JSON cleanup of responses (where required) which is useful
       * when REST APIs return invalid code (this is especially useful as Dojo can be quite particular about parsing
       * JSON).
       *
       * The function takes a single object as an argument that will allow updates to be made to include additional
       * data and provide defaults when it is not provided.
       *
       * By default this function will issue a POST method
       *
       * @typedef {Object} serviceXhrConfig
       * @property {String} url - Where should we send the request to.
       * @property {Object} [headers] headers - Request headers to send (replaces [the default headers]{@link module:alfresco/core/CoreXhr#getDefaultHeaders} if specified)
       * @property {Object} [data=null] - data for the request body
       * @property {boolean} [doNotCleanData=false] Pass true to SUPPRESS cleaning of the "data" object to [remove framework attributes]{@link module:alfresco/core/Core#alfCleanFrameworkAttributes} from it.
       * @property {String} [query=null] - data for the query string
       * @property {String} [handleAs=text] - TODO - document this feature.
       * @property {String} [method=POST] - HTTP method to use for XHR
       * @property {Object} [requestId] - TODO - document this feature
       * @property {function} [successCallback] - overrides the default success callback
       * @property {function} [failureCallback] - overrides the default failure callback
       * @property {function} [progressCallback] - overrides the default progress callback
       * @property {function} [authenticationFailureCallback] - overrides the default authentication failure behaviour
       * @property {function} [callbackScope=_this] - the scope to pass to the overridden callback function
       * @property {string} [alfTopic] - The topic to by published by the default request callbacks.
       * @property {string} [alfResponseScope=""] - The scope to use when publishing in the default request callbacks.
       * Appended with "_PROGRESS", "_FAILURE" or "_SUCCESS" depending on response status code.
       *
       * @instance
       * @callable
       * @param {serviceXhrConfig} config The configuration for the request
       */
      serviceXhr: function alfresco_core_CoreXhr__serviceXhr(config) {
         /*jshint maxcomplexity:false*/
         var _this = this;

         if (config)
         {
            if (!config.url)
            {
               this.alfLog("error", "An XHR request was made but no URL was provided", config);
            }
            else
            {
               var headers = (config.headers) ? config.headers : this.getDefaultHeaders();
               if (this.isCsrfFilterEnabled())
               {
                  headers[this.getCsrfHeader()] = this.getCsrfToken();
               }

               // Attempt to parse the data, but reset to null if not possible...
               var data = config.data;

               // Clean the data prior to sending, unless specifically told not to
               if (data && !config.doNotCleanData) {
                  data = this.alfCleanFrameworkAttributes(data);
               }

               if (headers["Content-Type"] === "application/json")
               {
                  try
                  {
                     data = (data && JSON.stringify(data)) || null;
                  }
                  catch (e)
                  {
                     this.alfLog("warn", "Could not stringify XHR JSON data", data);
                     data = null;
                  }
               }

               var options = {
                  handleAs: (config.handleAs) ? config.handleAs : "text",
                  method: (config.method) ? config.method : "POST",
                  data: data,
                  query: (config.query) ? config.query : null,
                  headers: headers
               };

               // Add in cache busting?
               if (config.method === "GET" && (config.preventCache || this.preventCache))
               {
                  // Add it from either the config (if supplied) or the default if not.
                  options.preventCache = (config.preventCache !== null)? config.preventCache : this.preventCache;
               }

               var url = this.encodeURIs ? encodeURI(config.url) : config.url;
               var request = xhr(url, options).then(function(response) {

                  var id = lang.getObject("requestId", false, config);
                  if (id)
                  {
                     delete _this.serviceRequests[id];
                  }

                  // HANDLE SUCCESS...
                  if (typeof response === "string" && lang.trim(response))
                  {
                     try
                     {
                        response = JSON.parse(_this.cleanupJSONResponse(response));
                     }
                     catch (e)
                     {
                        _this.alfLog("error", "An error occurred parsing an XHR JSON success response", response, this);
                     }
                  }
                  if (typeof config.successCallback === "function")
                  {
                     var callbackScope = config.successCallbackScope || config.callbackScope || _this;
                     config.successCallback.call(callbackScope, response, config);
                  }
                  else
                  {
                     _this.defaultSuccessCallback(response, config);
                  }

               }, function(response) {

                  // Handle authentication failure (401) or Session timeout
                  var callbackScope;
                  if (response.response && response.response.status === 401)
                  {
                     if (typeof config.authenticationFailureCallback === "function")
                     {
                        callbackScope = config.failureCallbackScope || config.callbackScope || _this;
                        config.authenticationFailureCallback.call(callbackScope, response, config);
                     }
                     else
                     {
                        var redirect = response.response.getHeader("Location");
                        if (redirect)
                        {
                           if (redirect.indexOf("http://") === 0 || redirect.indexOf("https://") === 0 )
                           {
                              window.location.href = redirect;
                           }
                           else
                           {
                              window.location.href = window.location.protocol + "//" + window.location.host + redirect;
                           }
                           return;
                        }
                        else
                        {
                           window.location.reload(true);
                           return;
                        }
                     }
                  }

                  // HANDLE FAILURE...
                  var id = lang.getObject("requestId", false, config);
                  if (id)
                  {
                     delete _this.serviceRequests[id];
                  }
                  if (typeof response === "string" && lang.trim(response))
                  {
                     try
                     {
                        response = JSON.parse(_this.cleanupJSONResponse(response));
                     }
                     catch (e)
                     {
                        _this.alfLog("error", "An error occurred parsing an XHR JSON failure response", response, this);
                     }
                  }
                  if (typeof config.failureCallback === "function")
                  {
                     callbackScope = config.failureCallbackScope || config.callbackScope || _this;
                     config.failureCallback.call(callbackScope, response, config);
                  }
                  else
                  {
                     _this.defaultFailureCallback(response, config);
                  }

               }, function(response) {

                  // HANDLE PROGRESS...
                  if (typeof response === "string" && lang.trim(response))
                  {
                     try
                     {
                        response = JSON.parse(_this.cleanupJSONResponse(response));
                     }
                     catch (e)
                     {
                        _this.alfLog("error", "An error occurred parsing an XHR JSON progress response", response, this);
                     }
                  }
                  if (typeof config.progressCallback === "function")
                  {
                     var callbackScope = config.progressCallbackScope || config.callbackScope || _this;
                     config.progressCallback.call(callbackScope, response, config);
                  }
                  else
                  {
                     _this.defaultProgressCallback(response, config);
                  }
               });

               // If a request ID has been provided then store it in a map so that we can kill the
               // request when asked. This entry should be cleaned up on success or failure request
               // responses to prevent memory leakage...
               if (config.requestId)
               {
                  this.serviceRequests[config.requestId] = request;
               }
               return request;
            }
         }
         else
         {
            this.alfLog("error", "A request was made to perform an XHR request, but no configuration for the request was provided");
         }
      },

      /**
       * This is the default success callback for XHR requests that will be used if no other is provided.
       *
       * @instance
       * @param {object} response The object returned from the successful XHR request
       * @param {object} requestConfig The original configuration passed when the request was made
       */
      defaultSuccessCallback: function alfresco_core_CoreXhr__defaultSuccessCallback(response, requestConfig) {
         this.alfLog("log", "[DEFAULT CALLBACK] The following successful response was received", response, requestConfig);

         if (requestConfig.alfTopic)
         {
            this.alfPublish(requestConfig.alfTopic + "_SUCCESS", {
               requestConfig: requestConfig,
               response: response
            }, false, false, requestConfig.alfResponseScope);
         }
         else if (requestConfig.data && requestConfig.data.alfResponseTopic) {
            this.alfPublish(requestConfig.data.alfResponseTopic, {
               requestConfig: requestConfig,
               response: response
            }, false, false, requestConfig.data.alfResponseScope);
         }
         else if (requestConfig.alfSuccessTopic) {
            this.alfPublish(requestConfig.alfSuccessTopic, {
               requestConfig: requestConfig,
               response: response
            }, false, false, requestConfig.alfResponseScope);
         }
         else
         {
            this.alfLog("warn", "[DEFAULT CALLBACK] Default success callback has been called but no requestConfig.alfTopic has been set.");
         }
      },

      /**
       * This is the default failure callback for XHR requests that will be used if no other is provided.
       *
       * @instance
       * @param {object} response The object returned from the failed XHR request
       * @param {object} requestConfig The original configuration passed when the request was made
       */
      defaultFailureCallback: function alfresco_core_CoreXhr__defaultFailureCallback(response, requestConfig) {
         this.alfLog("log", "[DEFAULT CALLBACK] The following failure response was received", response, requestConfig);
         if (requestConfig.alfTopic)
         {
            this.alfPublish(requestConfig.alfTopic + "_FAILURE", {
               requestConfig: requestConfig,
               response: response
            }, false, false, requestConfig.alfResponseScope);
         }
         else if (requestConfig.data && requestConfig.data.alfResponseTopic) {
            this.alfPublish(requestConfig.data.alfResponseTopic, {
               requestConfig: requestConfig,
               response: response
            }, false, false, requestConfig.data.alfResponseScope);
         }
         else if (requestConfig.alfFailureTopic) {
            this.alfPublish(requestConfig.alfFailureTopic, {
               requestConfig: requestConfig,
               response: response
            }, false, false, requestConfig.alfResponseScope);
         }
         
         if (typeof this.displayMessage === "function" && response.response.text)
         {
            try
            {
               var responseObj = JSON.parse(response.response.text);
               if (responseObj.message)
               {
                  var msg = responseObj.message;
                  // generic exception message (from standard error template for REST APIs)
                  // attempt to strip out message text if in standard format:
                  // "org.alfresco.package.SpecificException: 12345678 Rest Of Message"
                  // - give up and display all if not
                  var eIndex = msg.indexOf("Exception: ");
                  if (eIndex !== -1 && msg.length > eIndex + 20)
                  {
                     msg = msg.substring(eIndex + 20);
                  }
                  this.displayMessage(msg);
               }
            }
            catch (e)
            {
               // Ignore failures here. The parsing was a best effort to get a message.
            }
         }
      },

      /**
       * This is the default progress callback for XHR requests that will be used if no other is provided.
       *
       * @instance
       * @param {object} response The object returned from the progress update of the XHR request
       * @param {object} requestConfig The original configuration passed when the request was made
       */
      defaultProgressCallback: function alfresco_core_CoreXhr__defaultProgressCallback(response, requestConfig) {
         this.alfLog("log", "[DEFAULT CALLBACK] The following progress response was received", response, requestConfig);
         if (requestConfig.alfTopic)
         {
            this.alfPublish(requestConfig.alfTopic + "_PROGRESS", {
               requestConfig: requestConfig,
               response: response
            }, false, false, requestConfig.alfResponseScope);
         }
         else if (requestConfig.data && requestConfig.data.alfResponseTopic) {
            this.alfPublish(requestConfig.data.alfResponseTopic, {
               requestConfig: requestConfig,
               response: response
            }, false, false, requestConfig.data.alfResponseScope);
         }
      },

      /**
       * <p>Get the default headers. Currently these are:</p>
       * <ul>
       *    <li>Content-Type: application/json</li>
       *    <li>Accept-Language: [uses browser provided languages]</li>
       * </ul>
       *
       * @instance
       * @returns {object} The default headers
       */
      getDefaultHeaders: function alfresco_core_CoreXhr___getDefaultHeaders() {

         // Build up the Accept-Language header value
         var languages = navigator.languages;
         if (languages) 
         {
            languages = array.map(languages, function(nextLang) {
               return nextLang;
            }).join(", ");
         } 
         else 
         {
            languages = webScriptDefaults.ACCEPT_LANGUAGE;
         }

         // Last resort... if for any reason the webscriptDefaults hasn't worked.
         if (!languages)
         {
            languages = (navigator.language || navigator.userLanguage);
         }

         // Construct and return the headers
         var defaultHeaders = {
            "Content-Type": "application/json",
            "Accept-Language": languages
         };
         return defaultHeaders;
      },

      /**
       * Handles requests to stop a previous XHR request.
       *
       * @instance
       * @param {object} payload An object that should contain a 'requestId' attribute
       */
      onStopRequest: function alfresco_core_CoreXhr__onStopRequest(payload) {
         var id = lang.getObject("requestId", false, payload);
         if (id && this.serviceRequests[id])
         {
            this.alfLog("info", "Stopping XHR request: " + id);
            this.serviceRequests[id].cancel();
            delete this.serviceRequests[id];
         }
      },

      /**
       * Use this method and check if the CSRF filter is enabled before trying to set the CSRF header or parameter.
       * Will be disabled if the filter contains no rules.
       *
       * @instance
       * @return {*}
       */
      isCsrfFilterEnabled: function alfresco_core_CoreXhr__isCsrfFilterEnabled() {
         return AlfConstants.CSRF_POLICY.enabled;
      },

      /**
       * Returns the name of the request header to put the token in when sending XMLHttpRequests.
       *
       * @instance
       * @return {String} The name of the request header to put the token in.
       */
      getCsrfHeader: function alfresco_core_CoreXhr__getCsrfHeader() {
         return this.csrfResolve(AlfConstants.CSRF_POLICY.header);
      },

      /**
       * Returns the name of the request parameter to put the token in when sending multipart form uploads.
       *
       * @instance
       * @return {String} The name of the request header to put the token in.
       */
      getCsrfParameter: function alfresco_core_CoreXhr__getCsrfParameter() {
         return this.csrfResolve(AlfConstants.CSRF_POLICY.parameter);
      },

      /**
       * Returns the name of the cookie that holds the value of the token.
       *
       * @instance
       * @return {String} The name of the request header to put the token in.
       */
      getCsrfCookie: function alfresco_core_CoreXhr__getCsrfCookie() {
         return this.csrfResolve(AlfConstants.CSRF_POLICY.cookie);
      },

      /**
       * Returns the token.
       *
       * Note! Make sure to use this method just before a request is made against the server since it might have been
       * updated in another browser tab or window.
       *
       * @instance
       * @returns {String} The name of the request header to put the token in.
       */
      getCsrfToken: function alfresco_core_CoreXhr__getCsrfToken() {
         var token = null;
         var cookieName = this.getCsrfCookie();
         if (cookieName)
         {
            token = dojoCookie(cookieName);
            if (token)
            {
               // remove quotes to support Jetty app-server - bug where it quotes a valid cookie value see ALF-18823
               token = token.replace(/"/g, "");
            }
         }
         return token;
      },

      /**
       * @instance
       */
      csrfResolve: function alfresco_core_CoreXhr__csrfResolve(str) {
         return lang.replace(str, this.csrfProperties);
      }
   });
});