From aca042c43019724ea5c38f8c60a5e5cc290deb54 Mon Sep 17 00:00:00 2001 From: Valentin Laurin Date: Mon, 24 Feb 2025 16:59:35 +0000 Subject: [PATCH] Breaking: Refactor ApiClient Instead of creating an Axios instance for every request, allow for an Axios instance to be shared across all requests for a given client. This is required to enable the use of caching interceptors. As a consequence, interceptors can no longer be used for request-centric logic (abort/auth) and instead a decorator must be used. --- src/api/api-client.js | 55 ++++++++++------- src/api/api-client.test.js | 120 ++++++++++++++++++++++++++++++------- 2 files changed, 132 insertions(+), 43 deletions(-) diff --git a/src/api/api-client.js b/src/api/api-client.js index 1fa586d..3b856aa 100644 --- a/src/api/api-client.js +++ b/src/api/api-client.js @@ -1,32 +1,45 @@ -import axios from 'axios'; - -export const ApiClient = (apiFactory) => (options) => (req, res) => { +export const ApiClient = (apiFactory) => (axiosInstance, options = {}) => (req, res) => { const { accessTokenProvider, - baseURL, } = options; - const axiosInstance = axios.create({ - baseURL, - }); - /* - `close` event may be triggered on `req` as soon as request body is read, eg. for multi-part content, hence it cannot - be used reliably to detect abrupt connection termination. Instead we listen for `close` event on `res` which is more - reliable. + /** + * Create a new AbortController and tie it to the response `close` listener. + * + * `close` event may be triggered on `req` as soon as request body is read, eg. for multi-part content, hence it cannot + * be used reliably to detect abrupt connection termination. Instead, we listen for `close` event on `res` which is more + * reliable. */ const controller = new AbortController(); res.on('close', () => controller.abort()); - axiosInstance.interceptors.request.use(async (config) => { - return { - ...config, - signal: controller.signal, - headers: { - ...config.headers, - ...(accessTokenProvider ? {'Authorization': 'Bearer ' + await accessTokenProvider(req)} : {}), - }, - }; + const decorateWithoutData = (axiosMethod) => async (url, config) => axiosMethod(url, { + ...config, + signal: controller.signal, + headers: { + ...config.headers, + ...(accessTokenProvider ? {'Authorization': 'Bearer ' + await accessTokenProvider(req)} : {}), + }, }); - return apiFactory(axiosInstance, req); + const decorateWithData = (axiosMethod) => async (url, data, config) => axiosMethod(url, data, { + ...config, + signal: controller.signal, + headers: { + ...config.headers, + ...(accessTokenProvider ? {'Authorization': 'Bearer ' + await accessTokenProvider(req)} : {}), + }, + }); + + const decoratedAxiosInstance = { + get: decorateWithoutData(axiosInstance.get), + delete: decorateWithoutData(axiosInstance.delete), + head: decorateWithoutData(axiosInstance.head), + options: decorateWithoutData(axiosInstance.options), + post: decorateWithData(axiosInstance.post), + put: decorateWithData(axiosInstance.put), + patch: decorateWithData(axiosInstance.patch), + }; + + return apiFactory(decoratedAxiosInstance); } diff --git a/src/api/api-client.test.js b/src/api/api-client.test.js index 1519150..309ebac 100644 --- a/src/api/api-client.test.js +++ b/src/api/api-client.test.js @@ -1,4 +1,5 @@ -import {CanceledError} from 'axios'; +import {jest} from '@jest/globals'; +import axios, {CanceledError} from 'axios'; import nock from 'nock'; import {ApiClient} from './api-client.js'; @@ -23,17 +24,26 @@ export const MockResponse = () => { }; const TestApiClient = ApiClient((axiosInstance) => ({ - listSamples: () => axiosInstance.get(`/samples`, { + listSamples: () => axiosInstance.get('/samples', { headers: { 'accept': 'application/samples+json;charset=UTF-8', }, }), + createSample: (sample) => axiosInstance.post('/samples', sample, { + headers: { + 'accept': 'application/samples+json;charset=UTF-8', + } + }), })); -test('should make API calls as configured', async () => { +test('should make GET API calls as configured', async () => { const req = MockRequest(); const res = MockResponse(); - const client = TestApiClient({baseURL: 'https://api.quickcase.app'})(req, res); + + const axiosInstance = axios.create({ + baseURL: 'https://api.quickcase.app', + }); + const client = TestApiClient(axiosInstance)(req, res); const scope = nock('https://api.quickcase.app', { reqheaders: { @@ -61,12 +71,15 @@ test('should make API calls as configured', async () => { scope.done(); }); -test('should authorize API call with provided access token', async () => { +test('should authorize GET API call with provided access token', async () => { const req = MockRequest(); const res = MockResponse(); - const client = TestApiClient({ - accessTokenProvider: () => Promise.resolve('token-123'), + + const axiosInstance = axios.create({ baseURL: 'https://api.quickcase.app', + }); + const client = TestApiClient(axiosInstance, { + accessTokenProvider: () => Promise.resolve('token-123'), })(req, res); const scope = nock('https://api.quickcase.app', { @@ -96,30 +109,93 @@ test('should authorize API call with provided access token', async () => { scope.done(); }); -test('should abort API call when request aborted', async () => { +test('should make POST API calls as configured', async () => { const req = MockRequest(); const res = MockResponse(); - const client = TestApiClient({ + + const axiosInstance = axios.create({ baseURL: 'https://api.quickcase.app', + }); + const client = TestApiClient(axiosInstance)(req, res); + + const sample = {name: 'Some sample'}; + + const scope = nock('https://api.quickcase.app', { + reqheaders: { + 'accept': 'application/samples+json;charset=UTF-8', + }, + }) + .post('/samples', sample) + .reply(201, { + id: '1', + ...sample, + }); + + const createResponse = await client.createSample(sample); + + expect(createResponse.status).toBe(201); + expect(createResponse.data).toEqual({ + id: '1', + name: 'Some sample', + }); + + scope.done(); +}); + +test('should authorize POST API call with provided access token', async () => { + const req = MockRequest(); + const res = MockResponse(); + + const axiosInstance = axios.create({ + baseURL: 'https://api.quickcase.app', + }); + const client = TestApiClient(axiosInstance, { + accessTokenProvider: () => Promise.resolve('token-123'), })(req, res); - nock('https://api.quickcase.app', { - reqheaders: { - 'accept': 'application/samples+json;charset=UTF-8', - }, - }) - .get('/samples') - .delay(2000) - .reply(200, { - samples: [ - {id: 1}, - {id: 2}, - ], + const sample = {name: 'Some sample'}; + + const scope = nock('https://api.quickcase.app', { + reqheaders: { + 'accept': 'application/samples+json;charset=UTF-8', + 'Authorization': 'Bearer token-123', + }, + }) + .post('/samples', sample) + .reply(201, { + id: '1', + ...sample, }); + const createResponse = await client.createSample(sample); + + expect(createResponse.status).toBe(201); + expect(createResponse.data).toEqual({ + id: '1', + name: 'Some sample', + }); + + scope.done(); +}); + +test('should abort API call when request aborted', async () => { + const req = MockRequest(); + const res = MockResponse(); + + const canceledError = new CanceledError(); + + const axiosInstance = { + get: jest.fn().mockRejectedValue(canceledError) + }; + const client = TestApiClient(axiosInstance)(req, res); + const promise = client.listSamples(); res.trigger('close'); - await expect(promise).rejects.toEqual(new CanceledError()); + await expect(promise).rejects.toBe(canceledError); + + const requestConfig = axiosInstance.get.mock.calls[0][1]; + + expect(requestConfig.signal.aborted).toBe(true); });