// (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;
}
}
}