Extracted helper fetch function and migrated remoteServers redux action from axios to fetch

This commit is contained in:
Alejandro Celaya 2022-11-14 23:25:39 +01:00
parent e5afe4f767
commit d800062159
9 changed files with 67 additions and 78 deletions

View file

@ -119,29 +119,19 @@ export class ShlinkApiClient {
const normalizedQuery = stringifyQuery(rejectNilProps(query)); const normalizedQuery = stringifyQuery(rejectNilProps(query));
const stringifiedQuery = isEmpty(normalizedQuery) ? '' : `?${normalizedQuery}`; const stringifiedQuery = isEmpty(normalizedQuery) ? '' : `?${normalizedQuery}`;
return this.fetch(`${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}${stringifiedQuery}`, { return this.fetch<T>(`${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}${stringifiedQuery}`, {
method, method,
body: body && JSON.stringify(body), body: body && JSON.stringify(body),
headers: { 'X-Api-Key': this.apiKey }, headers: { 'X-Api-Key': this.apiKey },
}) }).catch((e: unknown) => {
.then(async (resp) => { if (!isRegularNotFound(parseApiError(e))) {
const parsed = await resp.json(); throw e;
}
if (!resp.ok) { // If we capture a not found error, let's assume this Shlink version does not support API v3, so we decrease to
throw parsed; // eslint-disable-line @typescript-eslint/no-throw-literal // v2 and retry
} this.apiVersion = 2;
return this.performRequest(url, method, query, body);
return parsed as T; // TODO Improve type inference here without explicit casting });
})
.catch((e: unknown) => {
if (!isRegularNotFound(parseApiError(e))) {
throw e;
}
// If we capture a not found error, let's assume this Shlink version does not support API v3, so we decrease to
// v2 and retry
this.apiVersion = 2;
return this.performRequest(url, method, query, body);
});
}; };
} }

View file

@ -2,7 +2,7 @@ import Bottle from 'bottlejs';
import { buildShlinkApiClient } from './ShlinkApiClientBuilder'; import { buildShlinkApiClient } from './ShlinkApiClientBuilder';
const provideServices = (bottle: Bottle) => { const provideServices = (bottle: Bottle) => {
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'fetch'); bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'jsonFetch');
}; };
export default provideServices; export default provideServices;

View file

@ -12,6 +12,7 @@ import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServ
import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar'; import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar';
import { ImageDownloader } from './ImageDownloader'; import { ImageDownloader } from './ImageDownloader';
import { ReportExporter } from './ReportExporter'; import { ReportExporter } from './ReportExporter';
import { jsonFetch } from '../../utils/helpers/fetch';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Services // Services
@ -19,6 +20,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.constant('console', global.console); bottle.constant('console', global.console);
bottle.constant('axios', axios); bottle.constant('axios', axios);
bottle.constant('fetch', (global as any).fetch.bind((global as any))); bottle.constant('fetch', (global as any).fetch.bind((global as any)));
bottle.serviceFactory('jsonFetch', jsonFetch, 'fetch');
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window'); bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv'); bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv');

View file

