Skip to content

Commit 866b951

Browse files
committed
Initial version of operation functions, AbstractApolloErrorProcessor
1 parent b86c7d7 commit 866b951

File tree

6 files changed

+584
-0
lines changed

6 files changed

+584
-0
lines changed
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import Vue from 'vue';
2+
import {
3+
ApolloError,
4+
ApolloErrorType,
5+
ApolloOperationContext,
6+
GraphQLError,
7+
InputValidationError,
8+
ProcessedApolloError,
9+
ServerError,
10+
UnauthorizedError,
11+
} from './types';
12+
13+
export function isApolloError(error: ApolloError | any): error is ApolloError {
14+
return error.graphQLErrors !== undefined;
15+
}
16+
17+
export function isGraphQLError(error: GraphQLError | any): error is GraphQLError {
18+
return error.extensions !== undefined;
19+
}
20+
21+
export abstract class AbstractApolloErrorProcessor<TApp = Vue, TContext = ApolloOperationContext> {
22+
public static FriendlyMessages: Record<string, string> = {
23+
FAILED_TO_FETCH:
24+
'Unable to communicate with server. The service may be down or you may be offline. Try again in a moment.',
25+
INTERNAL_SERVER_ERROR: `A server error has occurred.`,
26+
};
27+
28+
public processedErrors: ProcessedApolloError[];
29+
30+
protected readonly originalError: Error;
31+
protected readonly app: TApp;
32+
protected readonly context: TContext;
33+
34+
public constructor(error: ApolloError, app: TApp, context: TContext) {
35+
this.originalError = error;
36+
this.app = app;
37+
this.context = context;
38+
39+
this.processedErrors = this.processApolloError(error);
40+
}
41+
42+
public abstract showErrorNotifications(): void;
43+
44+
public cleanError(error: ApolloError | GraphQLError | Record<string, any>): Error {
45+
if (error instanceof Error) {
46+
return error;
47+
}
48+
49+
// the `error` object we have may not be an actual Error instance
50+
// create a new one suitable for e.g. capturing to Sentry
51+
const cleanError = new Error(error.message);
52+
53+
if (isGraphQLError(error)) {
54+
cleanError.name = 'GraphQLError' + (error.extensions?.code != null ? `[${error.extensions.code}]` : '');
55+
cleanError.stack = this.originalError.stack;
56+
57+
Object.defineProperty(cleanError, 'nodes', { value: error.nodes });
58+
Object.defineProperty(cleanError, 'source', { value: error.source });
59+
Object.defineProperty(cleanError, 'positions', { value: error.positions });
60+
Object.defineProperty(cleanError, 'path', { value: error.path });
61+
Object.defineProperty(cleanError, 'extensions', { value: JSON.stringify(error.extensions) });
62+
Object.defineProperty(cleanError, 'originalError', { value: this.originalError });
63+
} else {
64+
Object.keys(error).forEach(key => Object.defineProperty(cleanError, key, { value: error[key] }));
65+
}
66+
67+
return cleanError;
68+
}
69+
70+
protected isUnauthorizedError(error: GraphQLError): boolean {
71+
return (
72+
error.message === 'Unauthorized' ||
73+
error.extensions?.code === 'FORBIDDEN' ||
74+
error.extensions?.exception?.status === 401
75+
);
76+
}
77+
78+
// eslint-disable-next-line @typescript-eslint/no-empty-function,@typescript-eslint/no-unused-vars
79+
protected onUnauthorizedError(error: UnauthorizedError): void {
80+
// extending classes can take action here, e.g. go to log in page
81+
}
82+
83+
// eslint-disable-next-line @typescript-eslint/no-empty-function,@typescript-eslint/no-unused-vars
84+
protected onServerError(error: ServerError): void {
85+
// extending classes can take action here, e.g. capture to Sentry
86+
}
87+
88+
// eslint-disable-next-line @typescript-eslint/no-empty-function,@typescript-eslint/no-unused-vars
89+
protected onInputValidationError(error: InputValidationError): void {
90+
// extending classes can take action here, e.g. capture to Sentry
91+
}
92+
93+
protected getFriendlyMessage(errorCode: string, errorMessage: string): string;
94+
protected getFriendlyMessage(errorCode: string): string | undefined;
95+
protected getFriendlyMessage(errorCode: string, errorMessage?: string): string | undefined {
96+
return (this.constructor as typeof AbstractApolloErrorProcessor).FriendlyMessages[errorCode] ?? errorMessage;
97+
}
98+
99+
private processApolloError(error: ApolloError): ProcessedApolloError[] {
100+
if (error.graphQLErrors != null && error.graphQLErrors.length > 0) {
101+
// Successful request but with errors from the resolver
102+
return error.graphQLErrors.flatMap(graphQLError => this.processGraphQLError(graphQLError));
103+
}
104+
105+
if (
106+
error.networkError != null &&
107+
error.networkError.result != null &&
108+
error.networkError.result.errors != null &&
109+
error.networkError.result.errors.length > 0
110+
) {
111+
// Network error that contains GraphQL errors inside it. Can occur when server responds with a non-200 status code
112+
return error.networkError.result.errors.flatMap(graphQLError => this.processGraphQLError(graphQLError));
113+
}
114+
115+
if (error.networkError != null) {
116+
// Network error, e.g. server is not responding or some other exception occurs
117+
return this.processNetworkError(error);
118+
}
119+
120+
// Some other internal server error
121+
const processedError: ServerError = {
122+
type: ApolloErrorType.SERVER_ERROR,
123+
error,
124+
message: error.message,
125+
};
126+
127+
this.onServerError(processedError);
128+
129+
return [processedError];
130+
}
131+
132+
private processGraphQLError(error: GraphQLError): ProcessedApolloError[] {
133+
if (this.isUnauthorizedError(error)) {
134+
// Unauthorized (not logged in, or not allowed) error
135+
const processedError: UnauthorizedError = {
136+
type: ApolloErrorType.UNAUTHORIZED_ERROR,
137+
error,
138+
message: error.message,
139+
path: error.path,
140+
};
141+
142+
this.onUnauthorizedError(processedError);
143+
144+
return [processedError];
145+
}
146+
147+
if (error.extensions?.validationErrors != null) {
148+
// User input validation error
149+
const processedError: InputValidationError = {
150+
type: ApolloErrorType.INPUT_VALIDATION_ERROR,
151+
error,
152+
message: error.message,
153+
path: error.path,
154+
invalidArgs: error.extensions.invalidArgs,
155+
violations: error.extensions.validationErrors,
156+
};
157+
158+
this.onInputValidationError(processedError);
159+
160+
return [processedError];
161+
}
162+
163+
// Other GraphQL resolver error - probably a bug
164+
const processedError: ServerError = {
165+
type: error.extensions?.code != null ? error.extensions.code : ApolloErrorType.SERVER_ERROR,
166+
error,
167+
path: error.path,
168+
message: this.getFriendlyMessage('INTERNAL_SERVER_ERROR', error.message),
169+
};
170+
171+
this.onServerError(processedError);
172+
173+
return [processedError];
174+
}
175+
176+
private processNetworkError(error: ApolloError): ProcessedApolloError[] {
177+
const errors: ProcessedApolloError[] = [];
178+
let message: string;
179+
180+
if (error.networkError != null && error.networkError.message != null) {
181+
message = this.processErrorMessage(error.networkError.message);
182+
} else {
183+
message = this.processErrorMessage(error.message);
184+
}
185+
186+
switch (message) {
187+
case 'Failed to fetch':
188+
message = this.getFriendlyMessage('FAILED_TO_FETCH', message);
189+
break;
190+
}
191+
192+
errors.push({
193+
type: ApolloErrorType.NETWORK_ERROR,
194+
error,
195+
statusCode: error.networkError != null ? error.networkError.statusCode : undefined,
196+
message,
197+
});
198+
199+
return errors;
200+
}
201+
202+
private processErrorMessage(message: GraphQLError['message']): string {
203+
if (typeof message === 'object') {
204+
if (message.error != null) {
205+
return message.error;
206+
}
207+
208+
return 'Unknown error: ' + JSON.stringify(message);
209+
}
210+
211+
return message;
212+
}
213+
}

