Source: api-request.ts

/**
 * @fileoverview A Box API Request
 */

// @NOTE(fschott) 08/05/2014: THIS FILE SHOULD NOT BE ACCESSED DIRECTLY OUTSIDE OF API-REQUEST-MANAGER
// This module is used by APIRequestManager to make requests. If you'd like to make requests to the
// Box API, consider using APIRequestManager instead. {@Link APIRequestManager}

// ------------------------------------------------------------------------------
// Requirements
// ------------------------------------------------------------------------------

import assert from 'assert';
import { EventEmitter } from 'events';
import httpStatusCodes from 'http-status';
import Config from './util/config';
import getRetryTimeout from './util/exponential-backoff';

const request = require('@cypress/request');

// ------------------------------------------------------------------------------
// Typedefs and Callbacks
// ------------------------------------------------------------------------------

// @NOTE(fschott) 08-19-2014: We cannot return the request/response objects directly because they contain loads of extra
// information, unnecessary bloat, circular dependencies, and cause an infinite loop when stringifying.
/**
 * The API response object includes information about the request made and its response. The information attached is a subset
 * of the information returned by the request module, which is too large and complex to be safely handled (contains circular
 * references, errors on serialization, etc.)
 *
 * @typedef {Object} APIRequest~ResponseObject
 * @property {APIRequest~RequestObject} request Information about the request that generated this response
 * @property {int} statusCode The response HTTP status code
 * @property {Object} headers A collection of response headers
 * @property {Object|Buffer|string} [body] The response body. Encoded to JSON by default, but can be a buffer
 *  (if encoding fails or if json encoding is disabled) or a string (if string encoding is enabled). Will be undefined
 *  if no response body is sent.
 */
type APIRequestResponseObject = {
  request: APIRequestRequestObject;
  statusCode: number;
  headers: Record<string, string>;
  body?: object | Buffer | string;
};

// @NOTE(fschott) 08-19-2014: We cannot return the request/response objects directly because they contain loads of extra
// information, unnecessary bloat, circular dependencies, and cause an infinite loop when stringifying.
/**
 * The API request object includes information about the request made. The information attached is a subset of the information
 * of a request module instance, which is too large and complex to be safely handled (contains circular references, errors on
 * serialization, etc.).
 *
 * @typedef {Object} APIRequest~RequestObject
 * @property {Object} uri Information about the request, including host, path, and the full 'href' url
 * @property {string} method The request method (GET, POST, etc.)
 * @property {Object} headers A collection of headers sent with the request
 */

type APIRequestRequestObject = {
  uri: Record<string, any>;
  method: string;
  headers: Record<string, string>;
};

/**
 * The error returned by APIRequest callbacks, which includes any relevent, available information about the request
 * and response. Note that these properties do not exist on stream errors, only errors retuned to the callback.
 *
 * @typedef {Error} APIRequest~Error
 * @property {APIRequest~RequestObject} request Information about the request that generated this error
 * @property {APIRequest~ResponseObject} [response] Information about the response related to this error, if available
 * @property {int} [statusCode] The response HTTP status code
 * @property {boolean} [maxRetriesExceeded] True iff the max number of retries were exceeded. Otherwise, undefined.
 */

type APIRequestError = {
  request: APIRequestRequestObject;
  response?: APIRequestResponseObject;
  statusCode?: number;
  maxRetriesExceeded?: boolean;
};

/**
 * Callback invoked when an APIRequest request is complete and finalized. On success,
 * propagates the relevent response information. An err will indicate an unresolvable issue
 * with the request (permanent failure or temp error response from the server, retried too many times).
 *
 * @callback APIRequest~Callback
 * @param {?APIRequest~Error} err If Error object, API request did not get back the data it was supposed to. This
 *  could be either because of a temporary error, or a more serious error connecting to the API.
 * @param {APIRequest~ResponseObject} response The response returned by an APIRequestManager request
 */
type APIRequestCallback = (
  err?: APIRequestError | null,
  response?: APIRequestResponseObject
) => void;

// ------------------------------------------------------------------------------
// Private
// ------------------------------------------------------------------------------

// Message to replace removed headers with in the request
var REMOVED_HEADER_MESSAGE = '[REMOVED BY SDK]';

// Range of SERVER ERROR http status codes
var HTTP_STATUS_CODE_SERVER_ERROR_BLOCK_RANGE = [500, 599];

// Timer used to track elapsed time beginning from executing an async request to emitting the response.
var asyncRequestTimer: [number, number];

// A map of HTTP status codes and whether or not they can be retried
var retryableStatusCodes: Record<number, boolean> = {};
retryableStatusCodes[httpStatusCodes.REQUEST_TIMEOUT] = true;
retryableStatusCodes[httpStatusCodes.TOO_MANY_REQUESTS] = true;

/**
 * Returns true if the response info indicates a temporary/transient error.
 *
 * @param {?APIRequest~ResponseObject} response The response info from an API request,
 * or undefined if the API request did not return any response info.
 * @returns {boolean} True if the API call error is temporary (and hence can
 * be retried). False otherwise.
 * @private
 */
function isTemporaryError(response: APIRequestResponseObject) {
  var statusCode = response.statusCode;

  // An API error is a temporary/transient if it returns a 5xx HTTP Status, with the exception of the 507 status.
  // The API returns a 507 error when the user has run out of account space, in which case, it should be treated
  // as a permanent, non-retryable error.
  if (
    statusCode !== httpStatusCodes.INSUFFICIENT_STORAGE &&
    statusCode >= HTTP_STATUS_CODE_SERVER_ERROR_BLOCK_RANGE[0] &&
    statusCode <= HTTP_STATUS_CODE_SERVER_ERROR_BLOCK_RANGE[1]
  ) {
    return true;
  }

  // An API error is a temporary/transient error if it returns a HTTP Status that indicates it is a temporary,
  if (retryableStatusCodes[statusCode]) {
    return true;
  }

  return false;
}

function isClientErrorResponse(response: { statusCode: number }) {
  if (!response || typeof response !== 'object') {
    throw new Error(
      `Expecting response to be an object, got: ${String(response)}`
    );
  }
  const { statusCode } = response;
  if (typeof statusCode !== 'number') {
    throw new Error(
      `Expecting status code of response to be a number, got: ${String(
        statusCode
      )}`
    );
  }
  return 400 <= statusCode && statusCode < 500;
}

function createErrorForResponse(response: { statusCode: number }): Error {
  var errorMessage = `${response.statusCode} - ${
    (httpStatusCodes as any)[response.statusCode]
  }`;
  return new Error(errorMessage);
}

/**
 * Determine whether a given request can be retried, based on its options
 * @param {Object} options The request options
 * @returns {boolean} Whether or not the request is retryable
 * @private
 */
function isRequestRetryable(options: Record<string, any>) {
  return !options.formData;
}

/**
 * Clean sensitive headers from the request object. This prevents this data from
 * propagating out to the SDK and getting unintentionally logged via the error or
 * response objects. Note that this function modifies the given object and returns
 * nothing.
 *
 * @param {APIRequest~RequestObject} requestObj Any request object
 * @returns {void}
 * @private
 */
function cleanSensitiveHeaders(requestObj: APIRequestRequestObject) {
  if (requestObj.headers) {
    if (requestObj.headers.BoxApi) {
      requestObj.headers.BoxApi = REMOVED_HEADER_MESSAGE;
    }
    if (requestObj.headers.Authorization) {
      requestObj.headers.Authorization = REMOVED_HEADER_MESSAGE;
    }
  }
}

// ------------------------------------------------------------------------------
// Public
// ------------------------------------------------------------------------------

/**
 * APIRequest helps to prepare and execute requests to the Box API. It supports
 * retries, multipart uploads, and more.
 *

 * @param {Config} config Request-specific Config object
 * @param {EventEmitter} eventBus Event bus for the SDK instance
 * @constructor
 */
class APIRequest {
  config: Config;
  eventBus: EventEmitter;
  isRetryable: boolean;

  _callback?: APIRequestCallback;
  request?: any; // request.Request;
  stream?: any; // request.Request;
  numRetries?: number;

  constructor(config: Config, eventBus: EventEmitter) {
    assert(
      config instanceof Config,
      'Config must be passed to APIRequest constructor'
    );
    assert(
      eventBus instanceof EventEmitter,
      'Valid event bus must be passed to APIRequest constructor'
    );
    this.config = config;
    this.eventBus = eventBus;
    this.isRetryable = isRequestRetryable(config.request);
  }

  /**
   * Executes the request with the given options. If a callback is provided, we'll
   * handle the response via callbacks. Otherwise, the response will be streamed to
   * via the stream property. You can access this stream with the getResponseStream()
   * method.
   *
   * @param {APIRequest~Callback} [callback] Callback for handling the response
   * @returns {void}
   */
  execute(callback?: APIRequestCallback) {
    this._callback = callback || this._callback;

    // Initiate an async- or stream-based request, based on the presence of the callback.
    if (this._callback) {
      // Start the request timer immediately before executing the async request
      if (!asyncRequestTimer) {
        asyncRequestTimer = process.hrtime();
      }
      this.request = request(
        this.config.request,
        this._handleResponse.bind(this)
      );
    } else {
      this.request = request(this.config.request);
      this.stream = this.request;
      this.stream.on('error', (err: any) => {
        this.eventBus.emit('response', err);
      });
      this.stream.on('response', (response: any) => {
        if (isClientErrorResponse(response)) {
          this.eventBus.emit('response', createErrorForResponse(response));
          return;
        }
        this.eventBus.emit('response', null, response);
      });
    }
  }

