UNPKG

@gooddata/api-client-bear

Version:
357 lines • 15.2 kB
// (C) 2007-2023 GoodData Corporation import isPlainObject from "lodash/isPlainObject.js"; import isFunction from "lodash/isFunction.js"; import set from "lodash/set.js"; import defaults from "lodash/defaults.js"; import merge from "lodash/merge.js"; import result from "lodash/result.js"; import { stringify } from "./utils/queryString.js"; import { LIB_VERSION, LIB_NAME } from "./__version.js"; /** * Ajax wrapper around GDC authentication mechanisms, SST and TT token handling and polling. * Interface is the same as original jQuery.ajax. * * If token is expired, current request is "paused", token is refreshed and request is retried and result * is transparently returned to the original call. * * Additionally polling is handled. Only final result of polling returned. */ const DEFAULT_POLL_DELAY = 1000; const REST_API_VERSION_HEADER = "X-GDC-VERSION"; const REST_API_DEPRECATED_VERSION_HEADER = "X-GDC-DEPRECATED"; // The version used in X-GDC-VERSION header (see https://confluence.intgdc.com/display/Development/REST+API+versioning) const LATEST_REST_API_VERSION = 5; /** * Gooddata-js package signature * @internal */ export const thisPackage = { name: LIB_NAME, version: LIB_VERSION }; function simulateBeforeSend(url, settings) { const xhrMockInBeforeSend = { setRequestHeader(key, value) { set(settings, ["headers", key], value); }, }; if (isFunction(settings.beforeSend)) { settings.beforeSend(xhrMockInBeforeSend, url); } } function enrichSettingWithCustomDomain(originalUrl, originalSettings, domain) { let url = originalUrl; const settings = originalSettings; if (domain) { // protect url to be prepended with domain on retry if (originalUrl.indexOf(domain) === -1) { url = domain + originalUrl; } settings.mode = "cors"; settings.credentials = "include"; } return { url, settings }; } export function handlePolling(url, // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types settings, sendRequest) { const pollingDelay = result(settings, "pollDelay"); return new Promise((resolve, reject) => { setTimeout(() => { sendRequest(url, settings).then(resolve, reject); }, pollingDelay); }); } export function originPackageHeaders({ name, version }) { return { "X-GDC-JS-PKG": name, "X-GDC-JS-PKG-VERSION": version, }; } export class ApiError extends Error { cause; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types constructor(message, cause) { super(message); this.cause = cause; Object.setPrototypeOf(this, new.target.prototype); } } export class ApiResponseError extends ApiError { response; responseBody; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types constructor(message, response, responseBody) { super(message, null); this.response = response; this.responseBody = responseBody; } } export class ApiNetworkError extends ApiError { } export class ApiResponse { response; responseBody; constructor(response, responseBody) { this.response = response; this.responseBody = responseBody; } get data() { return this.getDataInner(); } getData() { return this.getDataInner(); } getRawData() { return this.responseBody; } getHeaders() { return this.response; } getDataInner() { if (typeof this.responseBody === "string") { try { return JSON.parse(this.responseBody); } catch (error) { throw new Error("Cannot parse responseBody."); } } throw new Error("ArrayBuffer ResponseBody cannot be parsed to requested type."); } } // the variable must be outside of the scope of the XhrModule to not log the message multiple times in SDK and KD let shouldLogDeprecatedRestApiCall = true; export class XhrModule { fetch; configStorage; tokenRequest; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types constructor(fetch, configStorage) { this.fetch = fetch; this.configStorage = configStorage; defaults(configStorage, { xhrSettings: {} }); } /** * Clears the indicator that is making the XHR module to perform token requests before making * the actual call. The module may get into this state typically during the application initialization * when the session is not yet authenticated. * * Calling this method will clear that indicator, ensuring that the next call will be called without * the leading call to token (which will typically fail). */ ensureNoLeadingTokenRequest() { this.tokenRequest = null; } /** * Back compatible method for setting common XHR settings * * Usually in our apps we used beforeSend ajax callback to set the X-GDC-REQUEST header with unique ID. * * @param settings - object XHR settings as */ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types ajaxSetup(settings) { Object.assign(this.configStorage.xhrSettings, settings); } async ajax(originalUrl, customSettings = {}) { // TODO refactor to: getRequestParams(originalUrl, customSettings); const firstSettings = this.createRequestSettings(customSettings); const { url, settings } = enrichSettingWithCustomDomain(originalUrl, firstSettings, this.configStorage.domain); simulateBeforeSend(url, settings); // mutates `settings` param if (this.tokenRequest) { return this.continueAfterTokenRequest(url, settings); } let response; try { // TODO: We should clean up the settings at this point to be pure `RequestInit` object response = await this.fetch(url, settings); } catch (e) { throw new ApiNetworkError(e.message, e); // TODO is it really necessary? couldn't we throw just Error? } // Fetch URL and resolve body promise (if left unresolved, the body isn't even shown in chrome-dev-tools) const responseBody = settings.arrayBufferResponseBody ? await response.arrayBuffer() : await response.text(); if (response.status === 401) { // if 401 is in login-request, it means wrong user/password (we wont continue) const isLoginRequest = url.indexOf("/gdc/account/login") !== -1; // if 401 is in token request already, it makes no sense to try handling unauthorized again by calling the same token endpoint again const isTokenRequest = url.indexOf("/gdc/account/token") !== -1; const shouldSkipUnauthorizedHandling = isLoginRequest || isTokenRequest; if (shouldSkipUnauthorizedHandling) { throw new ApiResponseError("Unauthorized", response, responseBody); } return this.handleUnauthorized(url, settings); } // Note: Fetch does redirects automagically for 301 (and maybe more .. TODO when?) // see https://fetch.spec.whatwg.org/#ref-for-concept-request%E2%91%A3%E2%91%A2 if (response.status === 202 && !settings.dontPollOnResult) { // poll on new provided url, fallback to the original one // (for example validElements returns 303 first with new url which may then return 202 to poll on) let responseJson; try { // If body was parsed as ArrayBuffer, decode it to text. We need to check // if body does not contain JSON response with "uri" used for polling. Before polled task // resolves, request to the task returns JSON body with "uri". When the task resolves, // a binary body is returned. responseJson = JSON.parse(this.responseBodyAsString(responseBody)); } catch (err) { responseJson = {}; } /** * It seems like 'fetch-mock' with Node 18 returns empty string in response.url, * and this issue doesn't happen with real BE. * For better backward compatibility, empty string will be treated as undefined. */ const responseUrl = response.url !== "" ? response.url : undefined; let finalUrl = responseJson.uri ?? responseUrl ?? url; const finalSettings = settings; // if the response is 202 and Location header is not empty, let's poll on the new Location if (response.headers.has("Location")) { finalUrl = response.headers.get("Location"); } finalSettings.method = "GET"; delete finalSettings.data; delete finalSettings.body; return handlePolling(finalUrl, finalSettings, this.ajax.bind(this)); } this.verifyRestApiDeprecationStatus(response.headers); if (response.status >= 200 && response.status <= 399) { return new ApiResponse(response, responseBody); } // throws on 400, 500, etc. throw new ApiResponseError(response.statusText, response, this.responseBodyAsString(responseBody)); } /** * Wrapper for xhr.ajax method GET */ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types get(url, settings) { return this.ajax(url, merge({ method: "GET" }, settings)); } /** * Wrapper for xhr.ajax method GET, returns parsed JSON response body */ getParsed(url, settings = {}) { const { data, ...restSettings } = settings; let urlWithParams = url; if (data) { urlWithParams = `${url}?${stringify(data)}`; } return this.ajax(urlWithParams, merge({ method: "GET" }, restSettings)).then((response) => response.getData()); } /** * Wrapper for xhr.ajax method HEAD */ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types head(url, settings) { return this.ajax(url, merge({ method: "HEAD" }, settings)); } /** * Wrapper for xhr.ajax method POST */ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types post(url, settings) { return this.ajax(url, merge({ method: "POST" }, settings)); } /** * Wrapper for xhr.ajax method POST, returns parsed JSON response body */ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types postParsed(url, settings) { return this.ajax(url, merge({ method: "POST" }, settings)).then((response) => response.getData()); } /** * Wrapper for xhr.ajax method PUT */ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types put(url, settings) { return this.ajax(url, merge({ method: "PUT" }, settings)); } /** * Wrapper for xhr.ajax method DELETE */ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types del(url, settings) { return this.ajax(url, merge({ method: "DELETE" }, settings)); } createRequestSettings(customSettings) { const settings = merge({ headers: { Accept: "application/json; charset=utf-8", "Content-Type": "application/json", [REST_API_VERSION_HEADER]: LATEST_REST_API_VERSION, ...originPackageHeaders(this.configStorage.originPackage || thisPackage), }, }, this.configStorage.xhrSettings, customSettings); settings.pollDelay = settings.pollDelay !== undefined ? settings.pollDelay : DEFAULT_POLL_DELAY; // TODO jquery compat - add to warnings settings.body = settings.data ? settings.data : settings.body; settings.mode = "same-origin"; settings.credentials = "same-origin"; if (isPlainObject(settings.body)) { settings.body = JSON.stringify(settings.body); } return settings; } continueAfterTokenRequest(url, settings) { return this.tokenRequest.then(async (response) => { if (!response.ok) { throw new ApiResponseError("Unauthorized", response, null); } this.tokenRequest = null; return this.ajax(url, settings); }, (reason) => { this.tokenRequest = null; return reason; }); } async handleUnauthorized(originalUrl, originalSettings) { // Create only single token request for any number of waiting request. // If token request exist, just listen for it's end. if (this.tokenRequest) { return this.continueAfterTokenRequest(originalUrl, originalSettings); } const { url, settings } = enrichSettingWithCustomDomain("/gdc/account/token", this.createRequestSettings({}), this.configStorage.domain); simulateBeforeSend(url, settings); // mutates `settings` param this.tokenRequest = this.fetch(url, settings); const response = await this.tokenRequest; const responseBody = await response.text(); this.tokenRequest = null; // TODO jquery compat - allow to attach unauthorized callback and call it if attached // if ((xhrObj.status === 401) && (isFunction(req.unauthorized))) { // req.unauthorized(xhrObj, textStatus, err, deferred); // return; // } // unauthorized handler is not defined or not http 401 // unauthorized when retrieving token -> not logged if (response.status === 401) { throw new ApiResponseError("Unauthorized", response, responseBody); } if (!response.ok) { // other non-2xx errors throw new ApiResponseError(response.statusText, response, responseBody); } return this.ajax(originalUrl, originalSettings); } logDeprecatedRestApiCall(deprecatedVersionDetails) { console.warn(`The REST API version ${LATEST_REST_API_VERSION} is deprecated (${deprecatedVersionDetails}). ` + "Please migrate your application to use GoodData.UI SDK or @gooddata/api-client-bear package that " + "supports newer version of the API."); } isRestApiDeprecated(responseHeaders) { return responseHeaders.has(REST_API_DEPRECATED_VERSION_HEADER); } verifyRestApiDeprecationStatus(responseHeaders) { if (shouldLogDeprecatedRestApiCall && this.isRestApiDeprecated(responseHeaders)) { const deprecatedVersionDetails = responseHeaders.get(REST_API_DEPRECATED_VERSION_HEADER); this.logDeprecatedRestApiCall(deprecatedVersionDetails); shouldLogDeprecatedRestApiCall = false; } } responseBodyAsString(responseBody) { const decoder = new TextDecoder("utf-8"); return responseBody instanceof ArrayBuffer ? decoder.decode(responseBody) : responseBody; } } //# sourceMappingURL=xhr.js.map