@ -1,19 +1,15 @@
import { pipe, prop } from 'ramda';
import { AxiosInstance } from 'axios';
import pack from '../../../package.json'; import pack from '../../../package.json';
import { hasServerData, ServerData } from '../data'; import { hasServerData, ServerData } from '../data';
import { createServers } from './servers'; import { createServers } from './servers';
import { createAsyncThunk } from '../../utils/helpers/redux'; import { createAsyncThunk } from '../../utils/helpers/redux';
import { Fetch } from '../../utils/types';
const responseToServersList = pipe( const responseToServersList = (data: any): ServerData[] => (Array.isArray(data) ? data.filter(hasServerData) : []);
prop<any, any>('data'),
(data: any): ServerData[] => (Array.isArray(data) ? data.filter(hasServerData) : []),
);
export const fetchServers = ({ get }: AxiosInstance) => createAsyncThunk( export const fetchServers = (fetch: Fetch) => createAsyncThunk(
'shlink/remoteServers/fetchServers', 'shlink/remoteServers/fetchServers',
async (_: void, { dispatch }): Promise<void> => { async (_: void, { dispatch }): Promise<void> => {
const resp = await get(`${pack.homepage}/servers.json`); const resp = await fetch<any>(`${pack.homepage}/servers.json`);
const result = responseToServersList(resp); const result = responseToServersList(resp);
dispatch(createServers(result)); dispatch(createServers(result));

View file

@ -80,7 +80,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('deleteServer', () => deleteServer); bottle.serviceFactory('deleteServer', () => deleteServer);
bottle.serviceFactory('editServer', () => editServer); bottle.serviceFactory('editServer', () => editServer);
bottle.serviceFactory('setAutoConnect', () => setAutoConnect); bottle.serviceFactory('setAutoConnect', () => setAutoConnect);
bottle.serviceFactory('fetchServers', fetchServers, 'axios'); bottle.serviceFactory('fetchServers', fetchServers, 'jsonFetch');
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer); bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);

View file

@ -0,0 +1,10 @@
export const jsonFetch = (fetch: typeof window.fetch) => <T>(url: string, options?: RequestInit) => fetch(url, options)
.then(async (resp) => {
const parsed = await resp.json();
if (!resp.ok) {
throw parsed; // eslint-disable-line @typescript-eslint/no-throw-literal
}
return parsed as T;
});

View file

@ -1,3 +1,3 @@
export type MediaMatcher = (query: string) => MediaQueryList; export type MediaMatcher = (query: string) => MediaQueryList;
export type Fetch = typeof window.fetch; export type Fetch = <T>(url: string, options?: RequestInit) => Promise<T>;

View file

@ -6,10 +6,8 @@ import { ShortUrl, ShortUrlsOrder } from '../../../src/short-urls/data';
import { Fetch } from '../../../src/utils/types'; import { Fetch } from '../../../src/utils/types';
describe('ShlinkApiClient', () => { describe('ShlinkApiClient', () => {
const buildFetch = (data: any) => jest.fn().mockResolvedValue({ json: () => Promise.resolve(data), ok: true }); const buildFetch = (data: any) => jest.fn().mockResolvedValue(data);
const buildRejectedFetch = (error: any) => jest.fn().mockResolvedValueOnce( const buildRejectedFetch = (error: any) => jest.fn().mockRejectedValueOnce(error);
{ json: () => Promise.resolve(error), ok: false },
);
const buildApiClient = (fetch: Fetch) => new ShlinkApiClient(fetch, '', ''); const buildApiClient = (fetch: Fetch) => new ShlinkApiClient(fetch, '', '');
const shortCodesWithDomainCombinations: [string, OptionalString][] = [ const shortCodesWithDomainCombinations: [string, OptionalString][] = [
['abc123', null], ['abc123', null],

View file

@ -1,5 +1,3 @@
import { Mock } from 'ts-mockery';
import { AxiosInstance } from 'axios';
import { fetchServers } from '../../../src/servers/reducers/remoteServers'; import { fetchServers } from '../../../src/servers/reducers/remoteServers';
import { createServers } from '../../../src/servers/reducers/servers'; import { createServers } from '../../../src/servers/reducers/servers';
@ -8,27 +6,24 @@ describe('remoteServersReducer', () => {
describe('fetchServers', () => { describe('fetchServers', () => {
const dispatch = jest.fn(); const dispatch = jest.fn();
const get = jest.fn(); const fetch = jest.fn();
const axios = Mock.of<AxiosInstance>({ get });
it.each([ it.each([
[ [
{ [
data: [ {
{ id: '111',
id: '111', name: 'acel.me from servers.json',
name: 'acel.me from servers.json', url: 'https://acel.me',
url: 'https://acel.me', apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0', },
}, {
{ id: '222',
id: '222', name: 'Local from servers.json',
name: 'Local from servers.json', url: 'http://localhost:8000',
url: 'http://localhost:8000', apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a', },
}, ],
],
},
{ {
111: { 111: {
id: '111', id: '111',
@ -45,26 +40,24 @@ describe('remoteServersReducer', () => {
}, },
], ],
[ [
{ [
data: [ {
{ id: '111',
id: '111', name: 'acel.me from servers.json',
name: 'acel.me from servers.json', url: 'https://acel.me',
url: 'https://acel.me', apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0', },
}, {
{ id: '222',
id: '222', name: 'Invalid',
name: 'Invalid', },
}, {
{ id: '333',
id: '333', name: 'Local from servers.json',
name: 'Local from servers.json', url: 'http://localhost:8000',
url: 'http://localhost:8000', apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a', },
}, ],
],
},
{ {
111: { 111: {
id: '111', id: '111',
@ -83,8 +76,8 @@ describe('remoteServersReducer', () => {
['<html></html>', {}], ['<html></html>', {}],
[{}, {}], [{}, {}],
])('tries to fetch servers from remote', async (mockedValue, expectedNewServers) => { ])('tries to fetch servers from remote', async (mockedValue, expectedNewServers) => {
get.mockResolvedValue(mockedValue); fetch.mockResolvedValue(mockedValue);
const doFetchServers = fetchServers(axios); const doFetchServers = fetchServers(fetch);
await doFetchServers()(dispatch, jest.fn(), {}); await doFetchServers()(dispatch, jest.fn(), {});
@ -95,7 +88,7 @@ describe('remoteServersReducer', () => {
expect(dispatch).toHaveBeenNthCalledWith(3, expect.objectContaining({ expect(dispatch).toHaveBeenNthCalledWith(3, expect.objectContaining({
type: doFetchServers.fulfilled.toString(), type: doFetchServers.fulfilled.toString(),
})); }));
expect(get).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(1);
}); });
}); });
}); });