  /**
   * Return the response read stream for a request. This will be undefined until
   * a stream-based request has been started.
   *
   * @returns {?ReadableStream} The response stream
   */
  getResponseStream() {
    return this.stream;
  }

  /**
   * Handle the request response in the callback case.
   *
   * @param {?Error} err An error, if one occurred
   * @param {Object} [response] The full response object, returned by the request module.
   *  Contains information about the request & response, including the response body itself.
   * @returns {void}
   * @private
   */
  _handleResponse(err?: any /* FIXME */, response?: any /* FIXME */) {
    // Clean sensitive headers here to prevent the user from accidentily using/logging them in prod
    cleanSensitiveHeaders(this.request!);

    // If the API connected successfully but responded with a temporary error (like a 5xx code,
    // a rate limited response, etc.) then this is considered an error as well.
    if (!err && isTemporaryError(response)) {
      err = createErrorForResponse(response);
    }

    if (err) {
      // Attach request & response information to the error object
      err.request = this.request;
      if (response) {
        err.response = response;
        err.statusCode = response.statusCode;
      }

      // Have the SDK emit the error response
      this.eventBus.emit('response', err);

      var isJWT = false;
      if (
        this.config.request.hasOwnProperty('form') &&
        this.config.request.form.hasOwnProperty('grant_type') &&
        this.config.request.form.grant_type ===
          'urn:ietf:params:oauth:grant-type:jwt-bearer'
      ) {
        isJWT = true;
      }
      // If our APIRequest instance is retryable, attempt a retry. Otherwise, finish and propagate the error. Doesn't retry when the request is for JWT authentication, since that is handled in retryJWTGrant.
      if (this.isRetryable && !isJWT) {
        this._retry(err);
      } else {
        this._finish(err);
      }

      return;
    }

    // If the request was successful, emit & propagate the response!
    this.eventBus.emit('response', null, response);
    this._finish(null, response);
  }

  /**
   * Attempt a retry. If the request hasn't exceeded it's maximum number of retries,
   * re-execute the request (after the retry interval). Otherwise, propagate a new error.
   *
   * @param {?Error} err An error, if one occurred
   * @returns {void}
   * @private
   */
  _retry(err?: any /* FIXME */) {
    this.numRetries = this.numRetries || 0;

    if (this.numRetries < this.config.numMaxRetries) {
      var retryTimeout;
      this.numRetries += 1;
      // If the retry strategy is defined, then use it to determine the time (in ms) until the next retry or to
      // propagate an error to the user.
      if (this.config.retryStrategy) {
        // Get the total elapsed time so far since the request was executed
        var totalElapsedTime = process.hrtime(asyncRequestTimer);
        var totalElapsedTimeMS =
          totalElapsedTime[0] * 1000 + totalElapsedTime[1] / 1000000;
        var retryOptions = {
          error: err,
          numRetryAttempts: this.numRetries,
          numMaxRetries: this.config.numMaxRetries,
          retryIntervalMS: this.config.retryIntervalMS,
          totalElapsedTimeMS,
        };

        retryTimeout = this.config.retryStrategy(retryOptions);

        // If the retry strategy doesn't return a number/time in ms, then propagate the response error to the user.
        // However, if the retry strategy returns its own error, this will be propagated to the user instead.
        if (typeof retryTimeout !== 'number') {
          if (retryTimeout instanceof Error) {
            err = retryTimeout;
          }
          this._finish(err);
          return;
        }
      } else if (
        err.hasOwnProperty('response') &&
        err.response.hasOwnProperty('headers') &&
        err.response.headers.hasOwnProperty('retry-after')
      ) {
        retryTimeout = err.response.headers['retry-after'] * 1000;
      } else {
        retryTimeout = getRetryTimeout(
          this.numRetries,
          this.config.retryIntervalMS
        );
      }
      setTimeout(this.execute.bind(this), retryTimeout);
    } else {
      err.maxRetriesExceeded = true;
      this._finish(err);
    }
  }

  /**
   * Propagate the response to the provided callback.
   *
   * @param {?Error} err An error, if one occurred
   * @param {APIRequest~ResponseObject} response Information about the request & response
   * @returns {void}
   * @private
   */
  _finish(err?: any, response?: APIRequestResponseObject) {
    var callback = this._callback!;
    process.nextTick(() => {
      if (err) {
        callback(err);
        return;
      }

      callback(null, response);
    });
  }
}

/**
 * @module box-node-sdk/lib/api-request
 * @see {@Link APIRequest}
 */
export = APIRequest;