diff --git a/.eslintrc b/.eslintrc index 5068e0603..3208aaf48 100644 --- a/.eslintrc +++ b/.eslintrc @@ -5,7 +5,8 @@ "eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking", - "plugin:react/recommended" + "plugin:react/recommended", + "plugin:@tanstack/query/recommended" ], "root": true, "ignorePatterns": ["vite.config.mts"], @@ -50,6 +51,7 @@ } }, "rules": { + "@tanstack/query/exhaustive-deps": "warn", "@typescript-eslint/ban-types": "warn", "@typescript-eslint/no-base-to-string": "warn", "@typescript-eslint/consistent-type-imports": "warn", diff --git a/package.json b/package.json index 526870636..c243b0d08 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ "@mui/styles": "^5.11.9", "@mui/x-date-pickers": "^5.0.18", "@mui/x-tree-view": "^7.6.2", + "@tanstack/query-sync-storage-persister": "^5.59.0", + "@tanstack/react-query": "^5.56.2", + "@tanstack/react-query-persist-client": "^5.59.0", "@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react-swc": "^3.5.0", "autosuggest-highlight": "^3.3.4", @@ -41,6 +44,7 @@ "i18next": "^22.4.9", "i18next-browser-languagedetector": "^7.0.1", "lodash": "^4.17.21", + "lz-string": "^1.5.0", "md5": "^2.3.0", "moment": "^2.30.1", "moment-timezone": "^0.5.40", @@ -101,6 +105,8 @@ "@iconify/icons-simple-icons": "^1.2.56", "@iconify/react": "^4.1.1", "@jest/globals": "^29.6.4", + "@tanstack/eslint-plugin-query": "^5.58.1", + "@tanstack/react-query-devtools": "^5.56.2", "@testing-library/dom": "^7.31.2", "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^14.1.2", diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index 0dca7954d..da68ae9ef 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -24,6 +24,7 @@ import Routes from 'components/routes/routes'; import Tos from 'components/routes/tos'; import setMomentFRLocale from 'helpers/moment-fr-locale'; import { getProvider } from 'helpers/utils'; +import { APIProvider } from 'lib/api/APIProvider'; import React, { useEffect, useState } from 'react'; import { BrowserRouter } from 'react-router-dom'; @@ -102,13 +103,15 @@ export const MyApp: React.FC = () => { const myUser: CustomAppUserService = useMyUser(); return ( - - - - - - - + + + + + + + + + ); }; diff --git a/src/helpers/xsrf.tsx b/src/helpers/xsrf.tsx index 47878f7ef..845a906da 100644 --- a/src/helpers/xsrf.tsx +++ b/src/helpers/xsrf.tsx @@ -5,7 +5,7 @@ * @returns the CSRF token * */ -export default function getXSRFCookie() { +export default function getXSRFCookie(): string { let xsrfToken = null; if (document.cookie !== undefined) { try { @@ -18,5 +18,5 @@ export default function getXSRFCookie() { // Ignore... we will return null } } - return xsrfToken; + return xsrfToken as string; } diff --git a/src/lib/api/APIProvider.tsx b/src/lib/api/APIProvider.tsx new file mode 100644 index 000000000..9207b82de --- /dev/null +++ b/src/lib/api/APIProvider.tsx @@ -0,0 +1,46 @@ +import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; +import { keepPreviousData, QueryClient } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; +import { compress, decompress } from 'lz-string'; +import React from 'react'; +import { DEFAULT_GC_TIME, DEFAULT_STALE_TIME } from './constants'; +import type { APIQueryKey } from './models'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + staleTime: DEFAULT_STALE_TIME, + gcTime: DEFAULT_GC_TIME, + placeholderData: keepPreviousData + } + } +}); + +type Props = { + children: React.ReactNode; +}; + +const persister = createSyncStoragePersister({ + storage: window.sessionStorage, + serialize: data => + compress( + JSON.stringify({ + ...data, + clientState: { + mutations: [], + queries: data.clientState.queries.filter(q => (q.queryKey[0] as APIQueryKey).allowCache) + } + }) + ), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + deserialize: data => JSON.parse(decompress(data)) +}); + +export const APIProvider = ({ children }: Props) => ( + + {children} + + +); diff --git a/src/lib/api/constants.ts b/src/lib/api/constants.ts new file mode 100644 index 000000000..fa30f5bf9 --- /dev/null +++ b/src/lib/api/constants.ts @@ -0,0 +1,9 @@ +export const DEFAULT_RETRY_MS = 10 * 1000; + +/** The time in milliseconds after data is considered stale. If set to Infinity, the data will never be considered stale. If set to a function, the function will be executed with the query to compute a staleTime. */ +export const DEFAULT_STALE_TIME = 1 * 60 * 1000; + +/** The time in milliseconds that unused/inactive cache data remains in memory. When a query's cache becomes unused or inactive, that cache data will be garbage collected after this duration. When different garbage collection times are specified, the longest one will be used. Setting it to Infinity will disable garbage collection. */ +export const DEFAULT_GC_TIME = 5 * 60 * 1000; + +export const DEFAULT_INVALIDATE_DELAY = 1 * 1000; diff --git a/src/lib/api/invalidateApiQuery.ts b/src/lib/api/invalidateApiQuery.ts new file mode 100644 index 000000000..4fe3294d7 --- /dev/null +++ b/src/lib/api/invalidateApiQuery.ts @@ -0,0 +1,21 @@ +import type { Query } from '@tanstack/react-query'; +import { queryClient } from './APIProvider'; +import { DEFAULT_INVALIDATE_DELAY } from './constants'; +import type { ApiCallProps } from './utils'; + +export const invalidateApiQuery = ( + filter: (key: ApiCallProps) => boolean, + delay: number = DEFAULT_INVALIDATE_DELAY +) => { + setTimeout(async () => { + await queryClient.invalidateQueries({ + predicate: ({ queryKey }: Query) => { + try { + return typeof queryKey[0] === 'object' && queryKey[0] && filter(queryKey[0]); + } catch (err) { + return false; + } + } + }); + }, delay); +}; diff --git a/src/lib/api/models.ts b/src/lib/api/models.ts new file mode 100644 index 000000000..6588707cf --- /dev/null +++ b/src/lib/api/models.ts @@ -0,0 +1,33 @@ +export type APIResponse = { + api_error_message: string; + api_response: T; + api_server_version: string; + api_status_code: number; +}; + +export type BlobResponse = { + api_error_message: string; + api_response: unknown; + api_server_version: string; + api_status_code: number; + filename: string; + size: number; + type: string; +}; + +export type APIReturn = { + statusCode: number; + serverVersion: string; + data: Response; + error: string; +}; + +export type APIQueryKey = { + url: string; + contentType: string; + method: string; + body: Body; + reloadOnUnauthorize: boolean; + enabled: boolean; + [key: string]: unknown; +}; diff --git a/src/lib/api/updateApiQuery.ts b/src/lib/api/updateApiQuery.ts new file mode 100644 index 000000000..948dc462e --- /dev/null +++ b/src/lib/api/updateApiQuery.ts @@ -0,0 +1,19 @@ +import type { Query } from '@tanstack/react-query'; +import { queryClient } from './APIProvider'; +import type { APIResponse } from './models'; +import type { ApiCallProps } from './utils'; + +export const updateApiQuery = (filter: (key: ApiCallProps) => boolean, update: (prev: T) => T) => { + queryClient.setQueriesData>( + { + predicate: ({ queryKey }: Query) => { + try { + return typeof queryKey[0] === 'object' && queryKey[0] && filter(queryKey[0]); + } catch (err) { + return false; + } + } + }, + prev => ({ ...prev, api_response: update(prev?.api_response) }) + ); +}; diff --git a/src/lib/api/useApiMutation.tsx b/src/lib/api/useApiMutation.tsx new file mode 100644 index 000000000..11daaeca5 --- /dev/null +++ b/src/lib/api/useApiMutation.tsx @@ -0,0 +1,79 @@ +import type { UseMutationOptions } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse } from './models'; +import { getAPIResponse, useApiCallFn } from './utils'; + +type Input = { + url: string; + contentType?: string; + method?: string; + body?: Body; +}; + +const DEFAULT_INPUT: Input = { url: null, contentType: 'application/json', method: 'GET', body: null }; + +type Types = { + body?: TBody; + error?: TError; + response?: TResponse; + input?: TVariables; + context?: TContext; + previous?: TPrevious; +}; + +type Props = Omit< + UseMutationOptions, APIResponse, T['input'], T['context']>, + 'mutationKey' | 'mutationFn' | 'onSuccess' | 'onMutate' | 'onSettled' +> & { + input: Input | ((input: T['input']) => Input); + reloadOnUnauthorize?: boolean; + retryAfter?: number; + onSuccess?: (props?: { + data: APIResponse; + input: T['input']; + context: T['context']; + }) => Promise | unknown; + onFailure?: (props?: { + error: APIResponse; + input: T['input']; + context: T['context']; + }) => Promise | unknown; + onEnter?: (props?: { input: T['input'] }) => unknown; + onExit?: (props?: { + data: APIResponse; + error: APIResponse; + input: T['input']; + context: T['context']; + }) => Promise | unknown; +}; + +export const useApiMutation = ({ + input = null, + reloadOnUnauthorize = true, + retryAfter = DEFAULT_RETRY_MS, + onSuccess = () => null, + onFailure = () => null, + onEnter = () => null, + onExit = () => null, + ...options +}: Props) => { + const apiCallFn = useApiCallFn, T['body']>(); + + const mutation = useMutation, APIResponse, T['input'], unknown>({ + ...options, + mutationFn: async (variables: T['input']) => + apiCallFn({ + ...DEFAULT_INPUT, + ...(typeof input === 'function' ? input(variables) : input), + reloadOnUnauthorize, + retryAfter + }), + onSuccess: (data, variables, context) => onSuccess({ data, input: variables, context }), + onError: (error, variables, context) => onFailure({ error, input: variables, context }), + onMutate: variables => onEnter({ input: variables }), + onSettled: (data, error, variables, context) => onExit({ data, error, input: variables, context }) + }); + + return { ...mutation, ...getAPIResponse(mutation?.data, mutation?.error, mutation?.failureReason) }; +}; diff --git a/src/lib/api/useApiQuery.tsx b/src/lib/api/useApiQuery.tsx new file mode 100644 index 000000000..1eebdeeea --- /dev/null +++ b/src/lib/api/useApiQuery.tsx @@ -0,0 +1,48 @@ +import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse } from './models'; +import type { ApiCallProps } from './utils'; +import { getAPIResponse, useApiCallFn } from './utils'; + +type Types = { + body?: TBody; + error?: TError; + response?: TResponse; +}; + +type Props = Omit< + DefinedInitialDataOptions, APIResponse, APIResponse, TQueryKey>, + 'queryKey' | 'initialData' | 'enabled' +> & + ApiCallProps; + +export const useApiQuery = ({ + url, + contentType = 'application/json', + method = 'GET', + body = null, + allowCache = false, + enabled = true, + reloadOnUnauthorize = true, + retryAfter = DEFAULT_RETRY_MS, + ...options +}: Props) => { + const queryClient = useQueryClient(); + const apiCallFn = useApiCallFn(); + + const query = useQuery, APIResponse, APIResponse, QueryKey>( + { + ...options, + queryKey: [{ url, contentType, method, body, allowCache, enabled, reloadOnUnauthorize, retryAfter }], + enabled: Boolean(enabled), + queryFn: async ({ signal }) => + apiCallFn({ url, contentType, method, body, allowCache, enabled, reloadOnUnauthorize, retryAfter, signal }), + retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, + retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)) + }, + queryClient + ); + + return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason) }; +}; diff --git a/src/lib/api/useBootstrap.tsx b/src/lib/api/useBootstrap.tsx new file mode 100644 index 000000000..97ddc056f --- /dev/null +++ b/src/lib/api/useBootstrap.tsx @@ -0,0 +1,166 @@ +import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import useALContext from 'components/hooks/useALContext'; +import type { LoginParamsProps } from 'components/hooks/useMyAPI'; +import useMySnackbar from 'components/hooks/useMySnackbar'; +import useQuota from 'components/hooks/useQuota'; +import type { Configuration } from 'components/models/base/config'; +import type { WhoAmIProps } from 'components/models/ui/user'; +import getXSRFCookie from 'helpers/xsrf'; +import { useTranslation } from 'react-i18next'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse } from './models'; +import { getAPIResponse, isAPIData } from './utils'; + +type Props = Omit< + DefinedInitialDataOptions, + 'queryKey' | 'initialData' | 'enabled' +> & { + switchRenderedApp: (value: string) => void; + setConfiguration: (cfg: Configuration) => void; + setLoginParams: (params: LoginParamsProps) => void; + setUser: (user: WhoAmIProps) => void; + setReady: (layout: boolean, borealis: boolean) => void; + retryAfter?: number; +}; + +export const useBootstrap = ({ + switchRenderedApp, + setConfiguration, + setLoginParams, + setUser, + setReady, + retryAfter = DEFAULT_RETRY_MS, + ...options +}: Props< + APIResponse, + APIResponse, + APIResponse, + QueryKey +>) => { + const { t } = useTranslation(); + const { showErrorMessage, closeSnackbar } = useMySnackbar(); + const { configuration: systemConfig } = useALContext(); + const { setApiQuotaremaining, setSubmissionQuotaremaining } = useQuota(); + + const queryClient = useQueryClient(); + + const query = useQuery< + APIResponse, + APIResponse, + APIResponse + >( + { + ...options, + queryKey: [ + { + url: '/api/v4/user/whoami/', + contentType: 'application/json', + method: 'GET', + allowCache: false, + retryAfter + } + ], + retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, + retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)), + queryFn: async ({ signal }) => { + // fetching the API's data + const res = await fetch('/api/v4/user/whoami/', { + method: 'GET', + credentials: 'same-origin', + headers: { 'X-XSRF-TOKEN': getXSRFCookie() }, + signal + }); + + // Setting the API quota + const apiQuota = res.headers.get('X-Remaining-Quota-Api'); + if (apiQuota) setApiQuotaremaining(parseInt(apiQuota)); + + // Setting the Submission quota + const submissionQuota = res.headers.get('X-Remaining-Quota-Submission'); + if (submissionQuota) setSubmissionQuotaremaining(parseInt(submissionQuota)); + + // Handle an unreachable API + if (res.status === 502) { + showErrorMessage(t('api.unreachable'), 10000); + return Promise.reject({ + api_error_message: t('api.unreachable'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 502 + }); + } + + const json = (await res.json()) as APIResponse; + + // Check for an invalid json format + if (!isAPIData(json)) { + showErrorMessage(t('api.invalid'), 30000); + switchRenderedApp('load'); + return Promise.reject({ + api_error_message: t('api.invalid'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 400 + }); + } + + const { api_error_message: error } = json; + + // Forbiden response indicate that the user's account is locked. + if (res.status === 403) { + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + setConfiguration(json.api_response as Configuration); + switchRenderedApp('locked'); + return Promise.reject(json); + } + + // Unauthorized response indicate that the user is not logged in. + if (res.status === 401) { + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + localStorage.setItem('loginParams', JSON.stringify(json.api_response)); + sessionStorage.clear(); + setLoginParams(json.api_response as LoginParamsProps); + switchRenderedApp('login'); + return Promise.reject(json); + } + + // Daily quota error, stop everything! + if (res.status === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { + switchRenderedApp('quota'); + return Promise.reject(json); + } + + // Handle all non-successful request + if (res.status !== 200) { + showErrorMessage(json.api_error_message); + return Promise.reject(json); + } + + if (res.status === 200) { + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + + const user = json.api_response as WhoAmIProps; + + // Set the current user + setUser(user); + + // Mark the interface ready + setReady(true, user.configuration.ui.api_proxies.includes('borealis')); + + // Render appropriate page + if (!user.agrees_with_tos && user.configuration.ui.tos) { + switchRenderedApp('tos'); + } else { + switchRenderedApp('routes'); + } + + return Promise.resolve(json); + } + } + }, + queryClient + ); + + return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason) }; +}; diff --git a/src/lib/api/useDownloadBlob.tsx b/src/lib/api/useDownloadBlob.tsx new file mode 100644 index 000000000..6a46bad98 --- /dev/null +++ b/src/lib/api/useDownloadBlob.tsx @@ -0,0 +1,148 @@ +import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import useALContext from 'components/hooks/useALContext'; +import useMySnackbar from 'components/hooks/useMySnackbar'; +import useQuota from 'components/hooks/useQuota'; +import { getFileName } from 'helpers/utils'; +import getXSRFCookie from 'helpers/xsrf'; +import { useTranslation } from 'react-i18next'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse, BlobResponse } from './models'; +import { getBlobResponse, isAPIData } from './utils'; + +type Props = Omit< + DefinedInitialDataOptions, + 'queryKey' | 'initialData' | 'enabled' +> & { + url: string; + allowCache?: boolean; + enabled?: boolean; + reloadOnUnauthorize?: boolean; + retryAfter?: number; +}; + +export const useMyQuery = ({ + url, + reloadOnUnauthorize = true, + retryAfter = DEFAULT_RETRY_MS, + allowCache = false, + enabled, + ...options +}: Props, BlobResponse, QueryKey>) => { + const { t } = useTranslation(); + const { showErrorMessage, closeSnackbar } = useMySnackbar(); + const { configuration: systemConfig } = useALContext(); + const { setApiQuotaremaining, setSubmissionQuotaremaining } = useQuota(); + + const queryClient = useQueryClient(); + + const query = useQuery, BlobResponse>( + { + ...options, + queryKey: [ + { + url, + method: 'GET', + allowCache, + enabled, + reloadOnUnauthorize, + retryAfter, + systemVersion: systemConfig.system.version + } + ], + retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, + retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)), + queryFn: async ({ signal }) => { + // Reject if the query is not enabled + if (!enabled) return Promise.reject(null); + + // fetching the API's data + const res = await fetch(url, { + method: 'GET', + credentials: 'same-origin', + headers: { 'X-XSRF-TOKEN': getXSRFCookie() }, + signal + }); + + // Setting the API quota + const apiQuota = res.headers.get('X-Remaining-Quota-Api'); + if (apiQuota) setApiQuotaremaining(parseInt(apiQuota)); + + // Setting the Submission quota + const submissionQuota = res.headers.get('X-Remaining-Quota-Submission'); + if (submissionQuota) setSubmissionQuotaremaining(parseInt(submissionQuota)); + + // Handle an unreachable API + if (res.status === 502) { + showErrorMessage(t('api.unreachable'), 10000); + return Promise.reject({ + api_error_message: t('api.unreachable'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 502 + }); + } + + const json = (await res.json()) as APIResponse; + + // Check for an invalid json format + if (!isAPIData(json)) { + showErrorMessage(t('api.invalid')); + return Promise.reject({ + api_error_message: t('api.invalid'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 400 + }); + } + + const { api_error_message: error } = json; + + // Reload when the user has exceeded their daily API call quota. + if (res.status === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { + window.location.reload(); + return Promise.reject(json); + } + + // Reject if the user has exceeded their daily submissions quota. + if (res.status === 503 && ['quota', 'submission'].every(v => error.includes(v))) { + return Promise.reject(json); + } + + // Reload when the user is not logged in + if (res.status === 401 && reloadOnUnauthorize) { + window.location.reload(); + return Promise.reject(json); + } + + // Reject if API Server is unavailable and should attempt to retry + if (res.status === 502) { + showErrorMessage(json.api_error_message, 30000); + return Promise.reject(json); + } + + // Handle successful request + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + + // Handle all non-successful request + if (res.status !== 200) { + showErrorMessage(json.api_error_message); + return Promise.reject(json); + } + + return Promise.resolve({ + api_error_message: '', + api_response: res.body, + api_server_version: systemConfig.system.version, + api_status_code: res.status, + filename: getFileName(res.headers.get('Content-Disposition')), + size: parseInt(res.headers.get('Content-Length')), + type: res.headers.get('Content-Type') + }); + } + }, + queryClient + ); + + return { ...query, ...getBlobResponse(query?.data, query?.error, query?.failureReason) }; +}; diff --git a/src/lib/api/useInfiniteApiQuery.tsx b/src/lib/api/useInfiniteApiQuery.tsx new file mode 100644 index 000000000..c4c386f53 --- /dev/null +++ b/src/lib/api/useInfiniteApiQuery.tsx @@ -0,0 +1,68 @@ +import type { DefinedInitialDataInfiniteOptions, InfiniteData, QueryKey } from '@tanstack/react-query'; +import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse } from './models'; +import { useThrottledState } from './useThrottledState'; +import type { ApiCallProps } from './utils'; +import { useApiCallFn } from './utils'; + +type Types = { + body?: TBody & { offset: number }; + error?: TError; + response?: TResponse; +}; + +type Props = Omit< + DefinedInitialDataInfiniteOptions< + APIResponse, + APIResponse, + InfiniteData, unknown>, + TQueryKey + >, + 'queryKey' | 'initialData' | 'enabled' +> & + ApiCallProps; + +export const useApiInfiniteQuery = ({ + url, + contentType = 'application/json', + method = 'GET', + body = null, + allowCache = false, + enabled = true, + reloadOnUnauthorize = true, + retryAfter = DEFAULT_RETRY_MS, + throttleTime = null, + ...options +}: Props) => { + const queryClient = useQueryClient(); + const apiCallFn = useApiCallFn(); + + const queryKey = useMemo>( + () => ({ url, contentType, method, body, allowCache, enabled, reloadOnUnauthorize, retryAfter, throttleTime }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [allowCache, JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttleTime, url] + ); + + const [throttledKey, isThrottling] = useThrottledState(queryKey, throttleTime); + + const query = useInfiniteQuery< + APIResponse, + APIResponse, + InfiniteData, unknown> + >( + { + ...options, + queryKey: [throttledKey], + enabled: Boolean(enabled) && !!throttledKey && !isThrottling, + queryFn: async ({ pageParam, signal }) => + apiCallFn({ ...throttledKey, body: { ...throttledKey.body, offset: pageParam }, signal }), + retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, + retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)) + }, + queryClient + ); + + return { ...query }; +}; diff --git a/src/lib/api/useThrottledApiQuery.tsx b/src/lib/api/useThrottledApiQuery.tsx new file mode 100644 index 000000000..f3de507ed --- /dev/null +++ b/src/lib/api/useThrottledApiQuery.tsx @@ -0,0 +1,58 @@ +import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse } from './models'; +import { useThrottledState } from './useThrottledState'; +import type { ApiCallProps } from './utils'; +import { getAPIResponse, useApiCallFn } from './utils'; + +type Types = { + body?: TBody; + error?: TError; + response?: TResponse; +}; + +type Props = Omit< + DefinedInitialDataOptions, APIResponse, APIResponse, TQueryKey>, + 'queryKey' | 'initialData' | 'enabled' +> & + ApiCallProps; + +export const useThrottledApiQuery = ({ + url, + contentType = 'application/json', + method = 'GET', + body = null, + allowCache = false, + enabled = true, + reloadOnUnauthorize = true, + retryAfter = DEFAULT_RETRY_MS, + throttleTime = null, + ...options +}: Props) => { + const queryClient = useQueryClient(); + const apiCallFn = useApiCallFn(); + + const queryKey = useMemo>( + () => ({ url, contentType, method, body, allowCache, enabled, reloadOnUnauthorize, retryAfter, throttleTime }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [allowCache, JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttleTime, url] + ); + + const [throttledKey, isThrottling] = useThrottledState(queryKey, throttleTime); + + const query = useQuery, APIResponse, APIResponse, QueryKey>( + { + ...options, + queryKey: [throttledKey], + enabled: Boolean(enabled) && !!throttledKey && !isThrottling, + queryFn: async ({ signal }) => apiCallFn({ signal, ...throttledKey }), + retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, + retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)) + }, + queryClient + ); + + return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason), isThrottling }; +}; diff --git a/src/lib/api/useThrottledState.tsx b/src/lib/api/useThrottledState.tsx new file mode 100644 index 000000000..bc742aaa3 --- /dev/null +++ b/src/lib/api/useThrottledState.tsx @@ -0,0 +1,35 @@ +import Throttler from 'commons/addons/utils/throttler'; +import { useEffect, useMemo, useState } from 'react'; + +export const useThrottledState = ( + state: T, + time: number = null, + initialState: T = null +): [T, boolean] => { + const [value, setValue] = useState(initialState); + const [isThrottling, setIsThrottling] = useState(true); + + const throttler = useMemo(() => (!time ? null : new Throttler(time)), [time]); + + useEffect(() => { + let ignore = false; + + if (!time) { + if (ignore) return; + setValue(state); + setIsThrottling(false); + } else { + setIsThrottling(true); + throttler.delay(() => { + if (ignore) return; + setIsThrottling(false); + setValue(state); + }); + } + return () => { + ignore = true; + }; + }, [state, throttler, time]); + + return [value, isThrottling]; +}; diff --git a/src/lib/api/utils.ts b/src/lib/api/utils.ts new file mode 100644 index 000000000..547f8eb4f --- /dev/null +++ b/src/lib/api/utils.ts @@ -0,0 +1,150 @@ +import useALContext from 'components/hooks/useALContext'; +import useMySnackbar from 'components/hooks/useMySnackbar'; +import useQuota from 'components/hooks/useQuota'; +import getXSRFCookie from 'helpers/xsrf'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse, BlobResponse } from './models'; + +export const isAPIData = (value: object): value is APIResponse => + value !== undefined && + value !== null && + 'api_response' in value && + 'api_error_message' in value && + 'api_server_version' in value && + 'api_status_code' in value; + +const getValue = (key, ...responses) => responses?.find(r => !!r?.[key])?.[key] || null; + +export const getAPIResponse = (data: APIResponse, error: APIResponse, failureReason: APIResponse) => ({ + statusCode: getValue('api_status_code', data, error, failureReason) as number, + serverVersion: getValue('api_server_version', data, error, failureReason) as string, + data: getValue('api_response', data, error, failureReason) as R, + error: getValue('api_error_message', data, error, failureReason) as E +}); + +export const getBlobResponse = (data: BlobResponse, error: APIResponse, failureReason: APIResponse) => ({ + statusCode: getValue('api_status_code', data, error, failureReason) as number, + serverVersion: getValue('api_server_version', data, error, failureReason) as string, + data: getValue('api_response', data, error, failureReason) as R, + error: getValue('api_error_message', data, error, failureReason) as E, + filename: getValue('filename', data, error, failureReason) as string, + size: getValue('size', data, error, failureReason) as number, + type: getValue('type', data, error, failureReason) as string +}); + +export type ApiCallProps = { + url: string; + contentType?: string; + method?: string; + body?: Body; + allowCache?: boolean; + enabled?: boolean; + reloadOnUnauthorize?: boolean; + retryAfter?: number; + signal?: AbortSignal; + throttleTime?: number; +}; + +export const useApiCallFn = () => { + const { t } = useTranslation(); + const { showErrorMessage, closeSnackbar } = useMySnackbar(); + const { configuration: systemConfig } = useALContext(); + const { setApiQuotaremaining, setSubmissionQuotaremaining } = useQuota(); + + return useCallback( + async ({ + url, + contentType = 'application/json', + method = 'GET', + body = null, + reloadOnUnauthorize = true, + retryAfter = DEFAULT_RETRY_MS, + enabled = true, + signal = null + }: ApiCallProps) => { + // Reject if the query is not enabled + if (!enabled) return Promise.reject(null); + + // fetching the API's data + const res = await fetch(url, { + method, + credentials: 'same-origin', + headers: { 'Content-Type': contentType, 'X-XSRF-TOKEN': getXSRFCookie() }, + body: (body === null ? null : contentType === 'application/json' ? JSON.stringify(body) : body) as BodyInit, + signal + }); + + // Setting the API quota + const apiQuota = res.headers.get('X-Remaining-Quota-Api'); + if (apiQuota) setApiQuotaremaining(parseInt(apiQuota)); + + // Setting the Submission quota + const submissionQuota = res.headers.get('X-Remaining-Quota-Submission'); + if (submissionQuota) setSubmissionQuotaremaining(parseInt(submissionQuota)); + + // Handle an unreachable API + if (res.status === 502) { + showErrorMessage(t('api.unreachable'), 10000); + return Promise.reject({ + api_error_message: t('api.unreachable'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 502 + }); + } + + const json = (await res.json()) as APIResponse; + + // Check for an invalid json format + if (!isAPIData(json)) { + showErrorMessage(t('api.invalid')); + return Promise.reject({ + api_error_message: t('api.invalid'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 400 + }); + } + + const { api_error_message: error } = json; + + // Reload when the user has exceeded their daily API call quota. + if (res.status === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { + window.location.reload(); + return Promise.reject(json); + } + + // Reject if the user has exceeded their daily submissions quota. + if (res.status === 503 && ['quota', 'submission'].every(v => error.includes(v))) { + return Promise.reject(json); + } + + // Reload when the user is not logged in + if (res.status === 401 && reloadOnUnauthorize) { + window.location.reload(); + return Promise.reject(json); + } + + // Reject if API Server is unavailable and should attempt to retry + if (res.status === 502) { + showErrorMessage(json.api_error_message, 30000); + return Promise.reject(json); + } + + // Handle successful request + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + + // Handle all non-successful request + if (res.status !== 200) { + showErrorMessage(json.api_error_message); + return Promise.reject(json); + } + + return Promise.resolve(json); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [setApiQuotaremaining, setSubmissionQuotaremaining, systemConfig.system.version, t] + ); +}; diff --git a/yarn.lock b/yarn.lock index a8b21b634..4fba0e01c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2289,6 +2289,64 @@ dependencies: "@swc/counter" "^0.1.3" +"@tanstack/eslint-plugin-query@^5.58.1": + version "5.58.1" + resolved "https://registry.yarnpkg.com/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.58.1.tgz#92893d6d0f895de1cafec5b21386154bcaf66d48" + integrity sha512-hJR3N5ilK60gCgDWr7pWHV/vDiDVczT95F8AGIcg1gf9117aLPK+LDu+xP2JuEWpWKpsQ6OpWdVMim9kKlMybw== + dependencies: + "@typescript-eslint/utils" "^8.3.0" + +"@tanstack/query-core@5.56.2": + version "5.56.2" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.56.2.tgz#2def2fb0290cd2836bbb08afb0c175595bb8109b" + integrity sha512-gor0RI3/R5rVV3gXfddh1MM+hgl0Z4G7tj6Xxpq6p2I03NGPaJ8dITY9Gz05zYYb/EJq9vPas/T4wn9EaDPd4Q== + +"@tanstack/query-core@5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.59.0.tgz#d8323f1c6eb0e573ab0aa85a7b7690d0c263818a" + integrity sha512-WGD8uIhX6/deH/tkZqPNcRyAhDUqs729bWKoByYHSogcshXfFbppOdTER5+qY7mFvu8KEFJwT0nxr8RfPTVh0Q== + +"@tanstack/query-devtools@5.56.1": + version "5.56.1" + resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.56.1.tgz#319c362dd19c6cfe005e74a8777baefa4a4f72de" + integrity sha512-xnp9jq/9dHfSCDmmf+A5DjbIjYqbnnUL2ToqlaaviUQGRTapXQ8J+GxusYUu1IG0vZMaWdiVUA4HRGGZYAUU+A== + +"@tanstack/query-persist-client-core@5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.59.0.tgz#a896386edde3531fec8c0a29aeae9f4ac4681f4c" + integrity sha512-uGXnTgck1AX2xXDVj417vtQD4Sz3J1D5iPxVhfUc7f/fkY9Qad2X7Id9mZUtll1/m9z55DfHoXMXlx5H1JK6fQ== + dependencies: + "@tanstack/query-core" "5.59.0" + +"@tanstack/query-sync-storage-persister@^5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-5.59.0.tgz#e3d66a36c25de94b89e563b4b669796fd36b19ae" + integrity sha512-PBQ6Chg/rgRQuQcTaFV/GiCDZdzZvbODKrpti+0fOPjKipEtodGRqtvYsPlf1Y7yb4AbZTECAKUQFjCv/gZVaA== + dependencies: + "@tanstack/query-core" "5.59.0" + "@tanstack/query-persist-client-core" "5.59.0" + +"@tanstack/react-query-devtools@^5.56.2": + version "5.56.2" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.56.2.tgz#c129cdb811927085434ea27691e4b7f605eb4128" + integrity sha512-7nINJtRZZVwhTTyDdMIcSaXo+EHMLYJu1S2e6FskvvD5prx87LlAXXWZDfU24Qm4HjshEtM5lS3HIOszNGblcw== + dependencies: + "@tanstack/query-devtools" "5.56.1" + +"@tanstack/react-query-persist-client@^5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-persist-client/-/react-query-persist-client-5.59.0.tgz#a73bfaf49fdc8d2502c6dc7661e2eb6ad9beeb8f" + integrity sha512-pUZxMXyy8Atv0rKzbRuCjC2wkQ6uTnKqlKtZ7djwv8r89tF1O5+p4DRCpMzQ+8w/qPRo9yMu/nDgGPcEZ1Fr8Q== + dependencies: + "@tanstack/query-persist-client-core" "5.59.0" + +"@tanstack/react-query@^5.56.2": + version "5.56.2" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.56.2.tgz#3a0241b9d010910905382f5e99160997b8795f91" + integrity sha512-SR0GzHVo6yzhN72pnRhkEFRAHMsUo5ZPzAxfTMvUxFIDVS6W9LYUp6nXW3fcHVdg0ZJl8opSH85jqahvm6DSVg== + dependencies: + "@tanstack/query-core" "5.56.2" + "@testing-library/dom@^7.31.2": version "7.31.2" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.31.2.tgz#df361db38f5212b88555068ab8119f5d841a8c4a" @@ -2705,6 +2763,14 @@ "@typescript-eslint/types" "7.8.0" "@typescript-eslint/visitor-keys" "7.8.0" +"@typescript-eslint/scope-manager@8.8.0": + version "8.8.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.8.0.tgz#30b23a6ae5708bd7882e40675ef2f1b2beac741f" + integrity sha512-EL8eaGC6gx3jDd8GwEFEV091210U97J0jeEHrAYvIYosmEGet4wJ+g0SYmLu+oRiAwbSA5AVrt6DxLHfdd+bUg== + dependencies: + "@typescript-eslint/types" "8.8.0" + "@typescript-eslint/visitor-keys" "8.8.0" + "@typescript-eslint/type-utils@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz#286f0389c41681376cdad96b309cedd17d70346a" @@ -2735,6 +2801,11 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.8.0.tgz#1fd2577b3ad883b769546e2d1ef379f929a7091d" integrity sha512-wf0peJ+ZGlcH+2ZS23aJbOv+ztjeeP8uQ9GgwMJGVLx/Nj9CJt17GWgWWoSmoRVKAX2X+7fzEnAjxdvK2gqCLw== +"@typescript-eslint/types@8.8.0": + version "8.8.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.8.0.tgz#08ea5df6c01984d456056434641491fbf7a1bf43" + integrity sha512-QJwc50hRCgBd/k12sTykOJbESe1RrzmX6COk8Y525C9l7oweZ+1lw9JiU56im7Amm8swlz00DRIlxMYLizr2Vw== + "@typescript-eslint/typescript-estree@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b" @@ -2762,6 +2833,20 @@ semver "^7.6.0" ts-api-utils "^1.3.0" +"@typescript-eslint/typescript-estree@8.8.0": + version "8.8.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.0.tgz#072eaab97fdb63513fabfe1cf271812affe779e3" + integrity sha512-ZaMJwc/0ckLz5DaAZ+pNLmHv8AMVGtfWxZe/x2JVEkD5LnmhWiQMMcYT7IY7gkdJuzJ9P14fRy28lUrlDSWYdw== + dependencies: + "@typescript-eslint/types" "8.8.0" + "@typescript-eslint/visitor-keys" "8.8.0" + debug "^4.3.4" + fast-glob "^3.3.2" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^1.3.0" + "@typescript-eslint/utils@5.62.0", "@typescript-eslint/utils@^5.58.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86" @@ -2789,6 +2874,16 @@ "@typescript-eslint/typescript-estree" "7.8.0" semver "^7.6.0" +"@typescript-eslint/utils@^8.3.0": + version "8.8.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.8.0.tgz#bd8607e3a68c461b69169c7a5824637dc9e8b3f1" + integrity sha512-QE2MgfOTem00qrlPgyByaCHay9yb1+9BjnMFnSFkUKQfu7adBXDTnCAivURnuPPAG/qiB+kzKkZKmKfaMT0zVg== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + "@typescript-eslint/scope-manager" "8.8.0" + "@typescript-eslint/types" "8.8.0" + "@typescript-eslint/typescript-estree" "8.8.0" + "@typescript-eslint/visitor-keys@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e" @@ -2805,6 +2900,14 @@ "@typescript-eslint/types" "7.8.0" eslint-visitor-keys "^3.4.3" +"@typescript-eslint/visitor-keys@8.8.0": + version "8.8.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.0.tgz#f93965abd38c82a1a1f5574290a50d02daf1cd2e" + integrity sha512-8mq51Lx6Hpmd7HnA2fcHQo3YgfX1qbccxQOgZcb4tvasu//zXRaA1j5ZRFeCw/VRAdFi4mRM9DnZw0Nu0Q2d1g== + dependencies: + "@typescript-eslint/types" "8.8.0" + eslint-visitor-keys "^3.4.3" + "@ungap/structured-clone@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"