diff --git a/package-lock.json b/package-lock.json index b35ddc10..c211d8db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "@fortawesome/free-solid-svg-icons": "^6.2.0", "@fortawesome/react-fontawesome": "^0.2.0", "@reduxjs/toolkit": "^1.9.0", - "axios": "^1.1.2", "bootstrap": "^5.2.2", "bottlejs": "^2.0.1", "bowser": "^2.11.0", @@ -7141,7 +7140,8 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true }, "node_modules/at-least-node": { "version": "1.0.0", @@ -7194,16 +7194,6 @@ "node": ">=4" } }, - "node_modules/axios": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.1.2.tgz", - "integrity": "sha512-bznQyETwElsXl2RK7HLLwb5GPpOLlycxHCtrpDR/4RqqBzjARaOTo3jz4IgtntWUYee7Ne4S8UHd92VCuzPaWA==", - "dependencies": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", @@ -8468,6 +8458,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -9085,6 +9076,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true, "engines": { "node": ">=0.4.0" } @@ -11251,6 +11243,7 @@ "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "dev": true, "funding": [ { "type": "individual", @@ -11422,6 +11415,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -17388,6 +17382,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "engines": { "node": ">= 0.6" } @@ -17396,6 +17391,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -19182,11 +19178,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, "node_modules/psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", @@ -32124,7 +32115,8 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true }, "at-least-node": { "version": "1.0.0", @@ -32152,16 +32144,6 @@ "integrity": "sha512-gd1kmb21kwNuWr6BQz8fv6GNECPBnUasepcoLbekws23NVBLODdsClRZ+bQ8+9Uomf3Sm3+Vwn0oYG9NvwnJCw==", "dev": true }, - "axios": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.1.2.tgz", - "integrity": "sha512-bznQyETwElsXl2RK7HLLwb5GPpOLlycxHCtrpDR/4RqqBzjARaOTo3jz4IgtntWUYee7Ne4S8UHd92VCuzPaWA==", - "requires": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, "axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", @@ -33100,6 +33082,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -33579,7 +33562,8 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true }, "depd": { "version": "1.1.2", @@ -35246,7 +35230,8 @@ "follow-redirects": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "dev": true }, "fork-ts-checker-webpack-plugin": { "version": "6.5.2", @@ -35351,6 +35336,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -39845,12 +39831,14 @@ "mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true }, "mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "requires": { "mime-db": "1.52.0" } @@ -41140,11 +41128,6 @@ "ipaddr.js": "1.9.1" } }, - "proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, "psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", diff --git a/package.json b/package.json index db375266..d3e83e73 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "@fortawesome/free-solid-svg-icons": "^6.2.0", "@fortawesome/react-fontawesome": "^0.2.0", "@reduxjs/toolkit": "^1.9.0", - "axios": "^1.1.2", "bootstrap": "^5.2.2", "bottlejs": "^2.0.1", "bowser": "^2.11.0", diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index 4b837c33..2f1c3de4 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,8 @@ 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'; +import { JsonFetch } from '../../utils/types'; const buildShlinkBaseUrl = (url: string, version: 2 | 3) => `${url}/rest/v${version}`; const rejectNilProps = reject(isNil); @@ -34,7 +34,7 @@ export class ShlinkApiClient { private apiVersion: 2 | 3; public constructor( - private readonly axios: AxiosInstance, + private readonly fetch: JsonFetch, private readonly baseUrl: string, private readonly apiKey: string, ) { @@ -43,42 +43,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 +87,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,31 +102,28 @@ 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) => { + }).catch((e: unknown) => { if (!isRegularNotFound(parseApiError(e))) { throw e; } @@ -138,4 +133,5 @@ export class ShlinkApiClient { 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..25196ac6 100644 --- a/src/api/services/ShlinkApiClientBuilder.ts +++ b/src/api/services/ShlinkApiClientBuilder.ts @@ -1,7 +1,7 @@ -import { AxiosInstance } from 'axios'; import { hasServerData, ServerWithId } from '../../servers/data'; import { GetState } from '../../container/types'; import { ShlinkApiClient } from './ShlinkApiClient'; +import { JsonFetch } from '../../utils/types'; const apiClients: Record = {}; @@ -16,14 +16,14 @@ const getSelectedServerFromState = (getState: GetState): ServerWithId => { return selectedServer; }; -export const buildShlinkApiClient = (axios: AxiosInstance) => (getStateOrSelectedServer: GetState | ServerWithId) => { +export const buildShlinkApiClient = (fetch: JsonFetch) => (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..07697256 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, 'jsonFetch'); }; export default provideServices; diff --git a/src/api/utils/index.ts b/src/api/utils/index.ts index 64b7c0a1..4d60a21e 100644 --- a/src/api/utils/index.ts +++ b/src/api/utils/index.ts @@ -1,4 +1,3 @@ -import { AxiosError } from 'axios'; import { ErrorTypeV2, ErrorTypeV3, @@ -8,11 +7,10 @@ import { RegularNotFound, } from '../types/errors'; -const isAxiosError = (e: unknown): e is AxiosError => !!e && typeof e === 'object' && 'response' in e; +const isProblemDetails = (e: unknown): e is ProblemDetailsError => + !!e && typeof e === 'object' && ['type', 'detail', 'title', 'status'].every((prop) => prop in e); -export const parseApiError = (e: unknown): ProblemDetailsError | undefined => ( - isAxiosError(e) ? e.response?.data : undefined -); +export const parseApiError = (e: unknown): ProblemDetailsError | undefined => (isProblemDetails(e) ? e : 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/ImageDownloader.ts b/src/common/services/ImageDownloader.ts index 2e131d7a..8371268e 100644 --- a/src/common/services/ImageDownloader.ts +++ b/src/common/services/ImageDownloader.ts @@ -1,11 +1,11 @@ -import { AxiosInstance } from 'axios'; +import { Fetch } from '../../utils/types'; import { saveUrl } from '../../utils/helpers/files'; export class ImageDownloader { - public constructor(private readonly axios: AxiosInstance, private readonly window: Window) {} + public constructor(private readonly fetch: Fetch, private readonly window: Window) {} public async saveImage(imgUrl: string, filename: string): Promise { - const { data } = await this.axios.get(imgUrl, { responseType: 'blob' }); + const data = await this.fetch(imgUrl).then((resp) => resp.blob()); const url = URL.createObjectURL(data); saveUrl(this.window, url, filename); diff --git a/src/common/services/provideServices.ts b/src/common/services/provideServices.ts index 3a613bd2..fdebe589 100644 --- a/src/common/services/provideServices.ts +++ b/src/common/services/provideServices.ts @@ -1,4 +1,3 @@ -import axios from 'axios'; import Bottle from 'bottlejs'; import { ScrollToTop } from '../ScrollToTop'; import { MainHeader } from '../MainHeader'; @@ -12,14 +11,16 @@ import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServ import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar'; import { ImageDownloader } from './ImageDownloader'; import { ReportExporter } from './ReportExporter'; +import { jsonFetch } from '../../utils/helpers/fetch'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Services 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)); + bottle.serviceFactory('jsonFetch', jsonFetch, 'fetch'); - bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window'); + bottle.service('ImageDownloader', ImageDownloader, 'fetch', 'window'); bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv'); // Components diff --git a/src/servers/reducers/remoteServers.ts b/src/servers/reducers/remoteServers.ts index 8f5083a8..98868d6f 100644 --- a/src/servers/reducers/remoteServers.ts +++ b/src/servers/reducers/remoteServers.ts @@ -1,19 +1,15 @@ -import { pipe, prop } from 'ramda'; -import { AxiosInstance } from 'axios'; import pack from '../../../package.json'; import { hasServerData, ServerData } from '../data'; import { createServers } from './servers'; import { createAsyncThunk } from '../../utils/helpers/redux'; +import { JsonFetch } from '../../utils/types'; -const responseToServersList = pipe( - prop('data'), - (data: any): ServerData[] => (Array.isArray(data) ? data.filter(hasServerData) : []), -); +const responseToServersList = (data: any): ServerData[] => (Array.isArray(data) ? data.filter(hasServerData) : []); -export const fetchServers = ({ get }: AxiosInstance) => createAsyncThunk( +export const fetchServers = (fetch: JsonFetch) => createAsyncThunk( 'shlink/remoteServers/fetchServers', async (_: void, { dispatch }): Promise => { - const resp = await get(`${pack.homepage}/servers.json`); + const resp = await fetch(`${pack.homepage}/servers.json`); const result = responseToServersList(resp); dispatch(createServers(result)); diff --git a/src/servers/services/provideServices.ts b/src/servers/services/provideServices.ts index 842f931a..73235dc5 100644 --- a/src/servers/services/provideServices.ts +++ b/src/servers/services/provideServices.ts @@ -80,7 +80,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('deleteServer', () => deleteServer); bottle.serviceFactory('editServer', () => editServer); bottle.serviceFactory('setAutoConnect', () => setAutoConnect); - bottle.serviceFactory('fetchServers', fetchServers, 'axios'); + bottle.serviceFactory('fetchServers', fetchServers, 'jsonFetch'); bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer); diff --git a/src/utils/helpers/fetch.ts b/src/utils/helpers/fetch.ts new file mode 100644 index 00000000..98f5fdb3 --- /dev/null +++ b/src/utils/helpers/fetch.ts @@ -0,0 +1,10 @@ +export const jsonFetch = (fetch: typeof window.fetch) => (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; + }); diff --git a/src/utils/types.ts b/src/utils/types.ts index 84bab12b..3009bcf7 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1 +1,5 @@ export type MediaMatcher = (query: string) => MediaQueryList; + +export type Fetch = typeof window.fetch; + +export type JsonFetch = (url: string, options?: RequestInit) => Promise; diff --git a/src/visits/reducers/common.ts b/src/visits/reducers/common.ts index af1eedd1..a5ae208f 100644 --- a/src/visits/reducers/common.ts +++ b/src/visits/reducers/common.ts @@ -20,20 +20,20 @@ type VisitsLoader = (page: number, itemsPerPage: number) => Promise Promise; interface VisitsAsyncThunkOptions { - name: string; + typePrefix: string; createLoaders: (params: T, getState: () => ShlinkState) => [VisitsLoader, LastVisitLoader]; getExtraFulfilledPayload: (params: T) => Partial; shouldCancel: (getState: () => ShlinkState) => boolean; } export const createVisitsAsyncThunk = ( - { name, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions, + { typePrefix, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions, ) => { - const progressChangedAction = createAction(`${name}/progressChanged`); - const largeAction = createAction(`${name}/large`); - const fallbackToIntervalAction = createAction(`${name}/fallbackToInterval`); + const progressChangedAction = createAction(`${typePrefix}/progressChanged`); + const largeAction = createAction(`${typePrefix}/large`); + const fallbackToIntervalAction = createAction(`${typePrefix}/fallbackToInterval`); - const asyncThunk = createAsyncThunk(name, async (params: T, { getState, dispatch }): Promise => { + const asyncThunk = createAsyncThunk(typePrefix, async (params: T, { getState, dispatch }): Promise => { const [visitsLoader, lastVisitLoader] = createLoaders(params, getState); const loadVisitsInParallel = async (pages: number[]): Promise => @@ -97,11 +97,15 @@ export const lastVisitLoaderForLoader = ( return async () => loader({ page: 1, itemsPerPage: 1 }).then(({ data }) => data[0]); }; +interface VisitsReducerOptions> { + name: string; + asyncThunkCreator: AT; + initialState: State; + filterCreatedVisits: (state: State, createdVisits: CreateVisit[]) => CreateVisit[]; +} + export const createVisitsReducer = >( - name: string, - asyncThunkCreator: AT, - initialState: State, - filterCreatedVisits: (state: State, createdVisits: CreateVisit[]) => CreateVisit[], + { name, asyncThunkCreator, initialState, filterCreatedVisits }: VisitsReducerOptions, ) => { const { asyncThunk, largeAction, fallbackToIntervalAction, progressChangedAction } = asyncThunkCreator; const { reducer, actions } = createSlice({ @@ -127,10 +131,10 @@ export const createVisitsReducer = { const { visits } = state; - // @ts-expect-error TODO Fix the state inferred type + // @ts-expect-error TODO Fix type inference const newVisits = filterCreatedVisits(state, payload.createdVisits).map(({ visit }) => visit); - return { ...state, visits: [...newVisits, ...visits] }; + return !newVisits.length ? state : { ...state, visits: [...newVisits, ...visits] }; }); }, }); diff --git a/src/visits/reducers/domainVisits.ts b/src/visits/reducers/domainVisits.ts index 9b258d09..a44d1cfc 100644 --- a/src/visits/reducers/domainVisits.ts +++ b/src/visits/reducers/domainVisits.ts @@ -27,7 +27,7 @@ const initialState: DomainVisits = { }; export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ - name: `${REDUCER_PREFIX}/getDomainVisits`, + typePrefix: `${REDUCER_PREFIX}/getDomainVisits`, createLoaders: ({ domain, query = {}, doIntervalFallback = false }: LoadDomainVisits, getState) => { const { getDomainVisits: getVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( @@ -43,17 +43,17 @@ export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => }); export const domainVisitsReducerCreator = ( - getVisitsCreator: ReturnType, -) => createVisitsReducer( - REDUCER_PREFIX, - // @ts-expect-error TODO Fix type inference - getVisitsCreator, + asyncThunkCreator: ReturnType, +) => createVisitsReducer({ + name: REDUCER_PREFIX, initialState, - ({ domain, query = {} }, createdVisits) => { + // @ts-expect-error TODO Fix type inference + asyncThunkCreator, + filterCreatedVisits: ({ domain, query = {} }, createdVisits) => { const { startDate, endDate } = query; return createdVisits.filter( ({ shortUrl, visit }) => shortUrl && domainMatches(shortUrl, domain) && isBetween(visit.date, startDate, endDate), ); }, -); +}); diff --git a/src/visits/reducers/nonOrphanVisits.ts b/src/visits/reducers/nonOrphanVisits.ts index 6edde6a0..83db3f43 100644 --- a/src/visits/reducers/nonOrphanVisits.ts +++ b/src/visits/reducers/nonOrphanVisits.ts @@ -15,7 +15,7 @@ const initialState: VisitsInfo = { }; export const getNonOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ - name: `${REDUCER_PREFIX}/getNonOrphanVisits`, + typePrefix: `${REDUCER_PREFIX}/getNonOrphanVisits`, createLoaders: ({ query = {}, doIntervalFallback = false }, getState) => { const { getNonOrphanVisits: shlinkGetNonOrphanVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => @@ -29,13 +29,13 @@ export const getNonOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) }); export const nonOrphanVisitsReducerCreator = ( - getVisitsCreator: ReturnType, -) => createVisitsReducer( - REDUCER_PREFIX, - getVisitsCreator, + asyncThunkCreator: ReturnType, +) => createVisitsReducer({ + name: REDUCER_PREFIX, initialState, - ({ query = {} }, createdVisits) => { + asyncThunkCreator, + filterCreatedVisits: ({ query = {} }, createdVisits) => { const { startDate, endDate } = query; return createdVisits.filter(({ visit }) => isBetween(visit.date, startDate, endDate)); }, -); +}); diff --git a/src/visits/reducers/orphanVisits.ts b/src/visits/reducers/orphanVisits.ts index 3b1bf87a..269aa764 100644 --- a/src/visits/reducers/orphanVisits.ts +++ b/src/visits/reducers/orphanVisits.ts @@ -24,13 +24,12 @@ const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) => !orphanVisitsType || orphanVisitsType === visit.type; export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ - name: `${REDUCER_PREFIX}/getOrphanVisits`, + typePrefix: `${REDUCER_PREFIX}/getOrphanVisits`, createLoaders: ({ orphanVisitsType, query = {}, doIntervalFallback = false }: LoadOrphanVisits, getState) => { const { getOrphanVisits: getVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => getVisits({ ...query, page, itemsPerPage }) .then((result) => { const visits = result.data.filter((visit) => isOrphanVisit(visit) && matchesType(visit, orphanVisitsType)); - return { ...result, data: visits }; }); const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, getVisits); @@ -42,13 +41,13 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => }); export const orphanVisitsReducerCreator = ( - getVisitsCreator: ReturnType, -) => createVisitsReducer( - REDUCER_PREFIX, - getVisitsCreator, + asyncThunkCreator: ReturnType, +) => createVisitsReducer({ + name: REDUCER_PREFIX, initialState, - ({ query = {} }, createdVisits) => { + asyncThunkCreator, + filterCreatedVisits: ({ query = {} }, createdVisits) => { const { startDate, endDate } = query; return createdVisits.filter(({ visit, shortUrl }) => !shortUrl && isBetween(visit.date, startDate, endDate)); }, -); +}); diff --git a/src/visits/reducers/shortUrlVisits.ts b/src/visits/reducers/shortUrlVisits.ts index dc434850..1c434787 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/src/visits/reducers/shortUrlVisits.ts @@ -25,7 +25,7 @@ const initialState: ShortUrlVisits = { }; export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ - name: `${REDUCER_PREFIX}/getShortUrlVisits`, + typePrefix: `${REDUCER_PREFIX}/getShortUrlVisits`, createLoaders: ({ shortCode, query = {}, doIntervalFallback = false }: LoadShortUrlVisits, getState) => { const { getShortUrlVisits: shlinkGetShortUrlVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => shlinkGetShortUrlVisits( @@ -46,17 +46,17 @@ export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) }); export const shortUrlVisitsReducerCreator = ( - getVisitsCreator: ReturnType, -) => createVisitsReducer( - REDUCER_PREFIX, - // @ts-expect-error TODO Fix type inference - getVisitsCreator, + asyncThunkCreator: ReturnType, +) => createVisitsReducer({ + name: REDUCER_PREFIX, initialState, - ({ shortCode, domain, query = {} }, createdVisits) => { + // @ts-expect-error TODO Fix type inference + asyncThunkCreator, + filterCreatedVisits: ({ shortCode, domain, query = {} }: ShortUrlVisits, createdVisits) => { const { startDate, endDate } = query; return createdVisits.filter( ({ shortUrl, visit }) => shortUrl && shortUrlMatches(shortUrl, shortCode, domain) && isBetween(visit.date, startDate, endDate), ); }, -); +}); diff --git a/src/visits/reducers/tagVisits.ts b/src/visits/reducers/tagVisits.ts index 1c1dace5..66b86ca0 100644 --- a/src/visits/reducers/tagVisits.ts +++ b/src/visits/reducers/tagVisits.ts @@ -24,7 +24,7 @@ const initialState: TagVisits = { }; export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ - name: `${REDUCER_PREFIX}/getTagVisits`, + typePrefix: `${REDUCER_PREFIX}/getTagVisits`, createLoaders: ({ tag, query = {}, doIntervalFallback = false }: LoadTagVisits, getState) => { const { getTagVisits: getVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( @@ -39,15 +39,15 @@ export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => cr shouldCancel: (getState) => getState().tagVisits.cancelLoad, }); -export const tagVisitsReducerCreator = (getTagVisitsCreator: ReturnType) => createVisitsReducer( - REDUCER_PREFIX, - // @ts-expect-error TODO Fix type inference - getTagVisitsCreator, +export const tagVisitsReducerCreator = (asyncThunkCreator: ReturnType) => createVisitsReducer({ + name: REDUCER_PREFIX, initialState, - ({ tag, query = {} }, createdVisits) => { + // @ts-expect-error TODO Fix type inference + asyncThunkCreator, + filterCreatedVisits: ({ tag, query = {} }: TagVisits, createdVisits) => { const { startDate, endDate } = query; return createdVisits.filter( ({ shortUrl, visit }) => shortUrl?.tags.includes(tag) && isBetween(visit.date, startDate, endDate), ); }, -); +}); diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts index 9448303e..5d1db797 100644 --- a/test/api/services/ShlinkApiClient.test.ts +++ b/test/api/services/ShlinkApiClient.test.ts @@ -1,16 +1,16 @@ -import { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios'; import { Mock } from 'ts-mockery'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { OptionalString } from '../../../src/utils/utils'; -import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/api/types'; +import { ShlinkDomain, ShlinkVisits, ShlinkVisitsOverview } from '../../../src/api/types'; import { ShortUrl, ShortUrlsOrder } from '../../../src/short-urls/data'; -import { Visit } from '../../../src/visits/types'; +import { JsonFetch } from '../../../src/utils/types'; +import { ErrorTypeV2, ErrorTypeV3 } from '../../../src/api/types/errors'; describe('ShlinkApiClient', () => { - 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 ][] = [ + const buildFetch = (data: any) => jest.fn().mockResolvedValue(data); + const buildRejectedFetch = (error: any) => jest.fn().mockRejectedValueOnce(error); + const buildApiClient = (fetch: JsonFetch) => new ShlinkApiClient(fetch, '', ''); + const shortCodesWithDomainCombinations: [string, OptionalString][] = [ ['abc123', null], ['abc123', undefined], ['abc123', 'example.com'], @@ -20,11 +20,9 @@ describe('ShlinkApiClient', () => { const expectedList = ['foo', 'bar']; it('properly returns short URLs list', async () => { - const { listShortUrls } = createApiClient({ - data: { - shortUrls: expectedList, - }, - }); + const { listShortUrls } = buildApiClient(buildFetch({ + shortUrls: expectedList, + })); const actualList = await listShortUrls(); @@ -32,20 +30,16 @@ describe('ShlinkApiClient', () => { }); it.each([ - [{ field: 'visits', dir: 'DESC' } as ShortUrlsOrder, 'visits-DESC'], - [{ field: 'longUrl', dir: 'ASC' } as ShortUrlsOrder, 'longUrl-ASC'], - [{ field: 'longUrl', dir: undefined } as ShortUrlsOrder, undefined], + [{ field: 'visits', dir: 'DESC' } as ShortUrlsOrder, '?orderBy=visits-DESC'], + [{ field: 'longUrl', dir: 'ASC' } as ShortUrlsOrder, '?orderBy=longUrl-ASC'], + [{ field: 'longUrl', dir: undefined } as ShortUrlsOrder, ''], ])('parses orderBy in params', async (orderBy, expectedOrderBy) => { - const axiosSpy = createAxiosMock({ - data: expectedList, - }); - const { listShortUrls } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({ data: expectedList }); + const { listShortUrls } = buildApiClient(fetch); await listShortUrls({ orderBy }); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - params: { orderBy: expectedOrderBy }, - })); + expect(fetch).toHaveBeenCalledWith(expect.stringContaining(`/short-urls${expectedOrderBy}`), expect.anything()); }); }); @@ -55,39 +49,38 @@ describe('ShlinkApiClient', () => { }; it('returns create short URL', async () => { - const { createShortUrl } = createApiClient({ data: shortUrl }); + const { createShortUrl } = buildApiClient(buildFetch(shortUrl)); const result = await createShortUrl({ longUrl: '' }); expect(result).toEqual(shortUrl); }); it('removes all empty options', async () => { - const axiosSpy = createAxiosMock({ data: shortUrl }); - const { createShortUrl } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({ data: shortUrl }); + const { createShortUrl } = buildApiClient(fetch); await createShortUrl({ longUrl: 'bar', customSlug: undefined, maxVisits: null }); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ data: { longUrl: 'bar' } })); + expect(fetch).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + body: JSON.stringify({ longUrl: 'bar' }), + })); }); }); describe('getShortUrlVisits', () => { it('properly returns short URL visits', async () => { const expectedVisits = ['foo', 'bar']; - const axiosSpy = createAxiosMock({ - data: { - visits: { - data: expectedVisits, - }, + const fetch = buildFetch({ + visits: { + data: expectedVisits, }, }); - const { getShortUrlVisits } = new ShlinkApiClient(axiosSpy, '', ''); + const { getShortUrlVisits } = buildApiClient(fetch); const actualVisits = await getShortUrlVisits('abc123', {}); expect({ data: expectedVisits }).toEqual(actualVisits); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('/short-urls/abc123/visits'), + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/short-urls/abc123/visits'), expect.objectContaining({ method: 'GET', })); }); @@ -96,20 +89,17 @@ describe('ShlinkApiClient', () => { describe('getTagVisits', () => { it('properly returns tag visits', async () => { const expectedVisits = ['foo', 'bar']; - const axiosSpy = createAxiosMock({ - data: { - visits: { - data: expectedVisits, - }, + const fetch = buildFetch({ + visits: { + data: expectedVisits, }, }); - const { getTagVisits } = new ShlinkApiClient(axiosSpy, '', ''); + const { getTagVisits } = buildApiClient(fetch); const actualVisits = await getTagVisits('foo', {}); expect({ data: expectedVisits }).toEqual(actualVisits); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('/tags/foo/visits'), + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/tags/foo/visits'), expect.objectContaining({ method: 'GET', })); }); @@ -118,20 +108,17 @@ describe('ShlinkApiClient', () => { describe('getDomainVisits', () => { it('properly returns domain visits', async () => { const expectedVisits = ['foo', 'bar']; - const axiosSpy = createAxiosMock({ - data: { - visits: { - data: expectedVisits, - }, + const fetch = buildFetch({ + visits: { + data: expectedVisits, }, }); - const { getDomainVisits } = new ShlinkApiClient(axiosSpy, '', ''); + const { getDomainVisits } = buildApiClient(fetch); const actualVisits = await getDomainVisits('foo.com', {}); expect({ data: expectedVisits }).toEqual(actualVisits); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('/domains/foo.com/visits'), + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/domains/foo.com/visits'), expect.objectContaining({ method: 'GET', })); }); @@ -140,19 +127,17 @@ describe('ShlinkApiClient', () => { describe('getShortUrl', () => { it.each(shortCodesWithDomainCombinations)('properly returns short URL', async (shortCode, domain) => { const expectedShortUrl = { foo: 'bar' }; - const axiosSpy = createAxiosMock({ - data: expectedShortUrl, - }); - const { getShortUrl } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch(expectedShortUrl); + const { getShortUrl } = buildApiClient(fetch); + const expectedQuery = domain ? `?domain=${domain}` : ''; const result = await getShortUrl(shortCode, domain); expect(expectedShortUrl).toEqual(result); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining(`/short-urls/${shortCode}`), - method: 'GET', - params: domain ? { domain } : {}, - })); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining(`/short-urls/${shortCode}${expectedQuery}`), + expect.objectContaining({ method: 'GET' }), + ); }); }); @@ -163,53 +148,49 @@ describe('ShlinkApiClient', () => { validSince: '2025-01-01T10:00:00+01:00', }; const expectedResp = Mock.of(); - const axiosSpy = createAxiosMock({ data: expectedResp }); - const { updateShortUrl } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch(expectedResp); + const { updateShortUrl } = buildApiClient(fetch); + const expectedQuery = domain ? `?domain=${domain}` : ''; const result = await updateShortUrl(shortCode, domain, meta); expect(expectedResp).toEqual(result); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining(`/short-urls/${shortCode}`), - method: 'PATCH', - params: domain ? { domain } : {}, - })); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining(`/short-urls/${shortCode}${expectedQuery}`), + expect.objectContaining({ method: 'PATCH' }), + ); }); }); describe('listTags', () => { it('properly returns list of tags', async () => { const expectedTags = ['foo', 'bar']; - const axiosSpy = createAxiosMock({ - data: { - tags: { data: expectedTags }, + const fetch = buildFetch({ + tags: { + data: expectedTags, }, }); - const { listTags } = new ShlinkApiClient(axiosSpy, '', ''); + const { listTags } = buildApiClient(fetch); const result = await listTags(); expect({ tags: expectedTags }).toEqual(result); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('/tags'), - method: 'GET', - })); + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/tags'), expect.objectContaining({ method: 'GET' })); }); }); describe('deleteTags', () => { it('properly deletes provided tags', async () => { const tags = ['foo', 'bar']; - const axiosSpy = createAxiosMock(); - const { deleteTags } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({}); + const { deleteTags } = buildApiClient(fetch); await deleteTags(tags); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('/tags'), - method: 'DELETE', - params: { tags }, - })); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining(`/tags?${tags.map((tag) => `tags%5B%5D=${tag}`).join('&')}`), + expect.objectContaining({ method: 'DELETE' }), + ); }); }); @@ -217,31 +198,30 @@ describe('ShlinkApiClient', () => { it('properly edits provided tag', async () => { const oldName = 'foo'; const newName = 'bar'; - const axiosSpy = createAxiosMock(); - const { editTag } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({}); + const { editTag } = buildApiClient(fetch); await editTag(oldName, newName); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining('/tags'), + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/tags'), expect.objectContaining({ method: 'PUT', - data: { oldName, newName }, + body: JSON.stringify({ oldName, newName }), })); }); }); describe('deleteShortUrl', () => { it.each(shortCodesWithDomainCombinations)('properly deletes provided short URL', async (shortCode, domain) => { - const axiosSpy = createAxiosMock({}); - const { deleteShortUrl } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({}); + const { deleteShortUrl } = buildApiClient(fetch); + const expectedQuery = domain ? `?domain=${domain}` : ''; await deleteShortUrl(shortCode, domain); - expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: expect.stringContaining(`/short-urls/${shortCode}`), - method: 'DELETE', - params: domain ? { domain } : {}, - })); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining(`/short-urls/${shortCode}${expectedQuery}`), + expect.objectContaining({ method: 'DELETE' }), + ); }); }); @@ -251,12 +231,12 @@ describe('ShlinkApiClient', () => { status: 'pass', version: '1.19.0', }; - const axiosSpy = createAxiosMock({ data: expectedData }); - const { health } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch(expectedData); + const { health } = buildApiClient(fetch); const result = await health(); - expect(axiosSpy).toHaveBeenCalled(); + expect(fetch).toHaveBeenCalled(); expect(result).toEqual(expectedData); }); }); @@ -267,12 +247,12 @@ describe('ShlinkApiClient', () => { token: 'abc.123.def', mercureHubUrl: 'http://example.com/.well-known/mercure', }; - const axiosSpy = createAxiosMock({ data: expectedData }); - const { mercureInfo } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch(expectedData); + const { mercureInfo } = buildApiClient(fetch); const result = await mercureInfo(); - expect(axiosSpy).toHaveBeenCalled(); + expect(fetch).toHaveBeenCalled(); expect(result).toEqual(expectedData); }); }); @@ -280,13 +260,12 @@ describe('ShlinkApiClient', () => { describe('listDomains', () => { it('returns domains', async () => { const expectedData = { data: [Mock.all(), Mock.all()] }; - const resp = { domains: expectedData }; - const axiosSpy = createAxiosMock({ data: resp }); - const { listDomains } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({ domains: expectedData }); + const { listDomains } = buildApiClient(fetch); const result = await listDomains(); - expect(axiosSpy).toHaveBeenCalled(); + expect(fetch).toHaveBeenCalled(); expect(result).toEqual(expectedData); }); }); @@ -294,76 +273,67 @@ describe('ShlinkApiClient', () => { describe('getVisitsOverview', () => { it('returns visits overview', async () => { const expectedData = Mock.all(); - const resp = { visits: expectedData }; - const axiosSpy = createAxiosMock({ data: resp }); - const { getVisitsOverview } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({ visits: expectedData }); + const { getVisitsOverview } = buildApiClient(fetch); const result = await getVisitsOverview(); - expect(axiosSpy).toHaveBeenCalled(); + expect(fetch).toHaveBeenCalled(); expect(result).toEqual(expectedData); }); }); describe('getOrphanVisits', () => { it('returns orphan visits', async () => { - const expectedData: Visit[] = []; - const resp = { visits: expectedData }; - const axiosSpy = createAxiosMock({ data: resp }); - const { getOrphanVisits } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({ visits: Mock.of({ data: [] }) }); + const { getOrphanVisits } = buildApiClient(fetch); const result = await getOrphanVisits(); - expect(axiosSpy).toHaveBeenCalled(); - expect(result).toEqual(expectedData); + expect(fetch).toHaveBeenCalled(); + expect(result).toEqual({ data: [] }); }); }); describe('getNonOrphanVisits', () => { it('returns non-orphan visits', async () => { - const expectedData: Visit[] = []; - const resp = { visits: expectedData }; - const axiosSpy = createAxiosMock({ data: resp }); - const { getNonOrphanVisits } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch({ visits: Mock.of({ data: [] }) }); + const { getNonOrphanVisits } = buildApiClient(fetch); const result = await getNonOrphanVisits(); - expect(axiosSpy).toHaveBeenCalled(); - expect(result).toEqual(expectedData); + expect(fetch).toHaveBeenCalled(); + expect(result).toEqual({ data: [] }); }); }); describe('editDomainRedirects', () => { it('returns the redirects', async () => { const resp = { baseUrlRedirect: null, regular404Redirect: 'foo', invalidShortUrlRedirect: 'bar' }; - const axiosSpy = createAxiosMock({ data: resp }); - const { editDomainRedirects } = new ShlinkApiClient(axiosSpy, '', ''); + const fetch = buildFetch(resp); + const { editDomainRedirects } = buildApiClient(fetch); const result = await editDomainRedirects({ domain: 'foo' }); - expect(axiosSpy).toHaveBeenCalled(); + expect(fetch).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, '', ''); + it.each([ + ['NOT_FOUND'], + [ErrorTypeV2.NOT_FOUND], + [ErrorTypeV3.NOT_FOUND], + ])('retries request if API version is not supported', async (type) => { + const fetch = buildRejectedFetch({ type, detail: 'detail', title: 'title', status: 404 }).mockImplementation( + buildFetch({}), + ); + const { editDomainRedirects } = buildApiClient(fetch); 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/'), - })); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenNthCalledWith(1, expect.stringContaining('/v3/'), expect.anything()); + expect(fetch).toHaveBeenNthCalledWith(2, expect.stringContaining('/v2/'), expect.anything()); }); }); }); diff --git a/test/api/services/ShlinkApiClientBuilder.test.ts b/test/api/services/ShlinkApiClientBuilder.test.ts index b67bd7e6..1b08d385 100644 --- a/test/api/services/ShlinkApiClientBuilder.test.ts +++ b/test/api/services/ShlinkApiClientBuilder.test.ts @@ -1,16 +1,14 @@ import { Mock } from 'ts-mockery'; -import { AxiosInstance } from 'axios'; import { buildShlinkApiClient } from '../../../src/api/services/ShlinkApiClientBuilder'; import { ReachableServer, SelectedServer } from '../../../src/servers/data'; import { ShlinkState } from '../../../src/container/types'; describe('ShlinkApiClientBuilder', () => { - const axiosMock = Mock.all(); + const fetch = jest.fn(); const server = (data: Partial) => Mock.of(data); const createBuilder = () => { - const builder = buildShlinkApiClient(axiosMock); - + const builder = buildShlinkApiClient(fetch); return (selectedServer: SelectedServer) => builder(() => Mock.of({ selectedServer })); }; @@ -44,7 +42,7 @@ describe('ShlinkApiClientBuilder', () => { it('does not fetch from state when provided param is already selected server', () => { const url = 'url'; const apiKey = 'apiKey'; - const apiClient = buildShlinkApiClient(axiosMock)(server({ url, apiKey })); + const apiClient = buildShlinkApiClient(fetch)(server({ url, apiKey })); expect(apiClient['baseUrl']).toEqual(url); // eslint-disable-line @typescript-eslint/dot-notation expect(apiClient['apiKey']).toEqual(apiKey); // eslint-disable-line @typescript-eslint/dot-notation diff --git a/test/common/services/ImageDownloader.test.ts b/test/common/services/ImageDownloader.test.ts index 20a381cf..2b537f20 100644 --- a/test/common/services/ImageDownloader.test.ts +++ b/test/common/services/ImageDownloader.test.ts @@ -1,25 +1,22 @@ -import { Mock } from 'ts-mockery'; -import { AxiosInstance } from 'axios'; import { ImageDownloader } from '../../../src/common/services/ImageDownloader'; import { windowMock } from '../../__mocks__/Window.mock'; describe('ImageDownloader', () => { - const get = jest.fn(); - const axios = Mock.of({ get }); + const fetch = jest.fn(); let imageDownloader: ImageDownloader; beforeEach(() => { jest.clearAllMocks(); (global as any).URL = { createObjectURL: () => '' }; - imageDownloader = new ImageDownloader(axios, windowMock); + imageDownloader = new ImageDownloader(fetch, windowMock); }); it('calls URL with response type blob', async () => { - get.mockResolvedValue({ data: {} }); + fetch.mockResolvedValue({ blob: () => new Blob() }); await imageDownloader.saveImage('/foo/bar.png', 'my-image.png'); - expect(get).toHaveBeenCalledWith('/foo/bar.png', { responseType: 'blob' }); + expect(fetch).toHaveBeenCalledWith('/foo/bar.png'); }); }); diff --git a/test/domains/reducers/domainsList.test.ts b/test/domains/reducers/domainsList.test.ts index 7efb6b00..b96a37d0 100644 --- a/test/domains/reducers/domainsList.test.ts +++ b/test/domains/reducers/domainsList.test.ts @@ -1,5 +1,4 @@ import { Mock } from 'ts-mockery'; -import { AxiosError } from 'axios'; import { DomainsList, replaceRedirectsOnDomain, @@ -25,11 +24,7 @@ describe('domainsListReducer', () => { Mock.of({ domain: 'Boo', status: 'validating' }), ]; const domains = [...filteredDomains, Mock.of({ domain: 'bar', status: 'validating' })]; - const error = Mock.of({ - response: { - data: { type: 'NOT_FOUND', status: 404 }, - }, - }); + const error = { type: 'NOT_FOUND', status: 404 }; const editDomainRedirectsThunk = editDomainRedirects(buildShlinkApiClient); const { reducer, listDomains: listDomainsAction, checkDomainHealth, filterDomains } = domainsListReducerCreator( buildShlinkApiClient, diff --git a/test/servers/reducers/remoteServers.test.ts b/test/servers/reducers/remoteServers.test.ts index 41e19238..efc44907 100644 --- a/test/servers/reducers/remoteServers.test.ts +++ b/test/servers/reducers/remoteServers.test.ts @@ -1,5 +1,3 @@ -import { Mock } from 'ts-mockery'; -import { AxiosInstance } from 'axios'; import { fetchServers } from '../../../src/servers/reducers/remoteServers'; import { createServers } from '../../../src/servers/reducers/servers'; @@ -8,27 +6,24 @@ describe('remoteServersReducer', () => { describe('fetchServers', () => { const dispatch = jest.fn(); - const get = jest.fn(); - const axios = Mock.of({ get }); + const fetch = jest.fn(); it.each([ [ - { - data: [ - { - id: '111', - name: 'acel.me from servers.json', - url: 'https://acel.me', - apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0', - }, - { - id: '222', - name: 'Local from servers.json', - url: 'http://localhost:8000', - apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a', - }, - ], - }, + [ + { + id: '111', + name: 'acel.me from servers.json', + url: 'https://acel.me', + apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0', + }, + { + id: '222', + name: 'Local from servers.json', + url: 'http://localhost:8000', + apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a', + }, + ], { 111: { id: '111', @@ -45,26 +40,24 @@ describe('remoteServersReducer', () => { }, ], [ - { - data: [ - { - id: '111', - name: 'acel.me from servers.json', - url: 'https://acel.me', - apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0', - }, - { - id: '222', - name: 'Invalid', - }, - { - id: '333', - name: 'Local from servers.json', - url: 'http://localhost:8000', - apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a', - }, - ], - }, + [ + { + id: '111', + name: 'acel.me from servers.json', + url: 'https://acel.me', + apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0', + }, + { + id: '222', + name: 'Invalid', + }, + { + id: '333', + name: 'Local from servers.json', + url: 'http://localhost:8000', + apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a', + }, + ], { 111: { id: '111', @@ -83,8 +76,8 @@ describe('remoteServersReducer', () => { ['', {}], [{}, {}], ])('tries to fetch servers from remote', async (mockedValue, expectedNewServers) => { - get.mockResolvedValue(mockedValue); - const doFetchServers = fetchServers(axios); + fetch.mockResolvedValue(mockedValue); + const doFetchServers = fetchServers(fetch); await doFetchServers()(dispatch, jest.fn(), {}); @@ -95,7 +88,7 @@ describe('remoteServersReducer', () => { expect(dispatch).toHaveBeenNthCalledWith(3, expect.objectContaining({ type: doFetchServers.fulfilled.toString(), })); - expect(get).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(1); }); }); }); diff --git a/test/short-urls/reducers/shortUrlDeletion.test.ts b/test/short-urls/reducers/shortUrlDeletion.test.ts index 9a65734d..e6bd8162 100644 --- a/test/short-urls/reducers/shortUrlDeletion.test.ts +++ b/test/short-urls/reducers/shortUrlDeletion.test.ts @@ -43,8 +43,8 @@ describe('shortUrlDeletionReducer', () => { })); it('returns errorData on DELETE_SHORT_URL_ERROR', () => { - const errorData = Mock.of({ type: 'bar' }); - const error = { response: { data: errorData } }; + const errorData = Mock.of({ type: 'bar', detail: 'detail', title: 'title', status: 400 }); + const error = errorData; expect(reducer(undefined, { type: deleteShortUrl.rejected.toString(), error })).toEqual({ shortCode: '',