Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 34 additions & 21 deletions src/api/api-client.js
Original file line number Diff line number Diff line change
@@ -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);
}
120 changes: 98 additions & 22 deletions src/api/api-client.test.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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: {
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -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);
});