feat(connection): optimize typing and API for SupersetClient (#635)

This commit is contained in:
Jesse Yang 2020-07-02 17:46:54 -07:00 committed by Yongjie Zhao
parent c9cc22ca1e
commit ba8c619c2e
19 changed files with 744 additions and 744 deletions

View File

@ -1,6 +1,6 @@
/* eslint-disable max-classes-per-file */
import React from 'react';
import { QueryFormData } from '@superset-ui/query/src';
import { QueryFormData } from '@superset-ui/query';
import { ChartMetadata, ChartPlugin } from '../../src';
const DIMENSION_STYLE = {

View File

@ -1,5 +1,5 @@
import SupersetClientClass from './SupersetClientClass';
import { ClientConfig, RequestConfig, SupersetClientInterface } from './types';
import { SupersetClientInterface } from './types';
let singletonClient: SupersetClientClass | undefined;
@ -7,28 +7,26 @@ function getInstance(): SupersetClientClass {
if (!singletonClient) {
throw new Error('You must call SupersetClient.configure(...) before calling other methods');
}
return singletonClient;
}
const SupersetClient: SupersetClientInterface = {
configure: (config?: ClientConfig): SupersetClientClass => {
configure: config => {
singletonClient = new SupersetClientClass(config);
return singletonClient;
},
delete: (request: RequestConfig) => getInstance().delete(request),
get: (request: RequestConfig) => getInstance().get(request),
getInstance,
init: (force?: boolean) => getInstance().init(force),
isAuthenticated: () => getInstance().isAuthenticated(),
post: (request: RequestConfig) => getInstance().post(request),
put: (request: RequestConfig) => getInstance().put(request),
reAuthenticate: () => getInstance().init(/* force = */ true),
request: (request: RequestConfig) => getInstance().request(request),
reset: () => {
singletonClient = undefined;
},
getInstance,
delete: request => getInstance().delete(request),
get: request => getInstance().get(request),
init: force => getInstance().init(force),
isAuthenticated: () => getInstance().isAuthenticated(),
post: request => getInstance().post(request),
put: request => getInstance().put(request),
reAuthenticate: () => getInstance().reAuthenticate(),
request: request => getInstance().request(request),
};
export default SupersetClient;

View File

@ -1,4 +1,4 @@
import callApi from './callApi';
import callApiAndParseWithTimeout from './callApi/callApiAndParseWithTimeout';
import {
ClientConfig,
ClientTimeout,
@ -11,7 +11,7 @@ import {
Mode,
Protocol,
RequestConfig,
SupersetClientResponse,
ParseMethod,
} from './types';
import { DEFAULT_FETCH_RETRY_OPTIONS } from './constants';
@ -20,6 +20,7 @@ export default class SupersetClientClass {
csrfToken?: CsrfToken;
csrfPromise?: CsrfPromise;
fetchRetryOptions?: FetchRetryOptions;
baseUrl: string;
protocol: Protocol;
host: Host;
headers: Headers;
@ -27,8 +28,9 @@ export default class SupersetClientClass {
timeout: ClientTimeout;
constructor({
protocol = 'http:',
host = 'localhost',
baseUrl = 'http://localhost',
host,
protocol,
headers = {},
fetchRetryOptions = {},
mode = 'same-origin',
@ -36,52 +38,57 @@ export default class SupersetClientClass {
credentials = undefined,
csrfToken = undefined,
}: ClientConfig = {}) {
const url = new URL(
host || protocol ? `${protocol || 'https:'}//${host || 'localhost'}` : baseUrl,
);
this.baseUrl = url.href.replace(/\/+$/, ''); // always strip trailing slash
this.host = url.host;
this.protocol = url.protocol as Protocol;
this.headers = { ...headers };
this.host = host;
this.mode = mode;
this.timeout = timeout;
this.protocol = protocol;
this.credentials = credentials;
this.csrfToken = csrfToken;
this.csrfPromise = undefined;
this.fetchRetryOptions = { ...DEFAULT_FETCH_RETRY_OPTIONS, ...fetchRetryOptions };
if (typeof this.csrfToken === 'string') {
this.headers = { ...this.headers, 'X-CSRFToken': this.csrfToken };
this.csrfPromise = Promise.resolve(this.csrfToken);
}
}
init(force: boolean = false): CsrfPromise {
async init(force: boolean = false): CsrfPromise {
if (this.isAuthenticated() && !force) {
return this.csrfPromise as CsrfPromise;
}
return this.getCSRFToken();
}
async reAuthenticate() {
return this.init(true);
}
isAuthenticated(): boolean {
// if CSRF protection is disabled in the Superset app, the token may be an empty string
return this.csrfToken !== null && this.csrfToken !== undefined;
}
async get(requestConfig: RequestConfig): Promise<SupersetClientResponse> {
async get<T extends ParseMethod = 'json'>(requestConfig: RequestConfig & { parseMethod?: T }) {
return this.request({ ...requestConfig, method: 'GET' });
}
async delete(requestConfig: RequestConfig): Promise<SupersetClientResponse> {
async delete<T extends ParseMethod = 'json'>(requestConfig: RequestConfig & { parseMethod?: T }) {
return this.request({ ...requestConfig, method: 'DELETE' });
}
async put(requestConfig: RequestConfig): Promise<SupersetClientResponse> {
async put<T extends ParseMethod = 'json'>(requestConfig: RequestConfig & { parseMethod?: T }) {
return this.request({ ...requestConfig, method: 'PUT' });
}
async post(requestConfig: RequestConfig): Promise<SupersetClientResponse> {
async post<T extends ParseMethod = 'json'>(requestConfig: RequestConfig & { parseMethod?: T }) {
return this.request({ ...requestConfig, method: 'POST' });
}
async request({
async request<T extends ParseMethod = 'json'>({
body,
credentials,
endpoint,
@ -97,44 +104,41 @@ export default class SupersetClientClass {
stringify,
timeout,
url,
}: RequestConfig): Promise<SupersetClientResponse> {
return this.ensureAuth().then(() =>
callApi({
body,
credentials: credentials ?? this.credentials,
fetchRetryOptions,
headers: { ...this.headers, ...headers },
method,
mode: mode ?? this.mode,
parseMethod,
postPayload,
jsonPayload,
signal,
stringify,
timeout: timeout ?? this.timeout,
url: this.getUrl({ endpoint, host, url }),
}),
);
}: RequestConfig & { parseMethod?: T }) {
await this.ensureAuth();
return callApiAndParseWithTimeout({
body,
credentials: credentials ?? this.credentials,
fetchRetryOptions,
headers: { ...this.headers, ...headers },
method,
mode: mode ?? this.mode,
parseMethod,
postPayload,
jsonPayload,
signal,
stringify,
timeout: timeout ?? this.timeout,
url: this.getUrl({ endpoint, host, url }),
});
}
ensureAuth(): CsrfPromise {
async ensureAuth(): CsrfPromise {
return (
this.csrfPromise ??
Promise.reject({
error: `SupersetClient has no CSRF token, ensure it is initialized or
try logging into the Superset instance at ${this.getUrl({
endpoint: '/login',
})}`,
error: `SupersetClient has not been provided a CSRF token, ensure it is
initialized with \`client.getCSRFToken()\` or try logging in at
${this.getUrl({ endpoint: '/login' })}`,
})
);
}
async getCSRFToken(): CsrfPromise {
async getCSRFToken() {
this.csrfToken = undefined;
// If we can request this resource successfully, it means that the user has
// authenticated. If not we throw an error prompting to authenticate.
this.csrfPromise = callApi({
this.csrfPromise = callApiAndParseWithTimeout({
credentials: this.credentials,
headers: {
...this.headers,
@ -143,19 +147,19 @@ export default class SupersetClientClass {
mode: this.mode,
timeout: this.timeout,
url: this.getUrl({ endpoint: 'superset/csrf_token/' }),
}).then(response => {
if (typeof response.json === 'object') {
this.csrfToken = response.json.csrf_token as string;
parseMethod: 'json',
}).then(({ json }) => {
if (typeof json === 'object') {
this.csrfToken = json.csrf_token as string;
if (typeof this.csrfToken === 'string') {
this.headers = { ...this.headers, 'X-CSRFToken': this.csrfToken };
}
}
if (!this.isAuthenticated()) {
return Promise.reject({ error: 'Failed to fetch CSRF token' });
if (this.isAuthenticated()) {
return this.csrfToken;
}
return this.csrfToken;
return Promise.reject({ error: 'Failed to fetch CSRF token' });
});
return this.csrfPromise;
}

View File

@ -10,7 +10,7 @@ import { CACHE_AVAILABLE, CACHE_KEY, HTTP_STATUS_NOT_MODIFIED, HTTP_STATUS_OK }
* @param {Payload} jsonPayload json payload to post, will automatically add Content-Type header
* @param {string} stringify whether to stringify field values when post as formData
*/
export default function callApi({
export default async function callApi({
body,
cache = 'default',
credentials = 'same-origin',
@ -40,39 +40,32 @@ export default function callApi({
if (
method === 'GET' &&
cache !== 'no-store' &&
cache !== 'reload' &&
CACHE_AVAILABLE &&
(self.location && self.location.protocol) === 'https:'
) {
return caches.open(CACHE_KEY).then(supersetCache =>
supersetCache
.match(url)
.then(cachedResponse => {
if (cachedResponse) {
// if we have a cached response, send its ETag in the
// `If-None-Match` header in a conditional request
const etag = cachedResponse.headers.get('Etag') as string;
request.headers = { ...request.headers, 'If-None-Match': etag };
}
return fetchWithRetry(url, request);
})
.then(response => {
if (response.status === HTTP_STATUS_NOT_MODIFIED) {
return supersetCache.match(url).then(cachedResponse => {
if (cachedResponse) {
return cachedResponse.clone();
}
throw new Error('Received 304 but no content is cached!');
});
}
if (response.status === HTTP_STATUS_OK && response.headers.get('Etag')) {
supersetCache.delete(url);
supersetCache.put(url, response.clone());
}
return response;
}),
);
const supersetCache = await caches.open(CACHE_KEY);
const cachedResponse = await supersetCache.match(url);
if (cachedResponse) {
// if we have a cached response, send its ETag in the
// `If-None-Match` header in a conditional request
const etag = cachedResponse.headers.get('Etag') as string;
request.headers = { ...request.headers, 'If-None-Match': etag };
}
const response = await fetchWithRetry(url, request);
if (response.status === HTTP_STATUS_NOT_MODIFIED) {
const cachedFullResponse = await supersetCache.match(url);
if (cachedFullResponse) {
return cachedFullResponse.clone();
}
throw new Error('Received 304 but no content is cached!');
}
if (response.status === HTTP_STATUS_OK && response.headers.get('Etag')) {
supersetCache.delete(url);
supersetCache.put(url, response.clone());
}
return response;
}
if (method === 'POST' || method === 'PATCH' || method === 'PUT') {
@ -80,13 +73,13 @@ export default function callApi({
try {
return JSON.parse(payloadString) as JsonObject;
} catch (error) {
throw new Error(`Invalid postPayload:\n\n${payloadString}`);
throw new Error(`Invalid payload:\n\n${payloadString}`);
}
};
// override request body with post payload
const payload: JsonObject | undefined =
typeof postPayload === 'string' ? tryParsePayload(postPayload) : postPayload;
if (typeof payload === 'object') {
// using FormData has the effect that Content-Type header is set to `multipart/form-data`,
// not e.g., 'application/x-www-form-urlencoded'

View File

@ -1,20 +1,17 @@
import callApi from './callApi';
import rejectAfterTimeout from './rejectAfterTimeout';
import parseResponse from './parseResponse';
import { CallApi, ClientTimeout, SupersetClientResponse, ParseMethod } from '../types';
import { CallApi, ClientTimeout, ParseMethod } from '../types';
export default function callApiAndParseWithTimeout({
export default async function callApiAndParseWithTimeout<T extends ParseMethod = 'json'>({
timeout,
parseMethod,
...rest
}: { timeout?: ClientTimeout; parseMethod?: ParseMethod } & CallApi): Promise<
SupersetClientResponse
> {
}: { timeout?: ClientTimeout; parseMethod?: T } & CallApi) {
const apiPromise = callApi(rest);
const racedPromise =
typeof timeout === 'number' && timeout > 0
? Promise.race([rejectAfterTimeout<Response>(timeout), apiPromise])
? Promise.race([apiPromise, rejectAfterTimeout<Response>(timeout)])
: apiPromise;
return parseResponse(racedPromise, parseMethod);

View File

@ -1,26 +1,43 @@
import { ParseMethod, SupersetClientResponse } from '../types';
import { ParseMethod, TextResponse, JsonResponse } from '../types';
function rejectIfNotOkay(response: Response): Promise<Response> {
if (!response.ok) return Promise.reject<Response>(response);
return Promise.resolve<Response>(response);
}
export default function parseResponse(
export default async function parseResponse<T extends ParseMethod = 'json'>(
apiPromise: Promise<Response>,
parseMethod: ParseMethod = 'json',
): Promise<SupersetClientResponse> {
const checkedPromise = apiPromise.then(rejectIfNotOkay);
parseMethod?: T,
) {
type ReturnType = T extends 'raw' | null
? Response
: T extends 'json' | undefined
? JsonResponse
: T extends 'text'
? TextResponse
: never;
const response = await apiPromise;
// reject failed HTTP requests with the raw response
if (!response.ok) {
// eslint-disable-next-line @typescript-eslint/no-throw-literal
throw response;
}
if (parseMethod === null) {
return apiPromise.then(rejectIfNotOkay);
if (parseMethod === null || parseMethod === 'raw') {
return response as ReturnType;
}
if (parseMethod === 'text') {
return checkedPromise.then(response => response.text().then(text => ({ response, text })));
const text = await response.text();
const result: TextResponse = {
response,
text,
};
return result as ReturnType;
}
if (parseMethod === 'json') {
return checkedPromise.then(response => response.json().then(json => ({ json, response })));
// by default treat this as json
if (parseMethod === undefined || parseMethod === 'json') {
const json = await response.json();
const result: JsonResponse = {
json,
response,
};
return result as ReturnType;
}
throw new Error(`Expected parseResponse=null|json|text, got '${parseMethod}'.`);
throw new Error(`Expected parseResponse=json|text|raw|null, got '${parseMethod}'.`);
}

View File

@ -1,6 +1,6 @@
// returns a Promise that rejects after the specified timeout
export default function rejectAfterTimeout<T>(timeout: number): Promise<T> {
return new Promise((resolve, reject) => {
export default function rejectAfterTimeout<T>(timeout: number) {
return new Promise<T>((resolve, reject) => {
setTimeout(
() =>
reject({

View File

@ -44,7 +44,7 @@ export type Method = RequestInit['method'];
export type Mode = RequestInit['mode'];
export type Redirect = RequestInit['redirect'];
export type ClientTimeout = number | undefined;
export type ParseMethod = 'json' | 'text' | null;
export type ParseMethod = 'json' | 'text' | 'raw' | null | undefined;
export type Signal = RequestInit['signal'];
export type Stringify = boolean;
export type Url = string;
@ -57,7 +57,6 @@ export interface RequestBase {
host?: Host;
mode?: Mode;
method?: Method;
parseMethod?: ParseMethod;
postPayload?: Payload;
jsonPayload?: Payload;
signal?: Signal;
@ -84,10 +83,14 @@ export interface RequestWithUrl extends RequestBase {
// this make sure at least one of `url` or `endpoint` is set
export type RequestConfig = RequestWithEndpoint | RequestWithUrl;
export interface JsonTextResponse {
json?: JsonObject;
export interface JsonResponse {
response: Response;
text?: string;
json: JsonObject;
}
export interface TextResponse {
response: Response;
text: string;
}
export type CsrfToken = string;
@ -95,28 +98,25 @@ export type CsrfPromise = Promise<string | undefined>;
export type Protocol = 'http:' | 'https:';
export interface ClientConfig {
baseUrl?: string;
host?: Host;
protocol?: Protocol;
credentials?: Credentials;
csrfToken?: CsrfToken;
fetchRetryOptions?: FetchRetryOptions;
headers?: Headers;
host?: Host;
protocol?: Protocol;
mode?: Mode;
timeout?: ClientTimeout;
}
export interface SupersetClientInterface {
export interface SupersetClientInterface
extends Pick<
SupersetClientClass,
'delete' | 'get' | 'post' | 'put' | 'request' | 'init' | 'isAuthenticated' | 'reAuthenticate'
> {
configure: (config?: ClientConfig) => SupersetClientClass;
delete: (request: RequestConfig) => Promise<SupersetClientResponse>;
get: (request: RequestConfig) => Promise<SupersetClientResponse>;
getInstance: (maybeClient?: SupersetClientClass) => SupersetClientClass;
init: (force?: boolean) => Promise<string | undefined>;
isAuthenticated: () => boolean;
post: (request: RequestConfig) => Promise<SupersetClientResponse>;
put: (request: RequestConfig) => Promise<SupersetClientResponse>;
reAuthenticate: () => Promise<string | undefined>;
request: (request: RequestConfig) => Promise<SupersetClientResponse>;
reset: () => void;
}
export type SupersetClientResponse = Response | JsonTextResponse;
export type SupersetClientResponse = Response | JsonResponse | TextResponse;

View File

@ -1,3 +1,21 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import fetchMock from 'fetch-mock';
import { SupersetClient, SupersetClientClass } from '../src';
@ -30,12 +48,12 @@ describe('SupersetClient', () => {
expect(SupersetClient.isAuthenticated).toThrow();
expect(SupersetClient.reAuthenticate).toThrow();
expect(SupersetClient.request).toThrow();
expect(SupersetClient.configure).not.toThrow();
});
// this also tests that the ^above doesn't throw if configure is called appropriately
it('calls appropriate SupersetClient methods when configured', () => {
it('calls appropriate SupersetClient methods when configured', async () => {
expect.assertions(10);
const mockGetUrl = '/mock/get/url';
const mockPostUrl = '/mock/post/url';
const mockRequestUrl = '/mock/request/url';
@ -43,8 +61,13 @@ describe('SupersetClient', () => {
const mockDeleteUrl = '/mock/delete/url';
const mockGetPayload = { get: 'payload' };
const mockPostPayload = { post: 'payload' };
const mockDeletePayload = { delete: 'ok' };
const mockPutPayload = { put: 'ok' };
fetchMock.get(mockGetUrl, mockGetPayload);
fetchMock.post(mockPostUrl, mockPostPayload);
fetchMock.delete(mockDeleteUrl, mockDeletePayload);
fetchMock.put(mockPutUrl, mockPutPayload);
fetchMock.get(mockRequestUrl, mockGetPayload);
const initSpy = jest.spyOn(SupersetClientClass.prototype, 'init');
const getSpy = jest.spyOn(SupersetClientClass.prototype, 'get');
@ -56,19 +79,19 @@ describe('SupersetClient', () => {
const requestSpy = jest.spyOn(SupersetClientClass.prototype, 'request');
SupersetClient.configure({});
SupersetClient.init();
await SupersetClient.init();
expect(initSpy).toHaveBeenCalledTimes(1);
expect(authenticatedSpy).toHaveBeenCalledTimes(1);
expect(authenticatedSpy).toHaveBeenCalledTimes(2);
expect(csrfSpy).toHaveBeenCalledTimes(1);
SupersetClient.get({ url: mockGetUrl });
SupersetClient.post({ url: mockPostUrl });
SupersetClient.delete({ url: mockDeleteUrl });
SupersetClient.put({ url: mockPutUrl });
SupersetClient.request({ url: mockRequestUrl });
await SupersetClient.get({ url: mockGetUrl });
await SupersetClient.post({ url: mockPostUrl });
await SupersetClient.delete({ url: mockDeleteUrl });
await SupersetClient.put({ url: mockPutUrl });
await SupersetClient.request({ url: mockRequestUrl });
SupersetClient.isAuthenticated();
SupersetClient.reAuthenticate();
await SupersetClient.reAuthenticate();
expect(initSpy).toHaveBeenCalledTimes(2);
expect(deleteSpy).toHaveBeenCalledTimes(1);

View File

@ -1,6 +1,23 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import fetchMock from 'fetch-mock';
import { SupersetClientClass, ClientConfig } from '../src';
import throwIfCalled from './utils/throwIfCalled';
import { LOGIN_GLOB } from './fixtures/constants';
describe('SupersetClientClass', () => {
@ -15,6 +32,11 @@ describe('SupersetClientClass', () => {
expect(client).toBeInstanceOf(SupersetClientClass);
});
it('fallback protocol to https when setting only host', () => {
const client = new SupersetClientClass({ host: 'TEST-HOST' });
expect(client.baseUrl).toEqual('https://test-host');
});
describe('.getUrl()', () => {
let client = new SupersetClientClass();
@ -36,12 +58,12 @@ describe('SupersetClientClass', () => {
});
it('constructs a valid url from config.host + endpoint if host is omitted', () => {
expect(client.getUrl({ endpoint: '/test' })).toBe('https://CONFIG_HOST/test');
expect(client.getUrl({ endpoint: '/test' })).toBe('https://config_host/test');
});
it('does not throw if url, endpoint, and host are', () => {
it('does not throw if url, endpoint, and host are all empty', () => {
client = new SupersetClientClass({ protocol: 'https:', host: '' });
expect(client.getUrl()).toBe('https:///');
expect(client.getUrl()).toBe('https://localhost/');
});
});
@ -52,194 +74,151 @@ describe('SupersetClientClass', () => {
fetchMock.get(LOGIN_GLOB, { csrf_token: 1234 }, { overwriteRoutes: true });
});
it('calls superset/csrf_token/ when init() is called if no CSRF token is passed', () => {
it('calls superset/csrf_token/ when init() is called if no CSRF token is passed', async () => {
expect.assertions(1);
return new SupersetClientClass().init().then(() => {
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1);
return true;
});
await new SupersetClientClass().init();
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1);
});
it('does NOT call superset/csrf_token/ when init() is called if a CSRF token is passed', () => {
it('does NOT call superset/csrf_token/ when init() is called if a CSRF token is passed', async () => {
expect.assertions(1);
return new SupersetClientClass({ csrfToken: 'abc' }).init().then(() => {
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0);
return true;
});
await new SupersetClientClass({ csrfToken: 'abc' }).init();
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0);
});
it('calls superset/csrf_token/ when init(force=true) is called even if a CSRF token is passed', () => {
it('calls superset/csrf_token/ when init(force=true) is called even if a CSRF token is passed', async () => {
expect.assertions(4);
const initialToken = 'initial_token';
const client = new SupersetClientClass({ csrfToken: initialToken });
return client.init().then(() => {
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0);
expect(client.csrfToken).toBe(initialToken);
await client.init();
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0);
expect(client.csrfToken).toBe(initialToken);
return client.init(true).then(() => {
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1);
expect(client.csrfToken).not.toBe(initialToken);
return true;
});
});
await client.init(true);
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1);
expect(client.csrfToken).not.toBe(initialToken);
});
it('throws if superset/csrf_token/ returns an error', () => {
it('throws if superset/csrf_token/ returns an error', async () => {
expect.assertions(1);
fetchMock.get(LOGIN_GLOB, () => Promise.reject({ status: 403 }), {
const rejectError = { status: 403 };
fetchMock.get(LOGIN_GLOB, () => Promise.reject(rejectError), {
overwriteRoutes: true,
});
return new SupersetClientClass({})
.init()
.then(throwIfCalled)
.catch((error: { status: number }) => {
expect(error.status).toBe(403);
return true;
});
try {
await new SupersetClientClass({}).init();
} catch (error) {
expect(error as typeof rejectError).toEqual(rejectError);
}
});
it('throws if superset/csrf_token/ does not return a token', () => {
const invalidCsrfTokenError = { error: 'Failed to fetch CSRF token' };
it('throws if superset/csrf_token/ does not return a token', async () => {
expect.assertions(1);
fetchMock.get(LOGIN_GLOB, {}, { overwriteRoutes: true });
return new SupersetClientClass({})
.init()
.then(throwIfCalled)
.catch((error: unknown) => {
expect(error).toBeDefined();
return true;
});
try {
await new SupersetClientClass({}).init();
} catch (error) {
expect(error as typeof invalidCsrfTokenError).toEqual(invalidCsrfTokenError);
}
});
it('does not set csrfToken if response is not json', () => {
it('does not set csrfToken if response is not json', async () => {
expect.assertions(1);
fetchMock.get(LOGIN_GLOB, '123', {
overwriteRoutes: true,
});
return new SupersetClientClass({})
.init()
.then(throwIfCalled)
.catch((error: unknown) => {
expect(error).toBeDefined();
return true;
});
try {
await new SupersetClientClass({}).init();
} catch (error) {
expect(error as typeof invalidCsrfTokenError).toEqual(invalidCsrfTokenError);
}
});
});
describe('.isAuthenticated()', () => {
afterEach(fetchMock.reset);
it('returns true if there is a token and false if not', () => {
it('returns true if there is a token and false if not', async () => {
expect.assertions(2);
const client = new SupersetClientClass({});
expect(client.isAuthenticated()).toBe(false);
return client.init().then(() => {
expect(client.isAuthenticated()).toBe(true);
return true;
});
await client.init();
expect(client.isAuthenticated()).toBe(true);
});
it('returns true if a token is passed at configuration', () => {
expect.assertions(2);
const clientWithoutToken = new SupersetClientClass({ csrfToken: undefined });
const clientWithToken = new SupersetClientClass({ csrfToken: 'token' });
expect(clientWithoutToken.isAuthenticated()).toBe(false);
expect(clientWithToken.isAuthenticated()).toBe(true);
});
});
describe('.ensureAuth()', () => {
it(`returns a promise that rejects if .init() has not been called`, () => {
it(`returns a promise that rejects if .init() has not been called`, async () => {
expect.assertions(2);
const client = new SupersetClientClass({});
return client
.ensureAuth()
.then(throwIfCalled)
.catch((error: { error: string }) => {
expect(error).toEqual(
expect.objectContaining({ error: expect.any(String) }) as typeof error,
);
expect(client.isAuthenticated()).toBe(false);
return true;
});
try {
await client.ensureAuth();
} catch (error) {
expect(error).toEqual({ error: expect.any(String) });
}
expect(client.isAuthenticated()).toBe(false);
});
it('returns a promise that resolves if .init() resolves successfully', () => {
it('returns a promise that resolves if .init() resolves successfully', async () => {
expect.assertions(1);
const client = new SupersetClientClass({});
await client.init();
await client.ensureAuth();
return client.init().then(() =>
client
.ensureAuth()
.then(throwIfCalled)
.catch(() => {
expect(client.isAuthenticated()).toBe(true);
return true;
}),
);
expect(client.isAuthenticated()).toBe(true);
});
it(`returns a promise that rejects if .init() is unsuccessful`, () => {
it(`returns a promise that rejects if .init() is unsuccessful`, async () => {
expect.assertions(4);
const rejectValue = { status: 403 };
fetchMock.get(LOGIN_GLOB, () => Promise.reject(rejectValue), {
overwriteRoutes: true,
});
expect.assertions(3);
const client = new SupersetClientClass({});
return client
.init()
.then(throwIfCalled)
.catch((error: unknown) => {
expect(error).toEqual(expect.objectContaining(rejectValue) as unknown);
try {
await client.init();
} catch (error) {
expect(error).toEqual(expect.objectContaining(rejectValue));
expect(client.isAuthenticated()).toBe(false);
try {
await client.ensureAuth();
} catch (error2) {
expect(error2).toEqual(expect.objectContaining(rejectValue));
expect(client.isAuthenticated()).toBe(false);
}
}
return client
.ensureAuth()
.then(throwIfCalled)
.catch((error2: unknown) => {
expect(error2).toEqual(expect.objectContaining(rejectValue) as unknown);
expect(client.isAuthenticated()).toBe(false);
// reset
fetchMock.get(
LOGIN_GLOB,
{ csrf_token: 1234 },
{
overwriteRoutes: true,
},
);
return true;
});
});
// reset
fetchMock.get(
LOGIN_GLOB,
{ csrf_token: 1234 },
{
overwriteRoutes: true,
},
);
});
});
describe('requests', () => {
afterEach(fetchMock.reset);
const protocol = 'https:';
const host = 'HOST';
const host = 'host';
const mockGetEndpoint = '/get/url';
const mockRequestEndpoint = '/request/url';
const mockPostEndpoint = '/post/url';
@ -263,34 +242,32 @@ describe('SupersetClientClass', () => {
fetchMock.get(mockTextUrl, mockTextJsonResponse);
fetchMock.post(mockTextUrl, mockTextJsonResponse);
it('checks for authentication before every get and post request', () => {
it('checks for authentication before every get and post request', async () => {
expect.assertions(6);
const authSpy = jest.spyOn(SupersetClientClass.prototype, 'ensureAuth');
const client = new SupersetClientClass({ protocol, host });
return client.init().then(() =>
Promise.all([
client.get({ url: mockGetUrl }),
client.post({ url: mockPostUrl }),
client.put({ url: mockPutUrl }),
client.delete({ url: mockDeleteUrl }),
client.request({ url: mockRequestUrl, method: 'DELETE' }),
]).then(() => {
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.calls(mockDeleteUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPutUrl)).toHaveLength(1);
expect(fetchMock.calls(mockRequestUrl)).toHaveLength(1);
expect(authSpy).toHaveBeenCalledTimes(5);
authSpy.mockRestore();
await client.init();
await client.get({ url: mockGetUrl });
await client.post({ url: mockPostUrl });
await client.put({ url: mockPutUrl });
await client.delete({ url: mockDeleteUrl });
await client.request({ url: mockRequestUrl, method: 'DELETE' });
return true;
}),
);
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.calls(mockDeleteUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPutUrl)).toHaveLength(1);
expect(fetchMock.calls(mockRequestUrl)).toHaveLength(1);
expect(authSpy).toHaveBeenCalledTimes(5);
authSpy.mockRestore();
});
it('sets protocol, host, headers, mode, and credentials from config', () => {
it('sets protocol, host, headers, mode, and credentials from config', async () => {
expect.assertions(3);
const clientConfig: ClientConfig = {
host,
protocol,
@ -300,60 +277,43 @@ describe('SupersetClientClass', () => {
};
const client = new SupersetClientClass(clientConfig);
await client.init();
await client.get({ url: mockGetUrl });
return client.init().then(() =>
client.get({ url: mockGetUrl }).then(() => {
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1];
expect(fetchRequest.mode).toBe(clientConfig.mode);
expect(fetchRequest.credentials).toBe(clientConfig.credentials);
expect(fetchRequest.headers).toEqual(
expect.objectContaining(clientConfig.headers) as typeof fetchRequest.headers,
);
return true;
}),
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1];
expect(fetchRequest.mode).toBe(clientConfig.mode);
expect(fetchRequest.credentials).toBe(clientConfig.credentials);
expect(fetchRequest.headers).toEqual(
expect.objectContaining(clientConfig.headers) as typeof fetchRequest.headers,
);
});
describe('.get()', () => {
it('makes a request using url or endpoint', () => {
expect.assertions(1);
it('makes a request using url or endpoint', async () => {
expect.assertions(2);
const client = new SupersetClientClass({ protocol, host });
await client.init();
return client.init().then(() =>
Promise.all([
client.get({ url: mockGetUrl }),
client.get({ endpoint: mockGetEndpoint }),
]).then(() => {
expect(fetchMock.calls(mockGetUrl)).toHaveLength(2);
await client.get({ url: mockGetUrl });
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
return true;
}),
);
await client.get({ endpoint: mockGetEndpoint });
expect(fetchMock.calls(mockGetUrl)).toHaveLength(2);
});
it('supports parsing a response as text', () => {
it('supports parsing a response as text', async () => {
expect.assertions(2);
const client = new SupersetClientClass({ protocol, host });
return client
.init()
.then(() =>
client
.get({ url: mockTextUrl, parseMethod: 'text' })
.then(({ text }) => {
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
expect(text).toBe(mockTextJsonResponse);
return true;
})
.catch(throwIfCalled),
)
.catch(throwIfCalled);
await client.init();
const { text } = await client.get({ url: mockTextUrl, parseMethod: 'text' });
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
expect(text).toBe(mockTextJsonResponse);
});
it('allows overriding host, headers, mode, and credentials per-request', () => {
it('allows overriding host, headers, mode, and credentials per-request', async () => {
expect.assertions(3);
const clientConfig: ClientConfig = {
host,
protocol,
@ -361,7 +321,6 @@ describe('SupersetClientClass', () => {
credentials: 'include',
headers: { my: 'header' },
};
const overrideConfig: ClientConfig = {
host: 'override_host',
mode: 'no-cors',
@ -370,46 +329,34 @@ describe('SupersetClientClass', () => {
};
const client = new SupersetClientClass(clientConfig);
await client.init();
await client.get({ url: mockGetUrl, ...overrideConfig });
return client
.init()
.then(() =>
client
.get({ url: mockGetUrl, ...overrideConfig })
.then(() => {
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1];
expect(fetchRequest.mode).toBe(overrideConfig.mode);
expect(fetchRequest.credentials).toBe(overrideConfig.credentials);
expect(fetchRequest.headers).toEqual(
expect.objectContaining(overrideConfig.headers) as typeof fetchRequest.headers,
);
return true;
})
.catch(throwIfCalled),
)
.catch(throwIfCalled);
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1];
expect(fetchRequest.mode).toBe(overrideConfig.mode);
expect(fetchRequest.credentials).toBe(overrideConfig.credentials);
expect(fetchRequest.headers).toEqual(
expect.objectContaining(overrideConfig.headers) as typeof fetchRequest.headers,
);
});
});
describe('.post()', () => {
it('makes a request using url or endpoint', () => {
expect.assertions(1);
it('makes a request using url or endpoint', async () => {
expect.assertions(2);
const client = new SupersetClientClass({ protocol, host });
await client.init();
return client.init().then(() =>
Promise.all([
client.post({ url: mockPostUrl }),
client.post({ endpoint: mockPostEndpoint }),
]).then(() => {
expect(fetchMock.calls(mockPostUrl)).toHaveLength(2);
await client.post({ url: mockPostUrl });
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
return true;
}),
);
await client.post({ endpoint: mockPostEndpoint });
expect(fetchMock.calls(mockPostUrl)).toHaveLength(2);
});
it('allows overriding host, headers, mode, and credentials per-request', () => {
it('allows overriding host, headers, mode, and credentials per-request', async () => {
expect.assertions(3);
const clientConfig: ClientConfig = {
host,
protocol,
@ -417,7 +364,6 @@ describe('SupersetClientClass', () => {
credentials: 'include',
headers: { my: 'header' },
};
const overrideConfig: ClientConfig = {
host: 'override_host',
mode: 'no-cors',
@ -426,70 +372,57 @@ describe('SupersetClientClass', () => {
};
const client = new SupersetClientClass(clientConfig);
await client.init();
await client.post({ url: mockPostUrl, ...overrideConfig });
return client.init().then(() =>
client.post({ url: mockPostUrl, ...overrideConfig }).then(() => {
const fetchRequest = fetchMock.calls(mockPostUrl)[0][1];
expect(fetchRequest.mode).toBe(overrideConfig.mode);
expect(fetchRequest.credentials).toBe(overrideConfig.credentials);
expect(fetchRequest.headers).toEqual(
expect.objectContaining(overrideConfig.headers) as typeof fetchRequest.headers,
);
const fetchRequest = fetchMock.calls(mockPostUrl)[0][1];
return true;
}),
expect(fetchRequest.mode).toBe(overrideConfig.mode);
expect(fetchRequest.credentials).toBe(overrideConfig.credentials);
expect(fetchRequest.headers).toEqual(
expect.objectContaining(overrideConfig.headers) as typeof fetchRequest.headers,
);
});
it('supports parsing a response as text', () => {
it('supports parsing a response as text', async () => {
expect.assertions(2);
const client = new SupersetClientClass({ protocol, host });
return client.init().then(() =>
client.post({ url: mockTextUrl, parseMethod: 'text' }).then(({ text }) => {
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
expect(text).toBe(mockTextJsonResponse);
return true;
}),
);
await client.init();
const { text } = await client.post({ url: mockTextUrl, parseMethod: 'text' });
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
expect(text).toBe(mockTextJsonResponse);
});
it('passes postPayload key,values in the body', () => {
it('passes postPayload key,values in the body', async () => {
expect.assertions(3);
const postPayload = { number: 123, array: [1, 2, 3] };
const client = new SupersetClientClass({ protocol, host });
await client.init();
await client.post({ url: mockPostUrl, postPayload });
return client.init().then(() =>
client.post({ url: mockPostUrl, postPayload }).then(() => {
const formData = fetchMock.calls(mockPostUrl)[0][1].body as FormData;
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
Object.entries(postPayload).forEach(([key, value]) => {
expect(formData.get(key)).toBe(JSON.stringify(value));
});
const formData = fetchMock.calls(mockPostUrl)[0][1].body as FormData;
return true;
}),
);
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
Object.entries(postPayload).forEach(([key, value]) => {
expect(formData.get(key)).toBe(JSON.stringify(value));
});
});
it('respects the stringify parameter for postPayload key,values', () => {
it('respects the stringify parameter for postPayload key,values', async () => {
expect.assertions(3);
const postPayload = { number: 123, array: [1, 2, 3] };
const client = new SupersetClientClass({ protocol, host });
await client.init();
await client.post({ url: mockPostUrl, postPayload, stringify: false });
return client.init().then(() =>
client.post({ url: mockPostUrl, postPayload, stringify: false }).then(() => {
const formData = fetchMock.calls(mockPostUrl)[0][1].body as FormData;
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
Object.entries(postPayload).forEach(([key, value]) => {
expect(formData.get(key)).toBe(String(value));
});
const formData = fetchMock.calls(mockPostUrl)[0][1].body as FormData;
return true;
}),
);
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
Object.entries(postPayload).forEach(([key, value]) => {
expect(formData.get(key)).toBe(String(value));
});
});
});
});

View File

@ -1,10 +1,26 @@
/* eslint promise/no-callback-in-promise: 'off' */
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import fetchMock from 'fetch-mock';
import callApi from '../../src/callApi/callApi';
import * as constants from '../../src/constants';
import { LOGIN_GLOB } from '../fixtures/constants';
import throwIfCalled from '../utils/throwIfCalled';
import { CallApi, JsonObject } from '../../src/types';
import { DEFAULT_FETCH_RETRY_OPTIONS } from '../../src/constants';
@ -47,27 +63,22 @@ describe('callApi()', () => {
afterEach(fetchMock.reset);
describe('request config', () => {
it('calls the right url with the specified method', () => {
it('calls the right url with the specified method', async () => {
expect.assertions(4);
return Promise.all([
await Promise.all([
callApi({ url: mockGetUrl, method: 'GET' }),
callApi({ url: mockPostUrl, method: 'POST' }),
callApi({ url: mockPutUrl, method: 'PUT' }),
callApi({ url: mockPatchUrl, method: 'PATCH' }),
]).then(() => {
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPutUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPatchUrl)).toHaveLength(1);
return true;
});
]);
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPutUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPatchUrl)).toHaveLength(1);
});
it('passes along mode, cache, credentials, headers, body, signal, and redirect parameters in the request', () => {
it('passes along mode, cache, credentials, headers, body, signal, and redirect parameters in the request', async () => {
expect.assertions(8);
const mockRequest: CallApi = {
url: mockGetUrl,
mode: 'cors',
@ -81,64 +92,55 @@ describe('callApi()', () => {
body: 'BODY',
};
return callApi(mockRequest).then(() => {
const calls = fetchMock.calls(mockGetUrl);
const fetchParams = calls[0][1];
expect(calls).toHaveLength(1);
expect(fetchParams.mode).toBe(mockRequest.mode);
expect(fetchParams.cache).toBe(mockRequest.cache);
expect(fetchParams.credentials).toBe(mockRequest.credentials);
expect(fetchParams.headers).toEqual(
expect.objectContaining(mockRequest.headers) as typeof fetchParams.headers,
);
expect(fetchParams.redirect).toBe(mockRequest.redirect);
expect(fetchParams.signal).toBe(mockRequest.signal);
expect(fetchParams.body).toBe(mockRequest.body);
return true;
});
await callApi(mockRequest);
const calls = fetchMock.calls(mockGetUrl);
const fetchParams = calls[0][1];
expect(calls).toHaveLength(1);
expect(fetchParams.mode).toBe(mockRequest.mode);
expect(fetchParams.cache).toBe(mockRequest.cache);
expect(fetchParams.credentials).toBe(mockRequest.credentials);
expect(fetchParams.headers).toEqual(
expect.objectContaining(mockRequest.headers) as typeof fetchParams.headers,
);
expect(fetchParams.redirect).toBe(mockRequest.redirect);
expect(fetchParams.signal).toBe(mockRequest.signal);
expect(fetchParams.body).toBe(mockRequest.body);
});
});
describe('POST requests', () => {
it('encodes key,value pairs from postPayload', () => {
it('encodes key,value pairs from postPayload', async () => {
expect.assertions(3);
const postPayload = { key: 'value', anotherKey: 1237 };
return callApi({ url: mockPostUrl, method: 'POST', postPayload }).then(() => {
const calls = fetchMock.calls(mockPostUrl);
expect(calls).toHaveLength(1);
await callApi({ url: mockPostUrl, method: 'POST', postPayload });
const calls = fetchMock.calls(mockPostUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0][1];
const body = fetchParams.body as FormData;
const fetchParams = calls[0][1];
const body = fetchParams.body as FormData;
Object.entries(postPayload).forEach(([key, value]) => {
expect(body.get(key)).toBe(JSON.stringify(value));
});
return true;
Object.entries(postPayload).forEach(([key, value]) => {
expect(body.get(key)).toBe(JSON.stringify(value));
});
});
// the reason for this is to omit strings like 'undefined' from making their way to the backend
it('omits key,value pairs from postPayload that have undefined values (POST)', () => {
it('omits key,value pairs from postPayload that have undefined values (POST)', async () => {
expect.assertions(3);
const postPayload = { key: 'value', noValue: undefined };
return callApi({ url: mockPostUrl, method: 'POST', postPayload }).then(() => {
const calls = fetchMock.calls(mockPostUrl);
expect(calls).toHaveLength(1);
await callApi({ url: mockPostUrl, method: 'POST', postPayload });
const calls = fetchMock.calls(mockPostUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0][1];
const body = fetchParams.body as FormData;
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
expect(body.get('noValue')).toBeNull();
return true;
});
const fetchParams = calls[0][1];
const body = fetchParams.body as FormData;
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
expect(body.get('noValue')).toBeNull();
});
it('respects the stringify flag in POST requests', () => {
it('respects the stringify flag in POST requests', async () => {
const postPayload = {
string: 'value',
number: 1237,
@ -150,68 +152,59 @@ describe('callApi()', () => {
expect.assertions(1 + 3 * Object.keys(postPayload).length);
return Promise.all([
await Promise.all([
callApi({ url: mockPostUrl, method: 'POST', postPayload }),
callApi({ url: mockPostUrl, method: 'POST', postPayload, stringify: false }),
callApi({ url: mockPostUrl, method: 'POST', jsonPayload: postPayload }),
]).then(() => {
const calls = fetchMock.calls(mockPostUrl);
expect(calls).toHaveLength(3);
]);
const calls = fetchMock.calls(mockPostUrl);
expect(calls).toHaveLength(3);
const stringified = calls[0][1].body as FormData;
const unstringified = calls[1][1].body as FormData;
const jsonRequestBody = JSON.parse(calls[2][1].body as string) as JsonObject;
const stringified = calls[0][1].body as FormData;
const unstringified = calls[1][1].body as FormData;
const jsonRequestBody = JSON.parse(calls[2][1].body as string) as JsonObject;
Object.entries(postPayload).forEach(([key, value]) => {
expect(stringified.get(key)).toBe(JSON.stringify(value));
expect(unstringified.get(key)).toBe(String(value));
expect(jsonRequestBody[key]).toEqual(value);
});
return true;
Object.entries(postPayload).forEach(([key, value]) => {
expect(stringified.get(key)).toBe(JSON.stringify(value));
expect(unstringified.get(key)).toBe(String(value));
expect(jsonRequestBody[key]).toEqual(value);
});
});
});
describe('PUT requests', () => {
it('encodes key,value pairs from postPayload', () => {
it('encodes key,value pairs from postPayload', async () => {
expect.assertions(3);
const postPayload = { key: 'value', anotherKey: 1237 };
return callApi({ url: mockPutUrl, method: 'PUT', postPayload }).then(() => {
const calls = fetchMock.calls(mockPutUrl);
expect(calls).toHaveLength(1);
await callApi({ url: mockPutUrl, method: 'PUT', postPayload });
const calls = fetchMock.calls(mockPutUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0][1];
const body = fetchParams.body as FormData;
const fetchParams = calls[0][1];
const body = fetchParams.body as FormData;
Object.entries(postPayload).forEach(([key, value]) => {
expect(body.get(key)).toBe(JSON.stringify(value));
});
return true;
Object.entries(postPayload).forEach(([key, value]) => {
expect(body.get(key)).toBe(JSON.stringify(value));
});
});
// the reason for this is to omit strings like 'undefined' from making their way to the backend
it('omits key,value pairs from postPayload that have undefined values (PUT)', () => {
it('omits key,value pairs from postPayload that have undefined values (PUT)', async () => {
expect.assertions(3);
const postPayload = { key: 'value', noValue: undefined };
return callApi({ url: mockPutUrl, method: 'PUT', postPayload }).then(() => {
const calls = fetchMock.calls(mockPutUrl);
expect(calls).toHaveLength(1);
await callApi({ url: mockPutUrl, method: 'PUT', postPayload });
const calls = fetchMock.calls(mockPutUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0][1];
const body = fetchParams.body as FormData;
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
expect(body.get('noValue')).toBeNull();
return true;
});
const fetchParams = calls[0][1];
const body = fetchParams.body as FormData;
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
expect(body.get('noValue')).toBeNull();
});
it('respects the stringify flag in PUT requests', () => {
it('respects the stringify flag in PUT requests', async () => {
const postPayload = {
string: 'value',
number: 1237,
@ -223,65 +216,56 @@ describe('callApi()', () => {
expect.assertions(1 + 2 * Object.keys(postPayload).length);
return Promise.all([
await Promise.all([
callApi({ url: mockPutUrl, method: 'PUT', postPayload }),
callApi({ url: mockPutUrl, method: 'PUT', postPayload, stringify: false }),
]).then(() => {
const calls = fetchMock.calls(mockPutUrl);
expect(calls).toHaveLength(2);
]);
const calls = fetchMock.calls(mockPutUrl);
expect(calls).toHaveLength(2);
const stringified = calls[0][1].body as FormData;
const unstringified = calls[1][1].body as FormData;
const stringified = calls[0][1].body as FormData;
const unstringified = calls[1][1].body as FormData;
Object.entries(postPayload).forEach(([key, value]) => {
expect(stringified.get(key)).toBe(JSON.stringify(value));
expect(unstringified.get(key)).toBe(String(value));
});
return true;
Object.entries(postPayload).forEach(([key, value]) => {
expect(stringified.get(key)).toBe(JSON.stringify(value));
expect(unstringified.get(key)).toBe(String(value));
});
});
});
describe('PATCH requests', () => {
it('encodes key,value pairs from postPayload', () => {
it('encodes key,value pairs from postPayload', async () => {
expect.assertions(3);
const postPayload = { key: 'value', anotherKey: 1237 };
return callApi({ url: mockPatchUrl, method: 'PATCH', postPayload }).then(() => {
const calls = fetchMock.calls(mockPatchUrl);
expect(calls).toHaveLength(1);
await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload });
const calls = fetchMock.calls(mockPatchUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0][1];
const body = fetchParams.body as FormData;
const fetchParams = calls[0][1];
const body = fetchParams.body as FormData;
Object.entries(postPayload).forEach(([key, value]) => {
expect(body.get(key)).toBe(JSON.stringify(value));
});
return true;
Object.entries(postPayload).forEach(([key, value]) => {
expect(body.get(key)).toBe(JSON.stringify(value));
});
});
// the reason for this is to omit strings like 'undefined' from making their way to the backend
it('omits key,value pairs from postPayload that have undefined values (PATCH)', () => {
it('omits key,value pairs from postPayload that have undefined values (PATCH)', async () => {
expect.assertions(3);
const postPayload = { key: 'value', noValue: undefined };
return callApi({ url: mockPatchUrl, method: 'PATCH', postPayload }).then(() => {
const calls = fetchMock.calls(mockPatchUrl);
expect(calls).toHaveLength(1);
await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload });
const calls = fetchMock.calls(mockPatchUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0][1];
const body = fetchParams.body as FormData;
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
expect(body.get('noValue')).toBeNull();
return true;
});
const fetchParams = calls[0][1];
const body = fetchParams.body as FormData;
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
expect(body.get('noValue')).toBeNull();
});
it('respects the stringify flag in PATCH requests', () => {
it('respects the stringify flag in PATCH requests', async () => {
const postPayload = {
string: 'value',
number: 1237,
@ -293,22 +277,19 @@ describe('callApi()', () => {
expect.assertions(1 + 2 * Object.keys(postPayload).length);
return Promise.all([
await Promise.all([
callApi({ url: mockPatchUrl, method: 'PATCH', postPayload }),
callApi({ url: mockPatchUrl, method: 'PATCH', postPayload, stringify: false }),
]).then(() => {
const calls = fetchMock.calls(mockPatchUrl);
expect(calls).toHaveLength(2);
]);
const calls = fetchMock.calls(mockPatchUrl);
expect(calls).toHaveLength(2);
const stringified = calls[0][1].body as FormData;
const unstringified = calls[1][1].body as FormData;
const stringified = calls[0][1].body as FormData;
const unstringified = calls[1][1].body as FormData;
Object.entries(postPayload).forEach(([key, value]) => {
expect(stringified.get(key)).toBe(JSON.stringify(value));
expect(unstringified.get(key)).toBe(String(value));
});
return true;
Object.entries(postPayload).forEach(([key, value]) => {
expect(stringified.get(key)).toBe(JSON.stringify(value));
expect(unstringified.get(key)).toBe(String(value));
});
});
});
@ -326,42 +307,34 @@ describe('callApi()', () => {
beforeEach(() => {
self.location.protocol = 'https:';
return caches.delete(constants.CACHE_KEY);
});
it('caches requests with ETags', () =>
callApi({ url: mockCacheUrl, method: 'GET' }).then(() => {
const calls = fetchMock.calls(mockCacheUrl);
expect(calls).toHaveLength(1);
it('caches requests with ETags', async () => {
expect.assertions(2);
await callApi({ url: mockCacheUrl, method: 'GET' });
const calls = fetchMock.calls(mockCacheUrl);
expect(calls).toHaveLength(1);
const supersetCache = await caches.open(constants.CACHE_KEY);
const cachedResponse = await supersetCache.match(mockCacheUrl);
expect(cachedResponse).toBeDefined();
});
return caches.open(constants.CACHE_KEY).then(supersetCache =>
supersetCache.match(mockCacheUrl).then(cachedResponse => {
expect(cachedResponse).toBeDefined();
return true;
}),
);
}));
it('will not use cache when running off an insecure connection', () => {
it('will not use cache when running off an insecure connection', async () => {
expect.assertions(2);
self.location.protocol = 'http:';
return callApi({ url: mockCacheUrl, method: 'GET' }).then(() => {
const calls = fetchMock.calls(mockCacheUrl);
expect(calls).toHaveLength(1);
await callApi({ url: mockCacheUrl, method: 'GET' });
const calls = fetchMock.calls(mockCacheUrl);
expect(calls).toHaveLength(1);
return caches.open(constants.CACHE_KEY).then(supersetCache =>
supersetCache.match(mockCacheUrl).then(cachedResponse => {
expect(cachedResponse).toBeUndefined();
return true;
}),
);
});
const supersetCache = await caches.open(constants.CACHE_KEY);
const cachedResponse = await supersetCache.match(mockCacheUrl);
expect(cachedResponse).toBeUndefined();
});
it('works when the Cache API is disabled', async () => {
expect.assertions(5);
// eslint-disable-next-line no-import-assign
Object.defineProperty(constants, 'CACHE_AVAILABLE', { value: false });
@ -383,26 +356,25 @@ describe('callApi()', () => {
Object.defineProperty(constants, 'CACHE_AVAILABLE', { value: true });
});
it('sends known ETags in the If-None-Match header', () =>
it('sends known ETags in the If-None-Match header', async () => {
expect.assertions(3);
// first call sets the cache
callApi({ url: mockCacheUrl, method: 'GET' }).then(() => {
const calls = fetchMock.calls(mockCacheUrl);
expect(calls).toHaveLength(1);
await callApi({ url: mockCacheUrl, method: 'GET' });
const calls = fetchMock.calls(mockCacheUrl);
expect(calls).toHaveLength(1);
// second call sends the Etag in the If-None-Match header
return callApi({ url: mockCacheUrl, method: 'GET' }).then(() => {
const fetchParams = calls[1][1];
const headers = { 'If-None-Match': 'etag' };
expect(calls).toHaveLength(2);
expect(fetchParams.headers).toEqual(
expect.objectContaining(headers) as typeof fetchParams.headers,
);
return true;
});
}));
// second call sends the Etag in the If-None-Match header
await callApi({ url: mockCacheUrl, method: 'GET' });
const fetchParams = calls[1][1];
const headers = { 'If-None-Match': 'etag' };
expect(calls).toHaveLength(2);
expect(fetchParams.headers).toEqual(
expect.objectContaining(headers) as typeof fetchParams.headers,
);
});
it('reuses cached responses on 304 status', async () => {
expect.assertions(3);
// first call sets the cache
await callApi({ url: mockCacheUrl, method: 'GET' });
const calls = fetchMock.calls(mockCacheUrl);
@ -417,23 +389,28 @@ describe('callApi()', () => {
expect(secondBody).toEqual('BODY');
});
it('throws error when cache fails on 304', () => {
it('throws error when cache fails on 304', async () => {
expect.assertions(2);
// this should never happen, since a 304 is only returned if we have
// the cached response and sent the If-None-Match header
const mockUncachedUrl = '/mock/uncached/url';
const mockCachedPayload = { status: 304 };
fetchMock.get(mockUncachedUrl, mockCachedPayload);
return callApi({ url: mockUncachedUrl, method: 'GET' }).catch(
(error: { message: string }) => {
const calls = fetchMock.calls(mockUncachedUrl);
expect(calls).toHaveLength(1);
expect(error.message).toEqual('Received 304 but no content is cached!');
},
);
try {
await callApi({ url: mockUncachedUrl, method: 'GET' });
} catch (error) {
const calls = fetchMock.calls(mockUncachedUrl);
expect(calls).toHaveLength(1);
expect((error as { message: string }).message).toEqual(
'Received 304 but no content is cached!',
);
}
});
it('returns original response if no Etag', async () => {
expect.assertions(3);
const url = mockGetUrl;
const response = await callApi({ url, method: 'GET' });
const calls = fetchMock.calls(url);
@ -444,6 +421,7 @@ describe('callApi()', () => {
});
it('returns original response if status not 304 or 200', async () => {
expect.assertions(2);
const url = mockNotFound;
const response = await callApi({ url, method: 'GET' });
const calls = fetchMock.calls(url);
@ -452,39 +430,40 @@ describe('callApi()', () => {
});
});
it('rejects after retrying thrice if the request throws', () => {
it('rejects after retrying thrice if the request throws', async () => {
expect.assertions(3);
return callApi({
fetchRetryOptions: DEFAULT_FETCH_RETRY_OPTIONS,
url: mockErrorUrl,
method: 'GET',
})
.then(throwIfCalled)
.catch((error: { status: number; statusText: string }) => {
expect(fetchMock.calls(mockErrorUrl)).toHaveLength(4);
expect(error.status).toBe(mockErrorPayload.status);
expect(error.statusText).toBe(mockErrorPayload.statusText);
try {
await callApi({
fetchRetryOptions: DEFAULT_FETCH_RETRY_OPTIONS,
url: mockErrorUrl,
method: 'GET',
});
} catch (error) {
const err = error as { status: number; statusText: string };
expect(fetchMock.calls(mockErrorUrl)).toHaveLength(4);
expect(err.status).toBe(mockErrorPayload.status);
expect(err.statusText).toBe(mockErrorPayload.statusText);
}
});
it('rejects without retries if the config is set to 0 retries', () => {
it('rejects without retries if the config is set to 0 retries', async () => {
expect.assertions(3);
return callApi({
fetchRetryOptions: { retries: 0 },
url: mockErrorUrl,
method: 'GET',
})
.then(throwIfCalled)
.catch((error: { status: number; statusText: string }) => {
expect(fetchMock.calls(mockErrorUrl)).toHaveLength(1);
expect(error.status).toBe(mockErrorPayload.status);
expect(error.statusText).toBe(mockErrorPayload.statusText);
try {
await callApi({
fetchRetryOptions: { retries: 0 },
url: mockErrorUrl,
method: 'GET',
});
} catch (error) {
const err = error as { status: number; statusText: string };
expect(fetchMock.calls(mockErrorUrl)).toHaveLength(1);
expect(err.status).toBe(mockErrorPayload.status);
expect(err.statusText).toBe(mockErrorPayload.statusText);
}
});
it('rejects after retrying thrice if the request returns a 503', async () => {
expect.assertions(2);
const url = mock503;
const response = await callApi({
fetchRetryOptions: DEFAULT_FETCH_RETRY_OPTIONS,
@ -496,13 +475,17 @@ describe('callApi()', () => {
expect(response.status).toEqual(503);
});
it('invalid json for postPayload should thrown error', () => {
expect(() => {
callApi({
it('invalid json for postPayload should thrown error', async () => {
expect.assertions(2);
try {
await callApi({
url: mockPostUrl,
method: 'POST',
postPayload: 'haha',
});
}).toThrow('Invalid postPayload:\n\nhaha');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect(error.message).toEqual('Invalid payload:\n\nhaha');
}
});
});

View File

@ -1,3 +1,21 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import fetchMock from 'fetch-mock';
import callApiAndParseWithTimeout from '../../src/callApi/callApiAndParseWithTimeout';
@ -8,7 +26,6 @@ import * as parseResponse from '../../src/callApi/parseResponse';
import * as rejectAfterTimeout from '../../src/callApi/rejectAfterTimeout';
import { LOGIN_GLOB } from '../fixtures/constants';
import throwIfCalled from '../utils/throwIfCalled';
describe('callApiAndParseWithTimeout()', () => {
beforeAll(() => {
@ -64,40 +81,39 @@ describe('callApiAndParseWithTimeout()', () => {
rejectionSpy.mockClear();
});
it('rejects if the request exceeds the timeout', () => {
return new Promise(done => {
expect.assertions(3);
jest.useFakeTimers();
it('rejects if the request exceeds the timeout', async () => {
expect.assertions(2);
jest.useFakeTimers();
const mockTimeoutUrl = '/mock/timeout/url';
const unresolvingPromise = new Promise(() => {});
fetchMock.get(mockTimeoutUrl, () => unresolvingPromise);
callApiAndParseWithTimeout({ url: mockTimeoutUrl, method: 'GET', timeout: 1 })
.then(throwIfCalled)
.catch((error: { error: string; statusText: string }) => {
expect(fetchMock.calls(mockTimeoutUrl)).toHaveLength(1);
expect(Object.keys(error)).toEqual(['error', 'statusText']);
expect(error.statusText).toBe('timeout');
return done(); // eslint-disable-line promise/no-callback-in-promise
});
const mockTimeoutUrl = '/mock/timeout/url';
const unresolvingPromise = new Promise(() => {});
fetchMock.get(mockTimeoutUrl, () => unresolvingPromise);
try {
const promise = callApiAndParseWithTimeout({
url: mockTimeoutUrl,
method: 'GET',
timeout: 1,
});
jest.advanceTimersByTime(2);
});
await promise;
} catch (error) {
expect(fetchMock.calls(mockTimeoutUrl)).toHaveLength(1);
expect(error).toEqual({
error: 'Request timed out',
statusText: 'timeout',
});
}
});
it('resolves if the request does not exceed the timeout', () => {
it('resolves if the request does not exceed the timeout', async () => {
expect.assertions(1);
return callApiAndParseWithTimeout({ url: mockGetUrl, method: 'GET', timeout: 100 }).then(
response => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
expect(response.json).toEqual(expect.objectContaining(mockGetPayload));
return true;
},
);
const { json } = await callApiAndParseWithTimeout({
url: mockGetUrl,
method: 'GET',
timeout: 100,
});
expect(json).toEqual(mockGetPayload);
});
});
});

View File

@ -1,10 +1,26 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import fetchMock from 'fetch-mock';
import callApi from '../../src/callApi/callApi';
import parseResponse from '../../src/callApi/parseResponse';
import { LOGIN_GLOB } from '../fixtures/constants';
import throwIfCalled from '../utils/throwIfCalled';
import { SupersetClientResponse } from '../../src';
describe('parseResponse()', () => {
beforeAll(() => {
@ -35,22 +51,17 @@ describe('parseResponse()', () => {
expect(parsedResponsePromise).toBeInstanceOf(Promise);
});
it('resolves to { json, response } if the request succeeds', () => {
it('resolves to { json, response } if the request succeeds', async () => {
expect.assertions(4);
const apiPromise = callApi({ url: mockGetUrl, method: 'GET' });
return parseResponse(apiPromise).then(args => {
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
const keys = Object.keys(args);
expect(keys).toContain('response');
expect(keys).toContain('json');
expect(args.json).toEqual(expect.objectContaining(mockGetPayload) as typeof args.json);
return true;
});
const args = await parseResponse(callApi({ url: mockGetUrl, method: 'GET' }));
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
const keys = Object.keys(args);
expect(keys).toContain('response');
expect(keys).toContain('json');
expect(args.json).toEqual(expect.objectContaining(mockGetPayload) as typeof args.json);
});
it('throws if `parseMethod=json` and .json() fails', () => {
it('throws if `parseMethod=json` and .json() fails', async () => {
expect.assertions(3);
const mockTextUrl = '/mock/text/url';
@ -58,20 +69,17 @@ describe('parseResponse()', () => {
'<html><head></head><body>I could be a stack trace or something</body></html>';
fetchMock.get(mockTextUrl, mockTextResponse);
const apiPromise = callApi({ url: mockTextUrl, method: 'GET' });
return parseResponse(apiPromise, 'json')
.then(throwIfCalled)
.catch((error: { stack: unknown; message: string }) => {
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
expect(error.stack).toBeDefined();
expect(error.message).toContain('Unexpected token');
return true;
});
try {
await parseResponse(callApi({ url: mockTextUrl, method: 'GET' }));
} catch (error) {
const err = error as Error;
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
expect(err.stack).toBeDefined();
expect(err.message).toContain('Unexpected token');
}
});
it('resolves to { text, response } if the `parseMethod=text`', () => {
it('resolves to { text, response } if the `parseMethod=text`', async () => {
expect.assertions(4);
// test with json + bigint to ensure that it was not first parsed as json
@ -79,53 +87,49 @@ describe('parseResponse()', () => {
const mockTextJsonResponse = '{ "value": 9223372036854775807 }';
fetchMock.get(mockTextParseUrl, mockTextJsonResponse);
const apiPromise = callApi({ url: mockTextParseUrl, method: 'GET' });
return parseResponse(apiPromise, 'text').then(args => {
expect(fetchMock.calls(mockTextParseUrl)).toHaveLength(1);
const keys = Object.keys(args);
expect(keys).toContain('response');
expect(keys).toContain('text');
expect(args.text).toBe(mockTextJsonResponse);
return true;
});
const args = await parseResponse(callApi({ url: mockTextParseUrl, method: 'GET' }), 'text');
expect(fetchMock.calls(mockTextParseUrl)).toHaveLength(1);
const keys = Object.keys(args);
expect(keys).toContain('response');
expect(keys).toContain('text');
expect(args.text).toBe(mockTextJsonResponse);
});
it('throws if parseMethod is not null|json|text', () => {
const apiPromise = callApi({ url: mockNoParseUrl, method: 'GET' });
// @ts-ignore - 'something-else' is *intentionally* an invalid type
expect(() => parseResponse(apiPromise, 'something-else')).toThrow();
it('throws if parseMethod is not null|json|text', async () => {
expect.assertions(1);
try {
await parseResponse(
callApi({ url: mockNoParseUrl, method: 'GET' }),
'something-else' as never,
);
} catch (error) {
expect(error.message).toEqual(expect.stringContaining('Expected parseResponse=json'));
}
});
it('resolves to the unmodified `Response` object if `parseMethod=null`', () => {
expect.assertions(2);
const apiPromise = callApi({ url: mockNoParseUrl, method: 'GET' });
return parseResponse(apiPromise, null).then((clientResponse: SupersetClientResponse) => {
const response = clientResponse as Response;
expect(fetchMock.calls(mockNoParseUrl)).toHaveLength(1);
expect(response.bodyUsed).toBe(false);
return true;
});
it('resolves to unmodified `Response` object if `parseMethod=null|raw`', async () => {
expect.assertions(3);
const responseNull = await parseResponse(callApi({ url: mockNoParseUrl, method: 'GET' }), null);
const responseRaw = await parseResponse(callApi({ url: mockNoParseUrl, method: 'GET' }), 'raw');
expect(fetchMock.calls(mockNoParseUrl)).toHaveLength(2);
expect(responseNull.bodyUsed).toBe(false);
expect(responseRaw.bodyUsed).toBe(false);
});
it('rejects if request.ok=false', () => {
it('rejects if request.ok=false', async () => {
expect.assertions(3);
const mockNotOkayUrl = '/mock/notokay/url';
fetchMock.get(mockNotOkayUrl, 404); // 404s result in not response.ok=false
expect.assertions(3);
const apiPromise = callApi({ url: mockNotOkayUrl, method: 'GET' });
return parseResponse(apiPromise)
.then(throwIfCalled)
.catch((error: { ok: boolean; status: number }) => {
expect(fetchMock.calls(mockNotOkayUrl)).toHaveLength(1);
expect(error.ok).toBe(false);
expect(error.status).toBe(404);
});
try {
await parseResponse(apiPromise);
} catch (error) {
const err = error as { ok: boolean; status: number };
expect(fetchMock.calls(mockNotOkayUrl)).toHaveLength(1);
expect(err.ok).toBe(false);
expect(err.status).toBe(404);
}
});
});

View File

@ -1,23 +1,34 @@
/* eslint promise/no-callback-in-promise: 'off' */
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import rejectAfterTimeout from '../../src/callApi/rejectAfterTimeout';
import throwIfCalled from '../utils/throwIfCalled';
describe('rejectAfterTimeout()', () => {
it('returns a promise that rejects after the specified timeout', () => {
return new Promise(done => {
expect.assertions(1);
jest.useFakeTimers();
rejectAfterTimeout(10)
.then(throwIfCalled)
.catch((error: Error) => {
expect(error).toBeDefined();
return done();
});
it('returns a promise that rejects after the specified timeout', async () => {
expect.assertions(1);
jest.useFakeTimers();
try {
const promise = rejectAfterTimeout(10);
jest.advanceTimersByTime(11);
jest.useRealTimers();
});
await promise;
} catch (error) {
expect(error).toBeDefined();
}
jest.useRealTimers();
});
});

View File

@ -1 +1,19 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const LOGIN_GLOB = 'glob:*superset/csrf_token/*'; // eslint-disable-line import/prefer-default-export

View File

@ -1,3 +0,0 @@
export default function throwIfCalled(args: unknown) {
throw new Error(`Unexpected call to throwIfCalled(): ${JSON.stringify(args)}`);
}

View File

@ -5,12 +5,14 @@ export type Props = {
};
export default function ErrorMessage({ error }: Props) {
// eslint-disable-next-line no-console
console.error(error);
return (
<div className="alert alert-danger">
<pre className="alert alert-danger">
{error.stack || error.message}
{!error.message &&
!error.stack &&
(typeof error === 'object' ? JSON.stringify(error) : String(error))}
</div>
(typeof error === 'object' ? JSON.stringify(error, null, 2) : String(error))}
</pre>
);
}

View File

@ -1,5 +1,5 @@
import React from 'react';
import { text, select } from '@storybook/addon-knobs';
import { text, select, withKnobs } from '@storybook/addon-knobs';
import { SuperChart, ChartDataProvider } from '@superset-ui/chart';
import { SupersetClient } from '@superset-ui/connection';
@ -26,13 +26,12 @@ const WORD_CLOUD_LEGACY = wordCloudFormData.viz_type;
const WORD_CLOUD = 'new_word_cloud';
new LegacyBigNumberPlugin().configure({ key: BIG_NUMBER }).register();
// @ts-ignore
// eslint-disable-next-line
new LegacySankeyPlugin().configure({ key: SANKEY }).register();
// @ts-ignore
// eslint-disable-next-line
new LegacySunburstPlugin().configure({ key: SUNBURST }).register();
// @ts-ignore
// eslint-disable-next-line
new LegacyWordCloudPlugin().configure({ key: WORD_CLOUD_LEGACY }).register();
// @ts-ignore
new WordCloudChartPlugin().configure({ key: WORD_CLOUD }).register();
const VIS_TYPES = [BIG_NUMBER, SANKEY, SUNBURST, WORD_CLOUD, WORD_CLOUD_LEGACY];
@ -46,14 +45,19 @@ const FORM_DATA_LOOKUP = {
export default {
title: 'Core Packages|@superset-ui/chart',
decorators: [
withKnobs({
escapeHTML: false,
}),
],
};
export const dataProvider = () => {
const host = text('Set Superset App host for CORS request', 'localhost:9000');
const host = text('Set Superset App host for CORS request', 'localhost:8088');
const visType = select('Chart Plugin Type', VIS_TYPES, VIS_TYPES[0]);
const formData = text('Override formData', JSON.stringify(FORM_DATA_LOOKUP[visType]));
const width = text('Vis width', '500');
const height = text('Vis height', '300');
const formData = text('Override formData', JSON.stringify(FORM_DATA_LOOKUP[visType]));
return (
<div style={{ margin: 16 }}>

View File

@ -1,6 +1,6 @@
import React from 'react';
import { select, text, withKnobs } from '@storybook/addon-knobs';
import { sankeyFormData } from '@superset-ui/chart/test/fixtures/formData';
import { bigNumberFormData } from '@superset-ui/chart/test/fixtures/formData';
import VerifyCORS, { Props as VerifyCORSProps } from '../../shared/components/VerifyCORS';
import Expandable from '../../shared/components/Expandable';
@ -21,14 +21,14 @@ export default {
};
export const configureCORS = () => {
const host = text('Superset App host for CORS request', 'localhost:9000');
const host = text('Superset App host for CORS request', 'localhost:8088');
const selectEndpoint = select('Endpoint', ENDPOINTS, '');
const customEndpoint = text('Custom Endpoint (override above)', '');
const endpoint = customEndpoint || selectEndpoint;
const method = endpoint ? select('Request method', REQUEST_METHODS, 'POST') : undefined;
const postPayload =
endpoint && method === 'POST'
? text('POST payload', JSON.stringify({ form_data: sankeyFormData }, null, 2))
? text('POST payload', JSON.stringify({ form_data: bigNumberFormData }))
: undefined;
return (