Source: app/net/gateway/Gateway.js

/* 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;