55f91d2cdf
* fix(workspace): auto approval * fix(scopes): access scopes across the server * fix(hasAccessRole): establish for all mutations * feat(token): scoping does not require the token to exist * chore(scopes): added additional roles * fix: replaced UNAUTHORIZED_ACCESS_ERROR with UNAUTHORIZED * fix(email): user list scopes
968 lines
29 KiB
TypeScript
968 lines
29 KiB
TypeScript
import { isNullOrUndefined, isUndefinedOrVoid } from '@speckle/shared'
|
|
import type { MaybeNullOrUndefined, Optional } from '@speckle/shared'
|
|
import { ApolloError, defaultDataIdFromObject } from '@apollo/client/core'
|
|
import type {
|
|
FetchResult,
|
|
DataProxy,
|
|
TypedDocumentNode,
|
|
ServerError,
|
|
ServerParseError,
|
|
ApolloCache,
|
|
Unmasked
|
|
} from '@apollo/client/core'
|
|
import { GraphQLError } from 'graphql'
|
|
import type { DocumentNode, GraphQLFormattedError } from 'graphql'
|
|
import {
|
|
flatten,
|
|
has,
|
|
isFunction,
|
|
isString,
|
|
isArray,
|
|
intersection,
|
|
get,
|
|
set,
|
|
cloneDeep,
|
|
isObjectLike
|
|
} from 'lodash-es'
|
|
import type { Modifier, ModifierDetails, Reference } from '@apollo/client/cache'
|
|
import type { Get, PartialDeep, Paths, ReadonlyDeep, Tagged } from 'type-fest'
|
|
import type { 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'
|
|
import type { ErrorResponse } from '@apollo/client/link/error'
|
|
import type {
|
|
AllObjectFieldArgTypes,
|
|
AllObjectTypes,
|
|
PermissionCheckResult
|
|
} from '~/lib/common/generated/gql/graphql'
|
|
|
|
/**
|
|
* Cache key of a specific cached GQL object. Tends to look like `Type:id`.
|
|
*/
|
|
export type ApolloCacheObjectKey<Type extends keyof AllObjectTypes> = Tagged<
|
|
string,
|
|
Type
|
|
>
|
|
|
|
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' as ApolloCacheObjectKey<'Query'>
|
|
export const ROOT_MUTATION = 'ROOT_MUTATION' as ApolloCacheObjectKey<'Mutation'>
|
|
export const ROOT_SUBSCRIPTION =
|
|
'ROOT_SUBSCRIPTION' as ApolloCacheObjectKey<'Subscription'>
|
|
|
|
type ModifyFnCacheDataSingle<Data> = Data extends Record<string, unknown>
|
|
? Data extends { id: string; __typename?: infer TypeName }
|
|
? CacheObjectReference<TypeName extends keyof AllObjectTypes ? TypeName : string>
|
|
: PartialDeep<{
|
|
[key in keyof Data]: ModifyFnCacheData<Data[key]>
|
|
}>
|
|
: Data
|
|
|
|
/**
|
|
* 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 Array<infer ArrayItem>
|
|
? ModifyFnCacheDataSingle<ArrayItem>[]
|
|
: ModifyFnCacheDataSingle<Data>
|
|
|
|
/**
|
|
* Get a cached object's identifier
|
|
*/
|
|
export function getCacheId<Type extends keyof AllObjectTypes>(
|
|
typeName: Type,
|
|
id: string
|
|
): ApolloCacheObjectKey<Type> {
|
|
const cachedId = defaultDataIdFromObject({
|
|
__typename: typeName,
|
|
id
|
|
})
|
|
if (!cachedId) throw new Error('Unable to build Apollo cache ID')
|
|
|
|
return cachedId as ApolloCacheObjectKey<Type>
|
|
}
|
|
|
|
export const getCacheKey = getCacheId
|
|
|
|
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 GraphQLFormattedError[]
|
|
let apolloError: Optional<ApolloError> = undefined
|
|
if (err instanceof ApolloError) {
|
|
gqlErrors = err.graphQLErrors
|
|
apolloError = err
|
|
|
|
if (!gqlErrors.length) {
|
|
if (
|
|
err.networkError &&
|
|
'result' in err.networkError &&
|
|
!isString(err.networkError.result) &&
|
|
isArray(err.networkError.result.errors)
|
|
) {
|
|
const errors = err.networkError.result.errors as Array<{ message: string }>
|
|
gqlErrors = errors.map((e) => new GraphQLError(e.message))
|
|
}
|
|
}
|
|
} 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[] | readonly GraphQLFormattedError[] | 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: Unmasked<TData>) => Unmasked<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 } = useSafeLogger()
|
|
|
|
if (!fragment && !query) {
|
|
throw new Error(
|
|
'Either fragment or query must be specified to be able to find cached data to update'
|
|
)
|
|
}
|
|
|
|
const readData = (): Unmasked<TData> | null => {
|
|
if (fragment) {
|
|
return cache.readFragment(fragment)
|
|
} else if (query) {
|
|
return cache.readQuery(query)
|
|
} else {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const writeData = (data: Unmasked<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<Type extends string = string> = {
|
|
readonly __ref: Type extends keyof AllObjectTypes
|
|
? ApolloCacheObjectKey<Type>
|
|
: string
|
|
}
|
|
|
|
/**
|
|
* 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<Type extends keyof AllObjectTypes>(
|
|
typeName: Type,
|
|
id: string
|
|
): CacheObjectReference<Type> {
|
|
return {
|
|
__ref: getCacheId(typeName, id)
|
|
} as CacheObjectReference<Type>
|
|
}
|
|
|
|
export const keyToRef = <Type extends keyof AllObjectTypes>(
|
|
key: ApolloCacheObjectKey<Type>
|
|
): CacheObjectReference<Type> => {
|
|
return {
|
|
__ref: key
|
|
} as CacheObjectReference<Type>
|
|
}
|
|
|
|
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
|
|
*/
|
|
export 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
|
|
* @deprecated Use modifyObjectField instead
|
|
*/
|
|
export function modifyObjectFields<
|
|
Variables extends Optional<Record<string, unknown>> = undefined,
|
|
FieldData = unknown
|
|
>(
|
|
cache: ApolloCache<unknown>,
|
|
id: string,
|
|
updater: (
|
|
fieldName: string,
|
|
variables: Variables,
|
|
value: ModifyFnCacheData<FieldData>,
|
|
details: Parameters<Modifier<ModifyFnCacheData<FieldData>>>[1] & {
|
|
ref: typeof getObjectReference
|
|
revolveFieldNameAndVariables: typeof revolveFieldNameAndVariables
|
|
}
|
|
) =>
|
|
| Optional<ModifyFnCacheData<FieldData>>
|
|
| Parameters<Modifier<ModifyFnCacheData<FieldData>>>[1]['DELETE']
|
|
| Parameters<Modifier<ModifyFnCacheData<FieldData>>>[1]['INVALIDATE']
|
|
| void,
|
|
options?: Partial<{
|
|
fieldNameWhitelist: string[]
|
|
debug: boolean
|
|
}>
|
|
) {
|
|
const { fieldNameWhitelist, debug = false } = options || {}
|
|
|
|
const { logger } = useSafeLogger()
|
|
const invocationId = nanoid()
|
|
const log = (...args: unknown[]) => {
|
|
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<Variables>(
|
|
storeFieldName,
|
|
fieldName
|
|
)
|
|
|
|
log('invoking updater', { fieldName, variables, fieldValue })
|
|
try {
|
|
const res = updater(
|
|
fieldName,
|
|
(variables || {}) as Variables,
|
|
fieldValue as ModifyFnCacheData<FieldData>,
|
|
{
|
|
...details,
|
|
ref: getObjectReference,
|
|
revolveFieldNameAndVariables
|
|
}
|
|
)
|
|
|
|
if (isUndefinedOrVoid(res)) {
|
|
return fieldValue as unknown
|
|
} else {
|
|
log('updater returned', { res })
|
|
return res
|
|
}
|
|
} catch (e) {
|
|
log('updater threw an error', e)
|
|
throw e
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* @deprecated Use modifyObjectField instead, just return the evict() call from the updater
|
|
*/
|
|
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: readonly GraphQLError[] | readonly GraphQLFormattedError[]
|
|
) => {
|
|
if (errors.some((e) => e.extensions?.code === 'FORBIDDEN')) return 403
|
|
if (
|
|
errors.some((e) =>
|
|
['UNAUTHENTICATED', 'UNAUTHORIZED', 'UNAUTHORIZED_ACCESS_ERROR'].includes(
|
|
(e.extensions?.code || '') as string
|
|
)
|
|
)
|
|
)
|
|
return 401
|
|
if (
|
|
errors.some((e) =>
|
|
['NOT_FOUND_ERROR', 'STREAM_NOT_FOUND', 'AUTOMATION_NOT_FOUND'].includes(
|
|
(e.extensions?.code || '') as string
|
|
)
|
|
)
|
|
)
|
|
return 404
|
|
|
|
return 500
|
|
}
|
|
|
|
export const errorFailedAtPathSegment = (
|
|
error: GraphQLError | GraphQLFormattedError,
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* Build skipLoggingErrors function that skips logging errors if there's only one error and it's related to a specific field
|
|
*/
|
|
export const skipLoggingErrorsIfOneFieldError =
|
|
(fieldName: string | string[]) =>
|
|
(err: ErrorResponse): boolean => {
|
|
const fieldNames = isArray(fieldName) ? fieldName : [fieldName]
|
|
return (
|
|
err.graphQLErrors?.length === 1 &&
|
|
err.graphQLErrors.some((e) => intersection(e.path || [], fieldNames).length > 0)
|
|
)
|
|
}
|
|
|
|
type NonUndefined<T> = T extends undefined ? never : T
|
|
|
|
/**
|
|
* Update field at specific path in object, only if it exists. Useful for cache modification
|
|
* when fields should only be updated if they exist.
|
|
*/
|
|
export const updatePathIfExists = <Value, Path extends Paths<Value> & string>(
|
|
val: Value,
|
|
path: Path,
|
|
updater: (val: NonUndefined<Get<Value, Path>>) => NonUndefined<Get<Value, Path>>
|
|
) => {
|
|
if (!val) return val
|
|
|
|
if (has(val, path)) {
|
|
const pathVal = get(val, path) as NonUndefined<Get<Value, Path>>
|
|
const newVal = updater(pathVal)
|
|
set(val, path, newVal)
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
/**
|
|
* Get value from specific path in object, only if it exists.
|
|
*/
|
|
export const getFromPathIfExists = <Value, Path extends Paths<Value> & string>(
|
|
val: MaybeNullOrUndefined<Value>,
|
|
path: Path
|
|
): Optional<Get<Value, Path>> => {
|
|
if (!val) return undefined
|
|
if (!has(val, path)) return undefined
|
|
return get(val, path) as Get<Value, Path>
|
|
}
|
|
|
|
type ModifyObjectFieldValue<
|
|
Type extends keyof AllObjectTypes,
|
|
Field extends keyof AllObjectTypes[Type]
|
|
> = ModifyFnCacheData<AllObjectTypes[Type][Field]>
|
|
|
|
/**
|
|
* Get keys of all cached objects by type
|
|
*/
|
|
export const getCachedObjectKeys = <Type extends keyof AllObjectTypes>(
|
|
cache: ApolloCache<unknown>,
|
|
type: Type
|
|
): ApolloCacheObjectKey<Type>[] => {
|
|
const data = cache.extract() as Record<string, unknown>
|
|
const objectIds = Object.keys(data).filter((k) => k.startsWith(`${type}:`))
|
|
return objectIds as ApolloCacheObjectKey<Type>[]
|
|
}
|
|
|
|
/**
|
|
* Simplified & improved version of modifyObjectFields, just targetting a single field for a cache modification
|
|
* @see modifyObjectFields
|
|
*/
|
|
export const modifyObjectField = <
|
|
Type extends keyof AllObjectTypes,
|
|
Field extends keyof AllObjectTypes[Type]
|
|
>(
|
|
cache: ApolloCache<unknown>,
|
|
key: ApolloCacheObjectKey<Type>,
|
|
fieldName: Field,
|
|
updater: (params: {
|
|
fieldName: string
|
|
variables: Field extends keyof AllObjectFieldArgTypes[Type]
|
|
? AllObjectFieldArgTypes[Type][Field]
|
|
: never
|
|
/**
|
|
* Value found in the cache. Read-only and should not be mutated directly. Use the
|
|
* createUpdatedValue() helper to build a new value with updated fields.
|
|
*/
|
|
value: ReadonlyDeep<ModifyObjectFieldValue<Type, Field>>
|
|
helpers: {
|
|
/**
|
|
* Build new value with the values at specific paths updated with the provided updater functions,
|
|
* ONLY if the paths exist in the cache.
|
|
*
|
|
* This function operates on a deeply cloned value that is safe to mutate
|
|
*/
|
|
createUpdatedValue: (
|
|
updateHandler: (params: {
|
|
/**
|
|
* Invoke this function to update one specific path in the object
|
|
*/
|
|
update: <Path extends Paths<ModifyObjectFieldValue<Type, Field>> & string>(
|
|
path: Path,
|
|
pathUpdate: (
|
|
val: NonUndefined<Get<ModifyObjectFieldValue<Type, Field>, Path>>
|
|
) => NonUndefined<Get<ModifyObjectFieldValue<Type, Field>, Path>>
|
|
) => MaybeNullOrUndefined<ModifyObjectFieldValue<Type, Field>>
|
|
}) => void
|
|
) => ModifyObjectFieldValue<Type, Field>
|
|
/**
|
|
* Get value from specific path, only if it exists in the cache value
|
|
*/
|
|
get: <Path extends Paths<ModifyObjectFieldValue<Type, Field>> & string>(
|
|
path: Path
|
|
) => Optional<Get<ModifyObjectFieldValue<Type, Field>, Path>>
|
|
/**
|
|
* Invoke and return this out from the modify call to evict the field from the cache
|
|
*/
|
|
evict: () => ModifierDetails['DELETE']
|
|
/**
|
|
* Read field data from a Reference object
|
|
*/
|
|
readField: <
|
|
ReadFieldType extends keyof AllObjectTypes,
|
|
ReadFieldName extends keyof AllObjectTypes[ReadFieldType] & string
|
|
>(
|
|
ref: CacheObjectReference<ReadFieldType>,
|
|
fieldName: ReadFieldName
|
|
) => Optional<ModifyFnCacheData<AllObjectTypes[ReadFieldType][ReadFieldName]>>
|
|
/**
|
|
* Get the object we're modifying as a readable object
|
|
*/
|
|
readObject: () => Partial<{
|
|
[prop in keyof AllObjectTypes[Type]]: ModifyFnCacheData<
|
|
AllObjectTypes[Type][prop]
|
|
>
|
|
}>
|
|
/**
|
|
* Build a reference object for a specific object in the cache
|
|
*/
|
|
ref: typeof getObjectReference
|
|
/**
|
|
* Build a reference object from a key
|
|
*/
|
|
keyToRef: typeof keyToRef
|
|
/**
|
|
* Parse a reference object to get its type and id separately
|
|
*/
|
|
fromRef: typeof parseObjectReference
|
|
}
|
|
}) =>
|
|
| Optional<ModifyObjectFieldValue<Type, Field>>
|
|
| ReadonlyDeep<ModifyObjectFieldValue<Type, Field>>
|
|
| ModifierDetails['DELETE']
|
|
| ModifierDetails['INVALIDATE']
|
|
| void,
|
|
options?: Partial<{
|
|
debug: boolean
|
|
/**
|
|
* Whether to auto evict values that have variables with common filters in them (e.g. a 'filter' or
|
|
* 'search' prop). Often its better to evict filtered values, because we can't tell if the newly
|
|
* added item should be included in the filtered list or not.
|
|
*
|
|
* If string array passed in, these extra filter keys will be checked to see if we need to evict
|
|
*/
|
|
autoEvictFiltered: boolean | string[]
|
|
}>
|
|
) => {
|
|
const { autoEvictFiltered } = options || {}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
modifyObjectFields<any, any>(
|
|
cache,
|
|
key,
|
|
(field, variables, value, details) => {
|
|
if (field !== fieldName) return
|
|
|
|
// Auto evict filtered values?
|
|
if (autoEvictFiltered && isObjectLike(variables)) {
|
|
const checkFilter = (filter: string) => {
|
|
if (!has(variables, filter)) return false
|
|
const val = get(variables, filter)
|
|
|
|
// True, if any primitive value (e.g. string, number) && arrays
|
|
if (isNullOrUndefined(val)) return false
|
|
if (isArray(val)) return true
|
|
if (!isObjectLike(val)) return true
|
|
return false
|
|
}
|
|
|
|
const commonFilters = [
|
|
'query',
|
|
'filter',
|
|
'search',
|
|
'filter.search',
|
|
'input.search',
|
|
...(isArray(autoEvictFiltered) ? autoEvictFiltered : [])
|
|
]
|
|
const hasFilter = commonFilters.some(checkFilter)
|
|
|
|
if (hasFilter) {
|
|
return details.DELETE
|
|
}
|
|
}
|
|
|
|
// Build helpers & clone value to allow for direct mutation
|
|
const createUpdatedValue = (
|
|
updateHandler: (params: {
|
|
update: <Path extends Paths<ModifyObjectFieldValue<Type, Field>> & string>(
|
|
path: Path,
|
|
pathUpdate: (
|
|
val: NonUndefined<Get<ModifyObjectFieldValue<Type, Field>, Path>>
|
|
) => NonUndefined<Get<ModifyObjectFieldValue<Type, Field>, Path>>
|
|
) => MaybeNullOrUndefined<ModifyObjectFieldValue<Type, Field>>
|
|
}) => void
|
|
) => {
|
|
let clonedValue = cloneDeep(value) as ModifyObjectFieldValue<Type, Field>
|
|
updateHandler({
|
|
update: (path, pathUpdate) => {
|
|
clonedValue = updatePathIfExists(clonedValue, path, pathUpdate)
|
|
return clonedValue
|
|
}
|
|
})
|
|
return clonedValue
|
|
}
|
|
|
|
const getIfExists = <
|
|
Path extends Paths<ModifyObjectFieldValue<Type, Field>> & string
|
|
>(
|
|
path: Path
|
|
) => getFromPathIfExists<ModifyObjectFieldValue<Type, Field>, Path>(value, path)
|
|
const evict = () => details.DELETE
|
|
|
|
const readField = <
|
|
ReadFieldType extends keyof AllObjectTypes,
|
|
ReadFieldName extends keyof AllObjectTypes[ReadFieldType] & string
|
|
>(
|
|
ref: CacheObjectReference<ReadFieldType>,
|
|
fieldName: ReadFieldName
|
|
) =>
|
|
details.readField(fieldName, ref) as Optional<
|
|
ModifyFnCacheData<AllObjectTypes[ReadFieldType][ReadFieldName]>
|
|
>
|
|
|
|
const readObject = () =>
|
|
new Proxy(
|
|
{} as Partial<{
|
|
[prop in keyof AllObjectTypes[Type]]: ModifyFnCacheData<
|
|
AllObjectTypes[Type][prop]
|
|
>
|
|
}>,
|
|
{
|
|
get(_target, prop) {
|
|
if (!isString(prop)) return undefined
|
|
|
|
const ref = keyToRef(key)
|
|
return details.readField(prop, ref)
|
|
}
|
|
}
|
|
)
|
|
|
|
return updater({
|
|
fieldName: field,
|
|
variables,
|
|
value,
|
|
helpers: {
|
|
createUpdatedValue,
|
|
get: getIfExists,
|
|
evict,
|
|
readField,
|
|
ref: getObjectReference,
|
|
fromRef: parseObjectReference,
|
|
keyToRef,
|
|
readObject
|
|
}
|
|
})
|
|
},
|
|
options
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Iterate over object field versions (same field can have different arg versions). If you also
|
|
* want to make updates, use modifyObjectField instead.
|
|
*/
|
|
export const iterateObjectField = <
|
|
Type extends keyof AllObjectTypes,
|
|
Field extends keyof AllObjectTypes[Type]
|
|
>(
|
|
cache: ApolloCache<unknown>,
|
|
key: ApolloCacheObjectKey<Type>,
|
|
fieldName: Field,
|
|
predicate: (params: {
|
|
fieldName: string
|
|
variables: Field extends keyof AllObjectFieldArgTypes[Type]
|
|
? AllObjectFieldArgTypes[Type][Field]
|
|
: never
|
|
/**
|
|
* Value found in the cache. Read-only and should not be mutated directly. Use the
|
|
* createUpdatedValue() helper to build a new value with updated fields.
|
|
*/
|
|
value: ReadonlyDeep<ModifyObjectFieldValue<Type, Field>>
|
|
helpers: {
|
|
/**
|
|
* Get value from specific path, only if it exists in the cache value
|
|
*/
|
|
get: <Path extends Paths<ModifyObjectFieldValue<Type, Field>> & string>(
|
|
path: Path
|
|
) => Optional<Get<ModifyObjectFieldValue<Type, Field>, Path>>
|
|
/**
|
|
* Read field data from a Reference object
|
|
*/
|
|
readField: <
|
|
ReadFieldType extends keyof AllObjectTypes,
|
|
ReadFieldName extends keyof AllObjectTypes[ReadFieldType] & string
|
|
>(
|
|
ref: CacheObjectReference<ReadFieldType>,
|
|
fieldName: ReadFieldName
|
|
) => Optional<ModifyFnCacheData<AllObjectTypes[ReadFieldType][ReadFieldName]>>
|
|
/**
|
|
* Get the object we're modifying as a readable object
|
|
*/
|
|
readObject: () => Partial<{
|
|
[prop in keyof AllObjectTypes[Type]]: ModifyFnCacheData<
|
|
AllObjectTypes[Type][prop]
|
|
>
|
|
}>
|
|
/**
|
|
* Build a reference object for a specific object in the cache
|
|
*/
|
|
ref: typeof getObjectReference
|
|
/**
|
|
* Parse a reference object to get its type and id separately
|
|
*/
|
|
fromRef: typeof parseObjectReference
|
|
}
|
|
}) => void,
|
|
options?: Partial<{
|
|
debug: boolean
|
|
}>
|
|
) => {
|
|
modifyObjectField<Type, Field>(
|
|
cache,
|
|
key,
|
|
fieldName,
|
|
(params) => {
|
|
predicate(params)
|
|
},
|
|
{
|
|
...(options || {}),
|
|
autoEvictFiltered: false // no mutations here
|
|
}
|
|
)
|
|
}
|
|
|
|
export const hasErrorWith = (params: {
|
|
errors: readonly GraphQLFormattedError[] | undefined
|
|
codes?: Array<string | RegExp>
|
|
message?: string
|
|
}) => {
|
|
const { errors, message, codes } = params
|
|
if (!errors?.length) return undefined
|
|
if (!message?.length && !codes?.length) return undefined
|
|
|
|
return errors.find((e) => {
|
|
const hasMessage = message && e.message.toLowerCase().includes(message)
|
|
const hasCodes =
|
|
codes &&
|
|
codes.some((testCode) => {
|
|
const code = e.extensions?.code
|
|
if (!code || !isString(code)) return false
|
|
return isString(testCode) ? code === testCode : code.match(testCode)
|
|
})
|
|
return hasMessage || hasCodes
|
|
})
|
|
}
|
|
|
|
export const errorsToAuthResult = (params: {
|
|
errors: readonly GraphQLFormattedError[] | undefined
|
|
}): PermissionCheckResult => {
|
|
const { errors } = params
|
|
if (!errors?.length) return { authorized: true, message: 'OK', code: 'OK' }
|
|
|
|
// Prioritize common error codes
|
|
const commonAuthError = hasErrorWith({
|
|
errors,
|
|
codes: [
|
|
/forbidden/i,
|
|
/unauthorized/i,
|
|
/not[_\s]found/i,
|
|
/not[_\s]authorized/i,
|
|
'SSO_SESSION_MISSING_OR_EXPIRED_ERROR'
|
|
]
|
|
})
|
|
if (commonAuthError) {
|
|
return {
|
|
authorized: false,
|
|
code: commonAuthError.extensions!.code as string,
|
|
message: commonAuthError.message,
|
|
payload: commonAuthError.extensions || null
|
|
}
|
|
}
|
|
|
|
const firstError = errors[0]
|
|
return {
|
|
authorized: false,
|
|
code:
|
|
firstError.extensions?.code && isString(firstError.extensions?.code)
|
|
? firstError.extensions?.code
|
|
: 'UNKNOWN_ERROR',
|
|
message: firstError.message,
|
|
payload: firstError.extensions || null
|
|
}
|
|
}
|
|
|
|
export const parseObjectKey = <Type extends keyof AllObjectTypes>(
|
|
key: ApolloCacheObjectKey<Type>
|
|
): { type: Type; id: string } => {
|
|
const [type, id] = key.split(':')
|
|
return { type: type as Type, id }
|
|
}
|
|
|
|
export const parseObjectReference = <Type extends keyof AllObjectTypes>(
|
|
ref: CacheObjectReference<Type>
|
|
): { type: Type; id: string } => {
|
|
return parseObjectKey(ref.__ref as ApolloCacheObjectKey<Type>)
|
|
}
|