/*
* Copyright (c) 2016-2017, Michael A. Updike All rights reserved.
* Licensed under Apache 2.0
* https://opensource.org/licenses/Apache-2.0
* https://github.com/opus1269/chrome-extension-utils/blob/master/LICENSE.md
*/
window.Chrome = window.Chrome || {};
/**
* Fetch with authentication and exponential back-off
* @namespace
*/
Chrome.Http = (function() {
'use strict';
new ExceptionHandler();
/**
* Http configuration
* @typedef {?{}} Chrome.Http.Config
* @property {boolean} [isAuth=false] - if true, authorization required
* @property {boolean} [retryToken=false] - if true, retry with new token
* @property {boolean} [interactive=false] - user initiated, if true
* @property {?string} [token=null] - auth token
* @property {boolean} [backoff=true] - if true, do exponential back-off
* @property {int} [maxRetries=_MAX_ATTEMPTS] - max retries
* @memberOf Chrome.Http
*/
/**
* Http response
* @typedef {{}} Chrome.Http.Response
* @property {boolean} ok - success flag
* @property {Function} json - returned data as JSON
* @property {int} status - HTTP response code
* @property {string} statusText - HTTP response message
* @memberOf Chrome.Http
*/
/**
* Authorization header
* @type {string}
* @private
* @memberOf Chrome.Http
*/
const _AUTH_HEADER = 'Authorization';
/**
* Bearer parameter for authorized call
* @type {string}
* @private
* @memberOf Chrome.Http
*/
const _BEARER = 'Bearer';
/**
* Max retries on 500 errors
* @type {int}
* @private
* @memberOf Chrome.Http
*/
const _MAX_RETRIES = 4;
/**
* Delay multiplier for exponential back-off
* @const
* @private
* @memberOf Chrome.Http
*/
const _DELAY = 1000;
/**
* Configuration object
* @type {Chrome.Http.Config}
* @private
* @memberOf Chrome.Http
*/
const _CONFIG = {
isAuth: false,
retryToken: false,
interactive: false,
token: null,
backoff: true,
maxRetries: _MAX_RETRIES,
};
/**
* Check response and act accordingly
* @param {Chrome.Http.Response} response - server response
* @param {string} url - server
* @param {Object} opts - fetch options
* @param {Chrome.Http.Config} conf - configuration
* @param {int} attempt - the retry attempt we are on
* @returns {Promise.<JSON>} response from server
* @private
* @memberOf Chrome.Http
*/
function _processResponse(response, url, opts, conf, attempt) {
if (response.ok) {
// request succeeded - woo hoo!
return response.json();
}
if (attempt >= conf.maxRetries) {
// request still failed after maxRetries
return Promise.reject(_getError(response));
}
const status = response.status;
if (conf.backoff && (status >= 500) && (status < 600)) {
// temporary server error, maybe. Retry with backoff
return _retry(url, opts, conf, attempt);
}
if (conf.isAuth && conf.token && conf.retryToken && (status === 401)) {
// could be expired token. Remove cached one and try again
return _retryToken(url, opts, conf, attempt);
}
if (conf.isAuth && conf.interactive && conf.token && conf.retryToken &&
(status === 403)) {
// user may have revoked access to extension at some point
// If interactive, retry so they can authorize again
return _retryToken(url, opts, conf, attempt);
}
// request failed
return Promise.reject(_getError(response));
}
/**
* Get Error message
* @param {Chrome.Http.Response} response - server response
* @returns {Error}
* @private
* @memberOf Chrome.Http
*/
function _getError(response) {
let msg = 'Unknown error.';
if (response && response.status &&
(typeof(response.statusText) !== 'undefined')) {
let statusMsg = Chrome.Locale.localize('err_status');
if ((typeof(statusMsg) === 'undefined') || (statusMsg === '')) {
// in case localize is missing
statusMsg = 'Status';
}
msg = `${statusMsg}: ${response.status}`;
msg += `\n${response.statusText}`;
}
return new Error(msg);
}
/**
* Get authorization token
* @param {boolean} isAuth - if true, authorization required
* @param {boolean} interactive - if true, user initiated
* @returns {Promise.<string>} auth token
* @private
* @memberOf Chrome.Http
*/
function _getAuthToken(isAuth, interactive) {
if (isAuth) {
return Chrome.Auth.getToken(interactive).then((token) => {
return Promise.resolve(token);
}).catch((err) => {
if (interactive && (err.message.includes('revoked') ||
err.message.includes('Authorization page could not be loaded'))) {
// try one more time non-interactively
// Always returns Authorization page error
// when first registering, Not sure why
// Other message is if user revoked access to extension
return Chrome.Auth.getToken(false);
} else {
return Promise.reject(err);
}
});
} else {
// non-authorization branch
return Promise.resolve(null);
}
}
/**
* Retry authorized fetch with exponential back-off
* @param {string} url - server request
* @param {Object} opts - fetch options
* @param {Chrome.Http.Config} conf - configuration
* @param {int} attempt - the retry attempt we are on
* @returns {Promise.<JSON>} response from server
* @private
* @memberOf Chrome.Http
*/
function _retry(url, opts, conf, attempt) {
attempt++;
// eslint-disable-next-line promise/avoid-new
return new Promise((resolve, reject) => {
const delay = (Math.pow(2, attempt) - 1) * _DELAY;
setTimeout(() => {
return _fetch(url, opts, conf, attempt).then(resolve, reject);
}, delay);
});
}
/**
* Retry fetch after removing cached auth token
* @param {string} url - server request
* @param {Object} opts - fetch options
* @param {Chrome.Http.Config} conf - configuration
* @param {int} attempt - the retry attempt we are on
* @returns {Promise.<JSON>} response from server
* @private
* @memberOf Chrome.Http
*/
function _retryToken(url, opts, conf, attempt) {
Chrome.GA.error('Refreshed auth token.', 'Http._retryToken');
return Chrome.Auth.removeCachedToken(conf.token).then(() => {
conf.token = null;
conf.retryToken = false;
return _fetch(url, opts, conf, attempt);
});
}
/**
* Perform fetch, optionally using authorization and exponential back-off
* @param {string} url - server request
* @param {Object} opts - fetch options
* @param {Chrome.Http.Config} conf - configuration
* @param {int} attempt - the retry attempt we are on
* @returns {Promise.<JSON>} response from server
* @private
* @memberOf Chrome.Http
*/
function _fetch(url, opts, conf, attempt) {
return _getAuthToken(conf.isAuth, conf.interactive).then((authToken) => {
if (conf.isAuth) {
conf.token = authToken;
opts.headers.set(_AUTH_HEADER, `${_BEARER} ${conf.token}`);
}
return fetch(url, opts);
}).then((response) => {
return _processResponse(response, url, opts, conf, attempt);
}).catch((err) => {
let msg = err.message;
if (msg === 'Failed to fetch') {
msg = Chrome.Locale.localize('err_network');
if ((typeof(msg) === 'undefined') || (msg === '')) {
// in case localize is missing
msg = 'Network error';
}
}
return Promise.reject(new Error(msg));
});
}
/**
* Do a server request
* @param {string} url - server request
* @param {Object} opts - fetch options
* @param {Chrome.Http.Config} conf - configuration
* @returns {Promise.<JSON>} response from server
* @private
* @memberOf Chrome.Http
*/
function _doIt(url, opts, conf) {
conf = (conf === null) ? _CONFIG : conf;
if (conf.isAuth) {
opts.headers.set(_AUTH_HEADER, `${_BEARER} unknown`);
}
let attempt = 0;
return _fetch(url, opts, conf, attempt);
}
return {
conf: _CONFIG,
/**
* Perform GET request
* @param {string} url - server request
* @param {Chrome.Http.Config} [conf=null] - configuration
* @returns {Promise.<JSON>} response from server
* @memberOf Chrome.Http
*/
doGet: function(url, conf = null) {
const opts = {method: 'GET', headers: new Headers({})};
return _doIt(url, opts, conf);
},
/**
* Perform POST request
* @param {string} url - server request
* @param {Chrome.Http.Config} [conf=null] - configuration
* @returns {Promise.<JSON>} response from server
* @memberOf Chrome.Http
*/
doPost: function(url, conf = null) {
const opts = {method: 'POST', headers: new Headers({})};
return _doIt(url, opts, conf);
},
};
})(window);