Repository: shlinkio/shlink-web-client
Pull request #363 (2020-12-22 09:08:12)

Feature/refactorings


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3ca7860..f84c1824 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
 
-## [Unreleased]
+## [3.0.0] - 2020-12-22
 ### Added
 * [#340](https://github.com/shlinkio/shlink-web-client/issues/340) Added new "overview" page, showing basic information of the active server.
 
diff --git a/src/api/ShlinkApiError.tsx b/src/api/ShlinkApiError.tsx
index 8c9e51f5..6ba2c01a 100644
--- a/src/api/ShlinkApiError.tsx
+++ b/src/api/ShlinkApiError.tsx
@@ -1,6 +1,7 @@
-import { isInvalidArgumentError, ProblemDetailsError } from '../utils/services/types';
+import { ProblemDetailsError } from './types';
+import { isInvalidArgumentError } from './utils';
 
-interface ShlinkApiErrorProps {
+export interface ShlinkApiErrorProps {
   errorData?: ProblemDetailsError;
   fallbackMessage?: string;
 }
diff --git a/src/utils/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts
similarity index 98%
rename from src/utils/services/ShlinkApiClient.ts
rename to src/api/services/ShlinkApiClient.ts
index b04b7738..f264d580 100644
--- a/src/utils/services/ShlinkApiClient.ts
+++ b/src/api/services/ShlinkApiClient.ts
@@ -3,7 +3,7 @@ import { isEmpty, isNil, reject } from 'ramda';
 import { AxiosInstance, AxiosResponse, Method } from 'axios';
 import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
 import { ShortUrl, ShortUrlData } from '../../short-urls/data';
-import { OptionalString } from '../utils';
+import { OptionalString } from '../../utils/utils';
 import {
   ShlinkHealth,
   ShlinkMercureInfo,
@@ -16,9 +16,7 @@ import {
   ShlinkDomain,
   ShlinkDomainsResponse,
   ShlinkVisitsOverview,
-} from './types';
-
-// TODO Move this file to api module
+} from '../types';
 
 const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
 const rejectNilProps = reject(isNil);
diff --git a/src/utils/services/ShlinkApiClientBuilder.ts b/src/api/services/ShlinkApiClientBuilder.ts
similarity index 97%
rename from src/utils/services/ShlinkApiClientBuilder.ts
rename to src/api/services/ShlinkApiClientBuilder.ts
index 328a1c8a..d2ba24cd 100644
--- a/src/utils/services/ShlinkApiClientBuilder.ts
+++ b/src/api/services/ShlinkApiClientBuilder.ts
@@ -4,8 +4,6 @@ import { hasServerData, SelectedServer, ServerWithId } from '../../servers/data'
 import { GetState } from '../../container/types';
 import ShlinkApiClient from './ShlinkApiClient';
 
-// TODO Move this file to api module
-
 const apiClients: Record<string, ShlinkApiClient> = {};
 
 const isGetState = (getStateOrSelectedServer: GetState | ServerWithId): getStateOrSelectedServer is GetState =>
diff --git a/src/api/services/provideServices.ts b/src/api/services/provideServices.ts
new file mode 100644
index 00000000..2d0abf4e
--- /dev/null
+++ b/src/api/services/provideServices.ts
@@ -0,0 +1,8 @@
+import Bottle from 'bottlejs';
+import buildShlinkApiClient from './ShlinkApiClientBuilder';
+
+const provideServices = (bottle: Bottle) => {
+  bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios');
+};
+
+export default provideServices;
diff --git a/src/utils/services/types.ts b/src/api/types/index.ts
similarity index 68%
rename from src/utils/services/types.ts
rename to src/api/types/index.ts
index 36f2fe82..c5728797 100644
--- a/src/utils/services/types.ts
+++ b/src/api/types/index.ts
@@ -1,8 +1,6 @@
-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';
-
-// TODO Move this file to api module
+import { Visit } from '../../visits/types';
+import { OptionalString } from '../../utils/utils';
+import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
 
 export interface ShlinkShortUrlsResponse {
   data: ShortUrl[];
@@ -76,21 +74,16 @@ export interface ProblemDetailsError {
   detail: string;
   title: string;
   status: number;
+
   [extraProps: string]: any;
 }
 
-interface InvalidArgumentError extends ProblemDetailsError {
+export interface InvalidArgumentError extends ProblemDetailsError {
   type: 'INVALID_ARGUMENT';
   invalidElements: string[];
 }
 
-interface InvalidShortUrlDeletion extends ProblemDetailsError {
+export interface InvalidShortUrlDeletion extends ProblemDetailsError {
   type: 'INVALID_SHORTCODE_DELETION';
   threshold: number;
 }
-
-export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError =>
-  error?.type === 'INVALID_ARGUMENT';
-
-export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion =>
-  error?.type === 'INVALID_SHORTCODE_DELETION';
diff --git a/src/api/util.ts b/src/api/util.ts
deleted file mode 100644
index 91901f1a..00000000
--- a/src/api/util.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import { AxiosError } from 'axios';
-import { ProblemDetailsError } from '../utils/services/types';
-
-export const parseApiError = (e: AxiosError<ProblemDetailsError>) => e.response?.data;
diff --git a/src/api/utils/index.ts b/src/api/utils/index.ts
new file mode 100644
index 00000000..6de7681f
--- /dev/null
+++ b/src/api/utils/index.ts
@@ -0,0 +1,10 @@
+import { AxiosError } from 'axios';
+import { InvalidArgumentError, InvalidShortUrlDeletion, ProblemDetailsError } from '../types';
+
+export const parseApiError = (e: AxiosError<ProblemDetailsError>) => e.response?.data;
+
+export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError =>
+  error?.type === 'INVALID_ARGUMENT';
+
+export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion =>
+  error?.type === 'INVALID_SHORTCODE_DELETION';
diff --git a/src/common/services/provideServices.ts b/src/common/services/provideServices.ts
index fd631e10..af8f7417 100644
--- a/src/common/services/provideServices.ts
+++ b/src/common/services/provideServices.ts
@@ -1,3 +1,4 @@
+import axios from 'axios';
 import Bottle, { Decorator } from 'bottlejs';
 import ScrollToTop from '../ScrollToTop';
 import MainHeader from '../MainHeader';
@@ -12,6 +13,7 @@ import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServ
 const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
   bottle.constant('window', (global as any).window);
   bottle.constant('console', global.console);
+  bottle.constant('axios', axios);
 
   bottle.serviceFactory('ScrollToTop', ScrollToTop);
   bottle.decorator('ScrollToTop', withRouter);
diff --git a/src/container/index.ts b/src/container/index.ts
index 5e746c62..f7a0d3cf 100644
--- a/src/container/index.ts
+++ b/src/container/index.ts
@@ -3,6 +3,7 @@ import { withRouter } from 'react-router-dom';
 import { connect as reduxConnect } from 'react-redux';
 import { pick } from 'ramda';
 import App from '../App';
+import provideApiServices from '../api/services/provideServices';
 import provideCommonServices from '../common/services/provideServices';
 import provideShortUrlsServices from '../short-urls/services/provideServices';
 import provideServersServices from '../servers/services/provideServices';
@@ -45,6 +46,7 @@ bottle.serviceFactory(
 bottle.decorator('App', connect([ 'servers' ], [ 'fetchServers' ]));
 
 provideCommonServices(bottle, connect, withRouter);
+provideApiServices(bottle);
 provideShortUrlsServices(bottle, connect);
 provideServersServices(bottle, connect, withRouter);
 provideTagsServices(bottle, connect);
diff --git a/src/domains/reducers/domainsList.ts b/src/domains/reducers/domainsList.ts
index 4a51acab..751e2f4f 100644
--- a/src/domains/reducers/domainsList.ts
+++ b/src/domains/reducers/domainsList.ts
@@ -1,7 +1,7 @@
 import { Action, Dispatch } from 'redux';
-import { ShlinkDomain } from '../../utils/services/types';
+import { ShlinkDomain } from '../../api/types';
 import { buildReducer } from '../../utils/helpers/redux';
-import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
+import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
 import { GetState } from '../../container/types';
 
 /* eslint-disable padding-line-between-statements */
diff --git a/src/mercure/reducers/mercureInfo.ts b/src/mercure/reducers/mercureInfo.ts
index 36017c73..5684b611 100644
--- a/src/mercure/reducers/mercureInfo.ts
+++ b/src/mercure/reducers/mercureInfo.ts
@@ -1,8 +1,8 @@
 import { Action, Dispatch } from 'redux';
-import { ShlinkMercureInfo } from '../../utils/services/types';
+import { ShlinkMercureInfo } from '../../api/types';
 import { GetState } from '../../container/types';
 import { buildReducer } from '../../utils/helpers/redux';
-import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
+import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
 
 /* eslint-disable padding-line-between-statements */
 export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
diff --git a/src/servers/reducers/selectedServer.ts b/src/servers/reducers/selectedServer.ts
index 3579a00e..38c7c133 100644
--- a/src/servers/reducers/selectedServer.ts
+++ b/src/servers/reducers/selectedServer.ts
@@ -4,9 +4,9 @@ import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListPara
 import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
 import { SelectedServer } from '../data';
 import { GetState } from '../../container/types';
-import { ShlinkHealth } from '../../utils/services/types';
+import { ShlinkHealth } from '../../api/types';
 import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
-import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
+import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
 
 /* eslint-disable padding-line-between-statements */
 export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
diff --git a/src/short-urls/Paginator.tsx b/src/short-urls/Paginator.tsx
index 30075da6..fff07aea 100644
--- a/src/short-urls/Paginator.tsx
+++ b/src/short-urls/Paginator.tsx
@@ -1,7 +1,7 @@
 import { Link } from 'react-router-dom';
 import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
 import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination';
-import { ShlinkPaginator } from '../utils/services/types';
+import { ShlinkPaginator } from '../api/types';
 import './Paginator.scss';
 
 interface PaginatorProps {
diff --git a/src/short-urls/helpers/DeleteShortUrlModal.tsx b/src/short-urls/helpers/DeleteShortUrlModal.tsx
index 119892d2..435b3318 100644
--- a/src/short-urls/helpers/DeleteShortUrlModal.tsx
+++ b/src/short-urls/helpers/DeleteShortUrlModal.tsx
@@ -5,7 +5,7 @@ import { ShortUrlDeletion } from '../reducers/shortUrlDeletion';
 import { ShortUrlModalProps } from '../data';
 import { handleEventPreventingDefault, OptionalString } from '../../utils/utils';
 import { Result } from '../../utils/Result';
-import { isInvalidDeletionError } from '../../utils/services/types';
+import { isInvalidDeletionError } from '../../api/utils';
 import { ShlinkApiError } from '../../api/ShlinkApiError';
 
 interface DeleteShortUrlModalConnectProps extends ShortUrlModalProps {
diff --git a/src/short-urls/reducers/shortUrlCreation.ts b/src/short-urls/reducers/shortUrlCreation.ts
index e6f54cec..f1f6d900 100644
--- a/src/short-urls/reducers/shortUrlCreation.ts
+++ b/src/short-urls/reducers/shortUrlCreation.ts
@@ -2,9 +2,9 @@ import { Action, Dispatch } from 'redux';
 import { GetState } from '../../container/types';
 import { ShortUrl, ShortUrlData } from '../data';
 import { buildReducer, buildActionCreator } from '../../utils/helpers/redux';
-import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
-import { ProblemDetailsError } from '../../utils/services/types';
-import { parseApiError } from '../../api/util';
+import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
+import { ProblemDetailsError } from '../../api/types';
+import { parseApiError } from '../../api/utils';
 
 /* eslint-disable padding-line-between-statements */
 export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
diff --git a/src/short-urls/reducers/shortUrlDeletion.ts b/src/short-urls/reducers/shortUrlDeletion.ts
index b4bf8966..23561c18 100644
--- a/src/short-urls/reducers/shortUrlDeletion.ts
+++ b/src/short-urls/reducers/shortUrlDeletion.ts
@@ -1,9 +1,9 @@
 import { Action, Dispatch } from 'redux';
 import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
-import { ProblemDetailsError } from '../../utils/services/types';
+import { ProblemDetailsError } from '../../api/types';
 import { GetState } from '../../container/types';
-import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
-import { parseApiError } from '../../api/util';
+import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
+import { parseApiError } from '../../api/utils';
 
 /* eslint-disable padding-line-between-statements */
 export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
diff --git a/src/short-urls/reducers/shortUrlEdition.ts b/src/short-urls/reducers/shortUrlEdition.ts
index 126b1287..246b2ae2 100644
--- a/src/short-urls/reducers/shortUrlEdition.ts
+++ b/src/short-urls/reducers/shortUrlEdition.ts
@@ -3,9 +3,9 @@ import { buildReducer } from '../../utils/helpers/redux';
 import { GetState } from '../../container/types';
 import { OptionalString } from '../../utils/utils';
 import { ShortUrlIdentifier } from '../data';
-import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
-import { ProblemDetailsError } from '../../utils/services/types';
-import { parseApiError } from '../../api/util';
+import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
+import { ProblemDetailsError } from '../../api/types';
+import { parseApiError } from '../../api/utils';
 
 /* eslint-disable padding-line-between-statements */
 export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
diff --git a/src/short-urls/reducers/shortUrlMeta.ts b/src/short-urls/reducers/shortUrlMeta.ts
index dc55dbaa..a878058f 100644
--- a/src/short-urls/reducers/shortUrlMeta.ts
+++ b/src/short-urls/reducers/shortUrlMeta.ts
@@ -3,9 +3,9 @@ import { ShortUrlIdentifier, ShortUrlMeta } from '../data';
 import { GetState } from '../../container/types';
 import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
 import { OptionalString } from '../../utils/utils';
-import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
-import { ProblemDetailsError } from '../../utils/services/types';
-import { parseApiError } from '../../api/util';
+import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
+import { ProblemDetailsError } from '../../api/types';
+import { parseApiError } from '../../api/utils';
 
 /* eslint-disable padding-line-between-statements */
 export const EDIT_SHORT_URL_META_START = 'shlink/shortUrlMeta/EDIT_SHORT_URL_META_START';
diff --git a/src/short-urls/reducers/shortUrlTags.ts b/src/short-urls/reducers/shortUrlTags.ts
index 6df90782..277386b2 100644
--- a/src/short-urls/reducers/shortUrlTags.ts
+++ b/src/short-urls/reducers/shortUrlTags.ts
@@ -3,9 +3,9 @@ import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
 import { GetState } from '../../container/types';
 import { OptionalString } from '../../utils/utils';
 import { ShortUrlIdentifier } from '../data';
-import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
-import { ProblemDetailsError } from '../../utils/services/types';
-import { parseApiError } from '../../api/util';
+import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
+import { ProblemDetailsError } from '../../api/types';
+import { parseApiError } from '../../api/utils';
 
 /* eslint-disable padding-line-between-statements */
 export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
diff --git a/src/short-urls/reducers/shortUrlsList.ts b/src/short-urls/reducers/shortUrlsList.ts
index dfba771c..1821643a 100644
--- a/src/short-urls/reducers/shortUrlsList.ts
+++ b/src/short-urls/reducers/shortUrlsList.ts
@@ -5,8 +5,8 @@ import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCr
 import { ShortUrl, ShortUrlIdentifier } from '../data';
 import { buildReducer } from '../../utils/helpers/redux';
 import { GetState } from '../../container/types';
-import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
-import { ShlinkShortUrlsResponse } from '../../utils/services/types';
+import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
+import { ShlinkShortUrlsResponse } from '../../api/types';
 import { EditShortUrlTagsAction, SHORT_URL_TAGS_EDITED } from './shortUrlTags';
 import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
 import { SHORT_URL_META_EDITED, ShortUrlMetaEditedAction } from './shortUrlMeta';
diff --git a/src/tags/reducers/tagDelete.ts b/src/tags/reducers/tagDelete.ts
index 4fb54621..acdac7c2 100644
--- a/src/tags/reducers/tagDelete.ts
+++ b/src/tags/reducers/tagDelete.ts
@@ -1,9 +1,9 @@
 import { Action, Dispatch } from 'redux';
 import { buildReducer } from '../../utils/helpers/redux';
 import { GetState } from '../../container/types';
-import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
-import { ProblemDetailsError } from '../../utils/services/types';
-import { parseApiError } from '../../api/util';
+import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
+import { ProblemDetailsError } from '../../api/types';
+import { parseApiError } from '../../api/utils';
 
 /* eslint-disable padding-line-between-statements */
 export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
diff --git a/src/tags/reducers/tagEdit.ts b/src/tags/reducers/tagEdit.ts
index b6f1f964..3bc85c88 100644
--- a/src/tags/reducers/tagEdit.ts
+++ b/src/tags/reducers/tagEdit.ts
@@ -3,9 +3,9 @@ import { Action, Dispatch } from 'redux';
 import { buildReducer } from '../../utils/helpers/redux';
 import { GetState } from '../../container/types';
 import ColorGenerator from '../../utils/services/ColorGenerator';
-import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
-import { ProblemDetailsError } from '../../utils/services/types';
-import { parseApiError } from '../../api/util';
+import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
+import { ProblemDetailsError } from '../../api/types';
+import { parseApiError } from '../../api/utils';
 
 /* eslint-disable padding-line-between-statements */
 export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
diff --git a/src/tags/reducers/tagsList.ts b/src/tags/reducers/tagsList.ts
index 826d333f..3cfc778f 100644
--- a/src/tags/reducers/tagsList.ts
+++ b/src/tags/reducers/tagsList.ts
@@ -2,14 +2,14 @@ import { isEmpty, reject } from 'ramda';
 import { Action, Dispatch } from 'redux';
 import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
 import { buildReducer } from '../../utils/helpers/redux';
-import { ProblemDetailsError, ShlinkTags } from '../../utils/services/types';
+import { ProblemDetailsError, ShlinkTags } from '../../api/types';
 import { GetState } from '../../container/types';
-import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
+import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
 import { TagStats } from '../data';
 import { CreateVisit, Stats } from '../../visits/types';
 import { DeleteTagAction, TAG_DELETED } from './tagDelete';
 import { EditTagAction, TAG_EDITED } from './tagEdit';
-import { parseApiError } from '../../api/util';
+import { parseApiError } from '../../api/utils';
 
 /* eslint-disable padding-line-between-statements */
 export const LIST_TAGS_START = 'shlink/tagsList/LIST_TAGS_START';
diff --git a/src/utils/services/provideServices.ts b/src/utils/services/provideServices.ts
index 34574b6e..e0788915 100644
--- a/src/utils/services/provideServices.ts
+++ b/src/utils/services/provideServices.ts
@@ -1,18 +1,13 @@
-import axios from 'axios';
 import Bottle from 'bottlejs';
 import { useStateFlagTimeout } from '../helpers/hooks';
 import LocalStorage from './LocalStorage';
 import ColorGenerator from './ColorGenerator';
-import buildShlinkApiClient from './ShlinkApiClientBuilder';
 
 const provideServices = (bottle: Bottle) => {
   bottle.constant('localStorage', (global as any).localStorage);
   bottle.service('Storage', LocalStorage, 'localStorage');
   bottle.service('ColorGenerator', ColorGenerator, 'Storage');
 
-  bottle.constant('axios', axios);
-  bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios');
-
   bottle.constant('setTimeout', global.setTimeout);
   bottle.constant('clearTimeout', global.clearTimeout);
   bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout', 'clearTimeout');
diff --git a/src/visits/ShortUrlVisits.tsx b/src/visits/ShortUrlVisits.tsx
index d7ceb2ef..a65df735 100644
--- a/src/visits/ShortUrlVisits.tsx
+++ b/src/visits/ShortUrlVisits.tsx
@@ -1,7 +1,7 @@
 import { useEffect } from 'react';
 import { RouteComponentProps } from 'react-router';
 import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
-import { ShlinkVisitsParams } from '../utils/services/types';
+import { ShlinkVisitsParams } from '../api/types';
 import { parseQuery } from '../utils/helpers/query';
 import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
 import ShortUrlVisitsHeader from './ShortUrlVisitsHeader';
diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx
index b0701737..acf60a9e 100644
--- a/src/visits/TagVisits.tsx
+++ b/src/visits/TagVisits.tsx
@@ -1,7 +1,7 @@
 import { RouteComponentProps } from 'react-router';
 import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
 import ColorGenerator from '../utils/services/ColorGenerator';
-import { ShlinkVisitsParams } from '../utils/services/types';
+import { ShlinkVisitsParams } from '../api/types';
 import { TagVisits as TagVisitsState } from './reducers/tagVisits';
 import TagVisitsHeader from './TagVisitsHeader';
 import VisitsStats from './VisitsStats';
diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx
index ceb11522..76fe5492 100644
--- a/src/visits/VisitsStats.tsx
+++ b/src/visits/VisitsStats.tsx
@@ -9,7 +9,7 @@ import { Location } from 'history';
 import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
 import Message from '../utils/Message';
 import { formatIsoDate } from '../utils/helpers/date';
-import { ShlinkVisitsParams } from '../utils/services/types';
+import { ShlinkVisitsParams } from '../api/types';
 import { DateInterval, DateRange, intervalToDateRange } from '../utils/dates/types';
 import { Result } from '../utils/Result';
 import { ShlinkApiError } from '../api/ShlinkApiError';
diff --git a/src/visits/reducers/common.ts b/src/visits/reducers/common.ts
index 765203c3..666e4419 100644
--- a/src/visits/reducers/common.ts
+++ b/src/visits/reducers/common.ts
@@ -1,8 +1,8 @@
 import { flatten, prop, range, splitEvery } from 'ramda';
 import { Action, Dispatch } from 'redux';
-import { ShlinkPaginator, ShlinkVisits } from '../../utils/services/types';
+import { ShlinkPaginator, ShlinkVisits } from '../../api/types';
 import { Visit, VisitsLoadFailedAction } from '../types';
-import { parseApiError } from '../../api/util';
+import { parseApiError } from '../../api/utils';
 
 const ITEMS_PER_PAGE = 5000;
 const PARALLEL_REQUESTS_COUNT = 4;
diff --git a/src/visits/reducers/shortUrlDetail.ts b/src/visits/reducers/shortUrlDetail.ts
index 07e27711..c8fb5bea 100644
--- a/src/visits/reducers/shortUrlDetail.ts
+++ b/src/visits/reducers/shortUrlDetail.ts
@@ -1,7 +1,7 @@
 import { Action, Dispatch } from 'redux';
 import { ShortUrl } from '../../short-urls/data';
 import { buildReducer } from '../../utils/helpers/redux';
-import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
+import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
 import { OptionalString } from '../../utils/utils';
 import { GetState } from '../../container/types';
 
diff --git a/src/visits/reducers/shortUrlVisits.ts b/src/visits/reducers/shortUrlVisits.ts
index 2126513e..8901a724 100644
--- a/src/visits/reducers/shortUrlVisits.ts
+++ b/src/visits/reducers/shortUrlVisits.ts
@@ -3,7 +3,7 @@ import { shortUrlMatches } from '../../short-urls/helpers';
 import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAction } from '../types';
 import { ShortUrlIdentifier } from '../../short-urls/data';
 import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
-import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
+import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
 import { GetState } from '../../container/types';
 import { OptionalString } from '../../utils/utils';
 import { getVisitsWithLoader } from './common';
diff --git a/src/visits/reducers/tagVisits.ts b/src/visits/reducers/tagVisits.ts
index 3f361acb..c0c4106b 100644
--- a/src/visits/reducers/tagVisits.ts
+++ b/src/visits/reducers/tagVisits.ts
@@ -1,7 +1,7 @@
 import { Action, Dispatch } from 'redux';
 import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAction } from '../types';
 import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
-import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
+import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
 import { GetState } from '../../container/types';
 import { getVisitsWithLoader } from './common';
 import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
diff --git a/src/visits/reducers/visitsOverview.ts b/src/visits/reducers/visitsOverview.ts
index e37202d7..e3f78e59 100644
--- a/src/visits/reducers/visitsOverview.ts
+++ b/src/visits/reducers/visitsOverview.ts
@@ -1,6 +1,6 @@
 import { Action, Dispatch } from 'redux';
-import { ShlinkVisitsOverview } from '../../utils/services/types';
-import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
+import { ShlinkVisitsOverview } from '../../api/types';
+import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
 import { GetState } from '../../container/types';
 import { buildReducer } from '../../utils/helpers/redux';
 import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts
index 91540c4a..fa076916 100644
--- a/src/visits/types/index.ts
+++ b/src/visits/types/index.ts
@@ -1,6 +1,6 @@
 import { Action } from 'redux';
 import { ShortUrl } from '../../short-urls/data';
-import { ProblemDetailsError } from '../../utils/services/types';
+import { ProblemDetailsError } from '../../api/types';
 
 export interface VisitsInfo {
   visits: Visit[];


Test patch:
diff --git a/test/api/ShlinkApiError.test.tsx b/test/api/ShlinkApiError.test.tsx
new file mode 100644
index 00000000..8726aa6d
--- /dev/null
+++ b/test/api/ShlinkApiError.test.tsx
@@ -0,0 +1,36 @@
+import { shallow, ShallowWrapper } from 'enzyme';
+import { Mock } from 'ts-mockery';
+import { ShlinkApiError, ShlinkApiErrorProps } from '../../src/api/ShlinkApiError';
+import { InvalidArgumentError, ProblemDetailsError } from '../../src/api/types';
+
+describe('<ShlinkApiError />', () => {
+  let wrapper: ShallowWrapper;
+  const createWrapper = (props: ShlinkApiErrorProps) => {
+    wrapper = shallow(<ShlinkApiError {...props} />);
+
+    return wrapper;
+  };
+
+  afterEach(() => wrapper?.unmount());
+
+  it.each([
+    [ undefined, 'the fallback', 'the fallback' ],
+    [ Mock.all<ProblemDetailsError>(), 'the fallback', 'the fallback' ],
+    [ Mock.of<ProblemDetailsError>({ detail: 'the detail' }), 'the fallback', 'the detail' ],
+  ])('renders proper message', (errorData, fallbackMessage, expectedMessage) => {
+    const wrapper = createWrapper({ errorData, fallbackMessage });
+
+    expect(wrapper.text()).toContain(expectedMessage);
+  });
+
+  it.each([
+    [ undefined, 0 ],
+    [ Mock.all<ProblemDetailsError>(), 0 ],
+    [ Mock.of<InvalidArgumentError>({ type: 'INVALID_ARGUMENT', invalidElements: [] }), 1 ],
+  ])('renders list of invalid elements when provided error is an InvalidError', (errorData, expectedElementsCount) => {
+    const wrapper = createWrapper({ errorData });
+    const p = wrapper.find('p');
+
+    expect(p).toHaveLength(expectedElementsCount);
+  });
+});
diff --git a/test/utils/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts
similarity index 98%
rename from test/utils/services/ShlinkApiClient.test.ts
rename to test/api/services/ShlinkApiClient.test.ts
index 09ff6a90..93a2d7dd 100644
--- a/test/utils/services/ShlinkApiClient.test.ts
+++ b/test/api/services/ShlinkApiClient.test.ts
@@ -1,8 +1,8 @@
 import { AxiosInstance, AxiosRequestConfig } from 'axios';
-import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
+import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
 import { OptionalString } from '../../../src/utils/utils';
 import { Mock } from 'ts-mockery';
-import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/utils/services/types';
+import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/api/types';
 
 describe('ShlinkApiClient', () => {
   const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance;
diff --git a/test/utils/services/ShlinkApiClientBuilder.test.ts b/test/api/services/ShlinkApiClientBuilder.test.ts
similarity index 95%
rename from test/utils/services/ShlinkApiClientBuilder.test.ts
rename to test/api/services/ShlinkApiClientBuilder.test.ts
index 0cd31204..cd6da949 100644
--- a/test/utils/services/ShlinkApiClientBuilder.test.ts
+++ b/test/api/services/ShlinkApiClientBuilder.test.ts
@@ -1,6 +1,6 @@
 import { Mock } from 'ts-mockery';
 import { AxiosInstance } from 'axios';
-import buildShlinkApiClient from '../../../src/utils/services/ShlinkApiClientBuilder';
+import buildShlinkApiClient from '../../../src/api/services/ShlinkApiClientBuilder';
 import { ReachableServer, SelectedServer } from '../../../src/servers/data';
 import { ShlinkState } from '../../../src/container/types';
 
diff --git a/test/domains/DomainSelector.test.tsx b/test/domains/DomainSelector.test.tsx
index 2e7a3d2f..0c1ab22c 100644
--- a/test/domains/DomainSelector.test.tsx
+++ b/test/domains/DomainSelector.test.tsx
@@ -3,7 +3,7 @@ import { Mock } from 'ts-mockery';
 import { DropdownItem, DropdownMenu, InputGroup } from 'reactstrap';
 import { DomainSelector } from '../../src/domains/DomainSelector';
 import { DomainsList } from '../../src/domains/reducers/domainsList';
-import { ShlinkDomain } from '../../src/utils/services/types';
+import { ShlinkDomain } from '../../src/api/types';
 
 describe('<DomainSelector />', () => {
   let wrapper: ShallowWrapper;
diff --git a/test/domains/reducers/domainsList.test.ts b/test/domains/reducers/domainsList.test.ts
index 1e090770..585bb1da 100644
--- a/test/domains/reducers/domainsList.test.ts
+++ b/test/domains/reducers/domainsList.test.ts
@@ -6,8 +6,8 @@ import reducer, {
   ListDomainsAction,
   listDomains as listDomainsAction,
 } from '../../../src/domains/reducers/domainsList';
-import { ShlinkDomain } from '../../../src/utils/services/types';
-import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
+import { ShlinkDomain } from '../../../src/api/types';
+import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
 
 describe('domainsList', () => {
   const domains = [ Mock.all<ShlinkDomain>(), Mock.all<ShlinkDomain>(), Mock.all<ShlinkDomain>() ];
diff --git a/test/mercure/reducers/mercureInfo.test.ts b/test/mercure/reducers/mercureInfo.test.ts
index 50d7ba15..239a9ba1 100644
--- a/test/mercure/reducers/mercureInfo.test.ts
+++ b/test/mercure/reducers/mercureInfo.test.ts
@@ -6,8 +6,8 @@ import reducer, {
   loadMercureInfo,
   GetMercureInfoAction,
 } from '../../../src/mercure/reducers/mercureInfo';
-import { ShlinkMercureInfo } from '../../../src/utils/services/types';
-import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
+import { ShlinkMercureInfo } from '../../../src/api/types';
+import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
 import { GetState } from '../../../src/container/types';
 
 describe('mercureInfoReducer', () => {
diff --git a/test/short-urls/helpers/DeleteShortUrlModal.test.tsx b/test/short-urls/helpers/DeleteShortUrlModal.test.tsx
index d39fdb27..0ee94da8 100644
--- a/test/short-urls/helpers/DeleteShortUrlModal.test.tsx
+++ b/test/short-urls/helpers/DeleteShortUrlModal.test.tsx
@@ -4,7 +4,7 @@ import { Mock } from 'ts-mockery';
 import DeleteShortUrlModal from '../../../src/short-urls/helpers/DeleteShortUrlModal';
 import { ShortUrl } from '../../../src/short-urls/data';
 import { ShortUrlDeletion } from '../../../src/short-urls/reducers/shortUrlDeletion';
-import { ProblemDetailsError } from '../../../src/utils/services/types';
+import { ProblemDetailsError } from '../../../src/api/types';
 import { Result } from '../../../src/utils/Result';
 
 describe('<DeleteShortUrlModal />', () => {
diff --git a/test/short-urls/reducers/shortUrlCreation.test.ts b/test/short-urls/reducers/shortUrlCreation.test.ts
index 7347e5fb..002cb368 100644
--- a/test/short-urls/reducers/shortUrlCreation.test.ts
+++ b/test/short-urls/reducers/shortUrlCreation.test.ts
@@ -9,7 +9,7 @@ import reducer, {
   CreateShortUrlAction,
 } from '../../../src/short-urls/reducers/shortUrlCreation';
 import { ShortUrl } from '../../../src/short-urls/data';
-import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
+import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
 import { ShlinkState } from '../../../src/container/types';
 
 describe('shortUrlCreationReducer', () => {
diff --git a/test/short-urls/reducers/shortUrlDeletion.test.ts b/test/short-urls/reducers/shortUrlDeletion.test.ts
index 5c68d2cd..e8a74df4 100644
--- a/test/short-urls/reducers/shortUrlDeletion.test.ts
+++ b/test/short-urls/reducers/shortUrlDeletion.test.ts
@@ -7,8 +7,8 @@ import reducer, {
   resetDeleteShortUrl,
   deleteShortUrl,
 } from '../../../src/short-urls/reducers/shortUrlDeletion';
-import { ProblemDetailsError } from '../../../src/utils/services/types';
-import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
+import { ProblemDetailsError } from '../../../src/api/types';
+import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
 
 describe('shortUrlDeletionReducer', () => {
   describe('reducer', () => {
diff --git a/test/short-urls/reducers/shortUrlsList.test.ts b/test/short-urls/reducers/shortUrlsList.test.ts
index b9a6ed64..da14fc13 100644
--- a/test/short-urls/reducers/shortUrlsList.test.ts
+++ b/test/short-urls/reducers/shortUrlsList.test.ts
@@ -10,8 +10,8 @@ import { SHORT_URL_DELETED } from '../../../src/short-urls/reducers/shortUrlDele
 import { SHORT_URL_META_EDITED } from '../../../src/short-urls/reducers/shortUrlMeta';
 import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation';
 import { ShortUrl } from '../../../src/short-urls/data';
-import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
-import { ShlinkPaginator, ShlinkShortUrlsResponse } from '../../../src/utils/services/types';
+import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
+import { ShlinkPaginator, ShlinkShortUrlsResponse } from '../../../src/api/types';
 import { CREATE_SHORT_URL } from '../../../src/short-urls/reducers/shortUrlCreation';
 import { SHORT_URL_EDITED } from '../../../src/short-urls/reducers/shortUrlEdition';
 
diff --git a/test/tags/reducers/tagDelete.test.ts b/test/tags/reducers/tagDelete.test.ts
index bc199d37..b51650d5 100644
--- a/test/tags/reducers/tagDelete.test.ts
+++ b/test/tags/reducers/tagDelete.test.ts
@@ -7,7 +7,7 @@ import reducer, {
   tagDeleted,
   deleteTag,
 } from '../../../src/tags/reducers/tagDelete';
-import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
+import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
 import { ShlinkState } from '../../../src/container/types';
 
 describe('tagDeleteReducer', () => {
diff --git a/test/tags/reducers/tagEdit.test.ts b/test/tags/reducers/tagEdit.test.ts
index 2bcfd3a6..08fbc1b9 100644
--- a/test/tags/reducers/tagEdit.test.ts
+++ b/test/tags/reducers/tagEdit.test.ts
@@ -8,7 +8,7 @@ import reducer, {
   editTag,
   EditTagAction,
 } from '../../../src/tags/reducers/tagEdit';
-import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
+import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
 import ColorGenerator from '../../../src/utils/services/ColorGenerator';
 import { ShlinkState } from '../../../src/container/types';
 
diff --git a/test/visits/reducers/shortUrlDetail.test.ts b/test/visits/reducers/shortUrlDetail.test.ts
index 582dd2d1..02d5bf39 100644
--- a/test/visits/reducers/shortUrlDetail.test.ts
+++ b/test/visits/reducers/shortUrlDetail.test.ts
@@ -7,7 +7,7 @@ import reducer, {
   ShortUrlDetailAction,
 } from '../../../src/visits/reducers/shortUrlDetail';
 import { ShortUrl } from '../../../src/short-urls/data';
-import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
+import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
 import { ShlinkState } from '../../../src/container/types';
 
 describe('shortUrlDetailReducer', () => {
diff --git a/test/visits/reducers/shortUrlVisits.test.ts b/test/visits/reducers/shortUrlVisits.test.ts
index 3467290b..ba6fe837 100644
--- a/test/visits/reducers/shortUrlVisits.test.ts
+++ b/test/visits/reducers/shortUrlVisits.test.ts
@@ -13,8 +13,8 @@ import reducer, {
 import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation';
 import { rangeOf } from '../../../src/utils/utils';
 import { Visit } from '../../../src/visits/types';
-import { ShlinkVisits } from '../../../src/utils/services/types';
-import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
+import { ShlinkVisits } from '../../../src/api/types';
+import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
 import { ShlinkState } from '../../../src/container/types';
 
 describe('shortUrlVisitsReducer', () => {
diff --git a/test/visits/reducers/tagVisits.test.ts b/test/visits/reducers/tagVisits.test.ts
index 2b419483..cd687b55 100644
--- a/test/visits/reducers/tagVisits.test.ts
+++ b/test/visits/reducers/tagVisits.test.ts
@@ -13,8 +13,8 @@ import reducer, {
 import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation';
 import { rangeOf } from '../../../src/utils/utils';
 import { Visit } from '../../../src/visits/types';
-import { ShlinkVisits } from '../../../src/utils/services/types';
-import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
+import { ShlinkVisits } from '../../../src/api/types';
+import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
 import { ShlinkState } from '../../../src/container/types';
 
 describe('tagVisitsReducer', () => {
diff --git a/test/visits/reducers/visitsOverview.test.ts b/test/visits/reducers/visitsOverview.test.ts
index 09999d25..5e3369ce 100644
--- a/test/visits/reducers/visitsOverview.test.ts
+++ b/test/visits/reducers/visitsOverview.test.ts
@@ -8,8 +8,8 @@ import reducer, {
   loadVisitsOverview,
 } from '../../../src/visits/reducers/visitsOverview';
 import { CreateVisitsAction } from '../../../src/visits/reducers/visitCreation';
-import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
-import { ShlinkVisitsOverview } from '../../../src/utils/services/types';
+import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
+import { ShlinkVisitsOverview } from '../../../src/api/types';
 import { ShlinkState } from '../../../src/container/types';
 
 describe('visitsOverview', () => {