Source

api.js

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

const request = require('request');
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;
}

/**
 * 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 {
      const requestOptions = {
        method: httpMethod,
        url,
        json: true,
        headers: {
          Authorization: `Bearer ${token}`,
        },
      };

      // Some firewalls strip the body of GET requests.
      // To get around this, we send the params for GET
      // requests as query params instead of a JSON body.
      if (httpMethod === 'GET' && options.params) {
        requestOptions.qs = options.params;
      } else if (options.params) {
        requestOptions.body = options.params;
      }

      log(`Request: ${httpMethod} ${url}`);

      // eslint-disable-next-line default-param-last
      request(requestOptions, (err, { statusCode = 500, headers, request: req } = {}, responseBody) => {
        const meta = {
          request: requestOptions,
          response: {
            responseBody,
            statusCode,
            headers,
          },
        };
        if (err) {
          log(err, { meta });

          callback(err, undefined, undefined, req);
        } else 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, req);
        } 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, req);
        } 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, req);
        } else {
          log(`Success: ${statusCode} ${url}`);

          callback(null, responseBody.result, responseBody, req);
        }
      });
    }
  });
}

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