1 /**
  2  * @fileOverview
  3  * @author <a href="https://www.labkey.org">LabKey</a> (<a href="mailto:info@labkey.com">info@labkey.com</a>)
  4  * @license Copyright (c) 2011-2017 LabKey Corporation
  5  * <p/>
  6  * Licensed under the Apache License, Version 2.0 (the "License");
  7  * you may not use this file except in compliance with the License.
  8  * You may obtain a copy of the License at
  9  * <p/>
 10  * http://www.apache.org/licenses/LICENSE-2.0
 11  * <p/>
 12  * Unless required by applicable law or agreed to in writing, software
 13  * distributed under the License is distributed on an "AS IS" BASIS,
 14  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 15  * See the License for the specific language governing permissions and
 16  * limitations under the License.
 17  * <p/>
 18  */
 19 
 20 /**
 21  * @namespace
 22  * Utility for making XHR.
 23  */
 24 LABKEY.Ajax = new function () {
 25     'use strict';
 26 
 27     var DEFAULT_HEADERS = LABKEY.defaultHeaders;
 28 
 29     var callback = function(fn, scope, args) {
 30         if (fn) {
 31             fn.apply(scope, args);
 32         }
 33     };
 34 
 35     // Returns true iff obj contains case-insensitive key
 36     var contains = function(obj, key) {
 37         if (key) {
 38             var lowerKey = key.toLowerCase();
 39             for (var k in obj) {
 40                 if (obj.hasOwnProperty(k) && k.toLowerCase() === lowerKey) {
 41                     return true;
 42                 }
 43             }
 44         }
 45         return false;
 46     };
 47 
 48     var configureOptions = function(config) {
 49         var url, params, method = 'GET', data, isForm = false;
 50 
 51         if (!config.hasOwnProperty('url') || config.url === null) {
 52             throw new Error("a URL is required to make a request");
 53         }
 54 
 55         url = config.url;
 56         params = config.params;
 57 
 58         // configure data
 59         if (config.form) {
 60             data = config.form instanceof FormData ? config.form : new FormData(config.form);
 61             isForm = true;
 62         }
 63         else if (config.jsonData) {
 64             data = JSON.stringify(config.jsonData);
 65         }
 66         else {
 67             data = null;
 68         }
 69 
 70         // configure method
 71         if (config.hasOwnProperty('method') && config.method !== null) {
 72             method = config.method.toUpperCase();
 73         }
 74         else if (data) {
 75             method = 'POST';
 76         }
 77 
 78         // configure params
 79         if (params !== undefined && params !== null) {
 80 
 81             var qs = LABKEY.ActionURL.queryString(params);
 82 
 83             // 26617: backwards compatibility to append params to the body in the case of a POST without form/jsonData
 84             if (method === 'POST' && (data === undefined || data === null)) {
 85                 data = qs;
 86             }
 87             else {
 88                 url += (url.indexOf('?') === -1 ? '?' : '&') + qs;
 89             }
 90         }
 91 
 92         return {
 93             url: url,
 94             method: method,
 95             data: data,
 96             isForm: isForm
 97         };
 98     };
 99 
100     var configureHeaders = function(xhr, config, options) {
101         var headers = config.headers,
102             jsonData = config.jsonData;
103 
104         if (headers === undefined || headers === null) {
105             headers = {};
106         }
107 
108         // only set Content-Type if this is not FormData and it has not been set explicitly
109         if (!options.isForm && !contains(headers, 'Content-Type')) {
110             if (jsonData !== undefined && jsonData !== null) {
111                 headers['Content-Type'] = 'application/json';
112             }
113             else {
114                 headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
115             }
116         }
117 
118         if (!contains(headers, 'X-Requested-With')) {
119             headers['X-Requested-With'] = 'XMLHttpRequest';
120         }
121 
122         for (var k in DEFAULT_HEADERS) {
123             if (DEFAULT_HEADERS.hasOwnProperty(k)) {
124                 xhr.setRequestHeader(k, DEFAULT_HEADERS[k]);
125             }
126         }
127 
128         for (var k in headers) {
129             if (headers.hasOwnProperty(k)) {
130                 xhr.setRequestHeader(k, headers[k]);
131             }
132         }
133 
134         return headers;
135     };
136 
137     /** @scope LABKEY.Ajax */
138     return {
139         DEFAULT_HEADERS : DEFAULT_HEADERS,
140 
141         /**
142          * Make a XMLHttpRequest nominally to a LabKey instance. Includes success/failure callback mechanism,
143          * HTTP header configuration, support for FormData, and parameter encoding amongst other features.
144          * @param config An object which contains the following configuration properties.
145          * @param {String} config.url the url used for the XMLHttpRequest. If you are making a request to the
146          *              LabKey Server instance see {@link LABKEY.ActionURL.buildURL} for helpful URL construction.
147          * @param {String} [config.method] the HTTP request method used for the XMLHttpRequest. Examples are "GET", "PUSH, "DELETE", etc.
148          *              Defaults to "GET" unless jsonData is supplied then the default is changed to "POST". For more information,
149          *              see this <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods">HTTP request method documentation</a>.
150          * @param {Object} [config.jsonData] data provided to the XMLHttpRequest.send(data) function. If the request is method "POST" this is the body of the request.
151          * @param {Object} [config.params] An object representing URL parameters that will be added to the URL.
152          *                 Note, that if the request is method "POST" and jsonData is not provided these params will be sent via the body of the request.
153          * @param {Object} [config.headers] Object specifying additional HTTP headers to add the request.
154          * @param {Mixed} [config.form] FormData or Object consumable by FormData that can be used to POST key/value pairs of form information.
155          *              For more information, see <a href="https://developer.mozilla.org/en-US/docs/Web/API/FormData">FormData documentation</a>.
156          * @param {Function} [config.success] A function called when a successful response is received (determined by XHR readyState and status).
157          *              It will be passed the following arguments:
158          * <ul>
159          * <li><b>xhr:</b> The XMLHttpRequest where the text of the response can be found on xhr.responseText amongst other properties</li>
160          * <li><b>originalConfig:</b> The config originally supplied to LABKEY.Ajax.request</li>
161          * </ul>
162          * @param {Function} [config.failure] A function called when a failure response is received (determined by
163          *              XHR readyState, status, or ontimeout if supplied). It will be passed the following arguments:
164          * <ul>
165          * <li><b>xhr:</b> The XMLHttpRequest where the text of the response can be found on xhr.responseText amongst other properties</li>
166          * <li><b>originalConfig:</b> The config originally supplied to LABKEY.Ajax.request</li>
167          * </ul>
168          * @param {Function} [config.callback] A function called after any success/failure response is received. It will
169          *              be passed the following arguments:
170          * <ul>
171          * <li><b>originalConfig:</b> The config originally supplied to LABKEY.Ajax.request</li>
172          * <li><b>success:</b> boolean value that is true if the request was successful</li>
173          * <li><b>xhr:</b> The XMLHttpRequest where the text of the response can be found on xhr.responseText amongst other properties</li>
174          * </ul>
175          * @param {Mixed} [config.scope] A scope for the callback functions. Defaults to "this".
176          * @param {Mixed} [config.timeout] If a non-null value is supplied then XMLHttpRequest.ontimeout will be hooked to failure.
177          * @returns {XMLHttpRequest}
178          */
179         request : function(config) {
180             var options = configureOptions(config),
181                 scope = config.hasOwnProperty('scope') && config.scope !== null ? config.scope : this,
182                 xhr = new XMLHttpRequest();
183 
184             xhr.onreadystatechange = function() {
185                 if (xhr.readyState === 4) {
186                     var success = (xhr.status >= 200 && xhr.status < 300) || xhr.status == 304;
187 
188                     callback(success ? config.success : config.failure, scope, [xhr, config]);
189                     callback(config.callback, scope, [config, success, xhr]);
190                 }
191             };
192 
193             xhr.open(options.method, options.url, true);
194 
195             // configure headers after request is open
196             configureHeaders(xhr, config, options);
197 
198             // configure timeout after request is open
199             if (config.hasOwnProperty('timeout') && config.timeout !== null) {
200                 xhr.ontimeout = function() {
201                     callback(config.failure, scope, [xhr, config]);
202                     callback(config.callback, scope, [config, false /* success */, xhr]);
203                 };
204             }
205 
206             xhr.send(options.data);
207 
208             return xhr;
209         }
210     }
211 };
212