import { orderToString, stringifyQuery } from '@shlinkio/shlink-frontend-kit'; import type { RegularNotFound, ShlinkApiClient as BaseShlinkApiClient, ShlinkDomainRedirects, ShlinkDomainsResponse, ShlinkEditDomainRedirects, ShlinkHealth, ShlinkMercureInfo, ShlinkShortUrlData, ShlinkShortUrlsListNormalizedParams, ShlinkShortUrlsListParams, ShlinkShortUrlsResponse, ShlinkTags, ShlinkTagsResponse, ShlinkTagsStatsResponse, ShlinkVisits, ShlinkVisitsOverview, ShlinkVisitsParams, } from '@shlinkio/shlink-web-component/api-contract'; import { ErrorTypeV2, ErrorTypeV3, } from '@shlinkio/shlink-web-component/api-contract'; import { isEmpty, isNil, reject } from 'ramda'; import type { ShlinkShortUrl, ShortUrlData } from '../../../shlink-web-component/src/short-urls/data'; import type { HttpClient } from '../../common/services/HttpClient'; import { replaceAuthorityFromUri } from '../../utils/helpers/uri'; import type { OptionalString } from '../../utils/utils'; type ApiVersion = 2 | 3; type RequestOptions = { url: string; method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; query?: object; body?: object; domain?: string; }; const buildShlinkBaseUrl = (url: string, version: ApiVersion) => `${url}/rest/v${version}`; const rejectNilProps = reject(isNil); const normalizeListParams = ( { orderBy = {}, excludeMaxVisitsReached, excludePastValidUntil, ...rest }: ShlinkShortUrlsListParams, ): ShlinkShortUrlsListNormalizedParams => ({ ...rest, excludeMaxVisitsReached: excludeMaxVisitsReached === true ? 'true' : undefined, excludePastValidUntil: excludePastValidUntil === true ? 'true' : undefined, orderBy: orderToString(orderBy), }); const isRegularNotFound = (error: unknown): error is RegularNotFound => { if (error === null || !(typeof error === 'object' && 'type' in error && 'status' in error)) { return false; } return (error.type === ErrorTypeV2.NOT_FOUND || error.type === ErrorTypeV3.NOT_FOUND) && error.status === 404; }; export class ShlinkApiClient implements BaseShlinkApiClient { private apiVersion: ApiVersion; public constructor( private readonly httpClient: HttpClient, public readonly baseUrl: string, public readonly apiKey: string, ) { this.apiVersion = 3; } public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise => this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>( { url: '/short-urls', query: normalizeListParams(params) }, ).then(({ shortUrls }) => shortUrls); public readonly createShortUrl = async (options: ShortUrlData): Promise => { const body = reject((value) => isEmpty(value) || isNil(value), options as any); return this.performRequest({ url: '/short-urls', method: 'POST', body }); }; public readonly getShortUrlVisits = async (shortCode: string, query?: ShlinkVisitsParams): Promise => this.performRequest<{ visits: ShlinkVisits }>({ url: `/short-urls/${shortCode}/visits`, query }) .then(({ visits }) => visits); public readonly getTagVisits = async (tag: string, query?: Omit): Promise => this.performRequest<{ visits: ShlinkVisits }>({ url: `/tags/${tag}/visits`, query }).then(({ visits }) => visits); public readonly getDomainVisits = async (domain: string, query?: Omit): Promise => this.performRequest<{ visits: ShlinkVisits }>({ url: `/domains/${domain}/visits`, query }).then(({ visits }) => visits); public readonly getOrphanVisits = async (query?: Omit): Promise => this.performRequest<{ visits: ShlinkVisits }>({ url: '/visits/orphan', query }).then(({ visits }) => visits); public readonly getNonOrphanVisits = async (query?: Omit): Promise => this.performRequest<{ visits: ShlinkVisits }>({ url: '/visits/non-orphan', query }).then(({ visits }) => visits); public readonly getVisitsOverview = async (): Promise => this.performRequest<{ visits: ShlinkVisitsOverview }>({ url: '/visits' }).then(({ visits }) => visits); public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise => this.performRequest({ url: `/short-urls/${shortCode}`, query: { domain } }); public readonly deleteShortUrl = async (shortCode: string, domain?: OptionalString): Promise => this.performEmptyRequest({ url: `/short-urls/${shortCode}`, method: 'DELETE', query: { domain } }); public readonly updateShortUrl = async ( shortCode: string, domain: OptionalString, body: ShlinkShortUrlData, ): Promise => this.performRequest({ url: `/short-urls/${shortCode}`, method: 'PATCH', query: { domain }, body }); public readonly listTags = async (): Promise => this.performRequest<{ tags: ShlinkTagsResponse }>({ url: '/tags', query: { withStats: 'true' } }) .then(({ tags }) => tags) .then(({ data, stats }) => ({ tags: data, stats })); public readonly tagsStats = async (): Promise => this.performRequest<{ tags: ShlinkTagsStatsResponse }>({ url: '/tags/stats' }) .then(({ tags }) => tags) .then(({ data }) => ({ tags: data.map(({ tag }) => tag), stats: data })); public readonly deleteTags = async (tags: string[]): Promise<{ tags: string[] }> => this.performEmptyRequest({ url: '/tags', method: 'DELETE', query: { tags } }).then(() => ({ tags })); public readonly editTag = async (oldName: string, newName: string): Promise<{ oldName: string; newName: string }> => this.performEmptyRequest({ url: '/tags', method: 'PUT', body: { oldName, newName }, }).then(() => ({ oldName, newName })); public readonly health = async (domain?: string): Promise => this.performRequest( { url: '/health', domain }, ); public readonly mercureInfo = async (): Promise => this.performRequest({ url: '/mercure-info' }); public readonly listDomains = async (): Promise => this.performRequest<{ domains: ShlinkDomainsResponse }>({ url: '/domains' }).then(({ domains }) => domains); public readonly editDomainRedirects = async ( domainRedirects: ShlinkEditDomainRedirects, ): Promise => this.performRequest({ url: '/domains/redirects', method: 'PATCH', body: domainRedirects }); private readonly performRequest = async (requestOptions: RequestOptions): Promise => this.httpClient.fetchJson(...this.toFetchParams(requestOptions)).catch( this.handleFetchError(() => this.httpClient.fetchJson(...this.toFetchParams(requestOptions))), ); private readonly performEmptyRequest = async (requestOptions: RequestOptions): Promise => this.httpClient.fetchEmpty(...this.toFetchParams(requestOptions)).catch( this.handleFetchError(() => this.httpClient.fetchEmpty(...this.toFetchParams(requestOptions))), ); private readonly toFetchParams = ({ url, method = 'GET', query = {}, body, domain, }: RequestOptions): [string, RequestInit] => { const normalizedQuery = stringifyQuery(rejectNilProps(query)); const stringifiedQuery = isEmpty(normalizedQuery) ? '' : `?${normalizedQuery}`; const baseUrl = domain ? replaceAuthorityFromUri(this.baseUrl, domain) : this.baseUrl; return [`${buildShlinkBaseUrl(baseUrl, this.apiVersion)}${url}${stringifiedQuery}`, { method, body: body && JSON.stringify(body), headers: { 'X-Api-Key': this.apiKey }, }]; }; private readonly handleFetchError = (retryFetch: Function) => (e: unknown) => { if (!isRegularNotFound(e)) { throw e; } // If we capture a not found error, let's assume this Shlink version does not support API v3, so we decrease to // v2 and retry this.apiVersion = 2; return retryFetch(); }; }