diff --git a/package-lock.json b/package-lock.json index 5e1b06d6..26892ac8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7d8e84be..b4ec1ce3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/mercure/reducers/mercureInfo.ts b/src/mercure/reducers/mercureInfo.ts index 582f76b7..768d8acf 100644 --- a/src/mercure/reducers/mercureInfo.ts +++ b/src/mercure/reducers/mercureInfo.ts @@ -55,7 +55,7 @@ export const loadMercureInfo = (buildShlinkApiClient: ShlinkApiClientBuilder) => try { const result = await mercureInfo(); - dispatch>({ type: GET_MERCURE_INFO, ...result }); + dispatch({ type: GET_MERCURE_INFO, ...result }); } catch (e) { dispatch({ type: GET_MERCURE_INFO_ERROR }); } diff --git a/src/short-urls/reducers/shortUrlsListParams.ts b/src/short-urls/reducers/shortUrlsListParams.ts index 9bc69594..e5ed91d5 100644 --- a/src/short-urls/reducers/shortUrlsListParams.ts +++ b/src/short-urls/reducers/shortUrlsListParams.ts @@ -20,7 +20,7 @@ export interface ShortUrlsListParams { searchTerm?: string; startDate?: string; endDate?: string; - orderBy?: object; + orderBy?: string | Record; } const initialState: ShortUrlsListParams = { page: '1' }; diff --git a/src/utils/services/ShlinkApiClient.js b/src/utils/services/ShlinkApiClient.js deleted file mode 100644 index a26d7078..00000000 --- a/src/utils/services/ShlinkApiClient.js +++ /dev/null @@ -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); - } - } -} diff --git a/src/utils/services/ShlinkApiClient.ts b/src/utils/services/ShlinkApiClient.ts new file mode 100644 index 00000000..84b5bc9e --- /dev/null +++ b/src/utils/services/ShlinkApiClient.ts @@ -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 => + this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params) + .then(({ data }) => data.shortUrls); + + public readonly createShortUrl = async (options: any): Promise => { // TODO CreateShortUrl interface + const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options); + + return this.performRequest('/short-urls', 'POST', {}, filteredOptions) + .then((resp) => resp.data); + }; + + public readonly getShortUrlVisits = async (shortCode: string, query?: ShlinkVisitsParams): Promise => + this.performRequest<{ visits: ShlinkVisits }>(`/short-urls/${shortCode}/visits`, 'GET', query) + .then(({ data }) => data.visits); + + public readonly getTagVisits = async (tag: string, query?: Omit): Promise => + this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query) + .then(({ data }) => data.visits); + + public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise => + this.performRequest(`/short-urls/${shortCode}`, 'GET', { domain }) + .then(({ data }) => data); + + public readonly deleteShortUrl = async (shortCode: string, domain?: OptionalString): Promise => + this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain }) + .then(() => {}); + + public readonly updateShortUrlTags = async ( + shortCode: string, + domain: OptionalString, + tags: string[], + ): Promise => + 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 => + this.performRequest(`/short-urls/${shortCode}`, 'PATCH', { domain }, meta) + .then(() => meta); + + public readonly listTags = async (): Promise => + 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 => + this.performRequest('/health', 'GET') + .then((resp) => resp.data); + + public readonly mercureInfo = async (): Promise => + this.performRequest('/mercure-info', 'GET') + .then((resp) => resp.data); + + private readonly performRequest = async (url: string, method: Method = 'GET', query = {}, body = {}): Promise> => { + 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); + } + }; +} diff --git a/src/utils/services/types.ts b/src/utils/services/types.ts index 0f9062f7..8c4c53d3 100644 --- a/src/utils/services/types.ts +++ b/src/utils/services/types.ts @@ -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; diff --git a/test/utils/services/ShlinkApiClient.test.js b/test/utils/services/ShlinkApiClient.test.ts similarity index 77% rename from test/utils/services/ShlinkApiClient.test.js rename to test/utils/services/ShlinkApiClient.test.ts index 55a4fd3b..db9193b3 100644 --- a/test/utils/services/ShlinkApiClient.test.js +++ b/test/utils/services/ShlinkApiClient.test.ts @@ -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(); diff --git a/test/utils/services/ShlinkApiClientBuilder.test.ts b/test/utils/services/ShlinkApiClientBuilder.test.ts index 5fac09ae..0cd31204 100644 --- a/test/utils/services/ShlinkApiClientBuilder.test.ts +++ b/test/utils/services/ShlinkApiClientBuilder.test.ts @@ -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 }); });