diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index fa0a8745..8850ff18 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -1,5 +1,5 @@ import { isEmpty, isNil, reject } from 'ramda'; -import { AxiosInstance, AxiosResponse, Method } from 'axios'; +import { AxiosError, AxiosInstance, AxiosResponse, Method } from 'axios'; import { ShortUrl, ShortUrlData } from '../../short-urls/data'; import { OptionalString } from '../../utils/utils'; import { @@ -17,10 +17,12 @@ import { ShlinkDomainRedirects, ShlinkShortUrlsListParams, ShlinkShortUrlsListNormalizedParams, + ProblemDetailsError, } from '../types'; import { orderToString } from '../../utils/helpers/ordering'; +import { isRegularNotFound } from '../utils'; -const buildShlinkBaseUrl = (url: string) => (url ? `${url}/rest/v2` : ''); +const buildShlinkBaseUrl = (url: string, version: 2 | 3) => `${url}/rest/v${version}`; const rejectNilProps = reject(isNil); const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => { const { orderBy = {}, ...rest } = params; @@ -29,11 +31,14 @@ const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShor }; export class ShlinkApiClient { + private apiVersion: 2 | 3; + public constructor( private readonly axios: AxiosInstance, private readonly baseUrl: string, private readonly apiKey: string, ) { + this.apiVersion = 3; } public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise => @@ -118,10 +123,19 @@ export class ShlinkApiClient { private readonly performRequest = async (url: string, method: Method = 'GET', query = {}, body = {}): Promise> => this.axios({ method, - url: `${buildShlinkBaseUrl(this.baseUrl)}${url}`, + url: `${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}`, headers: { 'X-Api-Key': this.apiKey }, params: rejectNilProps(query), data: body, paramsSerializer: { indexes: false }, + }).catch((e: AxiosError) => { + if (!isRegularNotFound(e.response?.data)) { + 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); }); } diff --git a/src/api/types/index.ts b/src/api/types/index.ts index 126750bd..d8d521cf 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -120,3 +120,8 @@ export interface InvalidShortUrlDeletion extends ProblemDetailsError { type: 'INVALID_SHORTCODE_DELETION' | 'INVALID_SHORT_URL_DELETION'; threshold: number; } + +export interface RegularNotFound extends ProblemDetailsError { + type: 'NOT_FOUND'; + status: 404; +} diff --git a/src/api/utils/index.ts b/src/api/utils/index.ts index af2517e0..0430bfd3 100644 --- a/src/api/utils/index.ts +++ b/src/api/utils/index.ts @@ -1,5 +1,5 @@ import { AxiosError } from 'axios'; -import { InvalidArgumentError, InvalidShortUrlDeletion, ProblemDetailsError } from '../types'; +import { InvalidArgumentError, InvalidShortUrlDeletion, ProblemDetailsError, RegularNotFound } from '../types'; export const parseApiError = (e: AxiosError) => e.response?.data; @@ -8,3 +8,6 @@ export const isInvalidArgumentError = (error?: ProblemDetailsError): error is In export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion => error?.type === 'INVALID_SHORTCODE_DELETION' || error?.type === 'INVALID_SHORT_URL_DELETION'; + +export const isRegularNotFound = (error?: ProblemDetailsError): error is RegularNotFound => + error?.type === 'NOT_FOUND' && error?.status === 404; diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts index f5f7b647..9448303e 100644 --- a/test/api/services/ShlinkApiClient.test.ts +++ b/test/api/services/ShlinkApiClient.test.ts @@ -1,4 +1,4 @@ -import { AxiosInstance, AxiosRequestConfig } from 'axios'; +import { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios'; import { Mock } from 'ts-mockery'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { OptionalString } from '../../../src/utils/utils'; @@ -87,7 +87,7 @@ describe('ShlinkApiClient', () => { expect({ data: expectedVisits }).toEqual(actualVisits); expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: '/short-urls/abc123/visits', + url: expect.stringContaining('/short-urls/abc123/visits'), method: 'GET', })); }); @@ -109,7 +109,7 @@ describe('ShlinkApiClient', () => { expect({ data: expectedVisits }).toEqual(actualVisits); expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: '/tags/foo/visits', + url: expect.stringContaining('/tags/foo/visits'), method: 'GET', })); }); @@ -131,7 +131,7 @@ describe('ShlinkApiClient', () => { expect({ data: expectedVisits }).toEqual(actualVisits); expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: '/domains/foo.com/visits', + url: expect.stringContaining('/domains/foo.com/visits'), method: 'GET', })); }); @@ -149,7 +149,7 @@ describe('ShlinkApiClient', () => { expect(expectedShortUrl).toEqual(result); expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: `/short-urls/${shortCode}`, + url: expect.stringContaining(`/short-urls/${shortCode}`), method: 'GET', params: domain ? { domain } : {}, })); @@ -170,7 +170,7 @@ describe('ShlinkApiClient', () => { expect(expectedResp).toEqual(result); expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: `/short-urls/${shortCode}`, + url: expect.stringContaining(`/short-urls/${shortCode}`), method: 'PATCH', params: domain ? { domain } : {}, })); @@ -190,7 +190,10 @@ describe('ShlinkApiClient', () => { const result = await listTags(); expect({ tags: expectedTags }).toEqual(result); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ url: '/tags', method: 'GET' })); + expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ + url: expect.stringContaining('/tags'), + method: 'GET', + })); }); }); @@ -203,7 +206,7 @@ describe('ShlinkApiClient', () => { await deleteTags(tags); expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: '/tags', + url: expect.stringContaining('/tags'), method: 'DELETE', params: { tags }, })); @@ -220,7 +223,7 @@ describe('ShlinkApiClient', () => { await editTag(oldName, newName); expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: '/tags', + url: expect.stringContaining('/tags'), method: 'PUT', data: { oldName, newName }, })); @@ -235,7 +238,7 @@ describe('ShlinkApiClient', () => { await deleteShortUrl(shortCode, domain); expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: `/short-urls/${shortCode}`, + url: expect.stringContaining(`/short-urls/${shortCode}`), method: 'DELETE', params: domain ? { domain } : {}, })); @@ -341,5 +344,26 @@ describe('ShlinkApiClient', () => { expect(axiosSpy).toHaveBeenCalled(); expect(result).toEqual(resp); }); + + it('retries request if API version is not supported', async () => { + const axiosSpy = jest.fn() + .mockImplementationOnce(() => Promise.reject(Mock.of({ + response: { + data: { type: 'NOT_FOUND', status: 404 }, + }, + }))) + .mockImplementation(createAxios({})) as unknown as AxiosInstance; + const { editDomainRedirects } = new ShlinkApiClient(axiosSpy, '', ''); + + await editDomainRedirects({ domain: 'foo' }); + + expect(axiosSpy).toHaveBeenCalledTimes(2); + expect(axiosSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({ + url: expect.stringContaining('/v3/'), + })); + expect(axiosSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({ + url: expect.stringContaining('/v2/'), + })); + }); }); });