From 16bee43f120602e01ea30f2105f1662917eeddce Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Nov 2022 16:57:16 +0100 Subject: [PATCH] Migrated ShlinkApiClient from axios to fetch --- src/api/services/ShlinkApiClient.ts | 83 ++++++++++++---------- src/api/services/ShlinkApiClientBuilder.ts | 5 +- src/api/services/provideServices.ts | 2 +- src/api/utils/index.ts | 13 +++- src/common/services/provideServices.ts | 1 + 5 files changed, 58 insertions(+), 46 deletions(-) diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index 4b837c33..6ea555ab 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -1,5 +1,4 @@ import { isEmpty, isNil, reject } from 'ramda'; -import { AxiosError, AxiosInstance, AxiosResponse, Method } from 'axios'; import { ShortUrl, ShortUrlData } from '../../short-urls/data'; import { OptionalString } from '../../utils/utils'; import { @@ -20,7 +19,7 @@ import { } from '../types'; import { orderToString } from '../../utils/helpers/ordering'; import { isRegularNotFound, parseApiError } from '../utils'; -import { ProblemDetailsError } from '../types/errors'; +import { stringifyQuery } from '../../utils/helpers/query'; const buildShlinkBaseUrl = (url: string, version: 2 | 3) => `${url}/rest/v${version}`; const rejectNilProps = reject(isNil); @@ -34,7 +33,7 @@ export class ShlinkApiClient { private apiVersion: 2 | 3; public constructor( - private readonly axios: AxiosInstance, + private readonly fetch: typeof window.fetch, private readonly baseUrl: string, private readonly apiKey: string, ) { @@ -43,42 +42,40 @@ export class ShlinkApiClient { public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise => this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeOrderByInParams(params)) - .then(({ data }) => data.shortUrls); + .then(({ shortUrls }) => shortUrls); public readonly createShortUrl = async (options: ShortUrlData): Promise => { const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options as any); - return this.performRequest('/short-urls', 'POST', {}, filteredOptions) - .then((resp) => resp.data); + return this.performRequest('/short-urls', 'POST', {}, filteredOptions); }; public readonly getShortUrlVisits = async (shortCode: string, query?: ShlinkVisitsParams): Promise => this.performRequest<{ visits: ShlinkVisits }>(`/short-urls/${shortCode}/visits`, 'GET', query) - .then(({ data }) => data.visits); + .then(({ visits }) => visits); public readonly getTagVisits = async (tag: string, query?: Omit): Promise => this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query) - .then(({ data }) => data.visits); + .then(({ visits }) => visits); public readonly getDomainVisits = async (domain: string, query?: Omit): Promise => this.performRequest<{ visits: ShlinkVisits }>(`/domains/${domain}/visits`, 'GET', query) - .then(({ data }) => data.visits); + .then(({ visits }) => visits); public readonly getOrphanVisits = async (query?: Omit): Promise => this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query) - .then(({ data }) => data.visits); + .then(({ visits }) => visits); public readonly getNonOrphanVisits = async (query?: Omit): Promise => this.performRequest<{ visits: ShlinkVisits }>('/visits/non-orphan', 'GET', query) - .then(({ data }) => data.visits); + .then(({ visits }) => visits); public readonly getVisitsOverview = async (): Promise => - this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET') - .then(({ data }) => data.visits); + this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits') + .then(({ visits }) => visits); public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise => - this.performRequest(`/short-urls/${shortCode}`, 'GET', { domain }) - .then(({ data }) => data); + this.performRequest(`/short-urls/${shortCode}`, 'GET', { domain }); public readonly deleteShortUrl = async (shortCode: string, domain?: OptionalString): Promise => this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain }) @@ -89,11 +86,11 @@ export class ShlinkApiClient { domain: OptionalString, edit: ShlinkShortUrlData, ): Promise => - this.performRequest(`/short-urls/${shortCode}`, 'PATCH', { domain }, edit).then(({ data }) => data); + this.performRequest(`/short-urls/${shortCode}`, 'PATCH', { domain }, edit); public readonly listTags = async (): Promise => this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' }) - .then((resp) => resp.data.tags) + .then(({ tags }) => tags) .then(({ data, stats }) => ({ tags: data, stats })); public readonly deleteTags = async (tags: string[]): Promise<{ tags: string[] }> => @@ -104,38 +101,46 @@ export class ShlinkApiClient { this.performRequest('/tags', 'PUT', {}, { oldName, newName }) .then(() => ({ oldName, newName })); - public readonly health = async (): Promise => - this.performRequest('/health', 'GET') - .then((resp) => resp.data); + public readonly health = async (): Promise => this.performRequest('/health', 'GET'); public readonly mercureInfo = async (): Promise => - this.performRequest('/mercure-info', 'GET') - .then((resp) => resp.data); + this.performRequest('/mercure-info', 'GET'); public readonly listDomains = async (): Promise => - this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains); + this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains').then(({ domains }) => domains); public readonly editDomainRedirects = async ( domainRedirects: ShlinkEditDomainRedirects, ): Promise => - this.performRequest('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data); + this.performRequest('/domains/redirects', 'PATCH', {}, domainRedirects); - private readonly performRequest = async (url: string, method: Method = 'GET', query = {}, body = {}): Promise> => - this.axios({ + private readonly performRequest = async (url: string, method = 'GET', query = {}, body?: object): Promise => { + const normalizedQuery = stringifyQuery(rejectNilProps(query)); + const stringifiedQuery = isEmpty(normalizedQuery) ? '' : `?${normalizedQuery}`; + + return this.fetch(`${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}${stringifiedQuery}`, { method, - url: `${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}`, + body: body && JSON.stringify(body), headers: { 'X-Api-Key': this.apiKey }, - params: rejectNilProps(query), - data: body, - paramsSerializer: { indexes: false }, - }).catch((e: AxiosError) => { - if (!isRegularNotFound(parseApiError(e))) { - throw e; - } + }) + .then(async (resp) => { + const parsed = await resp.json(); - // 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); - }); + if (!resp.ok) { + throw parsed; // eslint-disable-line @typescript-eslint/no-throw-literal + } + + 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); + }); + }; } diff --git a/src/api/services/ShlinkApiClientBuilder.ts b/src/api/services/ShlinkApiClientBuilder.ts index 33381b46..532bf04e 100644 --- a/src/api/services/ShlinkApiClientBuilder.ts +++ b/src/api/services/ShlinkApiClientBuilder.ts @@ -1,4 +1,3 @@ -import { AxiosInstance } from 'axios'; import { hasServerData, ServerWithId } from '../../servers/data'; import { GetState } from '../../container/types'; import { ShlinkApiClient } from './ShlinkApiClient'; @@ -16,14 +15,14 @@ const getSelectedServerFromState = (getState: GetState): ServerWithId => { return selectedServer; }; -export const buildShlinkApiClient = (axios: AxiosInstance) => (getStateOrSelectedServer: GetState | ServerWithId) => { +export const buildShlinkApiClient = (fetch: typeof window.fetch) => (getStateOrSelectedServer: GetState | ServerWithId) => { const { url, apiKey } = isGetState(getStateOrSelectedServer) ? getSelectedServerFromState(getStateOrSelectedServer) : getStateOrSelectedServer; const clientKey = `${url}_${apiKey}`; if (!apiClients[clientKey]) { - apiClients[clientKey] = new ShlinkApiClient(axios, url, apiKey); + apiClients[clientKey] = new ShlinkApiClient(fetch, url, apiKey); } return apiClients[clientKey]; diff --git a/src/api/services/provideServices.ts b/src/api/services/provideServices.ts index 3ddb60e7..a8d61f13 100644 --- a/src/api/services/provideServices.ts +++ b/src/api/services/provideServices.ts @@ -2,7 +2,7 @@ import Bottle from 'bottlejs'; import { buildShlinkApiClient } from './ShlinkApiClientBuilder'; const provideServices = (bottle: Bottle) => { - bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios'); + bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'fetch'); }; export default provideServices; diff --git a/src/api/utils/index.ts b/src/api/utils/index.ts index 64b7c0a1..25397b34 100644 --- a/src/api/utils/index.ts +++ b/src/api/utils/index.ts @@ -8,11 +8,18 @@ import { RegularNotFound, } from '../types/errors'; +const isProblemDetails = (e: unknown): e is ProblemDetailsError => + !!e && typeof e === 'object' && Object.keys(e).every((key) => ['type', 'detail', 'title', 'status'].includes(key)); + const isAxiosError = (e: unknown): e is AxiosError => !!e && typeof e === 'object' && 'response' in e; -export const parseApiError = (e: unknown): ProblemDetailsError | undefined => ( - isAxiosError(e) ? e.response?.data : undefined -); +export const parseApiError = (e: unknown): ProblemDetailsError | undefined => { + if (isProblemDetails(e)) { + return e; + } + + return (isAxiosError(e) ? e.response?.data : undefined); +}; export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError => error?.type === ErrorTypeV2.INVALID_ARGUMENT || error?.type === ErrorTypeV3.INVALID_ARGUMENT; diff --git a/src/common/services/provideServices.ts b/src/common/services/provideServices.ts index 3a613bd2..18558991 100644 --- a/src/common/services/provideServices.ts +++ b/src/common/services/provideServices.ts @@ -18,6 +18,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.constant('window', (global as any).window); bottle.constant('console', global.console); bottle.constant('axios', axios); + bottle.constant('fetch', (global as any).fetch.bind((global as any))); bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window'); bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv');