| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651 |
- /**
- * 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, JsonObject } from '@superset-ui/core';
- import * as constants from '../../../src/connection/constants';
- import callApi from '../../../src/connection/callApi/callApi';
- import { LOGIN_GLOB } from '../fixtures/constants';
- // missing the toString function causing method to error out when casting to String
- class BadObject {}
- const corruptObject = new BadObject();
- /* @ts-expect-error */
- BadObject.prototype.toString = undefined;
- const mockGetUrl = '/mock/get/url';
- const mockPostUrl = '/mock/post/url';
- const mockPutUrl = '/mock/put/url';
- const mockPatchUrl = '/mock/patch/url';
- const mockCacheUrl = '/mock/cache/url';
- const mockNotFound = '/mock/notfound';
- const mockErrorUrl = '/mock/error/url';
- const mock503 = '/mock/503';
- const mockGetPayload = { get: 'payload' };
- const mockPostPayload = { post: 'payload' };
- const mockPutPayload = { post: 'payload' };
- const mockPatchPayload = { post: 'payload' };
- const mockCachePayload = {
- status: 200,
- body: 'BODY',
- headers: { Etag: 'etag' },
- };
- const mockErrorPayload = { status: 500, statusText: 'Internal error' };
- describe('callApi()', () => {
- beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '1234' }));
- beforeEach(() => {
- fetchMock.get(mockGetUrl, mockGetPayload);
- fetchMock.post(mockPostUrl, mockPostPayload);
- fetchMock.put(mockPutUrl, mockPutPayload);
- fetchMock.patch(mockPatchUrl, mockPatchPayload);
- fetchMock.get(mockCacheUrl, mockCachePayload);
- fetchMock.get(mockNotFound, { status: 404 });
- fetchMock.get(mock503, { status: 503 });
- fetchMock.get(mockErrorUrl, () => Promise.reject(mockErrorPayload));
- });
- afterEach(() => fetchMock.reset());
- describe('request config', () => {
- it('calls the right url with the specified method', async () => {
- expect.assertions(4);
- await Promise.all([
- callApi({ url: mockGetUrl, method: 'GET' }),
- callApi({ url: mockPostUrl, method: 'POST' }),
- callApi({ url: mockPutUrl, method: 'PUT' }),
- callApi({ url: mockPatchUrl, method: 'PATCH' }),
- ]);
- 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', async () => {
- expect.assertions(8);
- const mockRequest: CallApi = {
- url: mockGetUrl,
- mode: 'cors',
- cache: 'default',
- credentials: 'include',
- headers: {
- custom: 'header',
- },
- redirect: 'follow',
- signal: undefined,
- body: 'BODY',
- };
- await callApi(mockRequest);
- const calls = fetchMock.calls(mockGetUrl);
- const fetchParams = calls[0][1] as RequestInit;
- 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', async () => {
- expect.assertions(3);
- const postPayload = { key: 'value', anotherKey: 1237 };
- await callApi({ url: mockPostUrl, method: 'POST', postPayload });
- const calls = fetchMock.calls(mockPostUrl);
- expect(calls).toHaveLength(1);
- const fetchParams = calls[0][1] as RequestInit;
- const body = fetchParams.body as FormData;
- 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)', async () => {
- expect.assertions(3);
- const postPayload = { key: 'value', noValue: undefined };
- await callApi({ url: mockPostUrl, method: 'POST', postPayload });
- const calls = fetchMock.calls(mockPostUrl);
- expect(calls).toHaveLength(1);
- const fetchParams = calls[0][1] as RequestInit;
- 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', async () => {
- const postPayload = {
- string: 'value',
- number: 1237,
- array: [1, 2, 3],
- object: { a: 'a', 1: 1 },
- null: null,
- emptyString: '',
- };
- expect.assertions(1 + 3 * Object.keys(postPayload).length);
- await Promise.all([
- callApi({ url: mockPostUrl, method: 'POST', postPayload }),
- callApi({
- url: mockPostUrl,
- method: 'POST',
- postPayload,
- stringify: false,
- }),
- callApi({ url: mockPostUrl, method: 'POST', jsonPayload: postPayload }),
- ]);
- const calls = fetchMock.calls(mockPostUrl);
- expect(calls).toHaveLength(3);
- const stringified = (calls[0][1] as RequestInit).body as FormData;
- const unstringified = (calls[1][1] as RequestInit).body as FormData;
- const jsonRequestBody = JSON.parse(
- (calls[2][1] as RequestInit).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);
- });
- });
- it('removes corrupt value when building formData with stringify = false', async () => {
- /*
- There has been a case when 'stringify' is false an object value on one of the
- attributes was missing a toString function making the cast to String() fail
- and causing entire method call to fail. The new logic skips corrupt values that fail cast to String()
- and allows all valid attributes to be added as key / value pairs to the formData
- instance. This test case replicates a corrupt object missing the .toString method
- representing a real bug report.
- */
- const postPayload = {
- string: 'value',
- number: 1237,
- array: [1, 2, 3],
- object: { a: 'a', 1: 1 },
- null: null,
- emptyString: '',
- // corruptObject has no toString method and will fail cast to String()
- corrupt: [corruptObject],
- };
- jest.spyOn(console, 'error').mockImplementation();
- await callApi({
- url: mockPostUrl,
- method: 'POST',
- postPayload,
- stringify: false,
- });
- const calls = fetchMock.calls(mockPostUrl);
- expect(calls).toHaveLength(1);
- const unstringified = (calls[0][1] as RequestInit).body as FormData;
- const hasCorruptKey = unstringified.has('corrupt');
- expect(hasCorruptKey).toBeFalsy();
- // When a corrupt attribute is encountered, a console.error call is made with info about the corrupt attribute
- // eslint-disable-next-line no-console
- expect(console.error).toHaveBeenCalledTimes(1);
- });
- });
- describe('PUT requests', () => {
- it('encodes key,value pairs from postPayload', async () => {
- expect.assertions(3);
- const postPayload = { key: 'value', anotherKey: 1237 };
- await callApi({ url: mockPutUrl, method: 'PUT', postPayload });
- const calls = fetchMock.calls(mockPutUrl);
- expect(calls).toHaveLength(1);
- const fetchParams = calls[0][1] as RequestInit;
- const body = fetchParams.body as FormData;
- 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)', async () => {
- expect.assertions(3);
- const postPayload = { key: 'value', noValue: undefined };
- await callApi({ url: mockPutUrl, method: 'PUT', postPayload });
- const calls = fetchMock.calls(mockPutUrl);
- expect(calls).toHaveLength(1);
- const fetchParams = calls[0][1] as RequestInit;
- 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', async () => {
- const postPayload = {
- string: 'value',
- number: 1237,
- array: [1, 2, 3],
- object: { a: 'a', 1: 1 },
- null: null,
- emptyString: '',
- };
- expect.assertions(1 + 2 * Object.keys(postPayload).length);
- await Promise.all([
- callApi({ url: mockPutUrl, method: 'PUT', postPayload }),
- callApi({
- url: mockPutUrl,
- method: 'PUT',
- postPayload,
- stringify: false,
- }),
- ]);
- const calls = fetchMock.calls(mockPutUrl);
- expect(calls).toHaveLength(2);
- const stringified = (calls[0][1] as RequestInit).body as FormData;
- const unstringified = (calls[1][1] as RequestInit).body as FormData;
- 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', async () => {
- expect.assertions(3);
- const postPayload = { key: 'value', anotherKey: 1237 };
- await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload });
- const calls = fetchMock.calls(mockPatchUrl);
- expect(calls).toHaveLength(1);
- const fetchParams = calls[0][1] as RequestInit;
- const body = fetchParams.body as FormData;
- 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)', async () => {
- expect.assertions(3);
- const postPayload = { key: 'value', noValue: undefined };
- await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload });
- const calls = fetchMock.calls(mockPatchUrl);
- expect(calls).toHaveLength(1);
- const fetchParams = calls[0][1] as RequestInit;
- 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', async () => {
- const postPayload = {
- string: 'value',
- number: 1237,
- array: [1, 2, 3],
- object: { a: 'a', 1: 1 },
- null: null,
- emptyString: '',
- };
- expect.assertions(1 + 2 * Object.keys(postPayload).length);
- await Promise.all([
- callApi({ url: mockPatchUrl, method: 'PATCH', postPayload }),
- callApi({
- url: mockPatchUrl,
- method: 'PATCH',
- postPayload,
- stringify: false,
- }),
- ]);
- const calls = fetchMock.calls(mockPatchUrl);
- expect(calls).toHaveLength(2);
- const stringified = (calls[0][1] as RequestInit).body as FormData;
- const unstringified = (calls[1][1] as RequestInit).body as FormData;
- Object.entries(postPayload).forEach(([key, value]) => {
- expect(stringified.get(key)).toBe(JSON.stringify(value));
- expect(unstringified.get(key)).toBe(String(value));
- });
- });
- });
- describe('caching', () => {
- const origLocation = window.location;
- beforeAll(() => {
- Object.defineProperty(window, 'location', { value: {} });
- });
- afterAll(() => {
- Object.defineProperty(window, 'location', { value: origLocation });
- });
- beforeEach(async () => {
- window.location.protocol = 'https:';
- await caches.delete(constants.CACHE_KEY);
- });
- 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();
- });
- it('will not use cache when running off an insecure connection', async () => {
- expect.assertions(2);
- window.location.protocol = 'http:';
- 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).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 });
- const firstResponse = await callApi({ url: mockCacheUrl, method: 'GET' });
- let calls = fetchMock.calls(mockCacheUrl);
- expect(calls).toHaveLength(1);
- const firstBody = await firstResponse.text();
- expect(firstBody).toEqual('BODY');
- const secondResponse = await callApi({
- url: mockCacheUrl,
- method: 'GET',
- });
- calls = fetchMock.calls(mockCacheUrl);
- const fetchParams = calls[1][1] as RequestInit;
- expect(calls).toHaveLength(2);
- // second call should not have If-None-Match header
- expect(fetchParams.headers).toBeUndefined();
- const secondBody = await secondResponse.text();
- expect(secondBody).toEqual('BODY');
- // eslint-disable-next-line no-import-assign
- Object.defineProperty(constants, 'CACHE_AVAILABLE', { value: true });
- });
- it('sends known ETags in the If-None-Match header', async () => {
- expect.assertions(3);
- // first call sets the cache
- await callApi({ url: mockCacheUrl, method: 'GET' });
- let calls = fetchMock.calls(mockCacheUrl);
- expect(calls).toHaveLength(1);
- // second call sends the Etag in the If-None-Match header
- await callApi({ url: mockCacheUrl, method: 'GET' });
- calls = fetchMock.calls(mockCacheUrl);
- const fetchParams = calls[1][1] as RequestInit;
- 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' });
- expect(fetchMock.calls(mockCacheUrl)).toHaveLength(1);
- // second call reuses the cached payload on a 304
- const mockCachedPayload = { status: 304 };
- fetchMock.get(mockCacheUrl, mockCachedPayload, { overwriteRoutes: true });
- const secondResponse = await callApi({
- url: mockCacheUrl,
- method: 'GET',
- });
- expect(fetchMock.calls(mockCacheUrl)).toHaveLength(2);
- const secondBody = await secondResponse.text();
- expect(secondBody).toEqual('BODY');
- });
- 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 };
- let error;
- fetchMock.get(mockUncachedUrl, mockCachedPayload);
- try {
- await callApi({ url: mockUncachedUrl, method: 'GET' });
- } catch (err) {
- error = err;
- } finally {
- 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);
- expect(calls).toHaveLength(1);
- expect(response.status).toEqual(200);
- const body = await response.json();
- expect(body as typeof mockGetPayload).toEqual(mockGetPayload);
- });
- 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);
- expect(calls).toHaveLength(1);
- expect(response.status).toEqual(404);
- });
- });
- it('rejects after retrying thrice if the request throws', async () => {
- expect.assertions(3);
- let error;
- try {
- await callApi({
- fetchRetryOptions: constants.DEFAULT_FETCH_RETRY_OPTIONS,
- url: mockErrorUrl,
- method: 'GET',
- });
- } catch (err) {
- error = err;
- } finally {
- 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', async () => {
- expect.assertions(3);
- let error;
- try {
- await callApi({
- fetchRetryOptions: { retries: 0 },
- url: mockErrorUrl,
- method: 'GET',
- });
- } catch (err) {
- error = err as { status: number; statusText: string };
- } finally {
- expect(fetchMock.calls(mockErrorUrl)).toHaveLength(1);
- expect(error?.status).toBe(mockErrorPayload.status);
- expect(error?.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: constants.DEFAULT_FETCH_RETRY_OPTIONS,
- url,
- method: 'GET',
- });
- const calls = fetchMock.calls(url);
- expect(calls).toHaveLength(4);
- expect(response.status).toEqual(503);
- });
- it('invalid json for postPayload should thrown error', async () => {
- expect.assertions(2);
- let error;
- try {
- await callApi({
- url: mockPostUrl,
- method: 'POST',
- postPayload: 'haha',
- });
- } catch (err) {
- error = err;
- } finally {
- expect(error).toBeInstanceOf(Error);
- expect(error.message).toEqual('Invalid payload:\n\nhaha');
- }
- });
- it('should accept search params object', async () => {
- expect.assertions(3);
- window.location.href = 'http://localhost';
- fetchMock.get(`glob:*/get-search*`, { yes: 'ok' });
- const response = await callApi({
- url: '/get-search',
- searchParams: {
- abc: 1,
- },
- method: 'GET',
- });
- const result = await response.json();
- expect(response.status).toEqual(200);
- expect(result).toEqual({ yes: 'ok' });
- expect(fetchMock.lastUrl()).toEqual(`http://localhost/get-search?abc=1`);
- });
- it('should accept URLSearchParams', async () => {
- expect.assertions(2);
- window.location.href = 'http://localhost';
- fetchMock.post(`glob:*/post-search*`, { yes: 'ok' });
- await callApi({
- url: '/post-search',
- searchParams: new URLSearchParams({
- abc: '1',
- }),
- method: 'POST',
- jsonPayload: { request: 'ok' },
- });
- expect(fetchMock.lastUrl()).toEqual(`http://localhost/post-search?abc=1`);
- expect(fetchMock.lastOptions()).toEqual(
- expect.objectContaining({
- body: JSON.stringify({ request: 'ok' }),
- }),
- );
- });
- it('should throw when both payloads provided', async () => {
- expect.assertions(1);
- fetchMock.post('/post-both-payload', {});
- let error;
- try {
- await callApi({
- url: '/post-both-payload',
- method: 'POST',
- postPayload: { a: 1 },
- jsonPayload: '{}',
- });
- } catch (err) {
- error = err;
- } finally {
- expect((error as Error).message).toContain(
- 'provide only one of jsonPayload or postPayload',
- );
- }
- });
- it('should accept FormData as postPayload', async () => {
- expect.assertions(1);
- fetchMock.post('/post-formdata', {});
- const payload = new FormData();
- await callApi({
- url: '/post-formdata',
- method: 'POST',
- postPayload: payload,
- });
- expect(fetchMock.lastOptions()?.body).toBe(payload);
- });
- it('should ignore "null" postPayload string', async () => {
- expect.assertions(1);
- fetchMock.post('/post-null-postpayload', {});
- fetchMock.post('/post-formdata', {});
- await callApi({
- url: '/post-formdata',
- method: 'POST',
- postPayload: 'null',
- });
- expect(fetchMock.lastOptions()?.body).toBeUndefined();
- });
- });
|