Files
speckle-server/packages/frontend-2/lib/core/configs/apollo.ts
T
Kristaps Fabians Geikins e4cc0cbc83 fix(workspaces): add email to existing account workflow bugfix (#2703)
* fix(fe2): properly handling invitedTeam auth error

* fix(fe2): disable invite dialog if not workspace admin

* fix(useremails): fix for default primary:true when creating new emails

* test fix
2024-08-20 15:45:34 +03:00

496 lines
15 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ApolloLink, InMemoryCache, split, from } from '@apollo/client/core'
import { setContext } from '@apollo/client/link/context'
import { SubscriptionClient } from 'subscriptions-transport-ws'
import type { ApolloConfigResolver } from '~~/lib/core/nuxt-modules/apollo/module'
import createUploadLink from 'apollo-upload-client/createUploadLink.mjs'
import { WebSocketLink } from '@apollo/client/link/ws'
import { getMainDefinition } from '@apollo/client/utilities'
import { Kind } from 'graphql'
import type { GraphQLError, OperationDefinitionNode } from 'graphql'
import type { CookieRef, NuxtApp } from '#app'
import type { Optional } from '@speckle/shared'
import { useAuthCookie } from '~~/lib/auth/composables/auth'
import {
buildAbstractCollectionMergeFunction,
buildArrayMergeFunction,
incomingOverwritesExistingMergeFunction,
mergeAsObjectsFunction
} from '~~/lib/core/helpers/apolloSetup'
import { onError } from '@apollo/client/link/error'
import { useNavigateToLogin, loginRoute } from '~~/lib/common/helpers/route'
import { useAppErrorState } from '~~/lib/core/composables/error'
import { isInvalidAuth } from '~~/lib/common/helpers/graphql'
import { isArray, isBoolean, omit } from 'lodash-es'
import { useRequestId } from '~/lib/core/composables/server'
const appName = 'frontend-2'
function createCache(): InMemoryCache {
return new InMemoryCache({
/**
* This is where you configure how various GQL fields should be read, written to or merged when new data comes in.
* If you define a merge function here, you don't need to duplicate the merge logic inside an `update()` callback
* of a fetchMore call, for example.
*
* Feel free to re-use utilities in the `apolloSetup` helper for defining merge functions or even use the ones that come from `@apollo/client/utilities`.
*
* Read more: https://www.apollographql.com/docs/react/caching/cache-field-behavior
*/
typePolicies: {
Query: {
fields: {
otherUser: {
read(original, { args, toReference }) {
if (args?.id) {
return toReference({ __typename: 'LimitedUser', id: args.id })
}
return original
}
},
activeUser: {
merge(existing, incoming, { mergeObjects }) {
return mergeObjects(existing, incoming)
},
read(original, { args, toReference }) {
if (args?.id) {
return toReference({ __typename: 'User', id: args.id })
}
return original
}
},
user: {
read(original, { args, toReference }) {
if (args?.id) {
return toReference({ __typename: 'User', id: args.id })
}
return original
}
},
stream: {
read(original, { args, toReference }) {
if (args?.id) {
return toReference({ __typename: 'Stream', id: args.id })
}
return original
}
},
userSearch: {
keyArgs: ['query', 'limit', 'archived', 'emailOnly'],
merge: buildAbstractCollectionMergeFunction('UserSearchResultCollection')
},
streams: {
keyArgs: ['query'],
merge: buildAbstractCollectionMergeFunction('StreamCollection', {
checkIdentity: true
})
},
project: {
read(original, { args, toReference }) {
if (args?.id) {
return toReference({ __typename: 'Project', id: args.id })
}
return original
}
},
projects: {
merge: buildArrayMergeFunction()
},
admin: {
merge: mergeAsObjectsFunction
},
automateFunctions: {
keyArgs: ['filter', 'limit'],
merge: buildAbstractCollectionMergeFunction('AutomateFunctionCollection')
}
}
},
AdminQueries: {
fields: {
inviteList: {
keyArgs: ['query'],
merge: buildAbstractCollectionMergeFunction('AdminInviteList')
},
projectList: {
keyArgs: ['query', 'visibility'],
merge: buildAbstractCollectionMergeFunction('ProjectCollection')
},
userList: {
keyArgs: ['query', 'role'],
merge: buildAbstractCollectionMergeFunction('AdminUserList')
}
}
},
LimitedUser: {
fields: {
commits: {
keyArgs: false,
merge: buildAbstractCollectionMergeFunction('CommitCollection')
}
}
},
User: {
fields: {
timeline: {
keyArgs: ['after', 'before'],
merge: buildAbstractCollectionMergeFunction('ActivityCollection')
},
commits: {
keyArgs: false,
merge: buildAbstractCollectionMergeFunction('CommitCollection')
},
favoriteStreams: {
keyArgs: false,
merge: buildAbstractCollectionMergeFunction('StreamCollection')
},
projects: {
keyArgs: ['filter', 'limit'],
merge: buildAbstractCollectionMergeFunction('ProjectCollection')
},
versions: {
keyArgs: ['authoredOnly', 'limit'],
merge: buildAbstractCollectionMergeFunction('CountOnlyCollection')
}
}
},
Project: {
fields: {
models: {
keyArgs: ['filter', 'limit'],
merge: buildAbstractCollectionMergeFunction('ModelCollection')
},
versions: {
keyArgs: ['filter', 'limit'],
merge: buildAbstractCollectionMergeFunction('VersionCollection')
},
commentThreads: {
keyArgs: ['filter', 'limit'],
merge: buildAbstractCollectionMergeFunction('CommentCollection')
},
modelsTree: {
keyArgs: ['filter', 'limit'],
merge: buildAbstractCollectionMergeFunction('ModelsTreeItemCollection')
},
replyAuthors: {
keyArgs: false,
merge: buildAbstractCollectionMergeFunction('CommentReplyAuthorCollection')
},
automations: {
keyArgs: ['filter', 'limit'],
merge: buildAbstractCollectionMergeFunction('AutomationCollection')
},
viewerResources: {
merge: (_existing, incoming) => [...incoming]
},
model: {
read(original, { args, toReference }) {
if (args?.id) {
return toReference({ __typename: 'Model', id: args.id })
}
return original
}
},
team: {
merge: (_existing, incoming) => incoming
},
invitedTeam: {
merge: (_existing, incoming) => incoming
},
pendingImportedModels: {
merge: (_existing, incoming) => incoming
}
}
},
Model: {
fields: {
versions: {
keyArgs: ['filter', 'limit'],
merge: buildAbstractCollectionMergeFunction('VersionCollection')
},
pendingImportedVersions: {
merge: (_existing, incoming) => incoming
}
}
},
Comment: {
fields: {
replies: {
keyArgs: ['limit']
}
}
},
Stream: {
fields: {
activity: {
keyArgs: ['after', 'before', 'actionType'],
merge: buildAbstractCollectionMergeFunction('ActivityCollection')
},
commits: {
keyArgs: false,
merge: buildAbstractCollectionMergeFunction('CommitCollection', {
checkIdentity: true
})
},
pendingCollaborators: {
merge: incomingOverwritesExistingMergeFunction
},
pendingAccessRequests: {
merge: incomingOverwritesExistingMergeFunction
}
}
},
Branch: {
fields: {
commits: {
keyArgs: false,
merge: buildAbstractCollectionMergeFunction('CommitCollection', {
checkIdentity: true
})
}
}
},
BranchCollection: {
merge: true
},
ServerStats: {
merge: true
},
WebhookEventCollection: {
merge: true
},
ServerInfo: {
merge: true
},
CommentThreadActivityMessage: {
merge: true
},
AutomateFunction: {
fields: {
releases: {
keyArgs: ['filter', 'limit'],
merge: buildAbstractCollectionMergeFunction(
'AutomateFunctionReleaseCollection'
)
}
}
},
Automation: {
fields: {
runs: {
keyArgs: ['limit'],
merge: buildAbstractCollectionMergeFunction('AutomateRunCollection')
}
}
},
Workspace: {
fields: {
invitedTeam: {
merge: (_existing, incoming) => incoming
},
team: {
merge: (_existing, incoming) => incoming
},
projects: {
keyArgs: ['filter', 'limit'],
merge: buildAbstractCollectionMergeFunction('ProjectCollection')
}
}
}
}
})
}
function createWsClient(params: {
wsEndpoint: string
authToken: CookieRef<Optional<string>>
reqId: string
}): SubscriptionClient {
const { wsEndpoint, authToken, reqId } = params
// WS IN SSR DOESN'T WORK CURRENTLY CAUSE OF SOME NUXT TRANSPILATION WEIRDNESS
// SO DON'T RUN createWsClient in SSR
// const wsImplementation = process.server ? (await import('ws')).default : undefined
return new SubscriptionClient(
wsEndpoint,
{
reconnect: true,
connectionParams: () => {
const Authorization = authToken.value ? `Bearer ${authToken.value}` : null
return Authorization
? { Authorization, headers: { Authorization, 'x-request-id': reqId } }
: {}
}
}
// wsImplementation
)
}
function createLink(params: {
httpEndpoint: string
wsClient?: SubscriptionClient
authToken: CookieRef<Optional<string>>
nuxtApp: NuxtApp
reqId: string
}): ApolloLink {
const { httpEndpoint, wsClient, authToken, nuxtApp, reqId } = params
const goToLogin = useNavigateToLogin()
const { registerError, isErrorState } = useAppErrorState()
const errorLink = onError((res) => {
const logger = nuxtApp.$logger
const isSubTokenMissingError = (res.networkError?.message || '').includes(
'need a token to subscribe'
)
const skipLoggingErrors = res.operation.getContext().skipLoggingErrors
const shouldSkip = isBoolean(skipLoggingErrors)
? skipLoggingErrors
: skipLoggingErrors?.(res)
if (!isSubTokenMissingError && !shouldSkip) {
const gqlErrors: Array<GraphQLError> = isArray(res.graphQLErrors)
? res.graphQLErrors
: []
const errMsg = res.networkError?.message || gqlErrors[0]?.message
logger.error(
{
...omit(res, ['forward', 'response']),
networkErrorMessage: res.networkError?.message,
gqlErrorMessages: gqlErrors.map((e) => e.message),
errorMessage: errMsg,
graphql: true
},
'Apollo Client error: {errorMessage}'
)
}
const { networkError } = res
if (networkError && isInvalidAuth(networkError)) {
// Reset auth
authToken.value = undefined
// A bit hacky, but since this may happen mid-routing, a standard router.push call may not work
if (import.meta.client) {
window.location.href = loginRoute
} else {
goToLogin()
}
}
registerError()
})
// Prepare links
const httpLink = createUploadLink({
uri: httpEndpoint
})
const authLink = setContext((_, ctx) => {
const { headers } = ctx
const authHeader = authToken.value
? { Authorization: `Bearer ${authToken.value}` }
: {}
return {
headers: {
...headers,
...authHeader,
'x-request-id': reqId
}
}
})
let link = authLink.concat(httpLink)
if (wsClient) {
const wsLink = new WebSocketLink(wsClient)
link = split(
({ query }) => {
const definition = getMainDefinition(query) as OperationDefinitionNode
const { kind, operation } = definition
return kind === Kind.OPERATION_DEFINITION && operation === 'subscription'
},
wsLink,
link
)
// Stopping WS when in error state
wsClient.use([
{
applyMiddleware: (_opt, next) => {
if (isErrorState.value) {
return // never invokes next() - essentially stuck
}
next()
}
}
])
}
// SSR req logging link
const loggerLink = new ApolloLink((operation, forward) => {
const startTime = Date.now()
const name = operation.operationName
nuxtApp.$logger.debug(
{ operation: name, graphql: true },
`Apollo operation {operation} started...`
)
return forward(operation).map((result) => {
const elapsed = new Date().getTime() - startTime
const success = !!(result.data && !result.errors?.length)
nuxtApp.$logger.info(
{
operation: name,
elapsed,
success,
graphql: true
},
`Apollo operation {operation} finished in {elapsed}ms`
)
return result
})
})
return from([...(import.meta.server ? [loggerLink] : []), errorLink, link])
}
const defaultConfigResolver: ApolloConfigResolver = () => {
const {
public: { speckleServerVersion = 'unknown' }
} = useRuntimeConfig()
const apiOrigin = useApiOrigin()
const nuxtApp = useNuxtApp()
const reqId = useRequestId()
const httpEndpoint = `${apiOrigin}/graphql`
const wsEndpoint = httpEndpoint.replace('http', 'ws')
const authToken = useAuthCookie()
const wsClient = import.meta.client
? createWsClient({ wsEndpoint, authToken, reqId })
: undefined
const link = createLink({ httpEndpoint, wsClient, authToken, nuxtApp, reqId })
return {
// If we don't markRaw the cache, sometimes we get cryptic internal Apollo Client errors that essentially
// result from parts of its internals being made reactive, even tho they shouldn't be
cache: markRaw(createCache()),
link,
name: appName,
version: speckleServerVersion
}
}
export default defaultConfigResolver