From cced6eed25b9553413d50e98b7f4c560bab21a62 Mon Sep 17 00:00:00 2001 From: Philippe Perrin Date: Wed, 16 Dec 2020 15:19:26 +0100 Subject: [PATCH] SONAR-13657 Replace request-legacy by our maintained request api --- .../components/extensions/exposeLibraries.ts | 18 +- .../legacy/__tests__/request-legacy-test.ts | 309 ----------------- .../extensions/legacy/request-legacy.ts | 325 ------------------ 3 files changed, 16 insertions(+), 636 deletions(-) delete mode 100644 server/sonar-web/src/main/js/app/components/extensions/legacy/__tests__/request-legacy-test.ts delete mode 100644 server/sonar-web/src/main/js/app/components/extensions/legacy/request-legacy.ts diff --git a/server/sonar-web/src/main/js/app/components/extensions/exposeLibraries.ts b/server/sonar-web/src/main/js/app/components/extensions/exposeLibraries.ts index 139dd09fa65..86d322c6983 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/exposeLibraries.ts +++ b/server/sonar-web/src/main/js/app/components/extensions/exposeLibraries.ts @@ -68,6 +68,15 @@ import Level from 'sonar-ui-common/components/ui/Level'; import Rating from 'sonar-ui-common/components/ui/Rating'; import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; import { formatMeasure } from 'sonar-ui-common/helpers/measures'; +import { + get, + getJSON, + getText, + post, + postJSON, + postJSONBody, + request +} from 'sonar-ui-common/helpers/request'; import NotFound from '../../../app/components/NotFound'; import Favorite from '../../../components/controls/Favorite'; import HomePageSelect from '../../../components/controls/HomePageSelect'; @@ -96,7 +105,6 @@ import addGlobalSuccessMessage from '../../utils/addGlobalSuccessMessage'; import throwGlobalError from '../../utils/throwGlobalError'; import A11ySkipTarget from '../a11y/A11ySkipTarget'; import Suggestions from '../embed-docs-modal/Suggestions'; -import request from './legacy/request-legacy'; const exposeLibraries = () => { const global = window as any; @@ -119,7 +127,13 @@ const exposeLibraries = () => { }; global.SonarMeasures = { ...measures, formatMeasure }; global.SonarRequest = { - ...request, + request, + get, + getJSON, + getText, + post, + postJSON, + postJSONBody, throwGlobalError, addGlobalSuccessMessage }; diff --git a/server/sonar-web/src/main/js/app/components/extensions/legacy/__tests__/request-legacy-test.ts b/server/sonar-web/src/main/js/app/components/extensions/legacy/__tests__/request-legacy-test.ts deleted file mode 100644 index e77a08d4466..00000000000 --- a/server/sonar-web/src/main/js/app/components/extensions/legacy/__tests__/request-legacy-test.ts +++ /dev/null @@ -1,309 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2021 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -import handleRequiredAuthentication from 'sonar-ui-common/helpers/handleRequiredAuthentication'; -import request from '../request-legacy'; - -const { checkStatus, delay, parseError, requestTryAndRepeatUntil } = request; - -jest.mock('sonar-ui-common/helpers/handleRequiredAuthentication', () => ({ default: jest.fn() })); -jest.mock('sonar-ui-common/helpers/cookies', () => ({ - getCookie: jest.fn().mockReturnValue('qwerasdf') -})); - -beforeAll(() => { - jest.useFakeTimers(); -}); - -afterAll(() => { - jest.useRealTimers(); -}); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe('parseError', () => { - it('should parse error and return the message', () => { - return expect( - parseError({ - response: { json: jest.fn().mockResolvedValue({ errors: [{ msg: 'Error1' }] }) } as any - }) - ).resolves.toBe('Error1'); - }); - - it('should parse error and return concatenated messages', () => { - return expect( - parseError({ - response: { - json: jest.fn().mockResolvedValue({ errors: [{ msg: 'Error1' }, { msg: 'Error2' }] }) - } as any - }) - ).resolves.toBe('Error1. Error2'); - }); - - it('should parse error and return default message if empty object', () => { - return expect( - parseError({ - response: { - json: jest.fn().mockResolvedValue({}) - } as any - }) - ).resolves.toBe('default_error_message'); - }); - - it('should parse error and return default message if undefined', () => { - return expect( - parseError({ - response: { - json: jest.fn().mockRejectedValue(undefined) - } as any - }) - ).resolves.toBe('default_error_message'); - }); -}); - -describe('requestTryAndRepeatUntil', () => { - jest.useFakeTimers(); - - beforeEach(() => { - jest.clearAllTimers(); - }); - - it('should repeat call until stop condition is met', async () => { - const apiCall = jest.fn().mockResolvedValue({ repeat: true }); - const stopRepeat = jest.fn().mockImplementation(({ repeat }) => !repeat); - - const promiseResult = requestTryAndRepeatUntil( - apiCall, - { max: -1, slowThreshold: -20 }, - stopRepeat - ); - - for (let i = 1; i < 5; i++) { - jest.runAllTimers(); - expect(apiCall).toBeCalledTimes(i); - // eslint-disable-next-line no-await-in-loop - await new Promise(setImmediate); - expect(stopRepeat).toBeCalledTimes(i); - } - apiCall.mockResolvedValue({ repeat: false }); - jest.runAllTimers(); - expect(apiCall).toBeCalledTimes(5); - await new Promise(setImmediate); - expect(stopRepeat).toBeCalledTimes(5); - - return expect(promiseResult).resolves.toEqual({ repeat: false }); - }); - - it('should repeat call as long as there is an error', async () => { - const apiCall = jest.fn().mockRejectedValue({ response: { status: 504 } }); - const stopRepeat = jest.fn().mockReturnValue(true); - const promiseResult = requestTryAndRepeatUntil( - apiCall, - { max: -1, slowThreshold: -20 }, - stopRepeat - ); - - for (let i = 1; i < 5; i++) { - jest.runAllTimers(); - expect(apiCall).toBeCalledTimes(i); - // eslint-disable-next-line no-await-in-loop - await new Promise(setImmediate); - } - apiCall.mockResolvedValue('Success'); - jest.runAllTimers(); - expect(apiCall).toBeCalledTimes(5); - await new Promise(setImmediate); - expect(stopRepeat).toBeCalledTimes(1); - - return expect(promiseResult).resolves.toBe('Success'); - }); - - it('should stop after 3 calls', async () => { - const apiCall = jest.fn().mockResolvedValue({}); - const stopRepeat = jest.fn().mockReturnValue(false); - const promiseResult = requestTryAndRepeatUntil( - apiCall, - { max: 3, slowThreshold: 0 }, - stopRepeat - ); - - // eslint-disable-next-line jest/valid-expect - expect(promiseResult).rejects.toBeUndefined(); - - for (let i = 1; i < 3; i++) { - jest.runAllTimers(); - expect(apiCall).toBeCalledTimes(i); - // eslint-disable-next-line no-await-in-loop - await new Promise(setImmediate); - } - apiCall.mockResolvedValue('Success'); - jest.runAllTimers(); - expect(apiCall).toBeCalledTimes(3); - }); - - it('should slow down after 2 calls', async () => { - const apiCall = jest.fn().mockResolvedValue({}); - const stopRepeat = jest.fn().mockReturnValue(false); - requestTryAndRepeatUntil(apiCall, { max: 5, slowThreshold: 3 }, stopRepeat); - - for (let i = 1; i < 3; i++) { - jest.advanceTimersByTime(500); - expect(apiCall).toBeCalledTimes(i); - // eslint-disable-next-line no-await-in-loop - await new Promise(setImmediate); - } - - jest.advanceTimersByTime(500); - expect(apiCall).toBeCalledTimes(2); - jest.advanceTimersByTime(2000); - expect(apiCall).toBeCalledTimes(2); - jest.advanceTimersByTime(500); - expect(apiCall).toBeCalledTimes(3); - await new Promise(setImmediate); - - jest.advanceTimersByTime(3000); - expect(apiCall).toBeCalledTimes(4); - }); -}); - -describe('checkStatus', () => { - it('should resolve with the response', () => { - const response = mockResponse(); - return expect(checkStatus(response)).resolves.toBe(response); - }); - - it('should reject with the response', () => { - const response = mockResponse({}, 500); - return expect(checkStatus(response)).rejects.toEqual({ response }); - }); - - it('should handle required authentication', () => { - return checkStatus(mockResponse({}, 401)).catch(() => { - expect(handleRequiredAuthentication).toBeCalled(); - }); - }); - - function mockResponse(headers: T.Dict = {}, status = 200): any { - return { - headers: { get: (prop: string) => headers[prop] }, - status - }; - } -}); - -describe('request functions', () => { - window.fetch = jest.fn(); - - beforeEach(() => { - (window.fetch as jest.Mock).mockReset(); - }); - - const jsonResponse = '{"foo": "bar"}'; - - it('getJSON should return correctly', () => { - const response = new Response(jsonResponse, { status: 200 }); - - (window.fetch as jest.Mock).mockResolvedValue(response); - - return request.getJSON('/api/foo', { q: 'a' }).then(response => { - expect(response).toEqual({ foo: 'bar' }); - }); - }); - - it('postJSON should return correctly', () => { - const response = new Response(jsonResponse, { status: 200 }); - - (window.fetch as jest.Mock).mockResolvedValue(response); - - return request.postJSON('/api/foo', { q: 'a' }).then(response => { - expect(response).toEqual({ foo: 'bar' }); - }); - }); - - it('post should return correctly', () => { - const response = new Response(null, { status: 200 }); - - (window.fetch as jest.Mock).mockResolvedValue(response); - - return request.post('/api/foo', { q: 'a' }).then(response => { - expect(response).toBeUndefined(); - }); - }); - - it('post should handle FormData correctly', () => { - const response = new Response(null, { status: 200 }); - - (window.fetch as jest.Mock).mockResolvedValue(response); - - const data = new FormData(); - data.set('q', 'a'); - - return request.post('/api/foo', data).then(response => { - expect(response).toBeUndefined(); - }); - }); - - it('requestDelete should return correctly', () => { - const response = new Response('ha!', { status: 200 }); - - (window.fetch as jest.Mock).mockResolvedValue(response); - - return request.requestDelete('/api/foo', { q: 'a' }).then(response => { - expect(response).toBe(response); - }); - }); - - it('getCorsJSON should return correctly', () => { - const response = new Response(jsonResponse, { status: 200 }); - - (window.fetch as jest.Mock).mockResolvedValue(response); - - return request.getCorsJSON('/api/foo').then(response => { - expect(response).toEqual({ foo: 'bar' }); - }); - }); - - it('getCorsJSON should reject correctly', () => { - const response = new Response(jsonResponse, { status: 418 }); - - (window.fetch as jest.Mock).mockResolvedValue(response); - - return request - .getCorsJSON('/api/foo') - .then(() => { - fail('should throw'); - }) - .catch(error => { - expect(error.response).toBe(response); - }); - }); -}); - -describe('delay', () => { - it('should work as expected', async () => { - const param = { some: 'response' }; - - const promise = delay(param); - jest.runAllTimers(); - - expect(await promise).toBe(param); - }); -}); diff --git a/server/sonar-web/src/main/js/app/components/extensions/legacy/request-legacy.ts b/server/sonar-web/src/main/js/app/components/extensions/legacy/request-legacy.ts deleted file mode 100644 index 1d3a51d8a27..00000000000 --- a/server/sonar-web/src/main/js/app/components/extensions/legacy/request-legacy.ts +++ /dev/null @@ -1,325 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2021 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -import { isNil, omitBy } from 'lodash'; -import { stringify } from 'querystring'; -import { getCookie } from 'sonar-ui-common/helpers/cookies'; -import { translate } from 'sonar-ui-common/helpers/l10n'; -import { getBaseUrl } from '../../../../helpers/system'; - -/* - WARNING /!\ WARNING - - This is a snapshot of requests.ts when it was extracted to sonar-ui-common - - It's sole purpose is to not break the compatibility with 3rd party extensions that might be using - the various helpers. - - Do not use these functions, but rather the ones from sonar-ui-common/helpers/request.ts -*/ - -function getCSRFTokenName(): string { - return 'X-XSRF-TOKEN'; -} - -function getCSRFTokenValue(): string { - const cookieName = 'XSRF-TOKEN'; - const cookieValue = getCookie(cookieName); - if (!cookieValue) { - return ''; - } - return cookieValue; -} - -/** - * Return an object containing a special http request header used to prevent CSRF attacks. - */ -function getCSRFToken(): T.Dict { - // Fetch API in Edge doesn't work with empty header, - // so we ensure non-empty value - const value = getCSRFTokenValue(); - return value ? { [getCSRFTokenName()]: value } : {}; -} - -type RequestData = T.Dict; - -function omitNil(obj: RequestData): RequestData { - return omitBy(obj, isNil); -} - -/** - * Default options for any request - */ -const DEFAULT_OPTIONS: { - credentials: RequestCredentials; - method: string; -} = { - credentials: 'same-origin', - method: 'GET' -}; - -/** - * Default request headers - */ -const DEFAULT_HEADERS = { - Accept: 'application/json' -}; - -/** - * Request - */ -class Request { - private data?: RequestData; - - constructor(private readonly url: string, private readonly options: { method?: string } = {}) { - this.url = url; - this.options = options; - } - - getSubmitData(customHeaders: any = {}): { url: string; options: RequestInit } { - let { url } = this; - const options: RequestInit = { ...DEFAULT_OPTIONS, ...this.options }; - - if (this.data) { - if (this.data instanceof FormData) { - options.body = this.data; - } else { - const strData = stringify(omitNil(this.data)); - if (options.method === 'GET') { - url += '?' + strData; - } else { - customHeaders['Content-Type'] = 'application/x-www-form-urlencoded'; - options.body = strData; - } - } - } - - options.headers = { - ...DEFAULT_HEADERS, - ...customHeaders - }; - return { url, options }; - } - - submit(): Promise { - const { url, options } = this.getSubmitData({ ...getCSRFToken() }); - return window.fetch(getBaseUrl() + url, options); - } - - setMethod(method: string): Request { - this.options.method = method; - return this; - } - - setData(data?: RequestData): Request { - if (data) { - this.data = data; - } - return this; - } -} - -/** - * Make a request - */ -function request(url: string): Request { - return new Request(url); -} - -/** - * Make a cors request - */ -function corsRequest(url: string, mode: RequestMode = 'cors'): Request { - const options: RequestInit = { mode }; - const request = new Request(url, options); - request.submit = function() { - const { url, options } = this.getSubmitData(); - return window.fetch(url, options); - }; - return request; -} - -/** - * Check that response status is ok - */ -function checkStatus(response: Response): Promise { - return new Promise((resolve, reject) => { - if (response.status === 401) { - import('sonar-ui-common/helpers/handleRequiredAuthentication') - .then(i => i.default()) - .then(reject, reject); - } else if (response.status >= 200 && response.status < 300) { - resolve(response); - } else { - reject({ response }); - } - }); -} - -/** - * Parse response as JSON - */ -function parseJSON(response: Response): Promise { - return response.json(); -} - -/** - * Parse response of failed request - */ -function parseError(error: { response: Response }): Promise { - const DEFAULT_MESSAGE = translate('default_error_message'); - /* eslint-disable-next-line promise/no-promise-in-callback*/ - return parseJSON(error.response) - .then(({ errors }) => errors.map((error: any) => error.msg).join('. ')) - .catch(() => DEFAULT_MESSAGE); -} - -/** - * Shortcut to do a GET request and return response json - */ -function getJSON(url: string, data?: RequestData): Promise { - return request(url) - .setData(data) - .submit() - .then(checkStatus) - .then(parseJSON); -} - -/** - * Shortcut to do a CORS GET request and return responsejson - */ -function getCorsJSON(url: string, data?: RequestData): Promise { - return corsRequest(url) - .setData(data) - .submit() - .then(response => { - if (response.status >= 200 && response.status < 300) { - return parseJSON(response); - } else { - return Promise.reject({ response }); - } - }); -} - -/** - * Shortcut to do a POST request and return response json - */ -function postJSON(url: string, data?: RequestData): Promise { - return request(url) - .setMethod('POST') - .setData(data) - .submit() - .then(checkStatus) - .then(parseJSON); -} - -/** - * Shortcut to do a POST request - */ -function post(url: string, data?: RequestData): Promise { - return new Promise((resolve, reject) => { - request(url) - .setMethod('POST') - .setData(data) - .submit() - .then(checkStatus) - .then(() => { - resolve(); - }, reject); - }); -} - -/** - * Shortcut to do a DELETE request and return response json - */ -function requestDelete(url: string, data?: RequestData): Promise { - return request(url) - .setMethod('DELETE') - .setData(data) - .submit() - .then(checkStatus); -} - -/** - * Delay promise for testing purposes - */ -function delay(response: any): Promise { - return new Promise(resolve => { - setTimeout(() => resolve(response), 1200); - }); -} - -function tryRequestAgain( - repeatAPICall: () => Promise, - tries: { max: number; slowThreshold: number }, - stopRepeat: (response: T) => boolean, - repeatErrors: number[] = [] -) { - tries.max--; - if (tries.max !== 0) { - return new Promise(resolve => { - setTimeout( - () => resolve(requestTryAndRepeatUntil(repeatAPICall, tries, stopRepeat, repeatErrors)), - tries.max > tries.slowThreshold ? 500 : 3000 - ); - }); - } - return Promise.reject(); -} - -function requestTryAndRepeatUntil( - repeatAPICall: () => Promise, - tries: { max: number; slowThreshold: number }, - stopRepeat: (response: T) => boolean, - repeatErrors: number[] = [] -) { - return repeatAPICall().then( - r => { - if (stopRepeat(r)) { - return r; - } - return tryRequestAgain(repeatAPICall, tries, stopRepeat, repeatErrors); - }, - (error: { response: Response }) => { - if (repeatErrors.length === 0 || repeatErrors.includes(error.response.status)) { - return tryRequestAgain(repeatAPICall, tries, stopRepeat, repeatErrors); - } - return Promise.reject(error); - } - ); -} - -export default { - checkStatus, - corsRequest, - delay, - getCorsJSON, - getCSRFToken, - getCSRFTokenName, - getCSRFTokenValue, - getJSON, - omitNil, - parseError, - parseJSON, - post, - postJSON, - request, - requestDelete, - requestTryAndRepeatUntil -}; -- 2.39.5