src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @file Automatically generated by barrelsby.
3+
*/
4+
5+
export * from './AbstractApolloErrorProcessor';
6+
export * from './mutation';
7+
export * from './query';
8+
export * from './subscription';
9+
export * from './types';

src/mutation.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { ApolloError, MutationOptions, OperationVariables } from 'apollo-client';
2+
import { DocumentNode } from 'graphql';
3+
import { ClientOptions, DollarApollo } from 'vue-apollo/types/vue-apollo';
4+
import { FetchResult } from 'apollo-link';
5+
import { FetchPolicy, MutationBaseOptions } from 'apollo-client/core/watchQueryOptions';
6+
import { Vue } from 'vue/types/vue';
7+
import mapValues from 'lodash.mapvalues';
8+
import isPlainObject from 'lodash.isplainobject';
9+
import {
10+
ApolloErrorHandlerResult,
11+
ApolloOperationContext,
12+
ApolloOperationErrorHandlerFunction,
13+
ProcessedApolloError,
14+
ValidationRuleViolation,
15+
} from './types';
16+
17+
export type ApolloComponentMutationFunction<R = any, TVariables = OperationVariables> = (
18+
options: MutationBaseOptions<R, TVariables> &
19+
ClientOptions & { mutation?: DocumentNode; context?: any; fetchPolicy?: FetchPolicy },
20+
) => Promise<FetchResult<R>>;
21+
22+
export type ApolloClientMutationFunction<R = any, TVariables = OperationVariables> = (
23+
options: MutationOptions<R, TVariables> & ClientOptions,
24+
) => Promise<FetchResult<R>>;
25+
26+
export type VueAppWithApollo = Vue & { $apollo: DollarApollo<any> };
27+
28+
// The client can be an instance of DollarApollo (this.$apollo) or a `mutate()` function from <ApolloMutation> scope
29+
export type ApolloMutationClient<TResult, TVariables extends OperationVariables> =
30+
| ApolloComponentMutationFunction<TResult, TVariables>
31+
| { mutate: ApolloClientMutationFunction<TResult, TVariables>; [key: string]: any };
32+
33+
// Mutation function with typings
34+
export type MutationOperationFunction<TResult, TVariables extends OperationVariables, TError = ApolloError> = (
35+
app: VueAppWithApollo,
36+
params: Omit<MutationOperationParams<TVariables, TError>, 'mutation'>,
37+
client?: ApolloMutationClient<TResult, TVariables>,
38+
) => Promise<MutationResult<TResult>>;
39+
40+
// Parameters given to a MutationOperationFunction
41+
export interface MutationOperationParams<
42+
TVariables extends OperationVariables,
43+
TError = ApolloError,
44+
TContext = ApolloOperationContext
45+
> {
46+
mutation: DocumentNode;
47+
variables: TVariables;
48+
context?: TContext;
49+
onError?: ApolloOperationErrorHandlerFunction<TError>;
50+
}
51+
52+
// Result object returned by a MutationOperationFunction
53+
export interface MutationResult<TData> {
54+
success: boolean;
55+
data?: TData | null;
56+
errors?: ProcessedApolloError[];
57+
validationRuleViolations?: ValidationRuleViolation[];
58+
}
59+
60+
/**
61+
* Accepts a plain object containing user input, and cleans any string fields by:
62+
*
63+
* 1) trimming whitespace, and then
64+
* 2) replacing empty strings with null
65+
*
66+
* It will recurse through any nested objects.
67+
*/
68+
export function cleanInput<T extends Record<string, any>>(input: T): T {
69+
return mapValues(input, value => {
70+
if (typeof value === 'string') {
71+
return value.trim().length > 0 ? value.trim() : null;
72+
}
73+
74+
if (isPlainObject(value)) {
75+
return cleanInput(value);
76+
}
77+
78+
return value;
79+
});
80+
}
81+
82+
export async function mutateWithErrorHandling<
83+
TResult,
84+
TVariables extends OperationVariables,
85+
TError,
86+
TApp extends VueAppWithApollo = VueAppWithApollo
87+
>(
88+
app: TApp,
89+
{ mutation, variables, onError, context }: MutationOperationParams<TVariables, TError>,
90+
client?: ApolloMutationClient<TResult, TVariables>,
91+
): Promise<MutationResult<TResult>> {
92+
const mutate =
93+
client === undefined ? app.$apollo : typeof client === 'function' ? client : client.mutate.bind(client);
94+
95+
try {
96+
const result = await mutate({
97+
mutation,
98+
variables: cleanInput(variables),
99+
});
100+
101+
if (result == null) {
102+
return { success: false };
103+
}
104+
105+
return { success: true, data: result.data };
106+
} catch (error) {
107+
const errorHandlerResult: ApolloErrorHandlerResult | undefined =
108+
onError != null ? onError(error, app, context) : undefined;
109+
110+
return {
111+
success: false,
112+
errors: errorHandlerResult?.processedErrors,
113+
validationRuleViolations: errorHandlerResult?.validationRuleViolations,
114+
};
115+
}
116+
}
117+
118+
export function createMutationFunction<
119+
TResult,
120+
TVariables extends OperationVariables,
121+
TError = ApolloError,
122+
TApp extends VueAppWithApollo = VueAppWithApollo
123+
>(
124+
mutation: DocumentNode,
125+
onError?: ApolloOperationErrorHandlerFunction<TError, TApp>,
126+
): MutationOperationFunction<TResult, TVariables, TError> {
127+
return (
128+
app: TApp,
129+
params: Omit<MutationOperationParams<TVariables, TError>, 'mutation'>,
130+
client?: ApolloMutationClient<TResult, TVariables>,
131+
): Promise<MutationResult<TResult>> => {
132+
return mutateWithErrorHandling(
133+
app,
134+
{
135+
mutation,
136+
variables: params.variables,
137+
onError: params.onError ?? onError,
138+
context: params.context,
139+
},
140+
client,
141+
);
142+
};
143+
}

0 commit comments

Comments
 (0)