From cc0cd3ec53b87dc1a14dd24c276d0532e7d359c4 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Tue, 13 Feb 2024 13:17:28 -0500 Subject: [PATCH] feat(serviceConfig): sw-2024 allow response polling (#1272) --- .env | 1 + src/services/README.md | 11 + .../__snapshots__/serviceConfig.test.js.snap | 235 ++++++++++++++++ .../common/__tests__/serviceConfig.test.js | 260 +++++++++++++++++- src/services/common/serviceConfig.js | 123 ++++++++- tests/__snapshots__/code.test.js.snap | 8 +- 6 files changed, 631 insertions(+), 7 deletions(-) diff --git a/.env b/.env index a3b05df14..f1e89d173 100644 --- a/.env +++ b/.env @@ -26,6 +26,7 @@ REACT_APP_UI_WINDOW_ID=curiosity REACT_APP_AJAX_TIMEOUT=60000 REACT_APP_AJAX_CACHE=15000 +REACT_APP_AJAX_POLL_INTERVAL=15000 REACT_APP_SELECTOR_CACHE=120000 REACT_APP_CONFIG_SERVICE_LOCALES_COOKIE=rh_locale diff --git a/src/services/README.md b/src/services/README.md index 8b8c39b7e..afe826bdb 100644 --- a/src/services/README.md +++ b/src/services/README.md @@ -160,6 +160,7 @@ Axios config for cancelling, caching, and emulated service calls. * [ServiceConfig](#Helpers.module_ServiceConfig) * [~globalXhrTimeout](#Helpers.module_ServiceConfig..globalXhrTimeout) : number + * [~globalPollInterval](#Helpers.module_ServiceConfig..globalPollInterval) : number * [~globalCancelTokens](#Helpers.module_ServiceConfig..globalCancelTokens) : object * [~globalResponseCache](#Helpers.module_ServiceConfig..globalResponseCache) : object * [~axiosServiceCall(config, options)](#Helpers.module_ServiceConfig..axiosServiceCall) ⇒ Promise.<\*> @@ -169,6 +170,12 @@ Axios config for cancelling, caching, and emulated service calls. ### ServiceConfig~globalXhrTimeout : number Set Axios XHR default timeout. +**Kind**: inner constant of [ServiceConfig](#Helpers.module_ServiceConfig) + + +### ServiceConfig~globalPollInterval : number +Set Axios polling default. + **Kind**: inner constant of [ServiceConfig](#Helpers.module_ServiceConfig) @@ -211,6 +218,8 @@ page or wait the "maxAge". config.paramsobject + config.pollObject | function + config.schemaArray config.transformArray @@ -224,6 +233,8 @@ page or wait the "maxAge". options.responseCacheobject options.xhrTimeoutnumber + + options.pollIntervalnumber diff --git a/src/services/common/__tests__/__snapshots__/serviceConfig.test.js.snap b/src/services/common/__tests__/__snapshots__/serviceConfig.test.js.snap index 422bcdc48..ce5372e40 100644 --- a/src/services/common/__tests__/__snapshots__/serviceConfig.test.js.snap +++ b/src/services/common/__tests__/__snapshots__/serviceConfig.test.js.snap @@ -313,6 +313,240 @@ exports[`ServiceConfig should handle cancelling service calls: cancelled request ] `; +exports[`ServiceConfig should handle polling service call errors: location error 1`] = ` +[ + [ + [Error: location string error], + ], +] +`; + +exports[`ServiceConfig should handle polling service call errors: status error 1`] = ` +[ + [ + [Error: status error], + ], + [ + [Error: status error], + ], + [ + [Error: status error], + ], +] +`; + +exports[`ServiceConfig should handle polling service call errors: status error polling 1`] = ` +{ + "status": [ + { + "count": 0, + "error": { + "data": "error", + "pollConfig": { + "__retryCount": 1, + "location": "/pollError", + "pollInterval": 1, + "status": [Function], + "validate": [Function], + }, + }, + "success": undefined, + }, + ], +} +`; + +exports[`ServiceConfig should handle polling service call errors: status of a status error 1`] = ` +[ + [ + [Error: status error], + ], +] +`; + +exports[`ServiceConfig should handle polling service call errors: status of a status error polling 1`] = ` +{ + "status": [ + { + "count": 0, + "error": { + "data": "error", + "pollConfig": { + "__retryCount": 1, + "location": "/pollError", + "pollInterval": 1, + "status": [Function], + "validate": [Function], + }, + }, + "success": undefined, + }, + ], +} +`; + +exports[`ServiceConfig should handle polling service call errors: validation error 1`] = ` +[ + [ + [Error: basic validation error], + ], +] +`; + +exports[`ServiceConfig should handle polling service calls: basic polling validator 1`] = ` +{ + "output": { + "data": "success", + "pollConfig": { + "__retryCount": 1, + "location": "/test/", + "pollInterval": 1, + "validate": [Function], + }, + }, + "validator": [ + { + "count": 0, + "success": { + "data": "success", + "pollConfig": [Function], + }, + }, + { + "count": 1, + "success": { + "data": "success", + "pollConfig": { + "__retryCount": 1, + "location": "/test/", + "pollInterval": 1, + "validate": [Function], + }, + }, + }, + ], +} +`; + +exports[`ServiceConfig should handle polling service calls: custom location 1`] = ` +{ + "output": { + "data": "success", + "pollConfig": { + "__retryCount": 2, + "location": [Function], + "pollInterval": 1, + "validate": [Function], + }, + }, + "validator": [ + { + "count": 0, + "success": { + "data": "success", + "pollConfig": { + "location": [Function], + "validate": [Function], + }, + "url": "/test/", + }, + }, + { + "count": 1, + "success": { + "data": "success", + "pollConfig": { + "__retryCount": 1, + "location": [Function], + "pollInterval": 1, + "validate": [Function], + }, + "url": "/pollSuccess/", + }, + }, + ], +} +`; + +exports[`ServiceConfig should handle polling service calls: specific polling validator 1`] = ` +{ + "output": { + "data": "success", + "pollConfig": { + "__retryCount": 1, + "location": "/test/", + "pollInterval": 1, + "validate": [Function], + }, + }, + "validator": [ + { + "count": 0, + "success": { + "data": "success", + "pollConfig": { + "validate": [Function], + }, + }, + }, + { + "count": 1, + "success": { + "data": "success", + "pollConfig": { + "__retryCount": 1, + "location": "/test/", + "pollInterval": 1, + "validate": [Function], + }, + }, + }, + ], +} +`; + +exports[`ServiceConfig should handle polling service calls: status polling 1`] = ` +{ + "output": { + "data": "success", + "pollConfig": { + "status": [Function], + "validate": [Function], + }, + }, + "status": [ + { + "count": 0, + "err": undefined, + "success": { + "data": "success", + "pollConfig": { + "__retryCount": 1, + "location": "/test/", + "pollInterval": 1, + "status": [Function], + "validate": [Function], + }, + }, + }, + { + "count": 1, + "err": undefined, + "success": { + "data": "success", + "pollConfig": { + "__retryCount": 2, + "location": "/test/", + "pollInterval": 1, + "status": [Function], + "validate": [Function], + }, + }, + }, + ], +} +`; + exports[`ServiceConfig should handle producing a service call configuration: response configs 1`] = ` [ "{ @@ -377,6 +611,7 @@ exports[`ServiceConfig should have specific properties and methods: specific pro [ "axiosServiceCall", "globalXhrTimeout", + "globalPollInterval", "globalCancelTokens", "globalResponseCache", ] diff --git a/src/services/common/__tests__/serviceConfig.test.js b/src/services/common/__tests__/serviceConfig.test.js index fd3f05fe9..17d3a3d30 100644 --- a/src/services/common/__tests__/serviceConfig.test.js +++ b/src/services/common/__tests__/serviceConfig.test.js @@ -31,13 +31,13 @@ describe('ServiceConfig', () => { beforeAll(() => { moxios.install(); - moxios.stubRequest(/\/test.*?/, { + moxios.stubRequest(/\/(test|pollSuccess).*?/, { status: 200, responseText: 'success', timeout: 1 }); - moxios.stubRequest(/\/error.*?/, { + moxios.stubRequest(/\/(error|pollError).*?/, { status: 404, responseText: 'error', timeout: 1 @@ -46,6 +46,7 @@ describe('ServiceConfig', () => { afterAll(() => { moxios.uninstall(); + jest.clearAllMocks(); }); it('should have specific properties and methods', () => { @@ -253,6 +254,261 @@ describe('ServiceConfig', () => { expect(responses).toMatchSnapshot('transformed responses'); }); + it('should handle polling service calls', async () => { + const basicPollValidator = jest.fn(); + const basicOutput = await serviceConfig.axiosServiceCall( + { + cache: true, + url: '/test/', + poll: (response, count) => { + basicPollValidator(response, count); + return count === 1; + } + }, + { pollInterval: 1 } + ); + + expect(basicPollValidator).toHaveBeenCalledTimes(2); + expect({ + validator: basicPollValidator.mock.calls.map(([response, count]) => ({ + success: { + data: response.data, + pollConfig: response.config.poll + }, + count + })), + output: { + pollConfig: basicOutput.config.poll, + data: basicOutput.data + } + }).toMatchSnapshot('basic polling validator'); + + const specificPollValidator = jest.fn(); + const specificOutput = await serviceConfig.axiosServiceCall( + { + cache: true, + url: '/test/', + poll: { + validate: (response, count) => { + specificPollValidator(response, count); + return count === 1; + } + } + }, + { pollInterval: 1 } + ); + + expect(specificPollValidator).toHaveBeenCalledTimes(2); + expect({ + validator: specificPollValidator.mock.calls.map(([response, count]) => ({ + success: { + data: response.data, + pollConfig: response.config.poll + }, + count + })), + output: { + pollConfig: specificOutput.config.poll, + data: specificOutput.data + } + }).toMatchSnapshot('specific polling validator'); + + const statusPoll = jest.fn(); + const statusOutput = await serviceConfig.axiosServiceCall( + { + cache: true, + url: '/test/', + poll: { + validate: (response, count) => count === 2, + status: (success, err, count) => { + statusPoll(success, err, count); + } + } + }, + { pollInterval: 1 } + ); + + // delay to give the internal status promise time to unwrap + await new Promise(resolve => { + setTimeout(() => resolve(), 25); + }); + + expect(statusPoll).toHaveBeenCalledTimes(2); + expect({ + status: statusPoll.mock.calls.map(([success, err, count]) => ({ + success: { + data: success.data, + pollConfig: success.config.poll + }, + err, + count + })), + output: { + data: statusOutput.data, + pollConfig: statusOutput.config.poll + } + }).toMatchSnapshot('status polling'); + + const mockLocation = jest.fn().mockImplementation(() => '/pollSuccess/'); + const locationOutput = await serviceConfig.axiosServiceCall( + { + cache: true, + url: '/test/', + poll: { + location: mockLocation, + validate: (response, count) => count === 2 + } + }, + { pollInterval: 1 } + ); + + expect(mockLocation).toHaveBeenCalledTimes(2); + expect({ + validator: mockLocation.mock.calls.map(([success, count]) => ({ + success: { + data: success.data, + url: success.request.url, + pollConfig: { ...success.config.poll, location: Function.prototype } + }, + count + })), + output: { + pollConfig: { ...locationOutput.config.poll, location: Function.prototype }, + data: locationOutput.data + } + }).toMatchSnapshot('custom location'); + }); + + it('should handle polling service call errors', async () => { + const consoleSpyError = jest.spyOn(console, 'error'); + + await serviceConfig.axiosServiceCall( + { + cache: false, + url: '/test/', + poll: () => { + throw new Error('basic validation error'); + } + }, + { pollInterval: 1 } + ); + expect(consoleSpyError.mock.calls).toMatchSnapshot('validation error'); + consoleSpyError.mockClear(); + + await serviceConfig.axiosServiceCall( + { + cache: false, + url: '/test/', + poll: { + validate: (response, count) => count === 1, + location: () => { + throw new Error('location string error'); + } + } + }, + { pollInterval: 1 } + ); + + // delay to give the internal status promise time to unwrap + await new Promise(resolve => { + setTimeout(() => resolve(), 25); + }); + + expect(consoleSpyError.mock.calls).toMatchSnapshot('location error'); + consoleSpyError.mockClear(); + + const statusErrorPoll = jest.fn(); + await serviceConfig.axiosServiceCall( + { + cache: false, + url: '/test/', + poll: { + location: '/pollError', + validate: (response, count) => count === 5, + status: (success, err, count) => { + statusErrorPoll(success, err, count); + } + } + }, + { pollInterval: 1 } + ); + + // delay to give the internal status promise time to unwrap + await new Promise(resolve => { + setTimeout(() => resolve(), 50); + }); + + expect(statusErrorPoll).toHaveBeenCalledTimes(1); + expect({ + status: statusErrorPoll.mock.calls.map(([success, err, count]) => ({ + error: { + data: err.response.data, + pollConfig: err.config.poll + }, + success, + count + })) + }).toMatchSnapshot('status error polling'); + + await serviceConfig.axiosServiceCall( + { + cache: false, + url: '/test/', + poll: { + validate: (response, count) => count === 3, + status: () => { + throw new Error('status error'); + } + } + }, + { pollInterval: 1 } + ); + + // delay to give the internal status promise time to unwrap + await new Promise(resolve => { + setTimeout(() => resolve(), 25); + }); + + expect(consoleSpyError.mock.calls).toMatchSnapshot('status error'); + consoleSpyError.mockClear(); + + const statusStatusErrorPoll = jest.fn(); + await serviceConfig.axiosServiceCall( + { + cache: false, + url: '/test/', + poll: { + location: '/pollError', + validate: (response, count) => count === 5, + status: (success, err, count) => { + statusStatusErrorPoll(success, err, count); + throw new Error('status error'); + } + } + }, + { pollInterval: 1 } + ); + + // delay to give the internal status promise time to unwrap + await new Promise(resolve => { + setTimeout(() => resolve(), 25); + }); + + expect(statusStatusErrorPoll).toHaveBeenCalledTimes(1); + expect({ + status: statusStatusErrorPoll.mock.calls.map(([success, err, count]) => ({ + error: { + data: err.response.data, + pollConfig: err.config.poll + }, + success, + count + })) + }).toMatchSnapshot('status of a status error polling'); + expect(consoleSpyError.mock.calls).toMatchSnapshot('status of a status error'); + consoleSpyError.mockClear(); + }); + it('should allow passing a function and emulating a service call', async () => { const responses = []; diff --git a/src/services/common/serviceConfig.js b/src/services/common/serviceConfig.js index 43102a30a..b267ec731 100644 --- a/src/services/common/serviceConfig.js +++ b/src/services/common/serviceConfig.js @@ -16,6 +16,13 @@ import { serviceHelpers } from './helpers'; */ const globalXhrTimeout = Number.parseInt(process.env.REACT_APP_AJAX_TIMEOUT, 10) || 60000; +/** + * Set Axios polling default. + * + * @type {number} + */ +const globalPollInterval = Number.parseInt(process.env.REACT_APP_AJAX_POLL_INTERVAL, 10) || 10000; + /** * Cache Axios service call cancel tokens. * @@ -34,7 +41,6 @@ const globalResponseCache = new LRUCache({ updateAgeOnGet: true }); -// ToDo: consider another way of hashing cacheIDs. base64 could get a little large depending on settings, i.e. md5 /** * Set Axios configuration. This includes response schema validation and caching. * Call platform "getUser" auth method, and apply service config. Service configuration @@ -48,6 +54,8 @@ const globalResponseCache = new LRUCache({ * @param {boolean} config.cancel * @param {string} config.cancelId * @param {object} config.params + * @param {{ location: Function|string, validate: Function, pollInterval: number, status: Function + * }|Function} config.poll * @param {Array} config.schema * @param {Array} config.transform * @param {string|Function} config.url @@ -55,11 +63,17 @@ const globalResponseCache = new LRUCache({ * @param {string} options.cancelledMessage * @param {object} options.responseCache * @param {number} options.xhrTimeout + * @param {number} options.pollInterval * @returns {Promise<*>} */ const axiosServiceCall = async ( config = {}, - { cancelledMessage = 'cancelled request', responseCache = globalResponseCache, xhrTimeout = globalXhrTimeout } = {} + { + cancelledMessage = 'cancelled request', + responseCache = globalResponseCache, + xhrTimeout = globalXhrTimeout, + pollInterval = globalPollInterval + } = {} ) => { const updatedConfig = { timeout: xhrTimeout, @@ -221,16 +235,119 @@ const axiosServiceCall = async ( } } + // apply a response poll + if (typeof updatedConfig.poll === 'function' || typeof updatedConfig.poll?.validate === 'function') { + axiosInstance.interceptors.response.use( + async response => { + const updatedResponse = { ...response }; + const callbackResponse = serviceHelpers.memoClone(updatedResponse); + + // passed config, allow future updates by passing modified poll config + const updatedPoll = { + ...updatedConfig.poll, + // internal counter passed towards validate + __retryCount: updatedConfig.poll.__retryCount ?? 0, + // a url, or callback that returns a url to poll the put/posted url + location: updatedConfig.poll.location || updatedConfig.url, + // only required param, a function, validate status in prep for next + validate: updatedConfig.poll.validate || updatedConfig.poll, + // a number, the setTimeout interval + pollInterval: updatedConfig.poll.pollInterval || pollInterval + }; + + let validated; + + try { + validated = await updatedPoll.validate.call(null, callbackResponse, updatedPoll.__retryCount); + } catch (err) { + console.error(err); + validated = true; + } + + if (validated === true) { + return updatedResponse; + } + + let tempLocation = updatedPoll.location; + + if (typeof tempLocation === 'function') { + try { + tempLocation = await tempLocation.call(null, callbackResponse, updatedPoll.__retryCount); + } catch (err) { + console.error(err); + tempLocation = updatedConfig.url; + } + } + + const pollResponse = new Promise((resolve, reject) => { + window.setTimeout(async () => { + try { + const output = await axiosServiceCall({ + ...config, + method: 'get', + data: undefined, + url: tempLocation, + cache: false, + poll: { ...updatedPoll, __retryCount: updatedPoll.__retryCount + 1 } + }); + + resolve(output); + } catch (e) { + reject(e); + } + }, updatedPoll.pollInterval); + }); + + // either apply a status resolver for up-to-date responses or chain poll-response to the response + if (typeof updatedPoll.status === 'function') { + pollResponse.then( + resolved => { + try { + updatedPoll.status.call(null, resolved, undefined, updatedPoll.__retryCount); + } catch (err) { + console.error(err); + } + }, + resolved => { + try { + updatedPoll.status.call( + null, + undefined, + { ...resolved, error: true, status: resolved?.response?.status }, + updatedPoll.__retryCount + ); + } catch (err) { + console.error(err); + } + } + ); + } else { + return pollResponse; + } + + return updatedResponse; + }, + response => Promise.reject(response) + ); + } + return axiosInstance(updatedConfig); }; -const serviceConfig = { axiosServiceCall, globalXhrTimeout, globalCancelTokens, globalResponseCache }; +const serviceConfig = { + axiosServiceCall, + globalXhrTimeout, + globalPollInterval, + globalCancelTokens, + globalResponseCache +}; export { serviceConfig as default, serviceConfig, axiosServiceCall, globalXhrTimeout, + globalPollInterval, globalCancelTokens, globalResponseCache }; diff --git a/tests/__snapshots__/code.test.js.snap b/tests/__snapshots__/code.test.js.snap index f8f3fa6df..9faa336f5 100644 --- a/tests/__snapshots__/code.test.js.snap +++ b/tests/__snapshots__/code.test.js.snap @@ -10,7 +10,11 @@ exports[`General code checks should only have specific console.[warn|log|info|er "redux/common/reduxHelpers.js:287: console.error(\`Error: Property \${prop} does not exist within the passed state.\`, state);", "redux/common/reduxHelpers.js:291: console.warn(\`Warning: Property \${prop} does not exist within the passed initialState.\`, initialState);", "services/common/helpers.js:105: console.error(", - "services/common/serviceConfig.js:141: console.warn(normalizeError);", - "services/common/serviceConfig.js:165: console.warn(normalizeError);", + "services/common/serviceConfig.js:155: console.warn(normalizeError);", + "services/common/serviceConfig.js:179: console.warn(normalizeError);", + "services/common/serviceConfig.js:263: console.error(err);", + "services/common/serviceConfig.js:277: console.error(err);", + "services/common/serviceConfig.js:308: console.error(err);", + "services/common/serviceConfig.js:320: console.error(err);", ] `;