UNPKG

@qikdev/sdk

Version:

Promise based Javascript SDK

369 lines (297 loc) 11.3 kB
import axios from "axios"; import qs from "qs"; import { version } from "../version.js"; /** * Creates a new instance of Axios, with automatic authentication and token refreshing functionality. * This module provides all the underlying functions for making http requests and interacting with the REST API. * It is a wrapper around the axios library, a popular promise based javascript request package. The api module is used by most other modules in the SDK and will handle authentication, token refreshing and other headers automatically. * @alias api * @constructor * @hideconstructor * @param {QikAPI} qik A reference to the parent instance of the QikCore module. This module is usually created by a QikCore instance that passes itself in as the first argument. */ const CancelToken = axios.CancelToken; /////////////////////////////////////// var QikAPI = function (qik) { var defaultCache; if (typeof window !== "undefined") { defaultCache = qik.cache.get("api"); } // axios 1.x exposes defaults.adapter as an array of adapter names — resolve it to the underlying function const defaultAdapter = axios.getAdapter(axios.defaults.adapter); ///////////////////////////////////////////////////// function getRequestCacheKey(config) { var key = [ config.method, config.url, config.headers?.Authorization, config.headers.Accept, JSON.stringify({ params: config.params, data: config.data, }), ] .filter(Boolean) .join("-"); return key; } /////////////////////////////////////// const inflightRequests = {}; let cacheAdapter = function (config) { var cacheKey = getRequestCacheKey(config); // If there is already an identical request being made if (!inflightRequests[cacheKey]) { // Be more efficient and wait for the existing request inflightRequests[cacheKey] = new Promise(function (resolve, reject) { var useCache; var cachedResponse; /////////////////////////////////////// switch (String(config.method).toLowerCase()) { case "post": case "patch": case "put": case "delete": //Unless we've specified we want a cache if (!config.cache) { //Don't use the cache config.cache = false; } break; } /////////////////////////////////////// if (config.cache === false) { //No cache so make new request } else { //Use the cache specified or the default cache useCache = config.cache || defaultCache; //If there is a cache if (useCache) { //If we have the cachedResponse version cachedResponse = useCache.get(cacheKey); } } /////////////////////////////////////// if (cachedResponse) { return resolve(cachedResponse); } var copy = Object.assign(config, { adapter: defaultAdapter }); return axios.request(config).then( function (res) { delete inflightRequests[cacheKey]; resolve(res); }, function (err) { delete inflightRequests[cacheKey]; reject(err); }, ); }); } return inflightRequests[cacheKey]; }; ////////////////////////////////////////////////////////////////////////////// const service = createNewAxios(cacheAdapter); ////////////////////////////////////////////////////////////////////////////// /** * * Generate an Endpoint URL, complete with authentication. * this function is helpful for generating authenticated links for the user to open in another window * @alias api.generateEndpointURL * @param {String} endpoint The endpoint to generate a url for * @param {Object} params Extra parameters for the url * @param {Object} options Additional request configuration * @param {Object} options.withoutToken Don't append the current user's access token to the url * @param {Object} options.file Whether the response for this request is expected to be a binary file type * @example * * * const result = sdk.api.generateEndpointURL('/image/61eca4746971e75c1fc670cf', { * w:100, * h:50, * }, {file:true}) * * // Would return something similar to: * // https://api.qik.dev/image/61eca4746971e75c1fc670cf?w=100&h=50&access_token=XXXX... */ service.generateEndpointURL = function (endpoint, params, options) { options = options || {}; params = params || {}; var token; const apiURL = options.file ? qik.fileAPI || qik.apiURL : qik.apiURL; //Append the access token to the url if (!options.withoutToken) { delete params.access_token; token = qik.auth.getCurrentToken(); } var stripLeadingTag = endpoint[0] == "/" ? endpoint.slice(1) : endpoint; var parameterString = qik.utils.mapParameters(params); if (token) { parameterString = parameterString ? `?access_token=${token}&${parameterString}` : `?access_token=${token}`; } else { parameterString = parameterString ? `?${parameterString}` : ""; } var url = `${apiURL}${endpoint}${parameterString}`; return url; }; ////////////////////////////////////////////////////////////////////////////// service.wasCancelled = function (err) { return axios.isCancel(err); }; ////////////////////////////////////////////////////////////////////////////// function createNewAxios(adapter) { var instance = axios.create({ paramsSerializer: { serialize: (params) => qs.stringify(params, { arrayFormat: "repeat" }), }, adapter, }); /////////////////////////////////////// instance.defaults.baseURL = qik.apiURL; instance.defaults.headers.common.Accept = "application/json"; ///////////////////////////////////////////////////// // Add relative date and timezone to every request instance.interceptors.request.use(function (config) { const inflightKey = getRequestCacheKey(config); if (config.withoutToken) { return config; } config.headers["qik-request-date"] = new Date().getTime(); if (Intl) { const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; if (tz) { config.headers["qik-request-timezone"] = tz; } } config.headers["qik-api-version"] = version; // Include the socket window id in the request if (qik?.socket?.windowID) { config.headers["qik-socket-window"] = qik.socket.windowID; } var token = qik.auth.getCurrentToken(); if (token) { config.headers["Authorization"] = `Bearer ${token}`; if (config.params && config.params.access_token) { delete config.params.access_token; } } return config; }); ///////////////////////////////////////////////////// let retryCount = 0; instance.interceptors.response.use( function (response) { var config = response.config; //Get the response status var status = response.status; switch (status) { case 204: // No content give it another try if (retryCount < 5) { retryCount++; // Wait a second and try again return new Promise(function (resolve, reject) { setTimeout(function () { return instance.request(config).then(resolve, reject); }, 800); }); } else { console.log("Failed after 5 retries"); retryCount = 0; } break; } return response; }, function (err) { if (axios.isCancel(err)) { return Promise.reject(err); } //Get the response status var status = err?.response?.status || err.status; //Check the status switch (status) { case 401: //Ignore let QikAuth handle it break; case 429: { // Rate-limited by the server's fairness/concurrency limiter. It // sends Retry-After (seconds); honour it — with jitter so a burst // of 429s doesn't retry in lockstep and re-trip the limit — and // retry a few times rather than surfacing the error. Retry state // lives on the request config, NOT the shared retryCount above, // because 429s arrive in concurrent bursts that would corrupt a // single shared counter. var config429 = err.config || {}; config429.__retry429 = (config429.__retry429 || 0) + 1; if (config429.__retry429 <= 3) { var retryAfterHeader = parseInt( err?.response?.headers?.["retry-after"], 10, ); var backoffMs = Number.isFinite(retryAfterHeader) ? retryAfterHeader * 1000 : 800 * config429.__retry429; var jitterMs = Math.floor(Math.random() * 500); return new Promise(function (resolve, reject) { setTimeout(function () { return instance.request(config429).then(resolve, reject); }, backoffMs + jitterMs); }); } break; } case 502: case 504: if (retryCount < 5) { retryCount++; // Wait a second and try again return new Promise(function (resolve, reject) { setTimeout(function () { return instance.request(err.config).then(resolve, reject); }, 800); }); } else { console.log("Failed after 5 retries"); retryCount = 0; } break; case 404: //Not found break; default: break; } ///////////////////////////////////////////////////// return Promise.reject(err); }, ); return instance; } ///////////////////////////////////////////////////// function retrieveIDs(data) { const text = typeof data === "string" ? data : JSON.stringify(data); const matches = text.match(/\b[0-9a-f]{24}\b/gi); return matches ? [...new Set(matches)] : []; } /////////////////////////////////////// /** * * Reference to the underlying axios package (https://www.npmjs.com/package/axios) * Useful for creating new instances of axios if you want to create http requests to * external APIs and not send tokens etc.. * @name api.axios * @example * const newRequest = await sdk.api.axios.get('https://otherapi.com/content/61eca4746971e75c1fc670cf'); */ service.CancelToken = CancelToken; service.axios = axios; /////////////////////////////////////// return service; }; /////////////////////////////////////// /////////////////////////////////////// /////////////////////////////////////// export { CancelToken as CancelToken }; export default QikAPI;