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 */ /* eslint-disable max-classes-per-file */
import React from 'react'; import React from 'react';
import { QueryFormData } from '@superset-ui/query/src'; import { QueryFormData } from '@superset-ui/query';
import { ChartMetadata, ChartPlugin } from '../../src'; import { ChartMetadata, ChartPlugin } from '../../src';
const DIMENSION_STYLE = { const DIMENSION_STYLE = {

View File

@ -1,5 +1,5 @@
import SupersetClientClass from './SupersetClientClass'; import SupersetClientClass from './SupersetClientClass';
import { ClientConfig, RequestConfig, SupersetClientInterface } from './types'; import { SupersetClientInterface } from './types';
let singletonClient: SupersetClientClass | undefined; let singletonClient: SupersetClientClass | undefined;
@ -7,28 +7,26 @@ function getInstance(): SupersetClientClass {
if (!singletonClient) { if (!singletonClient) {
throw new Error('You must call SupersetClient.configure(...) before calling other methods'); throw new Error('You must call SupersetClient.configure(...) before calling other methods');
} }
return singletonClient; return singletonClient;
} }
const SupersetClient: SupersetClientInterface = { const SupersetClient: SupersetClientInterface = {
configure: (config?: ClientConfig): SupersetClientClass => { configure: config => {
singletonClient = new SupersetClientClass(config); singletonClient = new SupersetClientClass(config);
return singletonClient; 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: () => { reset: () => {
singletonClient = undefined; 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; export default SupersetClient;

View File

@ -1,4 +1,4 @@
import callApi from './callApi'; import callApiAndParseWithTimeout from './callApi/callApiAndParseWithTimeout';
import { import {
ClientConfig, ClientConfig,
ClientTimeout, ClientTimeout,
@ -11,7 +11,7 @@ import {
Mode, Mode,
Protocol, Protocol,
RequestConfig, RequestConfig,
SupersetClientResponse, ParseMethod,
} from './types'; } from './types';
import { DEFAULT_FETCH_RETRY_OPTIONS } from './constants'; import { DEFAULT_FETCH_RETRY_OPTIONS } from './constants';
@ -20,6 +20,7 @@ export default class SupersetClientClass {
csrfToken?: CsrfToken; csrfToken?: CsrfToken;
csrfPromise?: CsrfPromise; csrfPromise?: CsrfPromise;
fetchRetryOptions?: FetchRetryOptions; fetchRetryOptions?: FetchRetryOptions;
baseUrl: string;
protocol: Protocol; protocol: Protocol;
host: Host; host: Host;
headers: Headers; headers: Headers;
@ -27,8 +28,9 @@ export default class SupersetClientClass {
timeout: ClientTimeout; timeout: ClientTimeout;
constructor({ constructor({
protocol = 'http:', baseUrl = 'http://localhost',
host = 'localhost', host,
protocol,
headers = {}, headers = {},
fetchRetryOptions = {}, fetchRetryOptions = {},
mode = 'same-origin', mode = 'same-origin',
@ -36,52 +38,57 @@ export default class SupersetClientClass {
credentials = undefined, credentials = undefined,
csrfToken = undefined, csrfToken = undefined,
}: ClientConfig = {}) { }: 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.headers = { ...headers };
this.host = host;
this.mode = mode; this.mode = mode;
this.timeout = timeout; this.timeout = timeout;
this.protocol = protocol;
this.credentials = credentials; this.credentials = credentials;
this.csrfToken = csrfToken; this.csrfToken = csrfToken;
this.csrfPromise = undefined;
this.fetchRetryOptions = { ...DEFAULT_FETCH_RETRY_OPTIONS, ...fetchRetryOptions }; this.fetchRetryOptions = { ...DEFAULT_FETCH_RETRY_OPTIONS, ...fetchRetryOptions };
if (typeof this.csrfToken === 'string') { if (typeof this.csrfToken === 'string') {
this.headers = { ...this.headers, 'X-CSRFToken': this.csrfToken }; this.headers = { ...this.headers, 'X-CSRFToken': this.csrfToken };
this.csrfPromise = Promise.resolve(this.csrfToken); this.csrfPromise = Promise.resolve(this.csrfToken);
} }
} }
init(force: boolean = false): CsrfPromise { async init(force: boolean = false): CsrfPromise {
if (this.isAuthenticated() && !force) { if (this.isAuthenticated() && !force) {
return this.csrfPromise as CsrfPromise; return this.csrfPromise as CsrfPromise;
} }
return this.getCSRFToken(); return this.getCSRFToken();
} }
async reAuthenticate() {
return this.init(true);
}
isAuthenticated(): boolean { isAuthenticated(): boolean {
// if CSRF protection is disabled in the Superset app, the token may be an empty string // if CSRF protection is disabled in the Superset app, the token may be an empty string
return this.csrfToken !== null && this.csrfToken !== undefined; 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' }); 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' }); 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' }); 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' }); return this.request({ ...requestConfig, method: 'POST' });
} }
async request({ async request<T extends ParseMethod = 'json'>({
body, body,
credentials, credentials,
endpoint, endpoint,
@ -97,44 +104,41 @@ export default class SupersetClientClass {
stringify, stringify,
timeout, timeout,
url, url,
}: RequestConfig): Promise<SupersetClientResponse> { }: RequestConfig & { parseMethod?: T }) {
return this.ensureAuth().then(() => await this.ensureAuth();
callApi({ return callApiAndParseWithTimeout({
body, body,
credentials: credentials ?? this.credentials, credentials: credentials ?? this.credentials,
fetchRetryOptions, fetchRetryOptions,
headers: { ...this.headers, ...headers }, headers: { ...this.headers, ...headers },
method, method,
mode: mode ?? this.mode, mode: mode ?? this.mode,
parseMethod, parseMethod,
postPayload, postPayload,
jsonPayload, jsonPayload,
signal, signal,
stringify, stringify,
timeout: timeout ?? this.timeout, timeout: timeout ?? this.timeout,
url: this.getUrl({ endpoint, host, url }), url: this.getUrl({ endpoint, host, url }),
}), });
);
} }
ensureAuth(): CsrfPromise { async ensureAuth(): CsrfPromise {
return ( return (
this.csrfPromise ?? this.csrfPromise ??
Promise.reject({ Promise.reject({
error: `SupersetClient has no CSRF token, ensure it is initialized or error: `SupersetClient has not been provided a CSRF token, ensure it is
try logging into the Superset instance at ${this.getUrl({ initialized with \`client.getCSRFToken()\` or try logging in at
endpoint: '/login', ${this.getUrl({ endpoint: '/login' })}`,
})}`,
}) })
); );
} }
async getCSRFToken(): CsrfPromise { async getCSRFToken() {
this.csrfToken = undefined; this.csrfToken = undefined;
// If we can request this resource successfully, it means that the user has // If we can request this resource successfully, it means that the user has
// authenticated. If not we throw an error prompting to authenticate. // authenticated. If not we throw an error prompting to authenticate.
this.csrfPromise = callApi({ this.csrfPromise = callApiAndParseWithTimeout({
credentials: this.credentials, credentials: this.credentials,
headers: { headers: {
...this.headers, ...this.headers,
@ -143,19 +147,19 @@ export default class SupersetClientClass {
mode: this.mode, mode: this.mode,
timeout: this.timeout, timeout: this.timeout,
url: this.getUrl({ endpoint: 'superset/csrf_token/' }), url: this.getUrl({ endpoint: 'superset/csrf_token/' }),
}).then(response => { parseMethod: 'json',
if (typeof response.json === 'object') { }).then(({ json }) => {
this.csrfToken = response.json.csrf_token as string; if (typeof json === 'object') {
this.csrfToken = json.csrf_token as string;
if (typeof this.csrfToken === 'string') { if (typeof this.csrfToken === 'string') {
this.headers = { ...this.headers, 'X-CSRFToken': this.csrfToken }; this.headers = { ...this.headers, 'X-CSRFToken': this.csrfToken };
} }
} }
if (!this.isAuthenticated()) { if (this.isAuthenticated()) {
return Promise.reject({ error: 'Failed to fetch CSRF token' }); return this.csrfToken;
} }
return this.csrfToken; return Promise.reject({ error: 'Failed to fetch CSRF token' });
}); });
return this.csrfPromise; 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 {Payload} jsonPayload json payload to post, will automatically add Content-Type header
* @param {string} stringify whether to stringify field values when post as formData * @param {string} stringify whether to stringify field values when post as formData
*/ */
export default function callApi({ export default async function callApi({
body, body,
cache = 'default', cache = 'default',
credentials = 'same-origin', credentials = 'same-origin',
@ -40,39 +40,32 @@ export default function callApi({
if ( if (
method === 'GET' && method === 'GET' &&
cache !== 'no-store' &&
cache !== 'reload' &&
CACHE_AVAILABLE && CACHE_AVAILABLE &&
(self.location && self.location.protocol) === 'https:' (self.location && self.location.protocol) === 'https:'
) { ) {
return caches.open(CACHE_KEY).then(supersetCache => const supersetCache = await caches.open(CACHE_KEY);
supersetCache const cachedResponse = await supersetCache.match(url);
.match(url) if (cachedResponse) {
.then(cachedResponse => { // if we have a cached response, send its ETag in the
if (cachedResponse) { // `If-None-Match` header in a conditional request
// if we have a cached response, send its ETag in the const etag = cachedResponse.headers.get('Etag') as string;
// `If-None-Match` header in a conditional request request.headers = { ...request.headers, 'If-None-Match': etag };
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);
return fetchWithRetry(url, request); if (cachedFullResponse) {
}) return cachedFullResponse.clone();
.then(response => { }
if (response.status === HTTP_STATUS_NOT_MODIFIED) { throw new Error('Received 304 but no content is cached!');
return supersetCache.match(url).then(cachedResponse => { }
if (cachedResponse) { if (response.status === HTTP_STATUS_OK && response.headers.get('Etag')) {
return cachedResponse.clone(); supersetCache.delete(url);
} supersetCache.put(url, response.clone());
throw new Error('Received 304 but no content is cached!'); }
}); return response;
}
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') { if (method === 'POST' || method === 'PATCH' || method === 'PUT') {
@ -80,13 +73,13 @@ export default function callApi({
try { try {
return JSON.parse(payloadString) as JsonObject; return JSON.parse(payloadString) as JsonObject;
} catch (error) { } catch (error) {
throw new Error(`Invalid postPayload:\n\n${payloadString}`); throw new Error(`Invalid payload:\n\n${payloadString}`);
} }
}; };
// override request body with post payload // override request body with post payload
const payload: JsonObject | undefined = const payload: JsonObject | undefined =
typeof postPayload === 'string' ? tryParsePayload(postPayload) : postPayload; typeof postPayload === 'string' ? tryParsePayload(postPayload) : postPayload;
if (typeof payload === 'object') { if (typeof payload === 'object') {
// using FormData has the effect that Content-Type header is set to `multipart/form-data`, // using FormData has the effect that Content-Type header is set to `multipart/form-data`,
// not e.g., 'application/x-www-form-urlencoded' // not e.g., 'application/x-www-form-urlencoded'

View File

@ -1,20 +1,17 @@
import callApi from './callApi'; import callApi from './callApi';
import rejectAfterTimeout from './rejectAfterTimeout'; import rejectAfterTimeout from './rejectAfterTimeout';
import parseResponse from './parseResponse'; 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, timeout,
parseMethod, parseMethod,
...rest ...rest
}: { timeout?: ClientTimeout; parseMethod?: ParseMethod } & CallApi): Promise< }: { timeout?: ClientTimeout; parseMethod?: T } & CallApi) {
SupersetClientResponse
> {
const apiPromise = callApi(rest); const apiPromise = callApi(rest);
const racedPromise = const racedPromise =
typeof timeout === 'number' && timeout > 0 typeof timeout === 'number' && timeout > 0
? Promise.race([rejectAfterTimeout<Response>(timeout), apiPromise]) ? Promise.race([apiPromise, rejectAfterTimeout<Response>(timeout)])
: apiPromise; : apiPromise;
return parseResponse(racedPromise, parseMethod); 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> { export default async function parseResponse<T extends ParseMethod = 'json'>(
if (!response.ok) return Promise.reject<Response>(response);
return Promise.resolve<Response>(response);
}
export default function parseResponse(
apiPromise: Promise<Response>, apiPromise: Promise<Response>,
parseMethod: ParseMethod = 'json', parseMethod?: T,
): Promise<SupersetClientResponse> { ) {
const checkedPromise = apiPromise.then(rejectIfNotOkay); 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) { if (parseMethod === null || parseMethod === 'raw') {
return apiPromise.then(rejectIfNotOkay); return response as ReturnType;
} }
if (parseMethod === 'text') { 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') { // by default treat this as json
return checkedPromise.then(response => response.json().then(json => ({ json, response }))); 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 // returns a Promise that rejects after the specified timeout
export default function rejectAfterTimeout<T>(timeout: number): Promise<T> { export default function rejectAfterTimeout<T>(timeout: number) {
return new Promise((resolve, reject) => { return new Promise<T>((resolve, reject) => {
setTimeout( setTimeout(
() => () =>
reject({ reject({

View File

@ -44,7 +44,7 @@ export type Method = RequestInit['method'];
export type Mode = RequestInit['mode']; export type Mode = RequestInit['mode'];
export type Redirect = RequestInit['redirect']; export type Redirect = RequestInit['redirect'];
export type ClientTimeout = number | undefined; 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 Signal = RequestInit['signal'];
export type Stringify = boolean; export type Stringify = boolean;
export type Url = string; export type Url = string;
@ -57,7 +57,6 @@ export interface RequestBase {
host?: Host; host?: Host;
mode?: Mode; mode?: Mode;
method?: Method; method?: Method;
parseMethod?: ParseMethod;
postPayload?: Payload; postPayload?: Payload;
jsonPayload?: Payload; jsonPayload?: Payload;
signal?: Signal; signal?: Signal;
@ -84,10 +83,14 @@ export interface RequestWithUrl extends RequestBase {
// this make sure at least one of `url` or `endpoint` is set // this make sure at least one of `url` or `endpoint` is set
export type RequestConfig = RequestWithEndpoint | RequestWithUrl; export type RequestConfig = RequestWithEndpoint | RequestWithUrl;
export interface JsonTextResponse { export interface JsonResponse {
json?: JsonObject;
response: Response; response: Response;
text?: string; json: JsonObject;
}
export interface TextResponse {
response: Response;
text: string;
} }
export type CsrfToken = string; export type CsrfToken = string;
@ -95,28 +98,25 @@ export type CsrfPromise = Promise<string | undefined>;
export type Protocol = 'http:' | 'https:'; export type Protocol = 'http:' | 'https:';
export interface ClientConfig { export interface ClientConfig {
baseUrl?: string;
host?: Host;
protocol?: Protocol;
credentials?: Credentials; credentials?: Credentials;
csrfToken?: CsrfToken; csrfToken?: CsrfToken;
fetchRetryOptions?: FetchRetryOptions; fetchRetryOptions?: FetchRetryOptions;
headers?: Headers; headers?: Headers;
host?: Host;
protocol?: Protocol;
mode?: Mode; mode?: Mode;
timeout?: ClientTimeout; timeout?: ClientTimeout;
} }
export interface SupersetClientInterface { export interface SupersetClientInterface
extends Pick<
SupersetClientClass,
'delete' | 'get' | 'post' | 'put' | 'request' | 'init' | 'isAuthenticated' | 'reAuthenticate'
> {
configure: (config?: ClientConfig) => SupersetClientClass; configure: (config?: ClientConfig) => SupersetClientClass;
delete: (request: RequestConfig) => Promise<SupersetClientResponse>;
get: (request: RequestConfig) => Promise<SupersetClientResponse>;
getInstance: (maybeClient?: SupersetClientClass) => SupersetClientClass; 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; 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 fetchMock from 'fetch-mock';
import { SupersetClient, SupersetClientClass } from '../src'; import { SupersetClient, SupersetClientClass } from '../src';
@ -30,12 +48,12 @@ describe('SupersetClient', () => {
expect(SupersetClient.isAuthenticated).toThrow(); expect(SupersetClient.isAuthenticated).toThrow();
expect(SupersetClient.reAuthenticate).toThrow(); expect(SupersetClient.reAuthenticate).toThrow();
expect(SupersetClient.request).toThrow(); expect(SupersetClient.request).toThrow();
expect(SupersetClient.configure).not.toThrow(); expect(SupersetClient.configure).not.toThrow();
}); });
// this also tests that the ^above doesn't throw if configure is called appropriately // 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 mockGetUrl = '/mock/get/url';
const mockPostUrl = '/mock/post/url'; const mockPostUrl = '/mock/post/url';
const mockRequestUrl = '/mock/request/url'; const mockRequestUrl = '/mock/request/url';
@ -43,8 +61,13 @@ describe('SupersetClient', () => {
const mockDeleteUrl = '/mock/delete/url'; const mockDeleteUrl = '/mock/delete/url';
const mockGetPayload = { get: 'payload' }; const mockGetPayload = { get: 'payload' };
const mockPostPayload = { post: 'payload' }; const mockPostPayload = { post: 'payload' };
const mockDeletePayload = { delete: 'ok' };
const mockPutPayload = { put: 'ok' };
fetchMock.get(mockGetUrl, mockGetPayload); fetchMock.get(mockGetUrl, mockGetPayload);
fetchMock.post(mockPostUrl, mockPostPayload); fetchMock.post(mockPostUrl, mockPostPayload);
fetchMock.delete(mockDeleteUrl, mockDeletePayload);
fetchMock.put(mockPutUrl, mockPutPayload);
fetchMock.get(mockRequestUrl, mockGetPayload);
const initSpy = jest.spyOn(SupersetClientClass.prototype, 'init'); const initSpy = jest.spyOn(SupersetClientClass.prototype, 'init');
const getSpy = jest.spyOn(SupersetClientClass.prototype, 'get'); const getSpy = jest.spyOn(SupersetClientClass.prototype, 'get');
@ -56,19 +79,19 @@ describe('SupersetClient', () => {
const requestSpy = jest.spyOn(SupersetClientClass.prototype, 'request'); const requestSpy = jest.spyOn(SupersetClientClass.prototype, 'request');
SupersetClient.configure({}); SupersetClient.configure({});
SupersetClient.init(); await SupersetClient.init();
expect(initSpy).toHaveBeenCalledTimes(1); expect(initSpy).toHaveBeenCalledTimes(1);
expect(authenticatedSpy).toHaveBeenCalledTimes(1); expect(authenticatedSpy).toHaveBeenCalledTimes(2);
expect(csrfSpy).toHaveBeenCalledTimes(1); expect(csrfSpy).toHaveBeenCalledTimes(1);
SupersetClient.get({ url: mockGetUrl }); await SupersetClient.get({ url: mockGetUrl });
SupersetClient.post({ url: mockPostUrl }); await SupersetClient.post({ url: mockPostUrl });
SupersetClient.delete({ url: mockDeleteUrl }); await SupersetClient.delete({ url: mockDeleteUrl });
SupersetClient.put({ url: mockPutUrl }); await SupersetClient.put({ url: mockPutUrl });
SupersetClient.request({ url: mockRequestUrl }); await SupersetClient.request({ url: mockRequestUrl });
SupersetClient.isAuthenticated(); SupersetClient.isAuthenticated();
SupersetClient.reAuthenticate(); await SupersetClient.reAuthenticate();
expect(initSpy).toHaveBeenCalledTimes(2); expect(initSpy).toHaveBeenCalledTimes(2);
expect(deleteSpy).toHaveBeenCalledTimes(1); 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 fetchMock from 'fetch-mock';
import { SupersetClientClass, ClientConfig } from '../src'; import { SupersetClientClass, ClientConfig } from '../src';
import throwIfCalled from './utils/throwIfCalled';
import { LOGIN_GLOB } from './fixtures/constants'; import { LOGIN_GLOB } from './fixtures/constants';
describe('SupersetClientClass', () => { describe('SupersetClientClass', () => {
@ -15,6 +32,11 @@ describe('SupersetClientClass', () => {
expect(client).toBeInstanceOf(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()', () => { describe('.getUrl()', () => {
let client = new SupersetClientClass(); let client = new SupersetClientClass();
@ -36,12 +58,12 @@ describe('SupersetClientClass', () => {
}); });
it('constructs a valid url from config.host + endpoint if host is omitted', () => { 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: '' }); 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 }); 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); expect.assertions(1);
await new SupersetClientClass().init();
return new SupersetClientClass().init().then(() => { expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1);
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1);
return true;
});
}); });
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); expect.assertions(1);
await new SupersetClientClass({ csrfToken: 'abc' }).init();
return new SupersetClientClass({ csrfToken: 'abc' }).init().then(() => { expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0);
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0);
return true;
});
}); });
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); expect.assertions(4);
const initialToken = 'initial_token'; const initialToken = 'initial_token';
const client = new SupersetClientClass({ csrfToken: initialToken }); const client = new SupersetClientClass({ csrfToken: initialToken });
return client.init().then(() => { await client.init();
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0); expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0);
expect(client.csrfToken).toBe(initialToken); expect(client.csrfToken).toBe(initialToken);
return client.init(true).then(() => { await client.init(true);
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1); expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1);
expect(client.csrfToken).not.toBe(initialToken); expect(client.csrfToken).not.toBe(initialToken);
return true;
});
});
}); });
it('throws if superset/csrf_token/ returns an error', () => { it('throws if superset/csrf_token/ returns an error', async () => {
expect.assertions(1); expect.assertions(1);
const rejectError = { status: 403 };
fetchMock.get(LOGIN_GLOB, () => Promise.reject({ status: 403 }), { fetchMock.get(LOGIN_GLOB, () => Promise.reject(rejectError), {
overwriteRoutes: true, overwriteRoutes: true,
}); });
try {
return new SupersetClientClass({}) await new SupersetClientClass({}).init();
.init() } catch (error) {
.then(throwIfCalled) expect(error as typeof rejectError).toEqual(rejectError);
.catch((error: { status: number }) => { }
expect(error.status).toBe(403);
return true;
});
}); });
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); expect.assertions(1);
fetchMock.get(LOGIN_GLOB, {}, { overwriteRoutes: true }); fetchMock.get(LOGIN_GLOB, {}, { overwriteRoutes: true });
try {
return new SupersetClientClass({}) await new SupersetClientClass({}).init();
.init() } catch (error) {
.then(throwIfCalled) expect(error as typeof invalidCsrfTokenError).toEqual(invalidCsrfTokenError);
.catch((error: unknown) => { }
expect(error).toBeDefined();
return true;
});
}); });
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', { fetchMock.get(LOGIN_GLOB, '123', {
overwriteRoutes: true, overwriteRoutes: true,
}); });
try {
return new SupersetClientClass({}) await new SupersetClientClass({}).init();
.init() } catch (error) {
.then(throwIfCalled) expect(error as typeof invalidCsrfTokenError).toEqual(invalidCsrfTokenError);
.catch((error: unknown) => { }
expect(error).toBeDefined();
return true;
});
}); });
}); });
describe('.isAuthenticated()', () => { describe('.isAuthenticated()', () => {
afterEach(fetchMock.reset); 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); expect.assertions(2);
const client = new SupersetClientClass({}); const client = new SupersetClientClass({});
expect(client.isAuthenticated()).toBe(false); expect(client.isAuthenticated()).toBe(false);
await client.init();
return client.init().then(() => { expect(client.isAuthenticated()).toBe(true);
expect(client.isAuthenticated()).toBe(true);
return true;
});
}); });
it('returns true if a token is passed at configuration', () => { it('returns true if a token is passed at configuration', () => {
expect.assertions(2); expect.assertions(2);
const clientWithoutToken = new SupersetClientClass({ csrfToken: undefined }); const clientWithoutToken = new SupersetClientClass({ csrfToken: undefined });
const clientWithToken = new SupersetClientClass({ csrfToken: 'token' }); const clientWithToken = new SupersetClientClass({ csrfToken: 'token' });
expect(clientWithoutToken.isAuthenticated()).toBe(false); expect(clientWithoutToken.isAuthenticated()).toBe(false);
expect(clientWithToken.isAuthenticated()).toBe(true); expect(clientWithToken.isAuthenticated()).toBe(true);
}); });
}); });
describe('.ensureAuth()', () => { 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); expect.assertions(2);
const client = new SupersetClientClass({}); const client = new SupersetClientClass({});
try {
return client await client.ensureAuth();
.ensureAuth() } catch (error) {
.then(throwIfCalled) expect(error).toEqual({ error: expect.any(String) });
.catch((error: { error: string }) => { }
expect(error).toEqual( expect(client.isAuthenticated()).toBe(false);
expect.objectContaining({ error: expect.any(String) }) as typeof error,
);
expect(client.isAuthenticated()).toBe(false);
return true;
});
}); });
it('returns a promise that resolves if .init() resolves successfully', () => { it('returns a promise that resolves if .init() resolves successfully', async () => {
expect.assertions(1); expect.assertions(1);
const client = new SupersetClientClass({}); const client = new SupersetClientClass({});
await client.init();
await client.ensureAuth();
return client.init().then(() => expect(client.isAuthenticated()).toBe(true);
client
.ensureAuth()
.then(throwIfCalled)
.catch(() => {
expect(client.isAuthenticated()).toBe(true);
return 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 }; const rejectValue = { status: 403 };
fetchMock.get(LOGIN_GLOB, () => Promise.reject(rejectValue), { fetchMock.get(LOGIN_GLOB, () => Promise.reject(rejectValue), {
overwriteRoutes: true, overwriteRoutes: true,
}); });
expect.assertions(3);
const client = new SupersetClientClass({}); const client = new SupersetClientClass({});
return client try {
.init() await client.init();
.then(throwIfCalled) } catch (error) {
.catch((error: unknown) => { expect(error).toEqual(expect.objectContaining(rejectValue));
expect(error).toEqual(expect.objectContaining(rejectValue) as unknown); expect(client.isAuthenticated()).toBe(false);
try {
await client.ensureAuth();
} catch (error2) {
expect(error2).toEqual(expect.objectContaining(rejectValue));
expect(client.isAuthenticated()).toBe(false);
}
}
return client // reset
.ensureAuth() fetchMock.get(
.then(throwIfCalled) LOGIN_GLOB,
.catch((error2: unknown) => { { csrf_token: 1234 },
expect(error2).toEqual(expect.objectContaining(rejectValue) as unknown); {
expect(client.isAuthenticated()).toBe(false); overwriteRoutes: true,
},
// reset );
fetchMock.get(
LOGIN_GLOB,
{ csrf_token: 1234 },
{
overwriteRoutes: true,
},
);
return true;
});
});
}); });
}); });
describe('requests', () => { describe('requests', () => {
afterEach(fetchMock.reset); afterEach(fetchMock.reset);
const protocol = 'https:'; const protocol = 'https:';
const host = 'HOST'; const host = 'host';
const mockGetEndpoint = '/get/url'; const mockGetEndpoint = '/get/url';
const mockRequestEndpoint = '/request/url'; const mockRequestEndpoint = '/request/url';
const mockPostEndpoint = '/post/url'; const mockPostEndpoint = '/post/url';
@ -263,34 +242,32 @@ describe('SupersetClientClass', () => {
fetchMock.get(mockTextUrl, mockTextJsonResponse); fetchMock.get(mockTextUrl, mockTextJsonResponse);
fetchMock.post(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); expect.assertions(6);
const authSpy = jest.spyOn(SupersetClientClass.prototype, 'ensureAuth'); const authSpy = jest.spyOn(SupersetClientClass.prototype, 'ensureAuth');
const client = new SupersetClientClass({ protocol, host }); const client = new SupersetClientClass({ protocol, host });
return client.init().then(() => await client.init();
Promise.all([ await client.get({ url: mockGetUrl });
client.get({ url: mockGetUrl }), await client.post({ url: mockPostUrl });
client.post({ url: mockPostUrl }), await client.put({ url: mockPutUrl });
client.put({ url: mockPutUrl }), await client.delete({ url: mockDeleteUrl });
client.delete({ url: mockDeleteUrl }), await client.request({ url: mockRequestUrl, method: 'DELETE' });
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();
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); expect.assertions(3);
const clientConfig: ClientConfig = { const clientConfig: ClientConfig = {
host, host,
protocol, protocol,
@ -300,60 +277,43 @@ describe('SupersetClientClass', () => {
}; };
const client = new SupersetClientClass(clientConfig); const client = new SupersetClientClass(clientConfig);
await client.init();
await client.get({ url: mockGetUrl });
return client.init().then(() => const fetchRequest = fetchMock.calls(mockGetUrl)[0][1];
client.get({ url: mockGetUrl }).then(() => { expect(fetchRequest.mode).toBe(clientConfig.mode);
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1]; expect(fetchRequest.credentials).toBe(clientConfig.credentials);
expect(fetchRequest.mode).toBe(clientConfig.mode); expect(fetchRequest.headers).toEqual(
expect(fetchRequest.credentials).toBe(clientConfig.credentials); expect.objectContaining(clientConfig.headers) as typeof fetchRequest.headers,
expect(fetchRequest.headers).toEqual(
expect.objectContaining(clientConfig.headers) as typeof fetchRequest.headers,
);
return true;
}),
); );
}); });
describe('.get()', () => { describe('.get()', () => {
it('makes a request using url or endpoint', () => { it('makes a request using url or endpoint', async () => {
expect.assertions(1); expect.assertions(2);
const client = new SupersetClientClass({ protocol, host }); const client = new SupersetClientClass({ protocol, host });
await client.init();
return client.init().then(() => await client.get({ url: mockGetUrl });
Promise.all([ expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
client.get({ url: mockGetUrl }),
client.get({ endpoint: mockGetEndpoint }),
]).then(() => {
expect(fetchMock.calls(mockGetUrl)).toHaveLength(2);
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); expect.assertions(2);
const client = new SupersetClientClass({ protocol, host }); const client = new SupersetClientClass({ protocol, host });
await client.init();
return client const { text } = await client.get({ url: mockTextUrl, parseMethod: 'text' });
.init() expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
.then(() => expect(text).toBe(mockTextJsonResponse);
client
.get({ url: mockTextUrl, parseMethod: 'text' })
.then(({ text }) => {
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
expect(text).toBe(mockTextJsonResponse);
return true;
})
.catch(throwIfCalled),
)
.catch(throwIfCalled);
}); });
it('allows overriding host, headers, mode, and credentials per-request', () => { it('allows overriding host, headers, mode, and credentials per-request', async () => {
expect.assertions(3); expect.assertions(3);
const clientConfig: ClientConfig = { const clientConfig: ClientConfig = {
host, host,
protocol, protocol,
@ -361,7 +321,6 @@ describe('SupersetClientClass', () => {
credentials: 'include', credentials: 'include',
headers: { my: 'header' }, headers: { my: 'header' },
}; };
const overrideConfig: ClientConfig = { const overrideConfig: ClientConfig = {
host: 'override_host', host: 'override_host',
mode: 'no-cors', mode: 'no-cors',
@ -370,46 +329,34 @@ describe('SupersetClientClass', () => {
}; };
const client = new SupersetClientClass(clientConfig); const client = new SupersetClientClass(clientConfig);
await client.init();
await client.get({ url: mockGetUrl, ...overrideConfig });
return client const fetchRequest = fetchMock.calls(mockGetUrl)[0][1];
.init() expect(fetchRequest.mode).toBe(overrideConfig.mode);
.then(() => expect(fetchRequest.credentials).toBe(overrideConfig.credentials);
client expect(fetchRequest.headers).toEqual(
.get({ url: mockGetUrl, ...overrideConfig }) expect.objectContaining(overrideConfig.headers) as typeof fetchRequest.headers,
.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);
}); });
}); });
describe('.post()', () => { describe('.post()', () => {
it('makes a request using url or endpoint', () => { it('makes a request using url or endpoint', async () => {
expect.assertions(1); expect.assertions(2);
const client = new SupersetClientClass({ protocol, host }); const client = new SupersetClientClass({ protocol, host });
await client.init();
return client.init().then(() => await client.post({ url: mockPostUrl });
Promise.all([ expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
client.post({ url: mockPostUrl }),
client.post({ endpoint: mockPostEndpoint }),
]).then(() => {
expect(fetchMock.calls(mockPostUrl)).toHaveLength(2);
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 = { const clientConfig: ClientConfig = {
host, host,
protocol, protocol,
@ -417,7 +364,6 @@ describe('SupersetClientClass', () => {
credentials: 'include', credentials: 'include',
headers: { my: 'header' }, headers: { my: 'header' },
}; };
const overrideConfig: ClientConfig = { const overrideConfig: ClientConfig = {
host: 'override_host', host: 'override_host',
mode: 'no-cors', mode: 'no-cors',
@ -426,70 +372,57 @@ describe('SupersetClientClass', () => {
}; };
const client = new SupersetClientClass(clientConfig); const client = new SupersetClientClass(clientConfig);
await client.init();
await client.post({ url: mockPostUrl, ...overrideConfig });
return client.init().then(() => const fetchRequest = fetchMock.calls(mockPostUrl)[0][1];
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,
);
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); expect.assertions(2);
const client = new SupersetClientClass({ protocol, host }); const client = new SupersetClientClass({ protocol, host });
await client.init();
return client.init().then(() => const { text } = await client.post({ url: mockTextUrl, parseMethod: 'text' });
client.post({ url: mockTextUrl, parseMethod: 'text' }).then(({ text }) => { expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1); expect(text).toBe(mockTextJsonResponse);
expect(text).toBe(mockTextJsonResponse);
return true;
}),
);
}); });
it('passes postPayload key,values in the body', () => { it('passes postPayload key,values in the body', async () => {
expect.assertions(3); expect.assertions(3);
const postPayload = { number: 123, array: [1, 2, 3] }; const postPayload = { number: 123, array: [1, 2, 3] };
const client = new SupersetClientClass({ protocol, host }); const client = new SupersetClientClass({ protocol, host });
await client.init();
await client.post({ url: mockPostUrl, postPayload });
return client.init().then(() => const formData = fetchMock.calls(mockPostUrl)[0][1].body as FormData;
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));
});
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); expect.assertions(3);
const postPayload = { number: 123, array: [1, 2, 3] }; const postPayload = { number: 123, array: [1, 2, 3] };
const client = new SupersetClientClass({ protocol, host }); const client = new SupersetClientClass({ protocol, host });
await client.init();
await client.post({ url: mockPostUrl, postPayload, stringify: false });
return client.init().then(() => const formData = fetchMock.calls(mockPostUrl)[0][1].body as FormData;
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));
});
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 fetchMock from 'fetch-mock';
import callApi from '../../src/callApi/callApi'; import callApi from '../../src/callApi/callApi';
import * as constants from '../../src/constants'; import * as constants from '../../src/constants';
import { LOGIN_GLOB } from '../fixtures/constants'; import { LOGIN_GLOB } from '../fixtures/constants';
import throwIfCalled from '../utils/throwIfCalled';
import { CallApi, JsonObject } from '../../src/types'; import { CallApi, JsonObject } from '../../src/types';
import { DEFAULT_FETCH_RETRY_OPTIONS } from '../../src/constants'; import { DEFAULT_FETCH_RETRY_OPTIONS } from '../../src/constants';
@ -47,27 +63,22 @@ describe('callApi()', () => {
afterEach(fetchMock.reset); afterEach(fetchMock.reset);
describe('request config', () => { 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); expect.assertions(4);
await Promise.all([
return Promise.all([
callApi({ url: mockGetUrl, method: 'GET' }), callApi({ url: mockGetUrl, method: 'GET' }),
callApi({ url: mockPostUrl, method: 'POST' }), callApi({ url: mockPostUrl, method: 'POST' }),
callApi({ url: mockPutUrl, method: 'PUT' }), callApi({ url: mockPutUrl, method: 'PUT' }),
callApi({ url: mockPatchUrl, method: 'PATCH' }), callApi({ url: mockPatchUrl, method: 'PATCH' }),
]).then(() => { ]);
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1); expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1); expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPutUrl)).toHaveLength(1); expect(fetchMock.calls(mockPutUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPatchUrl)).toHaveLength(1); expect(fetchMock.calls(mockPatchUrl)).toHaveLength(1);
return true;
});
}); });
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); expect.assertions(8);
const mockRequest: CallApi = { const mockRequest: CallApi = {
url: mockGetUrl, url: mockGetUrl,
mode: 'cors', mode: 'cors',
@ -81,64 +92,55 @@ describe('callApi()', () => {
body: 'BODY', body: 'BODY',
}; };
return callApi(mockRequest).then(() => { await callApi(mockRequest);
const calls = fetchMock.calls(mockGetUrl); const calls = fetchMock.calls(mockGetUrl);
const fetchParams = calls[0][1]; const fetchParams = calls[0][1];
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
expect(fetchParams.mode).toBe(mockRequest.mode); expect(fetchParams.mode).toBe(mockRequest.mode);
expect(fetchParams.cache).toBe(mockRequest.cache); expect(fetchParams.cache).toBe(mockRequest.cache);
expect(fetchParams.credentials).toBe(mockRequest.credentials); expect(fetchParams.credentials).toBe(mockRequest.credentials);
expect(fetchParams.headers).toEqual( expect(fetchParams.headers).toEqual(
expect.objectContaining(mockRequest.headers) as typeof fetchParams.headers, expect.objectContaining(mockRequest.headers) as typeof fetchParams.headers,
); );
expect(fetchParams.redirect).toBe(mockRequest.redirect); expect(fetchParams.redirect).toBe(mockRequest.redirect);
expect(fetchParams.signal).toBe(mockRequest.signal); expect(fetchParams.signal).toBe(mockRequest.signal);
expect(fetchParams.body).toBe(mockRequest.body); expect(fetchParams.body).toBe(mockRequest.body);
return true;
});
}); });
}); });
describe('POST requests', () => { describe('POST requests', () => {
it('encodes key,value pairs from postPayload', () => { it('encodes key,value pairs from postPayload', async () => {
expect.assertions(3); expect.assertions(3);
const postPayload = { key: 'value', anotherKey: 1237 }; const postPayload = { key: 'value', anotherKey: 1237 };
return callApi({ url: mockPostUrl, method: 'POST', postPayload }).then(() => { await callApi({ url: mockPostUrl, method: 'POST', postPayload });
const calls = fetchMock.calls(mockPostUrl); const calls = fetchMock.calls(mockPostUrl);
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
const fetchParams = calls[0][1]; const fetchParams = calls[0][1];
const body = fetchParams.body as FormData; const body = fetchParams.body as FormData;
Object.entries(postPayload).forEach(([key, value]) => { Object.entries(postPayload).forEach(([key, value]) => {
expect(body.get(key)).toBe(JSON.stringify(value)); expect(body.get(key)).toBe(JSON.stringify(value));
});
return true;
}); });
}); });
// the reason for this is to omit strings like 'undefined' from making their way to the backend // 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); expect.assertions(3);
const postPayload = { key: 'value', noValue: undefined }; const postPayload = { key: 'value', noValue: undefined };
return callApi({ url: mockPostUrl, method: 'POST', postPayload }).then(() => { await callApi({ url: mockPostUrl, method: 'POST', postPayload });
const calls = fetchMock.calls(mockPostUrl); const calls = fetchMock.calls(mockPostUrl);
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
const fetchParams = calls[0][1]; const fetchParams = calls[0][1];
const body = fetchParams.body as FormData; const body = fetchParams.body as FormData;
expect(body.get('key')).toBe(JSON.stringify(postPayload.key)); expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
expect(body.get('noValue')).toBeNull(); expect(body.get('noValue')).toBeNull();
return true;
});
}); });
it('respects the stringify flag in POST requests', () => { it('respects the stringify flag in POST requests', async () => {
const postPayload = { const postPayload = {
string: 'value', string: 'value',
number: 1237, number: 1237,
@ -150,68 +152,59 @@ describe('callApi()', () => {
expect.assertions(1 + 3 * Object.keys(postPayload).length); 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 }),
callApi({ url: mockPostUrl, method: 'POST', postPayload, stringify: false }), callApi({ url: mockPostUrl, method: 'POST', postPayload, stringify: false }),
callApi({ url: mockPostUrl, method: 'POST', jsonPayload: postPayload }), callApi({ url: mockPostUrl, method: 'POST', jsonPayload: postPayload }),
]).then(() => { ]);
const calls = fetchMock.calls(mockPostUrl); const calls = fetchMock.calls(mockPostUrl);
expect(calls).toHaveLength(3); expect(calls).toHaveLength(3);
const stringified = calls[0][1].body as FormData; const stringified = calls[0][1].body as FormData;
const unstringified = calls[1][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 jsonRequestBody = JSON.parse(calls[2][1].body as string) as JsonObject;
Object.entries(postPayload).forEach(([key, value]) => { Object.entries(postPayload).forEach(([key, value]) => {
expect(stringified.get(key)).toBe(JSON.stringify(value)); expect(stringified.get(key)).toBe(JSON.stringify(value));
expect(unstringified.get(key)).toBe(String(value)); expect(unstringified.get(key)).toBe(String(value));
expect(jsonRequestBody[key]).toEqual(value); expect(jsonRequestBody[key]).toEqual(value);
});
return true;
}); });
}); });
}); });
describe('PUT requests', () => { describe('PUT requests', () => {
it('encodes key,value pairs from postPayload', () => { it('encodes key,value pairs from postPayload', async () => {
expect.assertions(3); expect.assertions(3);
const postPayload = { key: 'value', anotherKey: 1237 }; const postPayload = { key: 'value', anotherKey: 1237 };
return callApi({ url: mockPutUrl, method: 'PUT', postPayload }).then(() => { await callApi({ url: mockPutUrl, method: 'PUT', postPayload });
const calls = fetchMock.calls(mockPutUrl); const calls = fetchMock.calls(mockPutUrl);
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
const fetchParams = calls[0][1]; const fetchParams = calls[0][1];
const body = fetchParams.body as FormData; const body = fetchParams.body as FormData;
Object.entries(postPayload).forEach(([key, value]) => { Object.entries(postPayload).forEach(([key, value]) => {
expect(body.get(key)).toBe(JSON.stringify(value)); expect(body.get(key)).toBe(JSON.stringify(value));
});
return true;
}); });
}); });
// the reason for this is to omit strings like 'undefined' from making their way to the backend // 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); expect.assertions(3);
const postPayload = { key: 'value', noValue: undefined }; const postPayload = { key: 'value', noValue: undefined };
return callApi({ url: mockPutUrl, method: 'PUT', postPayload }).then(() => { await callApi({ url: mockPutUrl, method: 'PUT', postPayload });
const calls = fetchMock.calls(mockPutUrl); const calls = fetchMock.calls(mockPutUrl);
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
const fetchParams = calls[0][1]; const fetchParams = calls[0][1];
const body = fetchParams.body as FormData; const body = fetchParams.body as FormData;
expect(body.get('key')).toBe(JSON.stringify(postPayload.key)); expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
expect(body.get('noValue')).toBeNull(); expect(body.get('noValue')).toBeNull();
return true;
});
}); });
it('respects the stringify flag in PUT requests', () => { it('respects the stringify flag in PUT requests', async () => {
const postPayload = { const postPayload = {
string: 'value', string: 'value',
number: 1237, number: 1237,
@ -223,65 +216,56 @@ describe('callApi()', () => {
expect.assertions(1 + 2 * Object.keys(postPayload).length); 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 }),
callApi({ url: mockPutUrl, method: 'PUT', postPayload, stringify: false }), callApi({ url: mockPutUrl, method: 'PUT', postPayload, stringify: false }),
]).then(() => { ]);
const calls = fetchMock.calls(mockPutUrl); const calls = fetchMock.calls(mockPutUrl);
expect(calls).toHaveLength(2); expect(calls).toHaveLength(2);
const stringified = calls[0][1].body as FormData; const stringified = calls[0][1].body as FormData;
const unstringified = calls[1][1].body as FormData; const unstringified = calls[1][1].body as FormData;
Object.entries(postPayload).forEach(([key, value]) => { Object.entries(postPayload).forEach(([key, value]) => {
expect(stringified.get(key)).toBe(JSON.stringify(value)); expect(stringified.get(key)).toBe(JSON.stringify(value));
expect(unstringified.get(key)).toBe(String(value)); expect(unstringified.get(key)).toBe(String(value));
});
return true;
}); });
}); });
}); });
describe('PATCH requests', () => { describe('PATCH requests', () => {
it('encodes key,value pairs from postPayload', () => { it('encodes key,value pairs from postPayload', async () => {
expect.assertions(3); expect.assertions(3);
const postPayload = { key: 'value', anotherKey: 1237 }; const postPayload = { key: 'value', anotherKey: 1237 };
return callApi({ url: mockPatchUrl, method: 'PATCH', postPayload }).then(() => { await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload });
const calls = fetchMock.calls(mockPatchUrl); const calls = fetchMock.calls(mockPatchUrl);
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
const fetchParams = calls[0][1]; const fetchParams = calls[0][1];
const body = fetchParams.body as FormData; const body = fetchParams.body as FormData;
Object.entries(postPayload).forEach(([key, value]) => { Object.entries(postPayload).forEach(([key, value]) => {
expect(body.get(key)).toBe(JSON.stringify(value)); expect(body.get(key)).toBe(JSON.stringify(value));
});
return true;
}); });
}); });
// the reason for this is to omit strings like 'undefined' from making their way to the backend // 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); expect.assertions(3);
const postPayload = { key: 'value', noValue: undefined }; const postPayload = { key: 'value', noValue: undefined };
return callApi({ url: mockPatchUrl, method: 'PATCH', postPayload }).then(() => { await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload });
const calls = fetchMock.calls(mockPatchUrl); const calls = fetchMock.calls(mockPatchUrl);
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
const fetchParams = calls[0][1]; const fetchParams = calls[0][1];
const body = fetchParams.body as FormData; const body = fetchParams.body as FormData;
expect(body.get('key')).toBe(JSON.stringify(postPayload.key)); expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
expect(body.get('noValue')).toBeNull(); expect(body.get('noValue')).toBeNull();
return true;
});
}); });
it('respects the stringify flag in PATCH requests', () => { it('respects the stringify flag in PATCH requests', async () => {
const postPayload = { const postPayload = {
string: 'value', string: 'value',
number: 1237, number: 1237,
@ -293,22 +277,19 @@ describe('callApi()', () => {
expect.assertions(1 + 2 * Object.keys(postPayload).length); 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 }),
callApi({ url: mockPatchUrl, method: 'PATCH', postPayload, stringify: false }), callApi({ url: mockPatchUrl, method: 'PATCH', postPayload, stringify: false }),
]).then(() => { ]);
const calls = fetchMock.calls(mockPatchUrl); const calls = fetchMock.calls(mockPatchUrl);
expect(calls).toHaveLength(2); expect(calls).toHaveLength(2);
const stringified = calls[0][1].body as FormData; const stringified = calls[0][1].body as FormData;
const unstringified = calls[1][1].body as FormData; const unstringified = calls[1][1].body as FormData;
Object.entries(postPayload).forEach(([key, value]) => { Object.entries(postPayload).forEach(([key, value]) => {
expect(stringified.get(key)).toBe(JSON.stringify(value)); expect(stringified.get(key)).toBe(JSON.stringify(value));
expect(unstringified.get(key)).toBe(String(value)); expect(unstringified.get(key)).toBe(String(value));
});
return true;
}); });
}); });
}); });
@ -326,42 +307,34 @@ describe('callApi()', () => {
beforeEach(() => { beforeEach(() => {
self.location.protocol = 'https:'; self.location.protocol = 'https:';
return caches.delete(constants.CACHE_KEY); return caches.delete(constants.CACHE_KEY);
}); });
it('caches requests with ETags', () => it('caches requests with ETags', async () => {
callApi({ url: mockCacheUrl, method: 'GET' }).then(() => { expect.assertions(2);
const calls = fetchMock.calls(mockCacheUrl); await callApi({ url: mockCacheUrl, method: 'GET' });
expect(calls).toHaveLength(1); 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 => it('will not use cache when running off an insecure connection', async () => {
supersetCache.match(mockCacheUrl).then(cachedResponse => { expect.assertions(2);
expect(cachedResponse).toBeDefined();
return true;
}),
);
}));
it('will not use cache when running off an insecure connection', () => {
self.location.protocol = 'http:'; self.location.protocol = 'http:';
return callApi({ url: mockCacheUrl, method: 'GET' }).then(() => { await callApi({ url: mockCacheUrl, method: 'GET' });
const calls = fetchMock.calls(mockCacheUrl); const calls = fetchMock.calls(mockCacheUrl);
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
return caches.open(constants.CACHE_KEY).then(supersetCache => const supersetCache = await caches.open(constants.CACHE_KEY);
supersetCache.match(mockCacheUrl).then(cachedResponse => { const cachedResponse = await supersetCache.match(mockCacheUrl);
expect(cachedResponse).toBeUndefined(); expect(cachedResponse).toBeUndefined();
return true;
}),
);
});
}); });
it('works when the Cache API is disabled', async () => { it('works when the Cache API is disabled', async () => {
expect.assertions(5);
// eslint-disable-next-line no-import-assign // eslint-disable-next-line no-import-assign
Object.defineProperty(constants, 'CACHE_AVAILABLE', { value: false }); Object.defineProperty(constants, 'CACHE_AVAILABLE', { value: false });
@ -383,26 +356,25 @@ describe('callApi()', () => {
Object.defineProperty(constants, 'CACHE_AVAILABLE', { value: true }); 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 // first call sets the cache
callApi({ url: mockCacheUrl, method: 'GET' }).then(() => { await callApi({ url: mockCacheUrl, method: 'GET' });
const calls = fetchMock.calls(mockCacheUrl); const calls = fetchMock.calls(mockCacheUrl);
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
// second call sends the Etag in the If-None-Match header // second call sends the Etag in the If-None-Match header
return callApi({ url: mockCacheUrl, method: 'GET' }).then(() => { await callApi({ url: mockCacheUrl, method: 'GET' });
const fetchParams = calls[1][1]; const fetchParams = calls[1][1];
const headers = { 'If-None-Match': 'etag' }; const headers = { 'If-None-Match': 'etag' };
expect(calls).toHaveLength(2); expect(calls).toHaveLength(2);
expect(fetchParams.headers).toEqual( expect(fetchParams.headers).toEqual(
expect.objectContaining(headers) as typeof fetchParams.headers, expect.objectContaining(headers) as typeof fetchParams.headers,
); );
});
return true;
});
}));
it('reuses cached responses on 304 status', async () => { it('reuses cached responses on 304 status', async () => {
expect.assertions(3);
// first call sets the cache // first call sets the cache
await callApi({ url: mockCacheUrl, method: 'GET' }); await callApi({ url: mockCacheUrl, method: 'GET' });
const calls = fetchMock.calls(mockCacheUrl); const calls = fetchMock.calls(mockCacheUrl);
@ -417,23 +389,28 @@ describe('callApi()', () => {
expect(secondBody).toEqual('BODY'); 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 // this should never happen, since a 304 is only returned if we have
// the cached response and sent the If-None-Match header // the cached response and sent the If-None-Match header
const mockUncachedUrl = '/mock/uncached/url'; const mockUncachedUrl = '/mock/uncached/url';
const mockCachedPayload = { status: 304 }; const mockCachedPayload = { status: 304 };
fetchMock.get(mockUncachedUrl, mockCachedPayload); fetchMock.get(mockUncachedUrl, mockCachedPayload);
return callApi({ url: mockUncachedUrl, method: 'GET' }).catch( try {
(error: { message: string }) => { await callApi({ url: mockUncachedUrl, method: 'GET' });
const calls = fetchMock.calls(mockUncachedUrl); } catch (error) {
expect(calls).toHaveLength(1); const calls = fetchMock.calls(mockUncachedUrl);
expect(error.message).toEqual('Received 304 but no content is cached!'); 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 () => { it('returns original response if no Etag', async () => {
expect.assertions(3);
const url = mockGetUrl; const url = mockGetUrl;
const response = await callApi({ url, method: 'GET' }); const response = await callApi({ url, method: 'GET' });
const calls = fetchMock.calls(url); const calls = fetchMock.calls(url);
@ -444,6 +421,7 @@ describe('callApi()', () => {
}); });
it('returns original response if status not 304 or 200', async () => { it('returns original response if status not 304 or 200', async () => {
expect.assertions(2);
const url = mockNotFound; const url = mockNotFound;
const response = await callApi({ url, method: 'GET' }); const response = await callApi({ url, method: 'GET' });
const calls = fetchMock.calls(url); 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); expect.assertions(3);
try {
return callApi({ await callApi({
fetchRetryOptions: DEFAULT_FETCH_RETRY_OPTIONS, fetchRetryOptions: DEFAULT_FETCH_RETRY_OPTIONS,
url: mockErrorUrl, url: mockErrorUrl,
method: 'GET', 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);
}); });
} 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); expect.assertions(3);
try {
return callApi({ await callApi({
fetchRetryOptions: { retries: 0 }, fetchRetryOptions: { retries: 0 },
url: mockErrorUrl, url: mockErrorUrl,
method: 'GET', 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);
}); });
} 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 () => { it('rejects after retrying thrice if the request returns a 503', async () => {
expect.assertions(2);
const url = mock503; const url = mock503;
const response = await callApi({ const response = await callApi({
fetchRetryOptions: DEFAULT_FETCH_RETRY_OPTIONS, fetchRetryOptions: DEFAULT_FETCH_RETRY_OPTIONS,
@ -496,13 +475,17 @@ describe('callApi()', () => {
expect(response.status).toEqual(503); expect(response.status).toEqual(503);
}); });
it('invalid json for postPayload should thrown error', () => { it('invalid json for postPayload should thrown error', async () => {
expect(() => { expect.assertions(2);
callApi({ try {
await callApi({
url: mockPostUrl, url: mockPostUrl,
method: 'POST', method: 'POST',
postPayload: 'haha', 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 fetchMock from 'fetch-mock';
import callApiAndParseWithTimeout from '../../src/callApi/callApiAndParseWithTimeout'; 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 * as rejectAfterTimeout from '../../src/callApi/rejectAfterTimeout';
import { LOGIN_GLOB } from '../fixtures/constants'; import { LOGIN_GLOB } from '../fixtures/constants';
import throwIfCalled from '../utils/throwIfCalled';
describe('callApiAndParseWithTimeout()', () => { describe('callApiAndParseWithTimeout()', () => {
beforeAll(() => { beforeAll(() => {
@ -64,40 +81,39 @@ describe('callApiAndParseWithTimeout()', () => {
rejectionSpy.mockClear(); rejectionSpy.mockClear();
}); });
it('rejects if the request exceeds the timeout', () => { it('rejects if the request exceeds the timeout', async () => {
return new Promise(done => { expect.assertions(2);
expect.assertions(3); jest.useFakeTimers();
jest.useFakeTimers();
const mockTimeoutUrl = '/mock/timeout/url'; const mockTimeoutUrl = '/mock/timeout/url';
const unresolvingPromise = new Promise(() => {}); const unresolvingPromise = new Promise(() => {});
fetchMock.get(mockTimeoutUrl, () => unresolvingPromise); 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
});
try {
const promise = callApiAndParseWithTimeout({
url: mockTimeoutUrl,
method: 'GET',
timeout: 1,
});
jest.advanceTimersByTime(2); 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); expect.assertions(1);
const { json } = await callApiAndParseWithTimeout({
return callApiAndParseWithTimeout({ url: mockGetUrl, method: 'GET', timeout: 100 }).then( url: mockGetUrl,
response => { method: 'GET',
// eslint-disable-next-line @typescript-eslint/no-unsafe-call timeout: 100,
expect(response.json).toEqual(expect.objectContaining(mockGetPayload)); });
expect(json).toEqual(mockGetPayload);
return true;
},
);
}); });
}); });
}); });

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 fetchMock from 'fetch-mock';
import callApi from '../../src/callApi/callApi'; import callApi from '../../src/callApi/callApi';
import parseResponse from '../../src/callApi/parseResponse'; import parseResponse from '../../src/callApi/parseResponse';
import { LOGIN_GLOB } from '../fixtures/constants'; import { LOGIN_GLOB } from '../fixtures/constants';
import throwIfCalled from '../utils/throwIfCalled';
import { SupersetClientResponse } from '../../src';
describe('parseResponse()', () => { describe('parseResponse()', () => {
beforeAll(() => { beforeAll(() => {
@ -35,22 +51,17 @@ describe('parseResponse()', () => {
expect(parsedResponsePromise).toBeInstanceOf(Promise); 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); expect.assertions(4);
const apiPromise = callApi({ url: mockGetUrl, method: 'GET' }); const args = await parseResponse(callApi({ url: mockGetUrl, method: 'GET' }));
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
return parseResponse(apiPromise).then(args => { const keys = Object.keys(args);
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1); expect(keys).toContain('response');
const keys = Object.keys(args); expect(keys).toContain('json');
expect(keys).toContain('response'); expect(args.json).toEqual(expect.objectContaining(mockGetPayload) as typeof args.json);
expect(keys).toContain('json');
expect(args.json).toEqual(expect.objectContaining(mockGetPayload) as typeof args.json);
return true;
});
}); });
it('throws if `parseMethod=json` and .json() fails', () => { it('throws if `parseMethod=json` and .json() fails', async () => {
expect.assertions(3); expect.assertions(3);
const mockTextUrl = '/mock/text/url'; 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>'; '<html><head></head><body>I could be a stack trace or something</body></html>';
fetchMock.get(mockTextUrl, mockTextResponse); fetchMock.get(mockTextUrl, mockTextResponse);
const apiPromise = callApi({ url: mockTextUrl, method: 'GET' }); try {
await parseResponse(callApi({ url: mockTextUrl, method: 'GET' }));
return parseResponse(apiPromise, 'json') } catch (error) {
.then(throwIfCalled) const err = error as Error;
.catch((error: { stack: unknown; message: string }) => { expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1); expect(err.stack).toBeDefined();
expect(error.stack).toBeDefined(); expect(err.message).toContain('Unexpected token');
expect(error.message).toContain('Unexpected token'); }
return true;
});
}); });
it('resolves to { text, response } if the `parseMethod=text`', () => { it('resolves to { text, response } if the `parseMethod=text`', async () => {
expect.assertions(4); expect.assertions(4);
// test with json + bigint to ensure that it was not first parsed as json // test with json + bigint to ensure that it was not first parsed as json
@ -79,53 +87,49 @@ describe('parseResponse()', () => {
const mockTextJsonResponse = '{ "value": 9223372036854775807 }'; const mockTextJsonResponse = '{ "value": 9223372036854775807 }';
fetchMock.get(mockTextParseUrl, mockTextJsonResponse); fetchMock.get(mockTextParseUrl, mockTextJsonResponse);
const apiPromise = callApi({ url: mockTextParseUrl, method: 'GET' }); const args = await parseResponse(callApi({ url: mockTextParseUrl, method: 'GET' }), 'text');
expect(fetchMock.calls(mockTextParseUrl)).toHaveLength(1);
return parseResponse(apiPromise, 'text').then(args => { const keys = Object.keys(args);
expect(fetchMock.calls(mockTextParseUrl)).toHaveLength(1); expect(keys).toContain('response');
const keys = Object.keys(args); expect(keys).toContain('text');
expect(keys).toContain('response'); expect(args.text).toBe(mockTextJsonResponse);
expect(keys).toContain('text');
expect(args.text).toBe(mockTextJsonResponse);
return true;
});
}); });
it('throws if parseMethod is not null|json|text', () => { it('throws if parseMethod is not null|json|text', async () => {
const apiPromise = callApi({ url: mockNoParseUrl, method: 'GET' }); expect.assertions(1);
try {
// @ts-ignore - 'something-else' is *intentionally* an invalid type await parseResponse(
expect(() => parseResponse(apiPromise, 'something-else')).toThrow(); 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`', () => { it('resolves to unmodified `Response` object if `parseMethod=null|raw`', async () => {
expect.assertions(2); expect.assertions(3);
const responseNull = await parseResponse(callApi({ url: mockNoParseUrl, method: 'GET' }), null);
const apiPromise = callApi({ url: mockNoParseUrl, method: 'GET' }); const responseRaw = await parseResponse(callApi({ url: mockNoParseUrl, method: 'GET' }), 'raw');
expect(fetchMock.calls(mockNoParseUrl)).toHaveLength(2);
return parseResponse(apiPromise, null).then((clientResponse: SupersetClientResponse) => { expect(responseNull.bodyUsed).toBe(false);
const response = clientResponse as Response; expect(responseRaw.bodyUsed).toBe(false);
expect(fetchMock.calls(mockNoParseUrl)).toHaveLength(1);
expect(response.bodyUsed).toBe(false);
return true;
});
}); });
it('rejects if request.ok=false', () => { it('rejects if request.ok=false', async () => {
expect.assertions(3);
const mockNotOkayUrl = '/mock/notokay/url'; const mockNotOkayUrl = '/mock/notokay/url';
fetchMock.get(mockNotOkayUrl, 404); // 404s result in not response.ok=false fetchMock.get(mockNotOkayUrl, 404); // 404s result in not response.ok=false
expect.assertions(3);
const apiPromise = callApi({ url: mockNotOkayUrl, method: 'GET' }); const apiPromise = callApi({ url: mockNotOkayUrl, method: 'GET' });
return parseResponse(apiPromise) try {
.then(throwIfCalled) await parseResponse(apiPromise);
.catch((error: { ok: boolean; status: number }) => { } catch (error) {
expect(fetchMock.calls(mockNotOkayUrl)).toHaveLength(1); const err = error as { ok: boolean; status: number };
expect(error.ok).toBe(false); expect(fetchMock.calls(mockNotOkayUrl)).toHaveLength(1);
expect(error.status).toBe(404); 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 rejectAfterTimeout from '../../src/callApi/rejectAfterTimeout';
import throwIfCalled from '../utils/throwIfCalled';
describe('rejectAfterTimeout()', () => { describe('rejectAfterTimeout()', () => {
it('returns a promise that rejects after the specified timeout', () => { it('returns a promise that rejects after the specified timeout', async () => {
return new Promise(done => { expect.assertions(1);
expect.assertions(1); jest.useFakeTimers();
jest.useFakeTimers(); try {
const promise = rejectAfterTimeout(10);
rejectAfterTimeout(10)
.then(throwIfCalled)
.catch((error: Error) => {
expect(error).toBeDefined();
return done();
});
jest.advanceTimersByTime(11); 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 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) { export default function ErrorMessage({ error }: Props) {
// eslint-disable-next-line no-console
console.error(error);
return ( return (
<div className="alert alert-danger"> <pre className="alert alert-danger">
{error.stack || error.message} {error.stack || error.message}
{!error.message && {!error.message &&
!error.stack && !error.stack &&
(typeof error === 'object' ? JSON.stringify(error) : String(error))} (typeof error === 'object' ? JSON.stringify(error, null, 2) : String(error))}
</div> </pre>
); );
} }

View File

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

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { select, text, withKnobs } from '@storybook/addon-knobs'; 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 VerifyCORS, { Props as VerifyCORSProps } from '../../shared/components/VerifyCORS';
import Expandable from '../../shared/components/Expandable'; import Expandable from '../../shared/components/Expandable';
@ -21,14 +21,14 @@ export default {
}; };
export const configureCORS = () => { 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 selectEndpoint = select('Endpoint', ENDPOINTS, '');
const customEndpoint = text('Custom Endpoint (override above)', ''); const customEndpoint = text('Custom Endpoint (override above)', '');
const endpoint = customEndpoint || selectEndpoint; const endpoint = customEndpoint || selectEndpoint;
const method = endpoint ? select('Request method', REQUEST_METHODS, 'POST') : undefined; const method = endpoint ? select('Request method', REQUEST_METHODS, 'POST') : undefined;
const postPayload = const postPayload =
endpoint && method === 'POST' endpoint && method === 'POST'
? text('POST payload', JSON.stringify({ form_data: sankeyFormData }, null, 2)) ? text('POST payload', JSON.stringify({ form_data: bigNumberFormData }))
: undefined; : undefined;
return ( return (