@elastic-suite/gally-admin-shared
Version:
Shared package for gally admin BO
297 lines (271 loc) • 8.4 kB
text/typescript
import {
apiUrl,
authErrorCodes,
authHeader,
contentDispositionHeader,
contentTypeHeader,
defaultPageSize,
languageHeader,
tokenStorageKey,
} from '../constants'
import {
IError,
IFetch,
IFetchApi,
IHydraResponse,
IParam,
IResource,
IResponseError,
ISearchParameters,
LoadStatus,
} from '../types'
import { fetchJson, fetchRaw } from './fetch'
import { HydraError, getFieldName, isHydraError, isJSonldType } from './hydra'
import { AuthError, isError } from './network'
import { storageGet, storageRemove } from './storage'
import { getListApiParameters, getUrl } from './url'
import { createUTCDateSafe } from './format'
export class ApiError extends Error {}
export function isApiError<T extends object>(
json: T | IResponseError
): json is IResponseError {
return 'code' in json && 'message' in json
}
export function getApiUrl(url = ''): string {
if (!url.startsWith('http')) {
if (url.length && !url.startsWith('/')) {
url = `/${url}`
}
url = `${apiUrl}${url}`
}
return url
}
export function fetchApi<T extends object>(
language: string,
resource: IResource | string,
searchParameters: ISearchParameters = {},
options: RequestInit = {},
secure = true
): Promise<T> {
const apiUrl =
typeof resource === 'string' ? getApiUrl(resource) : getApiUrl(resource.url)
const optionsHeaders = (options.headers as Record<string, string>) || {}
const shouldSetContentType = !(
contentTypeHeader in optionsHeaders && !optionsHeaders[contentTypeHeader]
)
// Filter out empty content type from options headers
// This is useful for multipart/form-data (file upload) that let the browser handle
// The content-type and the file boundaries automatically
const filteredOptionsHeaders = Object.fromEntries(
Object.entries(optionsHeaders).filter(([_, value]) => Boolean(value))
)
const headers: Record<string, string> = {
[languageHeader]: language,
...(shouldSetContentType && { [contentTypeHeader]: 'application/ld+json' }),
...filteredOptionsHeaders,
}
const token = storageGet(tokenStorageKey)
if (secure && token) {
headers[authHeader] = `Bearer ${token}`
}
return fetchJson<T>(getUrl(apiUrl, searchParameters).href, {
...options,
headers,
}).then(({ json, response }) => {
if (isApiError(json)) {
if (authErrorCodes.includes(json.code)) {
storageRemove(tokenStorageKey)
throw new AuthError(json.message)
}
throw new ApiError(json.message)
} else if (isJSonldType(json) && isHydraError(json)) {
throw new HydraError(json)
} else if (authErrorCodes.includes(response.status)) {
storageRemove(tokenStorageKey)
throw new AuthError('Unauthorized/Forbidden')
}
return json
})
}
/**
* Extracts the filename from a Content-Disposition header.
*/
function extractFilename(contentDisposition: string | null): string | null {
if (!contentDisposition) {
return null
}
const filenameMatch = contentDisposition.match(
/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
)
if (!filenameMatch?.[1]) {
return null
}
return filenameMatch[1].replace(/['"]/g, '')
}
export function fetchApiFile(
resource: IResource | string,
secure = true
): Promise<{
content: string
contentType: string | null
filename: string
status: LoadStatus
}> {
const token = storageGet(tokenStorageKey)
const headers: Record<string, string> = {}
if (secure && token) {
headers[authHeader] = `Bearer ${token}`
}
const apiUrl =
typeof resource === 'string' ? getApiUrl(resource) : getApiUrl(resource.url)
return fetchRaw(apiUrl, { headers }).then(({ content, response }) => {
if (authErrorCodes.includes(response.status)) {
storageRemove(tokenStorageKey)
throw new AuthError('Unauthorized/Forbidden')
}
return {
content,
contentType: response.headers.get(contentTypeHeader),
filename:
extractFilename(response.headers.get(contentDispositionHeader)) ?? '',
status:
response.status !== 200 ? LoadStatus.FAILED : LoadStatus.SUCCEEDED,
}
})
}
export function removeEmptyParameters(
searchParameters: ISearchParameters = {}
): ISearchParameters {
return Object.fromEntries(
Object.entries(searchParameters).filter(
([_, value]) => (value ?? '') !== ''
)
)
}
function formatFilterValue(value: IParam): boolean | number | string {
return value instanceof Date ? value.toISOString() : value
}
function formatRangeFilterKeys(key: string, value: IParam[]): string[] {
const baseKey = getFieldName(key)
return [
value[0] instanceof Date ? `${baseKey}[after]` : `${baseKey}[gte]`,
value[1] instanceof Date ? `${baseKey}[before]` : `${baseKey}[lte]`,
]
}
function formatDateRangeStart(value: Date): Date {
const startValue = createUTCDateSafe(value)
startValue.setUTCHours(0, 0, 0, 0)
return startValue
}
function formatDateRangeEnd(value: Date): Date {
const endValue = createUTCDateSafe(value)
endValue.setUTCHours(23, 59, 59, 999)
return endValue
}
function formatRangeFilterKeyValues(
key: string,
value: IParam[]
): [string, boolean | number | string][] {
const filterKeys = formatRangeFilterKeys(key, value)
const rangeFilterKeyValues: [string, boolean | number | string][] = []
if (value[0] !== '') {
const rangeStart =
value[0] instanceof Date ? formatDateRangeStart(value[0]) : value[0]
rangeFilterKeyValues.push([filterKeys[0], formatFilterValue(rangeStart)])
}
if (value[1] !== '') {
const rangeEnd =
value[1] instanceof Date ? formatDateRangeEnd(value[1]) : value[1]
rangeFilterKeyValues.push([filterKeys[1], formatFilterValue(rangeEnd)])
}
return rangeFilterKeyValues
}
export function getApiFilters(
filters: ISearchParameters = {}
): ISearchParameters {
return Object.fromEntries(
Object.entries(filters).reduce<[string, IParam | IParam[]][]>(
(acc, [key, value]) => {
if (key.endsWith('[between]')) {
acc.push(...formatRangeFilterKeyValues(key, value as IParam[]))
} else {
acc.push([key, formatFilterValue(value as IParam)])
}
return acc
},
[]
)
)
}
/**
* Check if filters are applied in addition to fixed filters.
*/
export function hasRealFilterApplied(
searchParameters: ISearchParameters,
fixedFilters: ISearchParameters
): boolean {
if (
(!searchParameters && !fixedFilters) ||
(!searchParameters && fixedFilters)
) {
return false
}
if (searchParameters && !fixedFilters) {
return true
}
const searchParametersCleaned = { ...searchParameters }
Object.keys(searchParametersCleaned).forEach((key) => {
if (key in fixedFilters) {
delete searchParametersCleaned[key]
}
})
return Object.keys(searchParametersCleaned).length > 0
}
export async function fetchApiUsingPagination<T extends object>(
fetchApi: IFetchApi,
resource: IResource | string,
searchParameters?: ISearchParameters,
options?: RequestInit,
rowsPerPage: number = defaultPageSize
): Promise<IFetch<T>> {
let currentPage = 0
let newParameters = getListApiParameters(
currentPage,
rowsPerPage,
searchParameters
)
const firstResponse: IHydraResponse<T> | IError = await fetchApi<T>(
resource,
newParameters,
options
)
if (isError(firstResponse)) {
return { error: firstResponse.error, status: LoadStatus.FAILED }
}
const totalItems = (firstResponse as IHydraResponse<T>)['hydra:totalItems']
const promises: Promise<IHydraResponse<T> | IError>[] = []
for (; totalItems > (currentPage + 1) * rowsPerPage; currentPage++) {
newParameters = getListApiParameters(
currentPage + 1,
rowsPerPage,
searchParameters
)
promises.push(fetchApi<T>(resource, newParameters, options))
}
const otherResponses = await Promise.all(promises)
const errors: IError[] = []
const hydraMemberOtherResponses = otherResponses.flatMap((json) => {
if (isError(json)) {
errors.push(json)
return []
}
return (json as IHydraResponse<T>)['hydra:member']
})
if (errors.length > 0) {
return { error: errors[0].error, status: LoadStatus.FAILED }
}
;(firstResponse as IHydraResponse<T>)['hydra:member'].push(
...hydraMemberOtherResponses
)
return { data: firstResponse, status: LoadStatus.SUCCEEDED }
}