UNPKG

kod-temp

Version:

Workano Communications SDK

207 lines (206 loc) 8.21 kB
import { Base64 } from 'js-base64'; import BadResponse from '../domain/BadResponse'; import ServerError from '../domain/ServerError'; import IssueReporter from '../service/IssueReporter'; const methods = ['head', 'get', 'post', 'put', 'delete', 'options']; const logger = IssueReporter ? IssueReporter.loggerFor('api') : console; const REQUEST_TIMEOUT_MS = 300 * 1000; // 300s like the Chrome engine default value. export default class ApiRequester { server; agent; clientId; token; tenant; fetchOptions; refreshTokenCallback; refreshTokenPromise; shouldLogErrors; requestTimeout; head; get; post; put; delete; options; static successResponseParser(response) { return response.status === 204 || response.status === 201 || response.status === 200; } static defaultParser(response) { return response.json().then((data) => ({ ...data, _headers: response.headers })); } static getQueryString(obj) { return Object.keys(obj).filter(key => obj[key]).map(key => `${key}=${encodeURIComponent(obj[key])}`).join('&'); } static base64Encode(str) { return typeof btoa !== 'undefined' ? btoa(str) : Base64.encode(str); } // @see https://github.com/facebook/flow/issues/183#issuecomment-358607052 constructor({ server, refreshTokenCallback, clientId, agent = null, token = null, fetchOptions, requestTimeout = REQUEST_TIMEOUT_MS, }) { this.server = server; this.agent = agent; this.clientId = clientId; this.refreshTokenCallback = refreshTokenCallback; this.refreshTokenPromise = null; this.fetchOptions = fetchOptions || {}; this.requestTimeout = requestTimeout || REQUEST_TIMEOUT_MS; if (token) { this.token = token; } this.shouldLogErrors = true; methods.forEach(method => { // @ts-ignore this[method] = function sugar(...args) { // Add method in arguments passed to `call` args.splice(1, 0, method); // @ts-ignore return this.call.call(this, ...args); }; }); } setRequestTimeout(requestTimeout) { this.requestTimeout = requestTimeout; } setTenant(tenant) { this.tenant = tenant; } setToken(token) { this.token = token; } setFetchOptions(options) { this.fetchOptions = options; } disableErrorLogging() { this.shouldLogErrors = false; } enableErrorLogging() { this.shouldLogErrors = true; } async call(path, method = 'get', body = null, headers = null, parse = ApiRequester.defaultParser, firstCall = true) { const url = this.computeUrl(method, path, body); const newHeaders = this.getHeaders(headers); let newBody = method === 'get' ? null : body; if (newBody && newHeaders['Content-Type'] === 'application/json') { newBody = JSON.stringify(newBody); } const isHead = method === 'head'; const hasEmptyResponse = method === 'delete' || isHead; const newParse = hasEmptyResponse && parse === ApiRequester.defaultParser ? ApiRequester.successResponseParser : parse; const fetchOptions = { ...(this.fetchOptions || {}) }; const controller = new AbortController(); const extraHeaders = fetchOptions.headers || {}; delete fetchOptions.headers; const options = { method, body: newBody, signal: controller ? controller.signal : null, headers: { ...this.getHeaders(headers), ...extraHeaders, }, agent: this.agent, ...fetchOptions, }; if (this.refreshTokenPromise) { logger.info('A token is already refreshing, waiting ...', { url, }); await this.refreshTokenPromise; } const start = new Date(); const requestPromise = fetch(url, options).then((response) => { const contentType = response.headers.get('content-type') || ''; const isJson = contentType.indexOf('application/json') !== -1; IssueReporter.logRequest(url, options, response, start); // Throw an error only if status >= 400 if ((isHead && response.status >= 500) || (!isHead && response.status >= 400)) { const promise = isJson ? response.json() : response.text(); const exceptionClass = response.status >= 500 ? ServerError : BadResponse; return promise.then(async (err) => { // Check if the token is still valid if (firstCall && this._checkTokenExpired(response, err)) { logger.warn('token expired', { error: err.reason, }); // Replay the call after refreshing the token return this._replayWithNewToken(err, path, method, body, headers, parse); } const error = typeof err === 'string' ? exceptionClass.fromText(err, response.status) : exceptionClass.fromResponse(err, response.status); if (this.shouldLogErrors) { logger.error('API error', error); } throw error; }); } return newParse(response, isJson); }).catch((error) => { if (this.shouldLogErrors) { logger.error('Fetch failed', { url, options, message: error.message, stack: error.stack, }); } throw error; }); const requestTimeout = new Promise((resolve, reject) => { setTimeout(() => { controller.abort(); reject(new Error(`Request timed out after ${this.requestTimeout} ms`)); }, this.requestTimeout); }); return Promise.race([requestPromise, requestTimeout]); } _checkTokenExpired(response, err) { // Special case when authenticating form a token: we got a 404 const isTokenNotFound = response.status === 404 && this._isTokenNotFound(err); return response.status === 401 || isTokenNotFound; } _isTokenNotFound(err) { return err && err.reason && err.reason[0] === 'No such token'; } _replayWithNewToken(err, path, method, body = null, headers = null, parse = undefined) { const isTokenNotFound = this._isTokenNotFound(err); let newPath = path; logger.info('refreshing token', { inProgress: !!this.refreshTokenPromise, }); this.refreshTokenPromise = this.refreshTokenPromise || this.refreshTokenCallback(); return this.refreshTokenPromise?.then(() => { this.refreshTokenPromise = null; logger.info('token refreshed', { isTokenNotFound, }); if (isTokenNotFound) { const pathParts = path.split('/'); pathParts.pop(); pathParts.push(this.token); newPath = pathParts.join('/'); } return this.call(newPath, method, body, headers, parse, false); }).catch(e => { this.refreshTokenPromise = null; throw e; }); } getHeaders(header) { if (header instanceof Object) { return header; } return { 'X-Auth-Token': this.token, ...(this.tenant ? { 'Wazo-Tenant': this.tenant, } : null), Accept: 'application/json', 'Content-Type': 'application/json', }; } computeUrl(method, path, body) { const url = `${this.baseUrl}/${path}`; return method === 'get' && body && Object.keys(body).length ? `${url}?${ApiRequester.getQueryString(body)}` : url; } get baseUrl() { return `https://${this.server}/api`; } }