UNPKG

@gooddata/gooddata-js

Version:
346 lines (287 loc) • 11.6 kB
// (C) 2007-2020 GoodData Corporation import isPlainObject from "lodash/isPlainObject"; import isFunction from "lodash/isFunction"; import set from "lodash/set"; import defaults from "lodash/defaults"; import merge from "lodash/merge"; import result from "lodash/result"; import { thisPackage } from "./util"; /** * 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. * @module xhr * @class xhr */ 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; function simulateBeforeSend(url: string, settings: any) { const xhrMockInBeforeSend = { setRequestHeader(key: string, value: string) { set(settings, ["headers", key], value); }, }; if (isFunction(settings.beforeSend)) { settings.beforeSend(xhrMockInBeforeSend, url); } } function enrichSettingWithCustomDomain(originalUrl: string, originalSettings: any, domain: string): any { 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: string, settings: any, sendRequest: (url: string, settings: any) => any, ): Promise<ApiResponse> { const pollingDelay: number = result(settings, "pollDelay"); return new Promise((resolve, reject) => { setTimeout(() => { sendRequest(url, settings).then(resolve, reject); }, pollingDelay); }); } export interface IPackageHeaders { name: string; version: string; } export function originPackageHeaders({ name, version }: IPackageHeaders) { return { "X-GDC-JS-PKG": name, "X-GDC-JS-PKG-VERSION": version, }; } export class ApiError extends Error { constructor(message: string, public cause: any) { super(message); } } export class ApiResponseError extends ApiError { constructor(message: string, public response: any, public responseBody: any) { super(message, null); } } export class ApiNetworkError extends ApiError {} export class ApiResponse { public response: Response; public responseBody: string; constructor(response: Response, responseBody: string) { this.response = response; this.responseBody = responseBody; } get data() { try { return JSON.parse(this.responseBody); } catch (error) { return this.responseBody; } } public getData() { try { return JSON.parse(this.responseBody); } catch (error) { return this.responseBody; } } public getHeaders() { return this.response; } } // 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 { private tokenRequest?: any; constructor(private fetch: any, private configStorage: any) { defaults(configStorage, { xhrSettings: {} }); } /** * 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 */ public ajaxSetup(settings: any) { Object.assign(this.configStorage.xhrSettings, settings); } public async ajax(originalUrl: string, customSettings = {}): Promise<ApiResponse> { // 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 = await response.text(); if (response.status === 401) { // if 401 is in login-request, it means wrong user/password (we wont continue) if (url.indexOf("/gdc/account/login") !== -1) { 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 finalUrl = response.url || 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, responseBody); } /** * Wrapper for xhr.ajax method GET */ public get(url: string, settings?: any) { return this.ajax(url, merge({ method: "GET" }, settings)); } /** * Wrapper for xhr.ajax method HEAD */ public head(url: string, settings?: any) { return this.ajax(url, merge({ method: "HEAD" }, settings)); } /** * Wrapper for xhr.ajax method POST */ public post(url: string, settings?: any) { return this.ajax(url, merge({ method: "POST" }, settings)); } /** * Wrapper for xhr.ajax method PUT */ public put(url: string, settings: any) { return this.ajax(url, merge({ method: "PUT" }, settings)); } /** * Wrapper for xhr.ajax method DELETE */ public del(url: string, settings?: any) { return this.ajax(url, merge({ method: "DELETE" }, settings)); } private createRequestSettings(customSettings: any) { 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; } private continueAfterTokenRequest(url: string, settings: any) { return this.tokenRequest.then( async (response: Response) => { if (!response.ok) { throw new ApiResponseError("Unauthorized", response, null); } this.tokenRequest = null; return this.ajax(url, settings); }, (reason: any) => { this.tokenRequest = null; return reason; }, ); } private async handleUnauthorized(originalUrl: string, originalSettings: any) { // 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, ); 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); } return this.ajax(originalUrl, originalSettings); } private logDeprecatedRestApiCall(deprecatedVersionDetails: string) { // tslint:disable-next-line:no-console console.warn( `The REST API version ${LATEST_REST_API_VERSION} is deprecated (${deprecatedVersionDetails}). ` + "Please migrate your application to use GoodData.UI SDK or @gooddata/gooddata-js package that " + "supports newer version of the API.", ); } private isRestApiDeprecated(responseHeaders: any) { return responseHeaders.has(REST_API_DEPRECATED_VERSION_HEADER); } private verifyRestApiDeprecationStatus(responseHeaders: any) { if (shouldLogDeprecatedRestApiCall && this.isRestApiDeprecated(responseHeaders)) { const deprecatedVersionDetails = responseHeaders.get(REST_API_DEPRECATED_VERSION_HEADER); this.logDeprecatedRestApiCall(deprecatedVersionDetails); shouldLogDeprecatedRestApiCall = false; } } }