/* global WP_DEFINE_IS_NODE */
import extend from 'extend';
import debugLib from 'debug';
import stringify from 'qs/lib/stringify';
import HttpMethod from '../../data/enum/HttpMethod';
import CachedCall from './CachedCall';
import GatewayError from './GatewayError';
import KeyedTaskQueue from '../../util/KeyedTaskQueue';
const debug = debugLib('SlimmingWorld:Gateway');
let fetchPonyfill = null;
if (!WP_DEFINE_IS_NODE) {
fetchPonyfill = require('fetch-ponyfill')({}); // eslint-disable-line global-require
}
/**
* SWO-6317 - add keepAlive agent to reuse sockets for all outbound http requests
*/
let keepaliveAgent;
let keepaliveHttpsAgent;
if (WP_DEFINE_IS_NODE) {
const Agent = require('agentkeepalive'); // eslint-disable-line global-require
const HttpsAgent = require('agentkeepalive').HttpsAgent; // eslint-disable-line global-require
const keepAliveConfig = {
maxSockets: 39,
maxFreeSockets: 10,
timeout: 60000,
keepAliveTimeout: 300000,
};
keepaliveAgent = new Agent(keepAliveConfig);
keepaliveHttpsAgent = new HttpsAgent(keepAliveConfig);
// Turn on for logging / debugging
// setInterval(() => {
// if (keepaliveAgent.statusChanged) {
// console.log(
// '[%s] http agent status changed: %j',
// new Date(),
// keepaliveAgent.getCurrentStatus(),
// );
// }
// if (keepaliveHttpsAgent.statusChanged) {
// console.log(
// '[%s] https agent status changed: %j',
// new Date(),
// keepaliveHttpsAgent.getCurrentStatus(),
// );
// }
// }, 1000);
}
// [end patch SWO-6317]
/**
* Calls a function that can return a promise, and wait for the result
* We are currently not interested in the result, only if we need to wait
*
* @private
* @param fn
* @param args
* @return {*}
*/
const callHook = (fn, args) => {
// don't fail, just continue
if (!fn) {
return Promise.resolve();
}
// call function
const result = fn(...args);
// if promise is returned, return the promise to wait for
if (result && typeof result.then === 'function') {
return result;
}
// else, return a resolved promise
return Promise.resolve();
};
/**
* Returns object with parsed max-age value and public form response
* */
const getAllowCacheHeaders = res => {
const cacheControl = res.headers.get('cache-control') || '';
const maxAge = parseInt((/max-age=(\d+),?/g.exec(cacheControl) || [])[1], 10);
return {
maxAge: isNaN(maxAge) ? null : maxAge,
hasPublic: cacheControl.includes('public'),
hasPrivate: cacheControl.includes('private'),
};
};
/**
* The Gateway class is used to communicate to the backend.
*
* The Gateway uses isomorphic-fetch to execute the XHR requests, so both client and server usage
* is supported.
*
* Setup
* -----
* The Gateway is created like this:
*
* ```
* this.gateway = new Gateway({
* // the base url
* url: 'http://www.example.com/api/v1/',
* mode: 'cors', // cors, no-cors, or same-origin
* // can be changed to other handles to support different kind of communication structures
* outputHandler: new RESTOutputHandler(),
* inputHandler: new RESTInputHandler(),
* // can be used to capture errors that happen on each request, and log them somewhere
* onError(error) {
* captureError(error);
* },
* // allows you to alter the request before it is sent, useful for things that rely on
* // 'global state', that you don't want to pass along in each request
* // e.g. adding auth headers
* beforeRequest(options) {
* return options;
* },
* }, true);
* ```
*
* You can pass any of the following options for global configuration:
* - url // the base url for all requests, the request url will be appended here
* - onError // can be used to capture errors that happen on each request, and log them somewhere
* - beforeRequest // allows you to alter the request before it is sent, useful for things that
* // rely on 'global state', that you don't want to pass along in each request
* // e.g. adding auth headers
*
* In addition you can set defaults for the normal request options used in the fetch api:
* https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch
*
* The options passed into the Gateway constructor are merged with the options that can be passed
* for each request.
*
* Examples
* -------
* ```
* // normal get request
* gatewayInstance.get('/subscription-packages/')
* .then(response => console.log(response));
*
* // when needing to add cookies, using the options
* gatewayInstance.get('/antiforgery/', null, { credentials: 'include' })
* .then(response => console.log(response));
*
* // when sending data and adding extra headers
* gatewayInstance.post('/accounts/', data, {
* headers: { 'X-XSRF-Token': response.data },
* credentials: 'include',
* })
* .then(response => console.log(response));
* ```
*/
class Gateway {
cachedCalls = {};
options = {};
/**
* @constructor
* @param {object} options Extends the fetch init options object, this will be merged
* with the same options object that can be specified per call, and passed to fetch when a
* request is done.
*/
constructor(options) {
if (options.useCache && typeof options.defaultMaxAge !== 'number') {
throw new Error('Please specify a defaultMaxAge when setting useCache to true');
}
this.setOptions(options);
this.callQueue = new KeyedTaskQueue();
}
/**
* Returns the passed options
*
* @returns {Object}
*/
getOptions() {
return this.options;
}
/**
* Sets global options
*
* Gateway defaults are managed here
*
* @param options {Object} global gateway options
*/
setOptions(options) {
this.options = extend(
this.options,
{
mode: 'same-origin', // cors, no-cors, or same-origin
redirect: 'follow', // follow, error, or manual
credentials: 'omit', // omit, same-origin, or include
},
options,
);
if (options && !this.options.outputHandler) {
debug('missing property outputHandler in Gateway options, falling back to default behavior');
}
}
/**
* GET shorthand for the execute method
*
* @param {string} action
* @param {any} data
* @param {Object} options_
*/
get(action, data = null, options_ = {}) {
const options = extend(true, { method: HttpMethod.GET }, this.options, options_);
if (this.options.useCache) {
options.cacheKey = options.cacheKey || options.url + action + JSON.stringify(data);
}
const generator = () => {
// check cached call
const p = this.checkCachedCall(options);
if (p) {
debug(`result '${action}' coming from cache!`);
return p;
}
// TODO: do this cleaner
if (data) {
// eslint-disable-next-line no-param-reassign
action += stringify(data, {
indices: false,
addQueryPrefix: true,
});
}
return this.execute(action, null, options);
};
return this.options.useCache ? this.callQueue.queue(generator, options.cacheKey) : generator();
}
/**
* POST shorthand for the execute method
*
* @param {string} action
* @param {any} data
* @param {IGatewayCallOptions} options_
* @return {Promise} A IGatewayResult<any> Promise
*/
post(action, data, options_) {
const options = extend(true, { method: HttpMethod.POST }, this.options, options_);
return this.execute(action, data, options);
}
/**
* PUT shorthand for the execute method
*
* @param {string} action
* @param {any} data
* @param {Object} options_
* @return {Promise} A IGatewayResult<any> Promise
*/
put(action, data, options_) {
const options = extend(true, { method: HttpMethod.PUT }, this.options, options_);
return this.execute(action, data, options);
}
/**
* PATCH shorthand for the execute method
*
* @param {string} action
* @param {any} data
* @param {Object} options_
* @return {Promise} A IGatewayResult<any> Promise
*/
patch(action, data, options_) {
const options = extend(true, { method: HttpMethod.PATCH }, this.options, options_);
return this.execute(action, data, options);
}
/**
* DELETE shorthand for the execute method
*
* @param {string} action
* @param {any} data
* @param {Object} options_
* @return {Promise} A IGatewayResult<any> Promise
*/
delete(action, data = null, options_) {
const options = extend(true, { method: HttpMethod.DELETE }, this.options, options_);
return this.execute(action, data, options);
}
/**
* HEAD shorthand for the execute method
*
* @param {string} action
* @param {any} data
* @param {Object} options_
* @return {Promise} A IGatewayResult<any> Promise
*/
head(action, data = null, options_ = {}) {
const options = { method: HttpMethod.HEAD, ...this.options, ...options_ };
if (data) {
// eslint-disable-next-line no-param-reassign
action += stringify(data, {
indices: false,
addQueryPrefix: true,
});
}
return this.execute(action, null, options);
}
/**
* Executes the gateway request
*
* @param {string} action
* @param {any} data
* @param {Object} options_
* @return {Promise} A IGatewayResult<any> Promise
*/
async execute(action, data = {}, options_) {
debug(`execute: ${action}, ${data}`);
// for when this method was called directly
let options = extend(true, {}, this.options, options_);
// missing url
if (!this.options.url) {
throw new Error('missing property url in Gateway options');
}
// missing request method
if (!options.method) {
throw new Error("Missing HTTP request method, please provide via the 'options.method'");
}
// replace {var} in the url with data.var or options.var, or {action} with the passed action.
options.url = options.url.replace(/\{([^}]+)\}/gi, (result, match) => {
if (match === 'action') {
return action;
}
return data[match] || options[match] || match;
});
// format data
if (options.outputHandler) {
options = options.outputHandler.format(action, data, options);
} else {
options.body = data;
}
debug('options: %o', options);
await callHook(options.beforeRequest, [options]);
const requestPromise = this.doRequest(options);
return requestPromise;
}
// eslint-disable-next-line class-methods-use-this
async doRequest(options) {
const { url, method, mode, redirect, body, headers, credentials } = options;
const fetchOptions = {
method,
mode,
redirect,
body,
headers,
credentials,
};
// [start patch SWO-6317]
if (WP_DEFINE_IS_NODE) {
if (url.startsWith('https')) {
fetchOptions.agent = keepaliveHttpsAgent;
} else {
fetchOptions.agent = keepaliveAgent;
}
}
// [end patch SWO-6317]
const response = await ((fetchPonyfill && fetchPonyfill.fetch) || fetch)(url, fetchOptions);
debug(`response: ${options.url}, ${response.status}`);
const responseText = response.status === 204 ? '' : await response.text();
let parsedResponseData = null;
let responseParseError = false;
if (responseText) {
try {
parsedResponseData = JSON.parse(responseText);
} catch (e) {
responseParseError = true;
}
}
if (!response.ok) {
const error = new GatewayError(
`API responded with statusCode ${response.status} for ${url}`,
options,
response.status,
responseText,
parsedResponseData,
);
options.onError && options.onError(error);
throw error;
}
if (!responseText && response.status !== 204 && response.status !== 202) {
// Call the onError handler to trigger error logging
if (options.onError) {
options.onError(
new GatewayError(
`API responded with statusCode ${response.status} for ${url}`,
options,
response.status,
responseText,
parsedResponseData,
),
);
}
// because the missing body is not necessarily a severe error, just resolve with an empty
// response instead of throwing the error
return responseText;
}
if (responseParseError) {
const error = new GatewayError(
`Error parsing API response (status=${response.status}) to JSON for ${url}`,
options,
response.status,
responseText,
parsedResponseData,
);
options.onError && options.onError(error);
throw error;
}
const allowCacheHeaders = getAllowCacheHeaders(response);
// store result in cache when appropriate
if (
response.status !== 204 &&
parsedResponseData &&
this.options.useCache &&
options.method === HttpMethod.GET &&
typeof allowCacheHeaders.maxAge === 'number' &&
!allowCacheHeaders.hasPrivate &&
allowCacheHeaders.hasPublic
) {
this.cachedCalls[options.cacheKey] = new CachedCall(
options.cacheKey,
JSON.stringify(parsedResponseData), // don't store as ref
allowCacheHeaders.maxAge !== null ? allowCacheHeaders.maxAge : this.options.defaultMaxAge,
);
}
return response.status === 204 ? responseText : parsedResponseData;
}
/**
* Checks if there is a cached call that can be returned instead of doing an actual request.
*
* @private
* @param {Object} options
* @returns {any}
*/
checkCachedCall(options) {
if (!this.options.useCache) {
return null;
}
if (this.cachedCalls[options.cacheKey]) {
const cc = this.cachedCalls[options.cacheKey];
// cache is expired, invalidate cache
if (cc.isExpired()) {
delete this.cachedCalls[options.cacheKey];
} else {
// we hit the cache, return the cached response asynchronously
return new Promise(resolve => {
resolve(JSON.parse(cc.result));
});
}
}
return null;
}
}
export default Gateway;