Updates ShlinkApiClint to use different methods to fetch, and fixed tests

This commit is contained in:
Alejandro Celaya 2022-11-20 12:51:07 +01:00
parent 1e155af948
commit 059fa37ca7
5 changed files with 80 additions and 62 deletions

View file

@ -24,11 +24,9 @@ import { HttpClient } from '../../common/services/HttpClient';
const buildShlinkBaseUrl = (url: string, version: 2 | 3) => `${url}/rest/v${version}`; const buildShlinkBaseUrl = (url: string, version: 2 | 3) => `${url}/rest/v${version}`;
const rejectNilProps = reject(isNil); const rejectNilProps = reject(isNil);
const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => { const normalizeOrderByInParams = (
const { orderBy = {}, ...rest } = params; { orderBy = {}, ...rest }: ShlinkShortUrlsListParams,
): ShlinkShortUrlsListNormalizedParams => ({ ...rest, orderBy: orderToString(orderBy) });
return { ...rest, orderBy: orderToString(orderBy) };
};
export class ShlinkApiClient { export class ShlinkApiClient {
private apiVersion: 2 | 3; private apiVersion: 2 | 3;
@ -47,7 +45,6 @@ export class ShlinkApiClient {
public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => { public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => {
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options as any); const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options as any);
return this.performRequest<ShortUrl>('/short-urls', 'POST', {}, filteredOptions); return this.performRequest<ShortUrl>('/short-urls', 'POST', {}, filteredOptions);
}; };
@ -56,31 +53,25 @@ export class ShlinkApiClient {
.then(({ visits }) => visits); .then(({ visits }) => visits);
public readonly getTagVisits = async (tag: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> => public readonly getTagVisits = async (tag: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query) this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query).then(({ visits }) => visits);
.then(({ visits }) => visits);
public readonly getDomainVisits = async (domain: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> => public readonly getDomainVisits = async (domain: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
this.performRequest<{ visits: ShlinkVisits }>(`/domains/${domain}/visits`, 'GET', query) this.performRequest<{ visits: ShlinkVisits }>(`/domains/${domain}/visits`, 'GET', query).then(({ visits }) => visits);
.then(({ visits }) => visits);
public readonly getOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> => public readonly getOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query) this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query).then(({ visits }) => visits);
.then(({ visits }) => visits);
public readonly getNonOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> => public readonly getNonOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
this.performRequest<{ visits: ShlinkVisits }>('/visits/non-orphan', 'GET', query) this.performRequest<{ visits: ShlinkVisits }>('/visits/non-orphan', 'GET', query).then(({ visits }) => visits);
.then(({ visits }) => visits);
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> => public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits') this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits').then(({ visits }) => visits);
.then(({ visits }) => visits);
public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise<ShortUrl> => public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise<ShortUrl> =>
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain }); this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain });
public readonly deleteShortUrl = async (shortCode: string, domain?: OptionalString): Promise<void> => public readonly deleteShortUrl = async (shortCode: string, domain?: OptionalString): Promise<void> =>
this.performRequest<void>(`/short-urls/${shortCode}`, 'DELETE', { domain }) this.performEmptyRequest(`/short-urls/${shortCode}`, 'DELETE', { domain });
.then(() => {});
public readonly updateShortUrl = async ( public readonly updateShortUrl = async (
shortCode: string, shortCode: string,
@ -95,12 +86,10 @@ export class ShlinkApiClient {
.then(({ data, stats }) => ({ tags: data, stats })); .then(({ data, stats }) => ({ tags: data, stats }));
public readonly deleteTags = async (tags: string[]): Promise<{ tags: string[] }> => public readonly deleteTags = async (tags: string[]): Promise<{ tags: string[] }> =>
this.performRequest<void>('/tags', 'DELETE', { tags }) this.performEmptyRequest('/tags', 'DELETE', { tags }).then(() => ({ tags }));
.then(() => ({ tags }));
public readonly editTag = async (oldName: string, newName: string): Promise<{ oldName: string; newName: string }> => public readonly editTag = async (oldName: string, newName: string): Promise<{ oldName: string; newName: string }> =>
this.performRequest<void>('/tags', 'PUT', {}, { oldName, newName }) this.performEmptyRequest('/tags', 'PUT', {}, { oldName, newName }).then(() => ({ oldName, newName }));
.then(() => ({ oldName, newName }));
public readonly health = async (): Promise<ShlinkHealth> => this.performRequest<ShlinkHealth>('/health', 'GET'); public readonly health = async (): Promise<ShlinkHealth> => this.performRequest<ShlinkHealth>('/health', 'GET');
@ -115,26 +104,35 @@ export class ShlinkApiClient {
): Promise<ShlinkDomainRedirects> => ): Promise<ShlinkDomainRedirects> =>
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects); this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects);
private readonly performRequest = async <T>(url: string, method = 'GET', query = {}, body?: object): Promise<T> => { private readonly performRequest = async <T>(url: string, method = 'GET', query = {}, body?: object): Promise<T> =>
this.httpClient.fetchJson<T>(...this.toFetchParams(url, method, query, body)).catch(
this.handleFetchError(() => this.httpClient.fetchJson<T>(...this.toFetchParams(url, method, query, body))),
);
private readonly performEmptyRequest = async (url: string, method = 'GET', query = {}, body?: object): Promise<void> =>
this.httpClient.fetchEmpty(...this.toFetchParams(url, method, query, body)).catch(
this.handleFetchError(() => this.httpClient.fetchEmpty(...this.toFetchParams(url, method, query, body))),
);
private readonly toFetchParams = (url: string, method: string, query = {}, body?: object): [string, RequestInit] => {
const normalizedQuery = stringifyQuery(rejectNilProps(query)); const normalizedQuery = stringifyQuery(rejectNilProps(query));
const stringifiedQuery = isEmpty(normalizedQuery) ? '' : `?${normalizedQuery}`; const stringifiedQuery = isEmpty(normalizedQuery) ? '' : `?${normalizedQuery}`;
return this.httpClient.fetchJson<T>( return [`${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}${stringifiedQuery}`, {
`${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}${stringifiedQuery}`, method,
{ body: body && JSON.stringify(body),
method, headers: { 'X-Api-Key': this.apiKey },
body: body && JSON.stringify(body), }];
headers: { 'X-Api-Key': this.apiKey }, };
},
).catch((e: unknown) => {
if (!isRegularNotFound(parseApiError(e))) {
throw e;
}
// If we capture a not found error, let's assume this Shlink version does not support API v3, so we decrease to private readonly handleFetchError = (retryFetch: Function) => (e: unknown) => {
// v2 and retry if (!isRegularNotFound(parseApiError(e))) {
this.apiVersion = 2; throw e;
return this.performRequest(url, method, query, body); }
});
// If we capture a not found error, let's assume this Shlink version does not support API v3, so we decrease to
// v2 and retry
this.apiVersion = 2;
return retryFetch();
}; };
} }

