Migrated ShlinkApiClient to TS

This commit is contained in:
Alejandro Celaya 2020-08-29 19:51:14 +02:00
parent ebd7a76896
commit ef630af154
9 changed files with 198 additions and 134 deletions

6
package-lock.json generated
View file

@ -3359,6 +3359,12 @@
"integrity": "sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw==",
"dev": true
},
"@types/qs": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.4.tgz",
"integrity": "sha512-+wYo+L6ZF6BMoEjtf8zB2esQsqdV6WsjRK/GP9WOgLPrq87PbNWgIxS76dS5uvl/QXtHGakZmwTznIfcPXcKlQ==",
"dev": true
},
"@types/ramda": {
"version": "0.27.14",
"resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.27.14.tgz",

View file

@ -80,6 +80,7 @@
"@types/jest": "^26.0.10",
"@types/leaflet": "^1.5.17",
"@types/moment": "^2.13.0",
"@types/qs": "^6.9.4",
"@types/ramda": "^0.27.14",
"@types/react": "^16.9.46",
"@types/react-datepicker": "~1.8.0",

View file

@ -55,7 +55,7 @@ export const loadMercureInfo = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
try {
const result = await mercureInfo();
dispatch<Action<ShlinkMercureInfo>>({ type: GET_MERCURE_INFO, ...result });
dispatch<GetMercureInfoAction>({ type: GET_MERCURE_INFO, ...result });
} catch (e) {
dispatch({ type: GET_MERCURE_INFO_ERROR });
}

View file

@ -20,7 +20,7 @@ export interface ShortUrlsListParams {
searchTerm?: string;
startDate?: string;
endDate?: string;
orderBy?: object;
orderBy?: string | Record<string, 'ASC' | 'DESC'>;
}
const initialState: ShortUrlsListParams = { page: '1' };

View file

@ -1,97 +0,0 @@
import qs from 'qs';
import { isEmpty, isNil, reject } from 'ramda';
const buildShlinkBaseUrl = (url, apiVersion) => url ? `${url}/rest/v${apiVersion}` : '';
const rejectNilProps = reject(isNil);
export default class ShlinkApiClient {
constructor(axios, baseUrl, apiKey) {
this.axios = axios;
this._apiVersion = 2;
this._baseUrl = baseUrl;
this._apiKey = apiKey || '';
}
listShortUrls = (options = {}) =>
this._performRequest('/short-urls', 'GET', options).then((resp) => resp.data.shortUrls);
createShortUrl = (options) => {
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options);
return this._performRequest('/short-urls', 'POST', {}, filteredOptions)
.then((resp) => resp.data);
};
getShortUrlVisits = (shortCode, query) =>
this._performRequest(`/short-urls/${shortCode}/visits`, 'GET', query)
.then((resp) => resp.data.visits);
getTagVisits = (tag, query) =>
this._performRequest(`/tags/${tag}/visits`, 'GET', query)
.then((resp) => resp.data.visits);
getShortUrl = (shortCode, domain) =>
this._performRequest(`/short-urls/${shortCode}`, 'GET', { domain })
.then((resp) => resp.data);
deleteShortUrl = (shortCode, domain) =>
this._performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
.then(() => ({}));
updateShortUrlTags = (shortCode, domain, tags) =>
this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags })
.then((resp) => resp.data.tags);
updateShortUrlMeta = (shortCode, domain, meta) =>
this._performRequest(`/short-urls/${shortCode}`, 'PATCH', { domain }, meta)
.then(() => meta);
listTags = () =>
this._performRequest('/tags', 'GET', { withStats: 'true' })
.then((resp) => resp.data.tags)
.then(({ data, stats }) => ({ tags: data, stats }));
deleteTags = (tags) =>
this._performRequest('/tags', 'DELETE', { tags })
.then(() => ({ tags }));
editTag = (oldName, newName) =>
this._performRequest('/tags', 'PUT', {}, { oldName, newName })
.then(() => ({ oldName, newName }));
health = () => this._performRequest('/health', 'GET').then((resp) => resp.data);
mercureInfo = () => this._performRequest('/mercure-info', 'GET').then((resp) => resp.data);
_performRequest = async (url, method = 'GET', query = {}, body = {}) => {
try {
return await this.axios({
method,
url: `${buildShlinkBaseUrl(this._baseUrl, this._apiVersion)}${url}`,
headers: { 'X-Api-Key': this._apiKey },
params: rejectNilProps(query),
data: body,
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
});
} catch (e) {
const { response } = e;
// Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error
// when performed from the browser (due to the preflight request not returning a 2xx status.
// See https://github.com/shlinkio/shlink/issues/614), which will make the "response" prop not to be set here.
// The bug will be fixed on upcoming Shlink patches, but for other versions, we can consider this situation as
// if a request has been performed to a not supported API version.
const apiVersionIsNotSupported = !response;
// When the request is not invalid or we have already tried both API versions, throw the error and let the
// caller handle it
if (!apiVersionIsNotSupported || this._apiVersion === 1) {
throw e;
}
this._apiVersion = 1;
return await this._performRequest(url, method, query, body);
}
}
}

