diff --git a/src/error/ApolloErrorHandlerResult.ts b/src/error/ApolloErrorHandlerResult.ts index 2ee87a6..017ae81 100644 --- a/src/error/ApolloErrorHandlerResult.ts +++ b/src/error/ApolloErrorHandlerResult.ts @@ -10,36 +10,38 @@ import { } from './types'; export interface ApolloErrorHandlerResultInterface { - processedErrors: ProcessedApolloError[]; + allErrors: ProcessedApolloError[]; validationRuleViolations?: ValidationRuleViolation[]; } export class ApolloErrorHandlerResult implements ApolloErrorHandlerResultInterface { + public readonly allErrors: ProcessedApolloError[]; + public constructor( - public readonly processedErrors: ProcessedApolloError[], + public readonly unhandledErrors: ProcessedApolloError[], public readonly handledErrors: ProcessedApolloError[], - ) {} + ) { + this.allErrors = [...unhandledErrors, ...handledErrors]; + } public get networkErrors(): NetworkError[] { - return this.processedErrors.filter((e): e is NetworkError => e.type === ApolloErrorType.NETWORK_ERROR); + return this.allErrors.filter((e): e is NetworkError => e.type === ApolloErrorType.NETWORK_ERROR); } public get serverErrors(): ServerError[] { - return this.processedErrors.filter((e): e is ServerError => e.type === ApolloErrorType.SERVER_ERROR); + return this.allErrors.filter((e): e is ServerError => e.type === ApolloErrorType.SERVER_ERROR); } public get unauthorizedErrors(): UnauthorizedError[] { - return this.processedErrors.filter((e): e is UnauthorizedError => e.type === ApolloErrorType.UNAUTHORIZED_ERROR); + return this.allErrors.filter((e): e is UnauthorizedError => e.type === ApolloErrorType.UNAUTHORIZED_ERROR); } public get userInputErrors(): UserInputError[] { - return this.processedErrors.filter((e): e is UserInputError => e.type === ApolloErrorType.BAD_USER_INPUT); + return this.allErrors.filter((e): e is UserInputError => e.type === ApolloErrorType.BAD_USER_INPUT); } public get inputValidationErrors(): InputValidationError[] { - return this.processedErrors.filter( - (e): e is InputValidationError => e.type === ApolloErrorType.INPUT_VALIDATION_ERROR, - ); + return this.allErrors.filter((e): e is InputValidationError => e.type === ApolloErrorType.INPUT_VALIDATION_ERROR); } public get validationRuleViolations(): ValidationRuleViolation[] { diff --git a/src/error/ApolloErrorProcessor.ts b/src/error/ApolloErrorProcessor.ts deleted file mode 100644 index 32cc06e..0000000 --- a/src/error/ApolloErrorProcessor.ts +++ /dev/null @@ -1,236 +0,0 @@ -import Vue from 'vue'; -import { ApolloOperationContext } from '../types'; -import { - ApolloError, - ApolloErrorType, - GraphQLError, - InputValidationError, - ProcessedApolloError, - ServerError, - UnauthorizedError, -} from './types'; -import { ApolloErrorHandlerResult } from './ApolloErrorHandlerResult'; - -export function isApolloError(error: ApolloError | any): error is ApolloError { - return error.graphQLErrors !== undefined; -} - -export function isGraphQLError(error: GraphQLError | any): error is GraphQLError { - return error.extensions !== undefined; -} - -export class ApolloErrorProcessor { - public static FriendlyMessages: Record = { - FAILED_TO_FETCH: - 'Unable to communicate with server. The service may be down or you may be offline. Try again in a moment.', - INTERNAL_SERVER_ERROR: `A server error has occurred.`, - }; - - public readonly processedErrors: ProcessedApolloError[]; - - protected readonly originalError: Error; - protected readonly app: TApp; - protected readonly context: TContext; - protected handledErrors: ProcessedApolloError[] = []; - - public constructor(error: ApolloError, app: TApp, context: TContext) { - this.originalError = error; - this.app = app; - this.context = context; - - this.processedErrors = this.processApolloError(error); - } - - public get result(): ApolloErrorHandlerResult { - return new ApolloErrorHandlerResult(this.processedErrors, this.handledErrors); - } - - public showErrorNotifications(): void { - // This is just an example - to do something else (e.g. showing a visible notification to the user), you should - // implement your own class that extends ApolloErrorProcessor and replace this showErrorNotifications method. - this.processedErrors.forEach(error => { - console.error(`${error.type}: ${error.message}`, error.error); - }); - } - - public cleanError(error: ApolloError | GraphQLError | Record): Error { - if (error instanceof Error) { - return error; - } - - // the `error` object we have may not be an actual Error instance - // create a new one suitable for e.g. capturing to Sentry - const cleanError = new Error(error.message); - - if (isGraphQLError(error)) { - cleanError.name = 'GraphQLError' + (error.extensions?.code != null ? `[${error.extensions.code}]` : ''); - cleanError.stack = this.originalError.stack; - - Object.defineProperty(cleanError, 'nodes', { value: error.nodes }); - Object.defineProperty(cleanError, 'source', { value: error.source }); - Object.defineProperty(cleanError, 'positions', { value: error.positions }); - Object.defineProperty(cleanError, 'path', { value: error.path }); - Object.defineProperty(cleanError, 'extensions', { value: JSON.stringify(error.extensions) }); - Object.defineProperty(cleanError, 'originalError', { value: this.originalError }); - } else { - Object.keys(error).forEach(key => Object.defineProperty(cleanError, key, { value: error[key] })); - } - - return cleanError; - } - - protected isUnauthorizedError(error: GraphQLError): boolean { - return ( - error.message === 'Unauthorized' || - error.extensions?.code === 'FORBIDDEN' || - error.extensions?.exception?.status === 401 - ); - } - - // eslint-disable-next-line @typescript-eslint/no-empty-function,@typescript-eslint/no-unused-vars - protected onUnauthorizedError(error: UnauthorizedError): void { - // extending classes can take action here, e.g. go to log in page - } - - // eslint-disable-next-line @typescript-eslint/no-empty-function,@typescript-eslint/no-unused-vars - protected onServerError(error: ServerError): void { - // extending classes can take action here, e.g. capture to Sentry - } - - // eslint-disable-next-line @typescript-eslint/no-empty-function,@typescript-eslint/no-unused-vars - protected onInputValidationError(error: InputValidationError): void { - // extending classes can take action here, e.g. capture to Sentry - } - - protected getFriendlyMessage(errorCode: string, errorMessage: string): string; - protected getFriendlyMessage(errorCode: string): string | undefined; - protected getFriendlyMessage(errorCode: string, errorMessage?: string): string | undefined { - return (this.constructor as typeof ApolloErrorProcessor).FriendlyMessages[errorCode] ?? errorMessage; - } - - protected processErrorMessage(message: GraphQLError['message']): string { - if (typeof message === 'object') { - if (message.error != null) { - return message.error; - } - - return 'Unknown error: ' + JSON.stringify(message); - } - - return message; - } - - protected normalizeError(error: GraphQLError | Error): Error { - if (isGraphQLError(error)) { - return { - ...error, - message: this.processErrorMessage(error.message), - }; - } - - return error; - } - - private processApolloError(error: ApolloError): ProcessedApolloError[] { - if (error.graphQLErrors != null && error.graphQLErrors.length > 0) { - // Successful request but with errors from the resolver - return error.graphQLErrors.flatMap(graphQLError => this.processGraphQLError(graphQLError)); - } - - if ( - error.networkError != null && - error.networkError.result != null && - error.networkError.result.errors != null && - error.networkError.result.errors.length > 0 - ) { - // Network error that contains GraphQL errors inside it. Can occur when server responds with a non-200 status code - return error.networkError.result.errors.flatMap(graphQLError => this.processGraphQLError(graphQLError)); - } - - if (error.networkError != null) { - // Network error, e.g. server is not responding or some other exception occurs - return this.processNetworkError(error); - } - - // Some other internal server error - const processedError: ServerError = { - type: ApolloErrorType.SERVER_ERROR, - error, - message: error.message, - }; - - this.onServerError(processedError); - - return [processedError]; - } - - private processGraphQLError(error: GraphQLError): ProcessedApolloError[] { - if (this.isUnauthorizedError(error)) { - // Unauthorized (not logged in, or not allowed) error - const processedError: UnauthorizedError = { - type: ApolloErrorType.UNAUTHORIZED_ERROR, - error: this.normalizeError(error), - message: this.processErrorMessage(error.message), - path: error.path, - }; - - this.onUnauthorizedError(processedError); - - return [processedError]; - } - - if (error.extensions?.validationErrors != null) { - // User input validation error - const processedError: InputValidationError = { - type: ApolloErrorType.INPUT_VALIDATION_ERROR, - error: this.normalizeError(error), - message: this.processErrorMessage(error.message), - path: error.path, - invalidArgs: error.extensions.invalidArgs, - violations: error.extensions.validationErrors, - }; - - this.onInputValidationError(processedError); - - return [processedError]; - } - - // Other GraphQL resolver error - probably a bug - const processedError: ServerError = { - type: error.extensions?.code != null ? error.extensions.code : ApolloErrorType.SERVER_ERROR, - error: this.normalizeError(error), - message: this.getFriendlyMessage('INTERNAL_SERVER_ERROR', this.processErrorMessage(error.message)), - path: error.path, - }; - - this.onServerError(processedError); - - return [processedError]; - } - - private processNetworkError(error: ApolloError): ProcessedApolloError[] { - const errors: ProcessedApolloError[] = []; - let message: string; - - if (error.networkError != null && error.networkError.message != null) { - message = this.processErrorMessage(error.networkError.message); - } else { - message = this.processErrorMessage(error.message); - } - - switch (message) { - case 'Failed to fetch': - message = this.getFriendlyMessage('FAILED_TO_FETCH', message); - break; - } - - errors.push({ - type: ApolloErrorType.NETWORK_ERROR, - error, - statusCode: error.networkError != null ? error.networkError.statusCode : undefined, - message, - }); - - return errors; - } -} diff --git a/src/error/handleApolloError.ts b/src/error/handleApolloError.ts index b64551f..7154327 100644 --- a/src/error/handleApolloError.ts +++ b/src/error/handleApolloError.ts @@ -1,8 +1,8 @@ -import { ApolloErrorProcessor } from './ApolloErrorProcessor'; +import { processApolloError } from './processApolloError'; import { ApolloOperationContext } from '../types'; import { Vue } from 'vue/types/vue'; import { ApolloError, ApolloOperationErrorHandlerFunction } from './types'; -import { ApolloErrorHandlerResultInterface } from './ApolloErrorHandlerResult'; +import { ApolloErrorHandlerResult, ApolloErrorHandlerResultInterface } from './ApolloErrorHandlerResult'; /** * This is a simple example of an error handler function. You can copy this and implement your own in your application. @@ -12,11 +12,22 @@ export const handleApolloError: ApolloOperationErrorHandlerFunction { - const processor = new ApolloErrorProcessor(error, app, context ?? {}); + const { unhandledErrors, handledErrors } = processApolloError(error, { + app, + context, + // Example of a handler function for a particular type of error: + onUnauthorizedError: error => { + console.warn('Unauthorized! Logging you out...', error); + //logout(); - processor.showErrorNotifications(); + // Returning true indicates to the processor that we've handled this error + return true; + }, + }); - return { - processedErrors: processor.processedErrors, - }; + unhandledErrors.forEach(error => { + console.error(`${error.type}: ${error.message}`, error.error); + }); + + return new ApolloErrorHandlerResult(unhandledErrors, handledErrors); }; diff --git a/src/error/index.ts b/src/error/index.ts index 578e7e8..bcb3be3 100644 --- a/src/error/index.ts +++ b/src/error/index.ts @@ -3,6 +3,6 @@ */ export * from './ApolloErrorHandlerResult'; -export * from './ApolloErrorProcessor'; export * from './handleApolloError'; +export * from './processApolloError'; export * from './types'; diff --git a/src/error/processApolloError.ts b/src/error/processApolloError.ts new file mode 100644 index 0000000..8ddd53a --- /dev/null +++ b/src/error/processApolloError.ts @@ -0,0 +1,213 @@ +import Vue from 'vue'; +import { ApolloOperationContext } from '../types'; +import { + ApolloError, + ApolloErrorType, + GraphQLError, + InputValidationError, + NetworkError, + ProcessedApolloError, + ServerError, + UnauthorizedError, +} from './types'; + +export function isApolloError(error: ApolloError | any): error is ApolloError { + return error.graphQLErrors !== undefined; +} + +export function isGraphQLError(error: GraphQLError | any): error is GraphQLError { + return error.extensions !== undefined; +} + +function normalizeErrorMessage(message: GraphQLError['message']): string { + if (typeof message === 'object') { + if (message.error != null) { + return message.error; + } + + return 'Unknown error: ' + JSON.stringify(message); + } + + return message; +} + +function normalizeError(error: GraphQLError | Error): Error { + if (isGraphQLError(error)) { + return { + ...error, + message: normalizeErrorMessage(error.message), + }; + } + + return error; +} + +function translateErrorMessage(messageOrCode: string, translations: Record): string { + return translations[messageOrCode] ?? messageOrCode; +} + +export interface ApolloErrorProcessorOptions { + app?: TApp; + context?: TContext; + isUnauthorizedError?: (error: GraphQLError) => boolean; + onUnauthorizedError?: (error: UnauthorizedError) => boolean | void; + onInputValidationError?: (error: InputValidationError) => boolean | void; + onServerError?: (error: ServerError) => boolean | void; + onNetworkError?: (error: NetworkError) => boolean | void; + translations?: Record; +} + +export const defaultErrorMessageTranslations: Record = { + FAILED_TO_FETCH: + 'Unable to communicate with server. The service may be down or you may be offline. Try again in a moment.', + INTERNAL_SERVER_ERROR: `A server error has occurred.`, +}; + +const defaultProcessorOptions: ApolloErrorProcessorOptions = { + isUnauthorizedError: (error: GraphQLError) => + error.message === 'Unauthorized' || + error.extensions?.code === 'FORBIDDEN' || + error.extensions?.exception?.status === 401, + translations: defaultErrorMessageTranslations, +}; + +export interface ApolloErrorProcessorResult { + unhandledErrors: ProcessedApolloError[]; + handledErrors: ProcessedApolloError[]; +} + +function processGraphQLError( + error: GraphQLError, + options: ApolloErrorProcessorOptions = {}, +): ApolloErrorProcessorResult { + options = { ...defaultProcessorOptions, ...options }; + const { isUnauthorizedError, onUnauthorizedError, onInputValidationError, onServerError, translations } = options; + + if (isUnauthorizedError != null && isUnauthorizedError(error)) { + // Unauthorized (not logged in, or not allowed) error + const processedError: UnauthorizedError = { + type: ApolloErrorType.UNAUTHORIZED_ERROR, + error: normalizeError(error), + message: normalizeErrorMessage(error.message), + path: error.path, + }; + + if (onUnauthorizedError != null && onUnauthorizedError(processedError)) { + return { unhandledErrors: [], handledErrors: [processedError] }; + } + + return { unhandledErrors: [processedError], handledErrors: [] }; + } + + if (error.extensions?.validationErrors != null) { + // User input validation error + const processedError: InputValidationError = { + type: ApolloErrorType.INPUT_VALIDATION_ERROR, + error: normalizeError(error), + message: normalizeErrorMessage(error.message), + path: error.path, + invalidArgs: error.extensions.invalidArgs, + violations: error.extensions.validationErrors, + }; + + if (onInputValidationError != null && onInputValidationError(processedError)) { + return { unhandledErrors: [], handledErrors: [processedError] }; + } + + return { unhandledErrors: [processedError], handledErrors: [] }; + } + + // Other GraphQL resolver error - probably a bug + const processedError: ServerError = { + type: error.extensions?.code != null ? error.extensions.code : ApolloErrorType.SERVER_ERROR, + error: normalizeError(error), + message: translateErrorMessage('INTERNAL_SERVER_ERROR', translations ?? defaultErrorMessageTranslations), + path: error.path, + }; + + if (onServerError != null && onServerError(processedError)) { + return { unhandledErrors: [], handledErrors: [processedError] }; + } + + return { unhandledErrors: [processedError], handledErrors: [] }; +} + +function processNetworkError( + error: ApolloError, + options: ApolloErrorProcessorOptions = {}, +): ApolloErrorProcessorResult { + options = { ...defaultProcessorOptions, ...options }; + const { onNetworkError, translations } = options; + + let message: string = + error.networkError != null && error.networkError.message != null + ? normalizeErrorMessage(error.networkError.message) + : normalizeErrorMessage(error.message); + + switch (message) { + case 'Failed to fetch': + message = translateErrorMessage('FAILED_TO_FETCH', translations ?? defaultErrorMessageTranslations); + break; + } + + const processedError: NetworkError = { + type: ApolloErrorType.NETWORK_ERROR, + error, + statusCode: error.networkError != null ? error.networkError.statusCode : undefined, + message, + }; + + if (onNetworkError != null && onNetworkError(processedError)) { + return { unhandledErrors: [], handledErrors: [processedError] }; + } + + return { unhandledErrors: [processedError], handledErrors: [processedError] }; +} + +export function processApolloError( + error: ApolloError, + options: ApolloErrorProcessorOptions = {}, +): ApolloErrorProcessorResult { + options = { ...defaultProcessorOptions, ...options }; + const { onServerError } = options; + + if (error.graphQLErrors != null && error.graphQLErrors.length > 0) { + // Successful request but with errors from the resolver + const errorProcessorResults = error.graphQLErrors.map(graphQLError => processGraphQLError(graphQLError, options)); + + return { + unhandledErrors: errorProcessorResults.flatMap(result => result.unhandledErrors), + handledErrors: errorProcessorResults.flatMap(result => result.handledErrors), + }; + } + + if (error.networkError?.result?.errors != null && error.networkError.result.errors.length > 0) { + // Network error that contains GraphQL errors inside it. Can occur when server responds with a non-200 status code + const errorProcessorResults = error.networkError.result.errors.map(graphQLError => + processGraphQLError(graphQLError, options), + ); + + return { + unhandledErrors: errorProcessorResults.flatMap(result => result.unhandledErrors), + handledErrors: errorProcessorResults.flatMap(result => result.handledErrors), + }; + } + + if (error.networkError != null) { + // Network error, e.g. server is not responding or some other exception occurs + return processNetworkError(error, options); + } + + // Some other internal server error + const processedError: ServerError = { + type: ApolloErrorType.SERVER_ERROR, + error, + message: error.message, + }; + + if (onServerError != null && onServerError(processedError)) { + return { unhandledErrors: [], handledErrors: [processedError] }; + } + + return { unhandledErrors: [processedError], handledErrors: [] }; +} diff --git a/src/mutation.ts b/src/mutation.ts index 9cb5ba1..a52cb5e 100644 --- a/src/mutation.ts +++ b/src/mutation.ts @@ -113,7 +113,7 @@ export async function mutateWithErrorHandling< return { success: false, - errors: errorHandlerResult?.processedErrors, + errors: errorHandlerResult?.allErrors, validationRuleViolations: errorHandlerResult?.validationRuleViolations, }; }