View file

@ -3,21 +3,23 @@ import { Fetch } from '../../utils/types';
export class HttpClient { export class HttpClient {
constructor(private readonly fetch: Fetch) {} constructor(private readonly fetch: Fetch) {}
public fetchJson<T>(url: string, options?: RequestInit): Promise<T> { public readonly fetchJson = <T>(url: string, options?: RequestInit): Promise<T> =>
return this.fetch(url, options).then(async (resp) => { this.fetch(url, options).then(async (resp) => {
const json = await resp.json();
if (!resp.ok) {
throw json;
}
return json as T;
});
public readonly fetchEmpty = (url: string, options?: RequestInit): Promise<void> =>
this.fetch(url, options).then(async (resp) => {
if (!resp.ok) { if (!resp.ok) {
throw await resp.json(); throw await resp.json();
} }
try {
return (await resp.json()) as T;
} catch (e) {
return undefined as T;
}
}); });
}
public fetchBlob(url: string): Promise<Blob> { public readonly fetchBlob = (url: string): Promise<Blob> => this.fetch(url).then((resp) => resp.blob());
return this.fetch(url).then((resp) => resp.blob());
}
} }

View file

@ -8,7 +8,8 @@ import { HttpClient } from '../../../src/common/services/HttpClient';
describe('ShlinkApiClient', () => { describe('ShlinkApiClient', () => {
const fetchJson = jest.fn().mockResolvedValue({}); const fetchJson = jest.fn().mockResolvedValue({});
const httpClient = Mock.of<HttpClient>({ fetchJson }); const fetchEmpty = jest.fn().mockResolvedValue(undefined);
const httpClient = Mock.of<HttpClient>({ fetchJson, fetchEmpty });
const buildApiClient = () => new ShlinkApiClient(httpClient, '', ''); const buildApiClient = () => new ShlinkApiClient(httpClient, '', '');
const shortCodesWithDomainCombinations: [string, OptionalString][] = [ const shortCodesWithDomainCombinations: [string, OptionalString][] = [
['abc123', null], ['abc123', null],
@ -196,7 +197,7 @@ describe('ShlinkApiClient', () => {
await deleteTags(tags); await deleteTags(tags);
expect(fetchJson).toHaveBeenCalledWith( expect(fetchEmpty).toHaveBeenCalledWith(
expect.stringContaining(`/tags?${tags.map((tag) => `tags%5B%5D=${tag}`).join('&')}`), expect.stringContaining(`/tags?${tags.map((tag) => `tags%5B%5D=${tag}`).join('&')}`),
expect.objectContaining({ method: 'DELETE' }), expect.objectContaining({ method: 'DELETE' }),
); );
@ -211,7 +212,7 @@ describe('ShlinkApiClient', () => {
await editTag(oldName, newName); await editTag(oldName, newName);
expect(fetchJson).toHaveBeenCalledWith(expect.stringContaining('/tags'), expect.objectContaining({ expect(fetchEmpty).toHaveBeenCalledWith(expect.stringContaining('/tags'), expect.objectContaining({
method: 'PUT', method: 'PUT',
body: JSON.stringify({ oldName, newName }), body: JSON.stringify({ oldName, newName }),
})); }));
@ -225,7 +226,7 @@ describe('ShlinkApiClient', () => {
await deleteShortUrl(shortCode, domain); await deleteShortUrl(shortCode, domain);
expect(fetchJson).toHaveBeenCalledWith( expect(fetchEmpty).toHaveBeenCalledWith(
expect.stringContaining(`/short-urls/${shortCode}${expectedQuery}`), expect.stringContaining(`/short-urls/${shortCode}${expectedQuery}`),
expect.objectContaining({ method: 'DELETE' }), expect.objectContaining({ method: 'DELETE' }),
); );

View file

@ -1,5 +1,5 @@
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { tagEdited, EditTagAction, tagEditReducerCreator } from '../../../src/tags/reducers/tagEdit'; import { tagEdited, editTag as editTagCreator, EditTagAction, tagEditReducerCreator } from '../../../src/tags/reducers/tagEdit';
import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient';
import { ColorGenerator } from '../../../src/utils/services/ColorGenerator'; import { ColorGenerator } from '../../../src/utils/services/ColorGenerator';
import { ShlinkState } from '../../../src/container/types'; import { ShlinkState } from '../../../src/container/types';
@ -11,7 +11,8 @@ describe('tagEditReducer', () => {
const editTagCall = jest.fn(); const editTagCall = jest.fn();
const buildShlinkApiClient = () => Mock.of<ShlinkApiClient>({ editTag: editTagCall }); const buildShlinkApiClient = () => Mock.of<ShlinkApiClient>({ editTag: editTagCall });
const colorGenerator = Mock.of<ColorGenerator>({ setColorForKey: jest.fn() }); const colorGenerator = Mock.of<ColorGenerator>({ setColorForKey: jest.fn() });
const { reducer, editTag } = tagEditReducerCreator(buildShlinkApiClient, colorGenerator); const editTag = editTagCreator(buildShlinkApiClient, colorGenerator);
const { reducer } = tagEditReducerCreator(editTag);
describe('reducer', () => { describe('reducer', () => {
it('returns loading on EDIT_TAG_START', () => { it('returns loading on EDIT_TAG_START', () => {

View file

@ -70,14 +70,30 @@ describe('tagsListReducer', () => {
const expectedTags = ['foo', 'renamed', 'baz'].sort(); const expectedTags = ['foo', 'renamed', 'baz'].sort();
expect(reducer( expect(reducer(
state({ tags, filteredTags: tags }), state({
{ tags,
type: tagEdited.toString(), filteredTags: tags,
payload: { oldName, newName }, stats: {
}, [oldName]: {
shortUrlsCount: 35,
visitsCount: 35,
},
},
}),
{ type: tagEdited.toString(), payload: { oldName, newName } },
)).toEqual({ )).toEqual({
tags: expectedTags, tags: expectedTags,
filteredTags: expectedTags, filteredTags: expectedTags,
stats: {
[oldName]: {
shortUrlsCount: 35,
visitsCount: 35,
},
[newName]: {
shortUrlsCount: 35,
visitsCount: 35,
},
},
}); });
}); });