Source: api.js

/**
 * API
 * @module api
 * @copyright 2015–2020 RewardOps Inc.
 */

const axios = require('axios');
const { isEmpty } = require('lodash');

const auth = require('./auth');
const emitter = require('./emitter');
const urls = require('./urls');
const { log } = require('./utils/logger');

/**
 * Generate extended `Error` object with `message`, `status`, and `result` properties.
 * @param {string} message Error message
 * @param {string|number} status HTTP status code
 * @param {object} result API response body
 *
 * @returns {Error} Returns extended `Error` object.
 * @private
 */
function getError(message, status, result) {
  const error = new Error();
  error.message = message;
  error.status = status;
  error.result = result;
  return error;
}

/**
 * Build a request-like object for compatibility with tests and callbacks
 * @param {object} axiosConfig The axios configuration object
 *
 * @returns {object} Returns an object with { headers, method, uri } properties
 * @private
 */
function buildRequestObj(axiosConfig) {
  // Use axios.getUri() to get the final URL with properly serialized params
  const requestUri = new URL(axios.getUri(axiosConfig));
  return {
    headers: axiosConfig.headers,
    method: axiosConfig.method,
    uri: requestUri, // URL object for test compatibility
  };
}

/**
 * Makes a call to the RewardOps API
 * @param {string} httpMethod The name of the HTTP method
 * @param {object} options API request options.
 * @param {string} options.path The relative path to the API endpoint.
 * @param {object} options.config The config object to use in the API request (usually the result of `RO.config.getAll()`).
 * @param {object} [options.params] A params object to send with the API request.
 *   For `GET` requests these are sent as query params. For other requests they are sent as a JSON body.
 * @param {module:api~requestCallback} callback Callback that handles the response
 *
 * @protected
 */
function apiCall(httpMethod, options, callback) {
  if (options.params && isEmpty(options.params)) {
    delete options.params;
  }

  auth.getToken(options.config, (error, token) => {
    const { apiVersion } = options.config;
    const apiBaseUrlOptions = apiVersion ? { apiVersion } : {};
    const url = urls.getApiBaseUrl(apiBaseUrlOptions) + options.path;

    if (error) {
      callback(error);
    } else {
      log(`Request: ${httpMethod} ${url}`);

      // Convert request options to axios format
      const axiosConfig = {
        method: httpMethod,
        url,
        headers: {
          Authorization: `Bearer ${token}`,
        },
        validateStatus: () => true, // Don't reject on any status code, handle all statuses in .then()
      };

      // Handle query params and body
      if (httpMethod === 'GET' && options.params) {
        axiosConfig.params = options.params;
      } else if (options.params) {
        axiosConfig.data = options.params;
      }

      axios(axiosConfig)
        .then((response) => {
          const { status: statusCode, headers, data: responseBody } = response;
          const meta = {
            request: axiosConfig,
            response: {
              responseBody,
              statusCode,
              headers,
            },
          };

          // Create a request-like object for compatibility with tests
          const requestObj = buildRequestObj(axiosConfig);

          if (statusCode === 401) {
            if (token === auth.token.access_token) {
              /*
               * When the server responds to an API call
               * that the token is invalid, and the token hasn't
               * since been updated, fire an "invalidateToken"
               * event, which deletes the existing token.
               */
              emitter.emit('invalidateToken');
            }

            apiCall(httpMethod, options, callback);
          } else if (responseBody && responseBody.error) {
            log('API Error: {responseBody}', { level: 'error', meta, data: { responseBody } });

            error = getError(responseBody.error, statusCode, responseBody.result);
            callback(error, undefined, undefined, requestObj);
          } else if (responseBody && responseBody.result && responseBody.result.error) {
            log('API Error: {errorDetails}', {
              level: 'error',
              meta,
              data: { errorDetails: responseBody.result.error.detail },
            });

            error = getError(responseBody.result.error.detail, statusCode, responseBody.result);
            callback(error, undefined, undefined, requestObj);
          } else if (['5', '4'].includes(String(statusCode)[0])) {
            /**
             * Handle unexpected errors 5XX or 4XX (i.e. 504: GatewayTimeout)
             */
            log(`API Error: Unknown cause, status ${statusCode}`, { level: 'error', meta });

            error = getError(null, statusCode, null);
            callback(error, undefined, undefined, requestObj);
          } else {
            log(`Success: ${statusCode} ${url}`);

            callback(null, responseBody.result, responseBody, requestObj);
          }
        })
        .catch((err) => {
          const meta = {
            request: axiosConfig,
            response: {
              responseBody: err.response && err.response.data,
              statusCode: (err.response && err.response.status) || 500,
              headers: err.response && err.response.headers,
            },
          };
          log(err, { meta });

          const requestObj = buildRequestObj(axiosConfig);
          callback(err, undefined, undefined, requestObj);
        });
    }
  });
}

/**
 * Enum for HTTP methods
 * @enum {string}
 * @private
 */
const HTTP_METHODS = {
  GET: 'GET',
  POST: 'POST',
  PATCH: 'PATCH',
  DELETE: 'DELETE',
};

/**
 * Container object for API call methods
 * @type {object}
 * @property {module:api~apiCall} get `GET` API call method
 * @property {module:api~apiCall} post `POST` API call method
 * @property {module:api~apiCall} patch `PATCH` API call method
 * @property {module:api~apiCall} delete `DELETE` API call method
 *
 * @see {@link HTTP_METHODS} for more information
 */
const api = {};

/**
 * Higher order function for generating methods on the {@link api} object.
 * @type {object}
 * @private
 */
const generateApiMethod = (httpMethod) => (options, callback) => {
  apiCall(httpMethod, options, callback);
};

/*
 * On module initialization, loop through the HTTP methods to
 * populate the {@link api} object.
 */
Object.keys(HTTP_METHODS).forEach((httpMethod) => {
  api[httpMethod.toLowerCase()] = generateApiMethod(httpMethod);
});

module.exports = api;

/**
 * Callbacks follow the [Node.js error-first callback pattern]{@link https://nodejs.org/api/errors.html#errors_error_first_callbacks}.
 * @callback module:api~requestCallback
 * @param {(Error|null)} error Either an `Error` object or `null` if there's no error
 * @param {object} responseBody The 'result' object from the API response body
 * @param {object} response The full body of the response from the API. This includes pagination details, if present
 * @param {object} [req] The full request object from the request library.
 */