diff --git a/apps/fishing-map/features/workspace/workspace.slice.ts b/apps/fishing-map/features/workspace/workspace.slice.ts index 333911845c..6ae0365c34 100644 --- a/apps/fishing-map/features/workspace/workspace.slice.ts +++ b/apps/fishing-map/features/workspace/workspace.slice.ts @@ -72,7 +72,7 @@ interface WorkspaceSliceState { const initialState: WorkspaceSliceState = { status: AsyncReducerStatus.Idle, customStatus: AsyncReducerStatus.Idle, - error: {}, + error: {} as AsyncError, data: null, password: '', lastVisited: undefined, diff --git a/apps/fishing-map/utils/async-slice.ts b/apps/fishing-map/utils/async-slice.ts index cadcd5774d..50bd5ed6fc 100644 --- a/apps/fishing-map/utils/async-slice.ts +++ b/apps/fishing-map/utils/async-slice.ts @@ -6,6 +6,7 @@ import { createEntityAdapter, IdSelector, } from '@reduxjs/toolkit' +import { ResponseError } from '@globalfishingwatch/api-client' export enum AsyncReducerStatus { Idle = 'idle', @@ -19,9 +20,7 @@ export enum AsyncReducerStatus { Error = 'error', } -export type AsyncError> = { - status?: number // HHTP error codes - message?: string +export type AsyncError> = ResponseError & { metadata?: Metadata } @@ -38,7 +37,7 @@ export type AsyncReducer = { export const asyncInitialState: AsyncReducer = { status: AsyncReducerStatus.Idle, statusId: null, - error: {}, + error: {} as AsyncError, ids: [], currentRequestIds: [], entities: {}, @@ -55,7 +54,7 @@ const getRequestIdsOnFinish = (currentRequestIds: string[], action: any) => { export const createAsyncSlice = < T, U extends { id: AsyncReducerId }, - Reducers extends SliceCaseReducers = SliceCaseReducers, + Reducers extends SliceCaseReducers = SliceCaseReducers >({ name = '', initialState = {} as T, diff --git a/libs/api-client/src/api-client.ts b/libs/api-client/src/api-client.ts index f4b4cd4a5d..6d6c08b7f7 100644 --- a/libs/api-client/src/api-client.ts +++ b/libs/api-client/src/api-client.ts @@ -7,34 +7,18 @@ import { UserPermission, } from '@globalfishingwatch/api-types' import { isUrlAbsolute } from './utils/url' -import { isAuthError, parseAPIError } from './utils/errors' - -export const API_GATEWAY = - process.env.API_GATEWAY || - process.env.REACT_APP_API_GATEWAY || - process.env.NEXT_PUBLIC_API_GATEWAY || - 'https://gateway.api.dev.globalfishingwatch.org' - -export const USER_TOKEN_STORAGE_KEY = 'GFW_API_USER_TOKEN' -export const USER_REFRESH_TOKEN_STORAGE_KEY = 'GFW_API_USER_REFRESH_TOKEN' -export const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v3' - -const DEBUG_API_REQUESTS: boolean = process.env.NEXT_PUBLIC_DEBUG_API_REQUESTS === 'true' -const AUTH_PATH = 'auth' -const REGISTER_PATH = 'registration' -export const GUEST_USER_TYPE = 'guest' - -export type V2MetadataError = Record -export interface V2MessageError { - detail: string - title: string - metadata?: V2MetadataError -} -export interface ResponseError { - status: number - message: string - messages?: V2MessageError[] -} +import { getIsUnauthorizedError, isAuthError, parseAPIError } from './utils/errors' +import { + API_GATEWAY, + API_VERSION, + AUTH_PATH, + DEBUG_API_REQUESTS, + GUEST_USER_TYPE, + REGISTER_PATH, + USER_REFRESH_TOKEN_STORAGE_KEY, + USER_TOKEN_STORAGE_KEY, +} from './config' +import { parseJSON, processStatus } from './utils/parse' interface UserTokens { token: string @@ -62,51 +46,6 @@ interface LibConfig { baseUrl?: string } -const processStatus = ( - response: Response, - requestStatus?: ResourceResponseType -): Promise => { - return new Promise(async (resolve, reject) => { - const { status, statusText } = response - try { - if (response.status >= 200 && response.status < 400) { - return resolve(response) - } - - if (requestStatus === 'default') { - return reject(response) - } - // Compatibility with v1 and v2 errors format - const errors = { - message: '', - messages: [], - } - if (response.status >= 400 && response.status < 500) { - await response.text().then((text) => { - try { - const res = JSON.parse(text) - errors.message = res.message - errors.messages = res.messages - } catch (e: any) { - errors.message = statusText - } - }) - } - return reject({ - status, - message: errors?.message || statusText, - messages: errors.messages, - }) - } catch (e: any) { - return reject({ status, message: statusText }) - } - }) -} - -const parseJSON = (response: Response) => response.json() -const isUnauthorizedError = (error: ResponseError) => - error && error.status > 400 && error.status < 403 - const isClientSide = typeof window !== 'undefined' export type RequestStatus = 'idle' | 'refreshingToken' | 'logging' | 'downloading' @@ -514,7 +453,7 @@ export class GFW_API_CLASS { } } catch (e: any) { if (!this.getToken() && !this.getRefreshToken()) { - const msg = isUnauthorizedError(e) + const msg = getIsUnauthorizedError(e) ? 'Invalid access token' : 'Error trying to generate tokens' if (this.debug) { @@ -567,7 +506,7 @@ export class GFW_API_CLASS { this.status = 'idle' return user } catch (e: any) { - const msg = isUnauthorizedError(e) + const msg = getIsUnauthorizedError(e) ? 'Invalid refresh token' : 'Error trying to refreshing the token' console.warn(e) diff --git a/libs/api-client/src/config.ts b/libs/api-client/src/config.ts new file mode 100644 index 0000000000..468a9703bc --- /dev/null +++ b/libs/api-client/src/config.ts @@ -0,0 +1,15 @@ +export const API_GATEWAY = + process.env.API_GATEWAY || + process.env.REACT_APP_API_GATEWAY || + process.env.NEXT_PUBLIC_API_GATEWAY || + 'https://gateway.api.dev.globalfishingwatch.org' + +export const USER_TOKEN_STORAGE_KEY = 'GFW_API_USER_TOKEN' +export const USER_REFRESH_TOKEN_STORAGE_KEY = 'GFW_API_USER_REFRESH_TOKEN' +export const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v3' + +export const DEBUG_API_REQUESTS: boolean = process.env.NEXT_PUBLIC_DEBUG_API_REQUESTS === 'true' +export const AUTH_PATH = 'auth' +export const REGISTER_PATH = 'registration' +export const GUEST_USER_TYPE = 'guest' +export const CONCURRENT_ERROR_STATUS = 429 diff --git a/libs/api-client/src/index.ts b/libs/api-client/src/index.ts index 93464659c0..5e835ae57f 100644 --- a/libs/api-client/src/index.ts +++ b/libs/api-client/src/index.ts @@ -2,4 +2,5 @@ export * from './utils/url' export * from './utils/search' export * from './utils/errors' export * from './utils/thinning' +export * from './config' export * from './api-client' diff --git a/libs/api-client/src/utils/errors.ts b/libs/api-client/src/utils/errors.ts index 6321c428f6..0d48b5852e 100644 --- a/libs/api-client/src/utils/errors.ts +++ b/libs/api-client/src/utils/errors.ts @@ -1,24 +1,48 @@ -import { ResponseError, V2MetadataError } from '../api-client' +import { CONCURRENT_ERROR_STATUS } from '../config' + +export type V2MetadataError = Record +export interface V2MessageError { + detail: string + title: string + metadata?: V2MetadataError +} +export interface ResponseError { + status: number + message: string + messages?: V2MessageError[] +} + +export const getIsUnauthorizedError = (error?: ResponseError | { status?: number }) => + error && error.status && error.status > 400 && error.status < 403 + +export const getIsConcurrentError = (error?: ResponseError | { status?: number }) => + error?.status === CONCURRENT_ERROR_STATUS + // The 524 timeout from cloudfare is not handled properly // and rejects with a typeError export const crossBrowserTypeErrorMessages = [ 'Load failed', // Safari 'Failed to fetch', // Chromium ] +export const getIsTimeoutError = (error?: ResponseError | { message?: string }) => { + if (!error?.message) return false + return crossBrowserTypeErrorMessages.some((e) => e.includes(error?.message as string)) +} + export const parseAPIErrorStatus = (error: ResponseError) => { - return error.status || (error as any).code || null + return error?.status || (error as any).code || null } export const parseAPIErrorMessage = (error: ResponseError) => { - if (error.messages?.length) { - return error.messages[0]?.detail + if (error?.messages?.length) { + return error?.messages[0]?.detail } - return error.message || '' + return error?.message || '' } export const parseAPIErrorMetadata = (error: ResponseError) => { - if (error.messages?.length) { - return error.messages[0]?.metadata + if (error?.messages?.length) { + return error?.messages[0]?.metadata } return {} as V2MetadataError } diff --git a/libs/api-client/src/utils/parse.ts b/libs/api-client/src/utils/parse.ts new file mode 100644 index 0000000000..d8ff6d9325 --- /dev/null +++ b/libs/api-client/src/utils/parse.ts @@ -0,0 +1,44 @@ +import { ResourceResponseType } from '@globalfishingwatch/api-types' + +export const processStatus = ( + response: Response, + requestStatus?: ResourceResponseType +): Promise => { + return new Promise(async (resolve, reject) => { + const { status, statusText } = response + try { + if (response.status >= 200 && response.status < 400) { + return resolve(response) + } + + if (requestStatus === 'default') { + return reject(response) + } + // Compatibility with v1 and v2 errors format + const errors = { + message: '', + messages: [], + } + if (response.status >= 400 && response.status < 500) { + await response.text().then((text) => { + try { + const res = JSON.parse(text) + errors.message = res.message + errors.messages = res.messages + } catch (e: any) { + errors.message = statusText + } + }) + } + return reject({ + status, + message: errors?.message || statusText, + messages: errors.messages, + }) + } catch (e: any) { + return reject({ status, message: statusText }) + } + }) +} + +export const parseJSON = (response: Response) => response.json()