@oat-sa/tao-core-sdk
Version:
Core libraries of TAO
303 lines (284 loc) • 12.9 kB
JavaScript
define(['jquery', 'lodash', 'i18n', 'module', 'context', 'core/promiseQueue', 'core/tokenHandler', 'core/logger'], function ($, _, __, module, context, promiseQueue, tokenHandlerFactory, loggerFactory) { 'use strict';
$ = $ && Object.prototype.hasOwnProperty.call($, 'default') ? $['default'] : $;
_ = _ && Object.prototype.hasOwnProperty.call(_, 'default') ? _['default'] : _;
__ = __ && Object.prototype.hasOwnProperty.call(__, 'default') ? __['default'] : __;
module = module && Object.prototype.hasOwnProperty.call(module, 'default') ? module['default'] : module;
context = context && Object.prototype.hasOwnProperty.call(context, 'default') ? context['default'] : context;
promiseQueue = promiseQueue && Object.prototype.hasOwnProperty.call(promiseQueue, 'default') ? promiseQueue['default'] : promiseQueue;
tokenHandlerFactory = tokenHandlerFactory && Object.prototype.hasOwnProperty.call(tokenHandlerFactory, 'default') ? tokenHandlerFactory['default'] : tokenHandlerFactory;
loggerFactory = loggerFactory && Object.prototype.hasOwnProperty.call(loggerFactory, 'default') ? loggerFactory['default'] : loggerFactory;
/**
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; under version 2
* of the License (non-upgradable).
*
* This program 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* Copyright (c) 2019 (original work) Open Assessment Technologies SA;
*/
const tokenHeaderName = 'X-CSRF-Token';
const tokenHandler = tokenHandlerFactory();
const queue = promiseQueue();
const logger = loggerFactory('core/request');
/**
* Create a new error based on the given response
* @param {Object} response - the server body response as plain object
* @param {String} fallbackMessage - the error message in case the response isn't correct
* @param {Number} httpCode - the response HTTP code
* @param {Boolean} httpSent - the sent status
* @returns {Error} the new error
*/
const createError = (response, fallbackMessage, httpCode, httpSent) => {
let err;
if (response) {
const code = response.errorCode || response.code;
const message = response.errorMsg || response.errorMessage || response.error || response.message;
if (code && message) {
err = new Error(`${code} : ${message}`);
} else if (message) {
err = new Error(`${message}`);
} else {
err = new Error(fallbackMessage);
}
}
err.response = response;
err.sent = httpSent;
err.source = response.source || 'request';
if (_.isNumber(httpCode)) {
err.code = httpCode;
}
return err;
};
/**
* Request content from a TAO endpoint
* @param {Object} options
* @param {String} options.url - the endpoint full url
* @param {String} [options.method='GET'] - the HTTP method
* @param {Object} [options.data] - additional parameters (if method is 'POST')
* @param {Object} [options.headers] - the HTTP headers
* @param {String} [options.contentType] - what kind of data we're sending - usually 'json'
* @param {String} [options.dataType] - what kind of data expected in response
* @param {Boolean} [options.noToken=false] - by default, a token is always sent. If noToken=true, disables the token requirement
* @param {Boolean} [options.background] - if true, the request should be done in the background, which in practice does not trigger the global handlers like ajaxStart or ajaxStop
* @param {Boolean} [options.sequential] - if true, the request must join a queue to be run sequentially
* @param {Number} [options.timeout] - timeout in seconds for the AJAX request
* @param {Object} [options.jwtTokenHandler] - JWT token handler instance
* @param {string} [options.logLevel] - Minimum log level for request
* @returns {Promise} resolves with response, or reject if something went wrong
*/
function request(options) {
// Allow external config to override user option
if (module.config().noToken) {
options.noToken = true;
}
if (_.isEmpty(options.url)) {
throw new TypeError('At least give a URL...');
}
// Request logger
const requestLogger = logger.child({
url: options.url
});
const {
logLevel
} = options;
if (logLevel) {
requestLogger.level(logLevel);
}
/**
* Function wrapper which allows the contents to be run now, or added to a queue
* @returns {Promise} resolves with response, or rejects if something went wrong
*/
const runRequest = () => {
let tempToken;
/**
* Fetches a security token and appends it to headers, if required
* Also saves the retrieved token in a temporary constiable, in case we need to re-enqueue it
* @returns {Promise<Object>} - resolves with headers object
*/
const computeCSRFTokenHeader = () => {
if (options.noToken) {
return Promise.resolve({});
}
return tokenHandler.getToken().then(token => {
tempToken = token;
return {
[tokenHeaderName]: token || 'none'
};
});
};
/**
* Fetches a JWT token if token handler is provided
* @returns {Promise<Object>} promise of JWT token header
*/
const computeJWTTokenHeader = () => {
const {
jwtTokenHandler
} = options;
if (jwtTokenHandler) {
return jwtTokenHandler.getToken().then(token => ({
Authorization: `Bearer ${token}`
}));
}
return Promise.resolve({});
};
/**
* Extends header object with token headers
* @returns {Promise<Object>} Promise of headers object
*/
const computeHeaders = () => Promise.all([computeCSRFTokenHeader(), computeJWTTokenHeader()]).then(_ref => {
let [csrfTokenHeader, jwtTokenHeader] = _ref;
return Object.assign({}, options.headers, csrfTokenHeader, jwtTokenHeader);
});
/**
* Replaces the locally-stored tempToken into the tokenStore
* Unsets the local copy
* @returns {Promise} - resolves when done
*/
const reEnqueueTempToken = () => {
if (tempToken) {
requestLogger.debug('re-enqueueing %s token %s', tokenHeaderName, tempToken);
return tokenHandler.setToken(tempToken).then(() => {
tempToken = null;
});
}
return Promise.resolve();
};
/**
* Extracts returned security token from headers and adds it to store
* @param {Object} xhr
* @returns {Promise} - resolves when done
*/
const setTokenFromXhr = xhr => {
if (_.isFunction(xhr.getResponseHeader)) {
const token = xhr.getResponseHeader(tokenHeaderName);
requestLogger.debug('received %s header %s', tokenHeaderName, token);
if (token) {
return tokenHandler.setToken(token);
}
}
return Promise.resolve();
};
/**
* Contains the request already tried to refresh the invalid access token
*/
let isAccessTokenRefreshTried = false;
return computeHeaders().then(customHeaders => new Promise((resolve, reject) => {
const noop = void 0;
const ajaxParameters = {
url: options.url,
method: options.method || 'GET',
headers: customHeaders,
data: options.data,
contentType: options.contentType || noop,
dataType: options.dataType || 'json',
async: true,
timeout: options.timeout * 1000 || context.timeout * 1000 || 0,
beforeSend() {
if (!_.isEmpty(customHeaders)) {
requestLogger.debug('sending %s header %s', tokenHeaderName, customHeaders && customHeaders[tokenHeaderName]);
}
},
global: !options.background //TODO fix this with TT-260
};
const onDone = (response, status, xhr) => {
setTokenFromXhr(xhr).then(() => {
if (xhr.status === 204 || response && response.errorCode === 204 || status === 'nocontent') {
// no content, so resolve with empty data.
return resolve();
}
// handle case where token expired or invalid
if (xhr.status === 403 || response && response.errorCode === 403) {
return reject(createError(response, `${xhr.status} : ${xhr.statusText}`, xhr.status, xhr.readyState > 0));
}
if (xhr.status === 200 || response && response.success === true) {
// there's some data
return resolve(response);
}
//the server has handled the error
reject(createError(response, __('The server has sent an empty response'), xhr.status, xhr.readyState > 0));
}).catch(error => {
requestLogger.error(error);
reject(createError(response, error, xhr.status, xhr.readyState > 0));
});
};
const onFail = (xhr, textStatus, errorThrown) => {
let response;
const jwtTokenHandler = options.jwtTokenHandler;
/**
* if access token expired then
* get new token
* update header with new token
* retry request
* */
if (xhr.status === 401 && !isAccessTokenRefreshTried && jwtTokenHandler) {
isAccessTokenRefreshTried = true;
jwtTokenHandler.refreshToken().then(computeJWTTokenHeader).then(jwtTokenHeaders => {
Object.assign(ajaxParameters.headers, jwtTokenHeaders);
$.ajax(ajaxParameters).done(onDone).fail(onFail);
})
// if refresh token was not success, fail with original error
.catch(() => {
onFail(xhr, textStatus, errorThrown);
});
return;
}
try {
response = JSON.parse(xhr.responseText);
} catch (parseErr) {
response = {};
}
const responseExtras = {
success: false,
source: 'network',
cause: options.url,
purpose: 'proxy',
context: this,
code: xhr.status,
sent: xhr.readyState > 0,
type: 'error',
textStatus: textStatus,
message: errorThrown || xhr.statusText || __('An error occurred!')
};
const enhancedResponse = Object.assign({}, responseExtras, response);
// if the request failed because the browser is offline,
// we need to recycle the used request token
let tokenHandlerPromise;
if (enhancedResponse.code === 0) {
tokenHandlerPromise = reEnqueueTempToken();
} else {
tokenHandlerPromise = setTokenFromXhr(xhr);
}
tokenHandlerPromise.then(() => {
reject(createError(enhancedResponse, `${xhr.status} : ${xhr.statusText}`, xhr.status, xhr.readyState > 0));
}).catch(error => {
requestLogger.error(error);
reject(createError(enhancedResponse, error, xhr.status, xhr.readyState > 0));
});
};
$.ajax(ajaxParameters).done(onDone).fail(onFail);
}));
};
// Decide how to launch the request based on certain params:
return tokenHandler.getQueueLength().then(queueLength => {
if (options.noToken === true) {
// no token protection, run the request
return runRequest();
} else if (options.sequential || queueLength === 1) {
// limited tokens, sequential queue must be used
return queue.serie(runRequest);
} else {
// tokens ready
return runRequest();
}
});
}
return request;
});