diff --git a/src/components/Content/Layout.tsx b/src/components/Content/Layout.tsx index 6e870453ed..e74f2d40ed 100644 --- a/src/components/Content/Layout.tsx +++ b/src/components/Content/Layout.tsx @@ -29,7 +29,6 @@ export const Layout: React.FC = () => { const { isAuthenticated } = useAuthStateContext(); useAnalyticsAttributes(); useAnnouncementToast(); - // this top-level query is required for authentication to work // afterware is used at apollo link level to authenticate or deauthenticate user based on response to query // therefore this could be any query as long as it is top-level diff --git a/src/gql/GQLWrapper.tsx b/src/gql/GQLWrapper.tsx index 6c2e5e857e..6072b29330 100644 --- a/src/gql/GQLWrapper.tsx +++ b/src/gql/GQLWrapper.tsx @@ -1,225 +1,12 @@ -import { - ApolloClient, - ApolloProvider, - InMemoryCache, - ApolloLink, - HttpLink, -} from "@apollo/client"; -import { onError } from "@apollo/client/link/error"; -import { RetryLink } from "@apollo/client/link/retry"; -import { routes } from "constants/routes"; -import { useAuthDispatchContext } from "context/Auth"; -import { environmentVariables } from "utils"; -import { - leaveBreadcrumb, - reportError, - SentryBreadcrumb, -} from "utils/errorReporting"; - -const { getGQLUrl } = environmentVariables; +import { ApolloProvider } from "@apollo/client"; +import { useCreateGQLCLient } from "hooks/useCreateGQLClient"; const GQLWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const { dispatchAuthenticated, logoutAndRedirect } = useAuthDispatchContext(); - return ( - - {children} - - ); -}; - -interface ClientLinkParams { - credentials?: string; - gqlURL?: string; - logoutAndRedirect?: () => void; - dispatchAuthenticated?: () => void; -} - -const cache = new InMemoryCache({ - typePolicies: { - Query: { - fields: { - distroEvents: { - keyArgs: ["$distroId"], - }, - projectEvents: { - keyArgs: ["$identifier"], - }, - repoEvents: { - keyArgs: ["$id"], - }, - }, - }, - GeneralSubscription: { - keyFields: false, - }, - DistroEventsPayload: { - fields: { - count: { - merge(existing = 0, incoming = 0) { - return existing + incoming; - }, - }, - eventLogEntries: { - merge(existing = [], incoming = []) { - return [...existing, ...incoming]; - }, - }, - }, - }, - ProjectEvents: { - fields: { - count: { - merge(existing = 0, incoming = 0) { - return existing + incoming; - }, - }, - eventLogEntries: { - merge(existing = [], incoming = []) { - return [...existing, ...incoming]; - }, - }, - }, - }, - ProjectAlias: { - keyFields: false, - }, - Project: { - keyFields: false, - }, - User: { - keyFields: ["userId"], - }, - Task: { - keyFields: ["execution", "id"], - fields: { - annotation: { - merge(existing, incoming, { mergeObjects }) { - return mergeObjects(existing, incoming); - }, - }, - taskLogs: { - merge(_, incoming) { - return incoming; - }, - }, - }, - }, - Patch: { - fields: { - time: { - merge(existing, incoming, { mergeObjects }) { - return mergeObjects(existing, incoming); - }, - }, - }, - }, - }, -}); - -const authLink = (logout: () => void): ApolloLink => - onError(({ networkError }) => { - if ( - // must perform these checks so that TS does not complain bc typings for network does not include 'statusCode' - networkError && - "statusCode" in networkError && - networkError.statusCode === 401 && - window.location.pathname !== routes.login - ) { - leaveBreadcrumb( - "Not Authenticated", - { status_code: 401 }, - SentryBreadcrumb.User, - ); - logout(); - } - }); - -const logErrorsLink = onError(({ graphQLErrors, operation }) => { - if (Array.isArray(graphQLErrors)) { - graphQLErrors.forEach((gqlErr) => { - const fingerprint = [operation.operationName]; - if (gqlErr?.path?.length) { - fingerprint.push(...gqlErr.path); - } - reportError(new Error(gqlErr.message), { - fingerprint, - tags: { operationName: operation.operationName }, - context: { - gqlErr, - variables: operation.variables, - }, - }).warning(); - }); + const client = useCreateGQLCLient(); + if (!client) { + return null; } - // dont track network errors here because they are - // very common when a user is not authenticated -}); - -const authenticateIfSuccessfulLink = ( - dispatchAuthenticated: () => void, -): ApolloLink => - new ApolloLink((operation, forward) => - forward(operation).map((response) => { - if (response && response.data) { - // if there is data in response then server responded with 200; therefore, is authenticated. - dispatchAuthenticated(); - } - leaveBreadcrumb( - "Graphql Request", - { - operationName: operation.operationName, - variables: operation.variables, - status: !response.errors ? "OK" : "ERROR", - errors: response.errors, - }, - SentryBreadcrumb.HTTP, - ); - return response; - }), - ); - -const retryLink = new RetryLink({ - delay: { - initial: 300, - max: 3000, - jitter: true, - }, - attempts: { - max: 5, - retryIf: (error): boolean => - error && error.response && error.response.status >= 500, - }, -}); - -const getGQLClient = ({ - credentials, - dispatchAuthenticated, - gqlURL, - logoutAndRedirect, -}: ClientLinkParams) => { - const link = new HttpLink({ - uri: gqlURL, - credentials, - }); - - const client = new ApolloClient({ - cache, - link: authenticateIfSuccessfulLink(dispatchAuthenticated) - .concat(authLink(logoutAndRedirect)) - .concat(logErrorsLink) - .concat(retryLink) - .concat(link), - }); - - return client; + return {children}; }; -export { cache }; export default GQLWrapper; diff --git a/src/hooks/useCreateGQLClient/index.ts b/src/hooks/useCreateGQLClient/index.ts new file mode 100644 index 0000000000..36639e017e --- /dev/null +++ b/src/hooks/useCreateGQLClient/index.ts @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import { HttpLink, ApolloClient, NormalizedCacheObject } from "@apollo/client"; +import { OperationDefinitionNode } from "graphql"; +import { useAuthDispatchContext } from "context/Auth"; +import { cache } from "gql/client/cache"; +import { + authenticateIfSuccessfulLink, + authLink, + leaveBreadcrumbLink, + logErrorsLink, + retryLink, +} from "gql/client/link"; +import { SECRET_FIELDS } from "gql/queries"; +import { environmentVariables } from "utils"; +import { leaveBreadcrumb, SentryBreadcrumb } from "utils/errorReporting"; +import { fetchWithRetry } from "utils/request"; + +const { getGQLUrl } = environmentVariables; + +export const useCreateGQLCLient = (): ApolloClient => { + const { dispatchAuthenticated, logoutAndRedirect } = useAuthDispatchContext(); + const [secretFields, setSecretFields] = useState(); + const [gqlClient, setGQLClient] = useState(); + + useEffect(() => { + fetchWithRetry(getGQLUrl(), secretFieldsReq) + .then(({ data }) => { + setSecretFields(data?.spruceConfig?.secretFields); + }) + .catch((err) => { + leaveBreadcrumb( + "SecretFields Query Error", + { + err, + }, + SentryBreadcrumb.HTTP, + ); + }); + }, []); + + useEffect(() => { + if (secretFields && !gqlClient) { + const client = new ApolloClient({ + cache, + link: authenticateIfSuccessfulLink(dispatchAuthenticated) + .concat(authLink(logoutAndRedirect)) + .concat(leaveBreadcrumbLink(secretFields)) + .concat(logErrorsLink) + .concat(retryLink) + .concat( + new HttpLink({ + uri: getGQLUrl(), + credentials: "include", + }), + ), + }); + setGQLClient(client); + } + }, [secretFields, gqlClient, dispatchAuthenticated, logoutAndRedirect]); + + return gqlClient; +}; + +const secretFieldsReq = { + credentials: "include" as RequestCredentials, + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + operationName: (SECRET_FIELDS.definitions[0] as OperationDefinitionNode) + .name.value, + query: SECRET_FIELDS.loc?.source.body, + variables: {}, + }), + method: "POST", + mode: "cors" as RequestMode, +};