View file

@ -0,0 +1,127 @@
import qs from 'qs';
import { isEmpty, isNil, reject } from 'ramda';
import { AxiosInstance, AxiosResponse, Method } from 'axios';
import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
import { ShortUrl } from '../../short-urls/data';
import { OptionalString } from '../utils';
import {
ShlinkHealth,
ShlinkMercureInfo,
ShlinkShortUrlsResponse,
ShlinkTags,
ShlinkTagsResponse,
ShlinkVisits,
ShlinkVisitsParams,
ShlinkShortUrlMeta,
} from './types';
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
const rejectNilProps = reject(isNil);
export default class ShlinkApiClient {
private apiVersion: number;
public constructor(
private readonly axios: AxiosInstance,
private readonly baseUrl: string,
private readonly apiKey: string,
) {
this.apiVersion = 2;
}
public readonly listShortUrls = async (params: ShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params)
.then(({ data }) => data.shortUrls);
public readonly createShortUrl = async (options: any): Promise<ShortUrl> => { // TODO CreateShortUrl interface
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options);
return this.performRequest<ShortUrl>('/short-urls', 'POST', {}, filteredOptions)
.then((resp) => resp.data);
};
public readonly getShortUrlVisits = async (shortCode: string, query?: ShlinkVisitsParams): Promise<ShlinkVisits> =>
this.performRequest<{ visits: ShlinkVisits }>(`/short-urls/${shortCode}/visits`, 'GET', query)
.then(({ data }) => data.visits);
public readonly getTagVisits = async (tag: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
.then(({ data }) => data.visits);
public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise<ShortUrl> =>
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain })
.then(({ data }) => data);
public readonly deleteShortUrl = async (shortCode: string, domain?: OptionalString): Promise<void> =>
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
.then(() => {});
public readonly updateShortUrlTags = async (
shortCode: string,
domain: OptionalString,
tags: string[],
): Promise<string[]> =>
this.performRequest<{ tags: string[] }>(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags })
.then(({ data }) => data.tags);
public readonly updateShortUrlMeta = async (
shortCode: string,
domain: OptionalString,
meta: ShlinkShortUrlMeta,
): Promise<ShlinkShortUrlMeta> =>
this.performRequest(`/short-urls/${shortCode}`, 'PATCH', { domain }, meta)
.then(() => meta);
public readonly listTags = async (): Promise<ShlinkTags> =>
this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' })
.then((resp) => resp.data.tags)
.then(({ data, stats }) => ({ tags: data, stats }));
public readonly deleteTags = async (tags: string[]): Promise<{ tags: string[] }> =>
this.performRequest('/tags', 'DELETE', { tags })
.then(() => ({ tags }));
public readonly editTag = async (oldName: string, newName: string): Promise<{ oldName: string; newName: string }> =>
this.performRequest('/tags', 'PUT', {}, { oldName, newName })
.then(() => ({ oldName, newName }));
public readonly health = async (): Promise<ShlinkHealth> =>
this.performRequest<ShlinkHealth>('/health', 'GET')
.then((resp) => resp.data);
public readonly mercureInfo = async (): Promise<ShlinkMercureInfo> =>
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
.then((resp) => resp.data);
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
try {
return await this.axios({
method,
url: `${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}`,
headers: { 'X-Api-Key': this.apiKey },
params: rejectNilProps(query),
data: body,
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
});
} catch (e) {
const { response } = e;
// Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error
// when performed from the browser (due to the preflight request not returning a 2xx status.
// See https://github.com/shlinkio/shlink/issues/614), which will make the "response" prop not to be set here.
// The bug will be fixed on upcoming Shlink patches, but for other versions, we can consider this situation as
// if a request has been performed to a not supported API version.
const apiVersionIsNotSupported = !response;
// When the request is not invalid or we have already tried both API versions, throw the error and let the
// caller handle it
if (!apiVersionIsNotSupported || this.apiVersion === 1) {
throw e;
}
this.apiVersion = this.apiVersion - 1;
return await this.performRequest(url, method, query, body);
}
};
}

View file

