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