UNPKG

box-ui-elements-mlh

Version:
521 lines (466 loc) 16.5 kB
/** * @flow * @file Network utilities * @author Box */ import axios from 'axios'; import type { $AxiosError, $AxiosXHR } from 'axios'; import getProp from 'lodash/get'; import includes from 'lodash/includes'; import lowerCase from 'lodash/lowerCase'; import TokenService from './TokenService'; import { HEADER_ACCEPT, HEADER_ACCEPT_LANGUAGE, HEADER_CLIENT_NAME, HEADER_CLIENT_VERSION, HEADER_CONTENT_TYPE, HTTP_GET, HTTP_POST, HTTP_PUT, HTTP_DELETE, HTTP_OPTIONS, HTTP_HEAD, HTTP_STATUS_CODE_RATE_LIMIT, } from '../constants'; import type { APIOptions, Method, PayloadType, RequestData } from '../common/types/api'; import type { StringAnyMap, StringMap, Token } from '../common/types/core'; const DEFAULT_UPLOAD_TIMEOUT_MS = 120000; const MAX_NUM_RETRIES = 3; const RETRYABLE_HTTP_METHODS = [HTTP_GET, HTTP_OPTIONS, HTTP_HEAD].map(lowerCase); class Xhr { id: ?string; axios: Axios; axiosSource: CancelTokenSource; clientName: ?string; language: ?string; token: Token; version: ?string; sharedLink: ?string; sharedLinkPassword: ?string; xhr: XMLHttpRequest; responseInterceptor: Function; requestInterceptor: ?Function; tokenService: TokenService; retryCount: number = 0; retryableStatusCodes: Array<number>; retryTimeout: ?TimeoutID; shouldRetry: boolean; /** * [constructor] * * @param {Object} options * @param {string} options.id - item id * @param {string} options.clientName - Client Name * @param {string|function} options.token - Auth token * @param {string} [options.language] - Accept-Language header value * @param {string} [options.sharedLink] - Shared link * @param {string} [options.sharedLinkPassword] - Shared link password * @param {string} [options.requestInterceptor] - Request interceptor * @param {string} [options.responseInterceptor] - Response interceptor * @param {number[]} [options.retryableStatusCodes] - Response codes to retry * @param {boolean} [options.shouldRetry] - Should retry failed requests * @return {Xhr} Cache instance */ constructor({ id, clientName, language, token, version, sharedLink, sharedLinkPassword, responseInterceptor, requestInterceptor, retryableStatusCodes = [HTTP_STATUS_CODE_RATE_LIMIT], shouldRetry = true, }: APIOptions = {}) { this.clientName = clientName; this.id = id; this.language = language; this.responseInterceptor = responseInterceptor || this.defaultResponseInterceptor; this.retryableStatusCodes = retryableStatusCodes; this.sharedLink = sharedLink; this.sharedLinkPassword = sharedLinkPassword; this.shouldRetry = shouldRetry; this.token = token; this.version = version; this.axios = axios.create(); this.axiosSource = axios.CancelToken.source(); this.axios.interceptors.response.use(this.responseInterceptor, this.errorInterceptor); if (typeof requestInterceptor === 'function') { this.axios.interceptors.request.use(requestInterceptor); } } /** * Default response interceptor which just returns the response * * @param {Object} response - the axios response * @return the response */ defaultResponseInterceptor(response: $AxiosXHR<any>) { return response; } /** * Determines if a request should be retried * * @param {Object} error - Error object from axios * @return {boolean} true if the request should be retried */ shouldRetryRequest(error: $AxiosError<any>): boolean { if (!this.shouldRetry || this.retryCount >= MAX_NUM_RETRIES) { return false; } const { response, request, config } = error; // Retry if there is a network error (e.g. ECONNRESET) or rate limited const status = getProp(response, 'status'); const method = getProp(config, 'method'); const isNetworkError = request && !response; const isRateLimitError = status === HTTP_STATUS_CODE_RATE_LIMIT; const isOtherRetryableError = includes(this.retryableStatusCodes, status) && includes(RETRYABLE_HTTP_METHODS, method); return isNetworkError || isRateLimitError || isOtherRetryableError; } /** * Calculate the exponential backoff time with randomized jitter. * * @param {number} numRetries Which retry number this one will be. Must be > 0 * @returns {number} The number of milliseconds after which to retry */ getExponentialRetryTimeoutInMs(numRetries: number): number { const randomizationMs = Math.ceil(Math.random() * 1000); const exponentialMs = 2 ** (numRetries - 1) * 1000; return exponentialMs + randomizationMs; } /** * Error interceptor that wraps the passed in responseInterceptor * * @param {Object} error - Error object from axios * @return {Promise} rejected promise with error info */ errorInterceptor = (error: $AxiosError<any>): Promise<any> => { const shouldRetry = this.shouldRetryRequest(error); if (shouldRetry) { this.retryCount += 1; const delay = this.getExponentialRetryTimeoutInMs(this.retryCount); return new Promise((resolve, reject) => { this.retryTimeout = setTimeout(() => { this.axios(error.config).then(resolve, reject); }, delay); }); } const errorObject = getProp(error, 'response.data') || error; // In the case of 401, response.data is empty so fall back to error this.responseInterceptor(errorObject); return Promise.reject(error); }; /** * Utility to parse a URL. * * @param {string} url - Url to parse * @return {Object} parsed url */ getParsedUrl(url: string) { const a = document.createElement('a'); a.href = url; return { api: url.replace(`${a.origin}/2.0`, ''), host: a.host, hostname: a.hostname, pathname: a.pathname, origin: a.origin, protocol: a.protocol, hash: a.hash, port: a.port, }; } /** * Builds a list of required XHR headers. * * @param {string} [id] - Optional box item id * @param {Object} [args] - Optional existing headers * @return {Object} Headers */ async getHeaders(id?: string, args: StringMap = {}) { const headers: StringMap = { Accept: 'application/json', [HEADER_CONTENT_TYPE]: 'application/json', ...args, }; if (this.language && !headers[HEADER_ACCEPT_LANGUAGE]) { headers[HEADER_ACCEPT_LANGUAGE] = this.language; } if (this.sharedLink) { headers.BoxApi = `shared_link=${this.sharedLink}`; if (this.sharedLinkPassword) { headers.BoxApi = `${headers.BoxApi}&shared_link_password=${this.sharedLinkPassword}`; } } if (this.clientName) { headers[HEADER_CLIENT_NAME] = this.clientName; } if (this.version) { headers[HEADER_CLIENT_VERSION] = this.version; } // If id is passed in, use that, otherwise default to this.id const itemId = id || this.id || ''; const token = await TokenService.getWriteToken(itemId, this.token); if (token) { // Only add a token when there was one found headers.Authorization = `Bearer ${token}`; } return headers; } /** * HTTP GETs a URL * * @param {string} id - Box item id * @param {string} url - The URL to fetch * @param {Object} [headers] - Key-value map of headers * @param {Object} [params] - Key-value map of querystring params * @return {Promise} - HTTP response */ get({ url, id, params = {}, headers = {}, }: { headers?: StringMap, id?: string, params?: StringAnyMap, url: string, }): Promise<StringAnyMap> { return this.getHeaders(id, headers).then(hdrs => this.axios.get(url, { cancelToken: this.axiosSource.token, params, headers: hdrs, parsedUrl: this.getParsedUrl(url), }), ); } /** * HTTP POSTs a URL with JSON data * * @param {string} id - Box item id * @param {string} url - The URL to fetch * @param {Object} data - JS Object representation of JSON data to send * @param {Object} params - Optional query params for the request * @param {Object} [headers] - Key-value map of headers * @param {string} [method] - xhr type * @return {Promise} - HTTP response */ post({ url, id, data, params, headers = {}, method = HTTP_POST, }: { data: PayloadType, headers?: StringMap, id?: string, method?: Method, params?: StringAnyMap, url: string, }): Promise<StringAnyMap> { return this.getHeaders(id, headers).then(hdrs => this.axios({ url, data, params, method, parsedUrl: this.getParsedUrl(url), headers: hdrs, }), ); } /** * HTTP PUTs a URL with JSON data * * @param {string} id - Box item id * @param {string} url - The URL to fetch * @param {Object} data - JS Object representation of JSON data to send * @param {Object} params - Optional query params for the request * @param {Object} [headers] - Key-value map of headers * @return {Promise} - HTTP response */ put({ url, id, data, params, headers = {} }: RequestData): Promise<StringAnyMap> { return this.post({ id, url, data, params, headers, method: HTTP_PUT }); } /** * HTTP DELETEs a URL with JSON data * * @param {string} id - Box item id * @param {string} url - The URL to fetch * @param {Object} data - JS Object representation of JSON data to send * @param {Object} [headers] - Key-value map of headers * @return {Promise} - HTTP response */ delete({ url, id, data = {}, headers = {}, }: { data?: StringAnyMap, headers?: StringMap, id?: string, url: string, }): Promise<StringAnyMap> { return this.post({ id, url, data, headers, method: HTTP_DELETE }); } /** * HTTP OPTIONs a URL with JSON data. * * @param {string} id - Box item id * @param {string} url - The URL to post to * @param {Object} data - The non-file post data that should accompany the post * @param {Object} [headers] - Key-value map of headers * @param {Function} successHandler - Load success handler * @param {Function} errorHandler - Error handler * @return {void} */ options({ id, url, data, headers = {}, successHandler, errorHandler, }: { data: StringAnyMap, errorHandler: Function, headers?: StringMap, id?: string, progressHandler?: Function, successHandler: Function, url: string, }): Promise<StringAnyMap> { return this.getHeaders(id, headers) .then(hdrs => this.axios({ url, data, method: HTTP_OPTIONS, headers: hdrs, }) .then(successHandler) .catch(errorHandler), ) .catch(errorHandler); } /** * HTTP POST or PUT a URL with File data. Uses native XHR for progress event. * * @param {string} id - Box item id * @param {string} url - The URL to post to * @param {Object} [data] - File data and attributes * @param {Object} [headers] - Key-value map of headers * @param {string} [method] - XHR method, supports 'POST' and 'PUT' * @param {Function} successHandler - Load success handler * @param {Function} errorHandler - Error handler * @param {Function} progressHandler - Progress handler * @param {boolean} [withIdleTimeout] - enable idle timeout * @param {number} [idleTimeoutDuration] - idle timeout duration * @param {Function} [idleTimeoutHandler] * @return {void} */ uploadFile({ id, url, data, headers = {}, method = HTTP_POST, successHandler, errorHandler, progressHandler, withIdleTimeout = false, idleTimeoutDuration = DEFAULT_UPLOAD_TIMEOUT_MS, idleTimeoutHandler, }: { data?: ?Blob | ?StringAnyMap, errorHandler: Function, headers?: StringMap, id?: string, idleTimeoutDuration?: number, idleTimeoutHandler?: Function, method?: Method, progressHandler: Function, successHandler: Function, url: string, withIdleTimeout?: boolean, }): Promise<any> { return this.getHeaders(id, headers) .then(hdrs => { let idleTimeout; let progressHandlerToUse = progressHandler; if (withIdleTimeout) { // Func that aborts upload and executes timeout callback const idleTimeoutFunc = () => { this.abort(); if (idleTimeoutHandler) { idleTimeoutHandler(); } }; idleTimeout = setTimeout(idleTimeoutFunc, idleTimeoutDuration); // Progress handler that aborts upload if there has been no progress for >= timeoutMs progressHandlerToUse = event => { clearTimeout(idleTimeout); idleTimeout = setTimeout(idleTimeoutFunc, idleTimeoutDuration); progressHandler(event); }; } this.axios({ url, data, transformRequest: (reqData, reqHeaders) => { // Remove Accept & Content-Type added by getHeaders() delete reqHeaders[HEADER_ACCEPT]; delete reqHeaders[HEADER_CONTENT_TYPE]; if (headers[HEADER_CONTENT_TYPE]) { reqHeaders[HEADER_CONTENT_TYPE] = headers[HEADER_CONTENT_TYPE]; } // Convert to FormData if needed if (reqData && !(reqData instanceof Blob) && reqData.attributes) { const formData = new FormData(); Object.keys(reqData).forEach(key => { formData.append(key, reqData[key]); }); return formData; } return reqData; }, method, headers: hdrs, onUploadProgress: progressHandlerToUse, cancelToken: this.axiosSource.token, }) .then(response => { clearTimeout(idleTimeout); successHandler(response); }) .catch(error => { clearTimeout(idleTimeout); errorHandler(error); }); }) .catch(errorHandler); } /** * Aborts an axios request. * * @return {void} */ abort(): void { if (this.retryTimeout) { clearTimeout(this.retryTimeout); } if (this.axiosSource) { this.axiosSource.cancel(); this.axiosSource = axios.CancelToken.source(); } } } export default Xhr;