@ -1,4 +1,11 @@
import { Visit } from '../../visits/types'; // FIXME Should be defined here
import { Visit } from '../../visits/types'; // FIXME Should be defined as part of this module
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data'; // FIXME Should be defined as part of this module
import { OptionalString } from '../utils';
export interface ShlinkShortUrlsResponse {
data: ShortUrl[];
pagination: ShlinkPaginator;
}
export interface ShlinkMercureInfo {
token: string;
@ -21,6 +28,11 @@ export interface ShlinkTags {
stats?: ShlinkTagsStats[]; // TODO Is only optional in old versions
}
export interface ShlinkTagsResponse {
data: string[];
stats?: ShlinkTagsStats[]; // TODO Is only optional in old versions
}
export interface ShlinkPaginator {
currentPage: number;
pagesCount: number;
@ -31,6 +43,18 @@ export interface ShlinkVisits {
pagination?: ShlinkPaginator; // TODO Is only optional in old versions
}
export interface ShlinkVisitsParams {
domain?: OptionalString;
page?: number;
itemsPerPage?: number;
startDate?: string;
endDate?: string;
}
export interface ShlinkShortUrlMeta extends ShortUrlMeta {
longUrl?: string;
}
export interface ProblemDetailsError {
type: string;
detail: string;

View file

@ -1,9 +1,12 @@
import { AxiosInstance, AxiosRequestConfig } from 'axios';
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
import { OptionalString } from '../../../src/utils/utils';
describe('ShlinkApiClient', () => {
const createAxiosMock = (data) => () => Promise.resolve(data);
const createApiClient = (data) => new ShlinkApiClient(createAxiosMock(data));
const shortCodesWithDomainCombinations = [
const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance;
const createAxiosMock = (data: AxiosRequestConfig = {}) => jest.fn(createAxios(data)) as unknown as AxiosInstance;
const createApiClient = (data: AxiosRequestConfig) => new ShlinkApiClient(createAxios(data), '', '');
const shortCodesWithDomainCombinations: [ string, OptionalString ][] = [
[ 'abc123', null ],
[ 'abc123', undefined ],
[ 'abc123', 'example.com' ],
@ -38,8 +41,8 @@ describe('ShlinkApiClient', () => {
});
it('removes all empty options', async () => {
const axiosSpy = jest.fn(createAxiosMock({ data: shortUrl }));
const { createShortUrl } = new ShlinkApiClient(axiosSpy);
const axiosSpy = createAxiosMock({ data: shortUrl });
const { createShortUrl } = new ShlinkApiClient(axiosSpy, '', '');
await createShortUrl(
{ foo: 'bar', empty: undefined, anotherEmpty: null },
@ -52,14 +55,14 @@ describe('ShlinkApiClient', () => {
describe('getShortUrlVisits', () => {
it('properly returns short URL visits', async () => {
const expectedVisits = [ 'foo', 'bar' ];
const axiosSpy = jest.fn(createAxiosMock({
const axiosSpy = createAxiosMock({
data: {
visits: {
data: expectedVisits,
},
},
}));
const { getShortUrlVisits } = new ShlinkApiClient(axiosSpy);
});
const { getShortUrlVisits } = new ShlinkApiClient(axiosSpy, '', '');
const actualVisits = await getShortUrlVisits('abc123', {});
@ -74,14 +77,14 @@ describe('ShlinkApiClient', () => {
describe('getTagVisits', () => {
it('properly returns tag visits', async () => {
const expectedVisits = [ 'foo', 'bar' ];
const axiosSpy = jest.fn(createAxiosMock({
const axiosSpy = createAxiosMock({
data: {
visits: {
data: expectedVisits,
},
},
}));
const { getTagVisits } = new ShlinkApiClient(axiosSpy);
});
const { getTagVisits } = new ShlinkApiClient(axiosSpy, '', '');
const actualVisits = await getTagVisits('foo', {});
@ -96,10 +99,10 @@ describe('ShlinkApiClient', () => {
describe('getShortUrl', () => {
it.each(shortCodesWithDomainCombinations)('properly returns short URL', async (shortCode, domain) => {
const expectedShortUrl = { foo: 'bar' };
const axiosSpy = jest.fn(createAxiosMock({
const axiosSpy = createAxiosMock({
data: expectedShortUrl,
}));
const { getShortUrl } = new ShlinkApiClient(axiosSpy);
});
const { getShortUrl } = new ShlinkApiClient(axiosSpy, '', '');
const result = await getShortUrl(shortCode, domain);
@ -115,10 +118,10 @@ describe('ShlinkApiClient', () => {
describe('updateShortUrlTags', () => {
it.each(shortCodesWithDomainCombinations)('properly updates short URL tags', async (shortCode, domain) => {
const expectedTags = [ 'foo', 'bar' ];
const axiosSpy = jest.fn(createAxiosMock({
const axiosSpy = createAxiosMock({
data: { tags: expectedTags },
}));
const { updateShortUrlTags } = new ShlinkApiClient(axiosSpy);
});
const { updateShortUrlTags } = new ShlinkApiClient(axiosSpy, '', '');
const result = await updateShortUrlTags(shortCode, domain, expectedTags);
@ -137,8 +140,8 @@ describe('ShlinkApiClient', () => {
maxVisits: 50,
validSince: '2025-01-01T10:00:00+01:00',
};
const axiosSpy = jest.fn(createAxiosMock());
const { updateShortUrlMeta } = new ShlinkApiClient(axiosSpy);
const axiosSpy = createAxiosMock();
const { updateShortUrlMeta } = new ShlinkApiClient(axiosSpy, '', '');
const result = await updateShortUrlMeta(shortCode, domain, expectedMeta);
@ -154,12 +157,12 @@ describe('ShlinkApiClient', () => {
describe('listTags', () => {
it('properly returns list of tags', async () => {
const expectedTags = [ 'foo', 'bar' ];
const axiosSpy = jest.fn(createAxiosMock({
const axiosSpy = createAxiosMock({
data: {
tags: { data: expectedTags },
},
}));
const { listTags } = new ShlinkApiClient(axiosSpy);
});
const { listTags } = new ShlinkApiClient(axiosSpy, '', '');
const result = await listTags();
@ -171,8 +174,8 @@ describe('ShlinkApiClient', () => {
describe('deleteTags', () => {
it('properly deletes provided tags', async () => {
const tags = [ 'foo', 'bar' ];
const axiosSpy = jest.fn(createAxiosMock({}));
const { deleteTags } = new ShlinkApiClient(axiosSpy);
const axiosSpy = createAxiosMock();
const { deleteTags } = new ShlinkApiClient(axiosSpy, '', '');
await deleteTags(tags);
@ -188,8 +191,8 @@ describe('ShlinkApiClient', () => {
it('properly edits provided tag', async () => {
const oldName = 'foo';
const newName = 'bar';
const axiosSpy = jest.fn(createAxiosMock({}));
const { editTag } = new ShlinkApiClient(axiosSpy);
const axiosSpy = createAxiosMock();
const { editTag } = new ShlinkApiClient(axiosSpy, '', '');
await editTag(oldName, newName);
@ -203,8 +206,8 @@ describe('ShlinkApiClient', () => {
describe('deleteShortUrl', () => {
it.each(shortCodesWithDomainCombinations)('properly deletes provided short URL', async (shortCode, domain) => {
const axiosSpy = jest.fn(createAxiosMock({}));
const { deleteShortUrl } = new ShlinkApiClient(axiosSpy);
const axiosSpy = createAxiosMock({});
const { deleteShortUrl } = new ShlinkApiClient(axiosSpy, '', '');
await deleteShortUrl(shortCode, domain);
@ -222,8 +225,8 @@ describe('ShlinkApiClient', () => {
status: 'pass',
version: '1.19.0',
};
const axiosSpy = jest.fn(createAxiosMock({ data: expectedData }));
const { health } = new ShlinkApiClient(axiosSpy);
const axiosSpy = createAxiosMock({ data: expectedData });
const { health } = new ShlinkApiClient(axiosSpy, '', '');
const result = await health();
@ -238,8 +241,8 @@ describe('ShlinkApiClient', () => {
token: 'abc.123.def',
mercureHubUrl: 'http://example.com/.well-known/mercure',
};
const axiosSpy = jest.fn(createAxiosMock({ data: expectedData }));
const { mercureInfo } = new ShlinkApiClient(axiosSpy);
const axiosSpy = createAxiosMock({ data: expectedData });
const { mercureInfo } = new ShlinkApiClient(axiosSpy, '', '');
const result = await mercureInfo();

View file

@ -46,7 +46,7 @@ describe('ShlinkApiClientBuilder', () => {
const apiKey = 'apiKey';
const apiClient = buildShlinkApiClient(axiosMock)(server({ url, apiKey }));
expect(apiClient._baseUrl).toEqual(url);
expect(apiClient._apiKey).toEqual(apiKey);
expect(apiClient['baseUrl']).toEqual(url); // eslint-disable-line dot-notation
expect(apiClient['apiKey']).toEqual(apiKey); // eslint-disable-line dot-notation
});
});