Files
speckle-server/packages/frontend-2/lib/common/helpers/graphql.ts
T
Kristaps Fabians Geikins 83d8035dc2 chore: upgrade to eslint 9 (#2348)
* root + server

* frontend

* frontend-2

* dui3

* dui3

* tailwind theme

* ui-components

* preview service

* viewer

* viewer-sandbox

* fileimport-service

* webhook service

* objectloader

* shared

* ui-components-nuxt

* WIP full config

* WIP full linter

* eslint projectwide util

* minor fix

* removing redundant ci

* clean up test errors

* fixed prettier formatting

* CI improvements

* TSC lint fix

* 'buildBatch' needs to be async since some batch types (like Text) require it. Removed a disabled liniting rule from ObjLoader

* removed unnecessary void

---------

Co-authored-by: AlexandruPopovici <alexandrupopoviciioan@gmail.com>
2024-06-12 14:38:02 +03:00

476 lines
14 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { isUndefinedOrVoid } from '@speckle/shared'
import type { Optional } from '@speckle/shared'
import { ApolloError, defaultDataIdFromObject } from '@apollo/client/core'
import type {
FetchResult,
DataProxy,
TypedDocumentNode,
ServerError,
ServerParseError,
ApolloCache
} from '@apollo/client/core'
import { GraphQLError } from 'graphql'
import type { DocumentNode } from 'graphql'
import { flatten, isUndefined, has, isFunction, isString } from 'lodash-es'
import type { Modifier, Reference } from '@apollo/client/cache'
import type { PartialDeep } from 'type-fest'
import type { GraphQLErrors, NetworkError } from '@apollo/client/errors'
import { nanoid } from 'nanoid'
import { StackTrace } from '~~/lib/common/helpers/debugging'
import dayjs from 'dayjs'
import { base64Encode } from '~/lib/common/helpers/encodeDecode'
export const isServerError = (err: Error): err is ServerError =>
has(err, 'response') && has(err, 'result') && has(err, 'statusCode')
export const isServerParseError = (err: Error): err is ServerParseError =>
has(err, 'response') && has(err, 'bodyText') && has(err, 'statusCode')
export const ROOT_QUERY = 'ROOT_QUERY'
export const ROOT_MUTATION = 'ROOT_MUTATION'
export const ROOT_SUBSCRIPTION = 'ROOT_SUBSCRIPTION'
/**
* Utility type for typing cached data in Apollo modify functions.
* Essentially inside a modify function all references to cached objects that can be uniquely identified (have an ID field) are converted
* to CacheObjectReference objects and additionally all properties are optional as you can never know what exactly has been requested & cached
* and what hasn't
*/
export type ModifyFnCacheData<Data> = Data extends
| Record<string, unknown>
| Record<string, unknown>[]
? Data extends { id: string }
? CacheObjectReference
: Data extends { id: string }[]
? CacheObjectReference[]
: PartialDeep<{
[key in keyof Data]: Data[key] extends { id: string }
? CacheObjectReference
: Data[key] extends { id: string }[]
? CacheObjectReference[]
: ModifyFnCacheData<Data[key]>
}>
: Data
/**
* Get a cached object's identifier
*/
export function getCacheId(typeName: string, id: string) {
const cachedId = defaultDataIdFromObject({
__typename: typeName,
id
})
if (!cachedId) throw new Error('Unable to build Apollo cache ID')
return cachedId
}
export function isInvalidAuth(error: ApolloError | NetworkError) {
const networkError = error instanceof ApolloError ? error.networkError : error
if (
!networkError ||
(!isServerError(networkError) && !isServerParseError(networkError))
)
return false
const statusCode = networkError.statusCode
const hasCorrectCode = [403].includes(statusCode)
if (!hasCorrectCode) return false
const message: string | undefined = isServerError(networkError)
? isString(networkError.result)
? networkError.result
: networkError.result?.error
: networkError.bodyText
return (message || '').toLowerCase().includes('token')
}
/**
* Convert an error thrown during $apollo.mutate() into a fetch result
*/
export function convertThrowIntoFetchResult(
err: unknown
): FetchResult<undefined> & { apolloError?: ApolloError; isInvalidAuth: boolean } {
let gqlErrors: readonly GraphQLError[]
let apolloError: Optional<ApolloError> = undefined
if (err instanceof ApolloError) {
gqlErrors = err.graphQLErrors
apolloError = err
} else if (err instanceof Error) {
gqlErrors = [new GraphQLError(err.message)]
} else {
gqlErrors = [new GraphQLError(`${err}`)]
}
const hasAuthIssue = apolloError && isInvalidAuth(apolloError)
return {
data: undefined,
errors: gqlErrors,
apolloError,
isInvalidAuth: !!hasAuthIssue
}
}
/**
* Get first error message from a GQL errors array
*/
export function getFirstErrorMessage(
errs: readonly GraphQLError[] | undefined | null,
fallbackMessage = 'An unexpected issue occurred'
): string {
return errs?.[0]?.message || fallbackMessage
}
/**
* Find some cached Apollo data through a fragment/query and use the updater function
* to return the replacement for the data that the fragment initially found
* @returns Whether an update was made
*/
export function updateCacheByFilter<TData, TVariables = unknown>(
cache: ApolloCache<unknown>,
filter: {
fragment?: DataProxy.Fragment<TVariables, TData>
query?: DataProxy.Query<TVariables, TData>
},
/**
* If returns undefined/void, then updating is essentially canceled. Be careful not to
* mutate anything being passed into this function! E.g. if you want to mutate arrays,
* create new arrays through slice()/filter() instead
*/
updater: (data: TData) => TData | undefined,
options: Partial<{
/**
* Whether to suppress errors that occur when the fragment being queried
* doesn't find anything
* Default: true
*/
ignoreCacheErrors: boolean
/**
* Whether to overwrite the old cache results, instead of triggering a merge function
* to merge the old and new results.
* Default: true
*/
overwrite: boolean
}> = {}
): boolean {
const { fragment, query } = filter
const { ignoreCacheErrors = true, overwrite = true } = options
const logger = useLogger()
if (!fragment && !query) {
throw new Error(
'Either fragment or query must be specified to be able to find cached data to update'
)
}
const readData = (): TData | null => {
if (fragment) {
return cache.readFragment(fragment)
} else if (query) {
return cache.readQuery(query)
} else {
return null
}
}
const writeData = (data: TData): boolean => {
if (fragment) {
cache.writeFragment({ ...fragment, data, overwrite })
return true
} else if (query) {
cache.writeQuery({ ...query, data, overwrite })
return true
} else {
return false
}
}
try {
const currentData = readData()
if (!currentData) return false
const newData = updater(currentData)
if (isUndefinedOrVoid(newData)) return false
return writeData(newData)
} catch (e: unknown) {
if (e instanceof Error) {
// Invalid fragment - throw always
if (e.message.toLowerCase().includes('no fragment named')) {
throw e
}
}
if (ignoreCacheErrors) {
logger.warn('Failed Apollo cache update:', e)
return false
}
throw e
}
}
/**
* A graphql-codegen generated fragment doesn't include the definition of other fragments in it,
* so if you ever need a document that contains a fragment as well as other fragments that it uses
* you can use this helper
*
* TODO: Figure out if we can turn off dedupeFragments to get fragments to contain all dependency
* fragments as well. Previously this caused an error when duplicate fragments were sent to the API
* through a query/mutation.
*/
export function addFragmentDependencies<R = unknown, V = unknown>(
fragment: TypedDocumentNode<R, V>,
...fragmentDependencies: DocumentNode[]
) {
return {
kind: 'Document',
definitions: [
...fragment.definitions.filter((d) => d.kind === 'FragmentDefinition'),
...flatten(
fragmentDependencies.map((f) =>
f.definitions.filter((d) => d.kind === 'FragmentDefinition')
)
)
]
} as TypedDocumentNode<R, V>
}
/**
* Resolve the string key of a field in the apollo cache. Is useful in cache.modify() calls.
*/
export function getStoreFieldName(
fieldName: string,
variables?: Record<string, unknown>
) {
return (
fieldName +
(Object.values(variables || {}).length ? `:${JSON.stringify(variables)}` : '')
)
}
/**
* Inside cache.modify calls you'll get these instead of full objects when reading fields that hold
* identifiable objects or object arrays
*/
export type CacheObjectReference = Reference
/**
* Objects & object arrays in `cache.modify` calls are represented through reference objects, so
* if you want to add new ones you shouldn't add the entire object, but only its reference
*/
export function getObjectReference(typeName: string, id: string): CacheObjectReference {
return {
__ref: getCacheId(typeName, id)
}
}
export function isReference(obj: unknown): obj is CacheObjectReference {
return has(obj, '__ref')
}
/**
* Resolve the field name and variables from an Apollo store field name which
* is usually a string like "fieldName:{"var1":"val1","var2":"val2"}"
* @param storeFieldName
* @param fieldName
*/
const revolveFieldNameAndVariables = <
V extends Optional<Record<string, unknown>> = undefined
>(
storeFieldName: string,
fieldName?: string
) => {
let variables: Optional<V> = undefined
if (!fieldName) {
fieldName = /^[a-zA-Z0-9_-]+(?=[:(])/.exec(storeFieldName)?.[0]
}
if (!fieldName?.length) return { fieldName: storeFieldName, variables }
const variablesStringbase = storeFieldName.substring(fieldName.length)
if (variablesStringbase.startsWith(':')) {
variables = JSON.parse(variablesStringbase.substring(1)) as V
} else if (variablesStringbase.startsWith('(')) {
variables = JSON.parse(
variablesStringbase.substring(1, variablesStringbase.length - 1)
) as V
}
return {
fieldName,
variables
}
}
/**
* Iterate over a cached object's fields and optionally update them. Similar to cache.modify, except allows
* better filtering capabilities to filter filters to update (e.g. you can actually get each field's variables)
* Note: This uses cache.modify underneath which means that `data` will only hold object references (CacheObjectReference) not
* full objects. Read more: https://www.apollographql.com/docs/react/caching/cache-interaction/#values-vs-references
*/
export function modifyObjectFields<
V extends Optional<Record<string, unknown>> = undefined,
D = unknown
>(
cache: ApolloCache<unknown>,
id: string,
updater: (
fieldName: string,
variables: V,
value: ModifyFnCacheData<D>,
details: Parameters<Modifier<ModifyFnCacheData<D>>>[1] & {
ref: typeof getObjectReference
revolveFieldNameAndVariables: typeof revolveFieldNameAndVariables
}
) =>
| Optional<ModifyFnCacheData<D>>
| Parameters<Modifier<ModifyFnCacheData<D>>>[1]['DELETE']
| Parameters<Modifier<ModifyFnCacheData<D>>>[1]['INVALIDATE'],
options?: Partial<{
fieldNameWhitelist: string[]
debug: boolean
}>
) {
const { fieldNameWhitelist, debug = !!(import.meta.dev && import.meta.client) } =
options || {}
const logger = useLogger()
const invocationId = nanoid()
const log = (...args: Parameters<typeof logger.debug>) => {
if (!debug) return
const [message, ...rest] = args
logger.debug(`[${invocationId}] ${message}`, ...rest)
}
log(
'modifyObjectFields invoked',
{
id,
fieldNameWhitelist
},
new StackTrace()
)
cache.modify({
id,
fields(fieldValue, details) {
const { storeFieldName, fieldName } = details
if (fieldNameWhitelist?.length && !fieldNameWhitelist.includes(fieldName)) {
return fieldValue as unknown
}
const { variables } = revolveFieldNameAndVariables<V>(storeFieldName, fieldName)
log('invoking updater', { fieldName, variables, fieldValue })
const res = updater(
fieldName,
(variables || {}) as V,
fieldValue as ModifyFnCacheData<D>,
{
...details,
ref: getObjectReference,
revolveFieldNameAndVariables
}
)
if (isUndefined(res)) {
return fieldValue as unknown
} else {
log('updater returned', { res })
return res
}
}
})
}
/**
* Iterate over a cached object's fields and evict/delete the ones that the predicate returns true for
* Note: This uses cache.modify underneath which means that `data` will only hold object references (CacheObjectReference) not
* full objects. Read more: https://www.apollographql.com/docs/react/caching/cache-interaction/#values-vs-references
*/
export function evictObjectFields<
V extends Optional<Record<string, unknown>> = undefined,
D = unknown
>(
cache: ApolloCache<unknown>,
id: string,
predicate:
| ((
fieldName: string,
variables: V,
value: ModifyFnCacheData<D>,
details: Parameters<Modifier<ModifyFnCacheData<D>>>[1] & {
revolveFieldNameAndVariables: typeof revolveFieldNameAndVariables
}
) => boolean)
| string[]
) {
modifyObjectFields<V, D>(
cache,
id,
(fieldName, variables, value, details) => {
if (isFunction(predicate)) {
if (
!predicate(fieldName, variables, value, {
...details,
revolveFieldNameAndVariables
})
)
return undefined
} else {
const predicateFields = predicate
if (!predicateFields.includes(fieldName)) return undefined
}
return details.DELETE as ModifyFnCacheData<D>
},
{ debug: false }
)
}
export const resolveGenericStatusCode = (errors: GraphQLErrors) => {
if (errors.some((e) => e.extensions?.code === 'FORBIDDEN')) return 403
if (
errors.some((e) =>
['UNAUTHENTICATED', 'UNAUTHORIZED_ACCESS_ERROR'].includes(
e.extensions?.code || ''
)
)
)
return 401
if (
errors.some((e) =>
['NOT_FOUND_ERROR', 'STREAM_NOT_FOUND', 'AUTOMATION_NOT_FOUND'].includes(
e.extensions?.code || ''
)
)
)
return 404
return 500
}
export const errorFailedAtPathSegment = (error: GraphQLError, segment: string) => {
const path = error.path || []
return path[path.length - 1] === segment
}
export const getDateCursorFromReference = (params: {
ref: Reference
dateProp: string
readField: (fieldName: string, ref: Reference) => unknown
}): string | null => {
const dateStr = params.readField(params.dateProp, params.ref) as string
if (!dateStr || !isString(dateStr)) return null
const date = dayjs(dateStr)
if (!date.isValid()) return null
const iso = date.toISOString()
return base64Encode(iso)
}