Skip to content

Commit bafc03f

Browse files
committed
feat: add payload.error field to all mutation resolvers. If client request error field then typed error will be returned in mutation payload, otherwise it will be on top-level errors field.
Example query: ```graphql mutation { createOne(record: { name: "John", someStrangeField: "Test" }) { record { name } error { message ... on ValidationError { errors { message path value } } ... on MongoError { code } __typename } } } ```
1 parent 48f3e06 commit bafc03f

20 files changed

+362
-293
lines changed

src/__tests__/github_issues/248-test.ts

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,10 @@ describe("issue #248 - payloads' errors", () => {
3535

3636
const User = mongoose.model('User', UserSchema);
3737
const UserTC = composeWithMongoose(User);
38-
schemaComposer.Query.addFields({
39-
findMany: UserTC.getResolver('findMany'),
40-
});
38+
schemaComposer.Query.addFields({ noop: 'String' });
4139
schemaComposer.Mutation.addFields({
4240
createOne: UserTC.getResolver('createOne'),
41+
createMany: UserTC.getResolver('createMany'),
4342
});
4443
const schema = schemaComposer.buildSchema();
4544

@@ -56,8 +55,11 @@ describe("issue #248 - payloads' errors", () => {
5655
__typename
5756
message
5857
... on ValidationError {
59-
path
60-
value
58+
errors {
59+
message
60+
path
61+
value
62+
}
6163
}
6264
}
6365
}
@@ -71,9 +73,14 @@ describe("issue #248 - payloads' errors", () => {
7173
record: null,
7274
error: {
7375
__typename: 'ValidationError',
74-
message: 'this is a validate message',
75-
path: 'someStrangeField',
76-
value: 'Test',
76+
message: 'User validation failed: someStrangeField: this is a validate message',
77+
errors: [
78+
{
79+
message: 'this is a validate message',
80+
path: 'someStrangeField',
81+
value: 'Test',
82+
},
83+
],
7784
},
7885
},
7986
},
@@ -94,8 +101,6 @@ describe("issue #248 - payloads' errors", () => {
94101
`,
95102
});
96103

97-
(res as any).errors[0] = convertToSimpleObject((res as any).errors[0]);
98-
99104
expect(res).toEqual({
100105
data: {
101106
createOne: null,
@@ -104,25 +109,64 @@ describe("issue #248 - payloads' errors", () => {
104109
expect.objectContaining({
105110
message: 'User validation failed: someStrangeField: this is a validate message',
106111
extensions: {
107-
validationErrors: {
108-
someStrangeField: {
112+
name: 'ValidationError',
113+
errors: [
114+
{
109115
message: 'this is a validate message',
110116
path: 'someStrangeField',
111117
value: 'Test',
112118
},
113-
},
119+
],
114120
},
115121
path: ['createOne'],
116122
}),
117123
],
118124
});
119125
});
120-
});
121126

122-
function convertToSimpleObject(theClass: Error): Record<string, any> {
123-
const keys = Object.getOwnPropertyNames(theClass);
124-
return keys.reduce((classAsObj, key) => {
125-
classAsObj[key] = (theClass as any)[key];
126-
return classAsObj;
127-
}, {} as Record<string, any>);
128-
}
127+
it('check validation for createMany', async () => {
128+
const res = await graphql.graphql({
129+
schema,
130+
source: `
131+
mutation {
132+
createMany(records: [{ name: "Ok"}, { name: "John", someStrangeField: "Test" }]) {
133+
records {
134+
name
135+
}
136+
error {
137+
__typename
138+
message
139+
... on ValidationError {
140+
errors {
141+
message
142+
path
143+
value
144+
}
145+
}
146+
}
147+
}
148+
}
149+
`,
150+
});
151+
152+
expect(res).toEqual({
153+
data: {
154+
createMany: {
155+
records: null,
156+
error: {
157+
__typename: 'ValidationError',
158+
message: 'User validation failed: someStrangeField: this is a validate message',
159+
errors: [
160+
{
161+
message: 'this is a validate message',
162+
path: '1.someStrangeField',
163+
// ^^ - we add idx of broken record
164+
value: 'Test',
165+
},
166+
],
167+
},
168+
},
169+
},
170+
});
171+
});
172+
});

src/errors/MongoError.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { MongoError } from 'mongodb';
2+
import { SchemaComposer, ObjectTypeComposer } from 'graphql-compose';
3+
4+
export { MongoError };
5+
6+
export function getMongoErrorOTC(schemaComposer: SchemaComposer<any>): ObjectTypeComposer {
7+
return schemaComposer.getOrCreateOTC('MongoError', (otc) => {
8+
otc.addFields({
9+
message: {
10+
description: 'MongoDB error message',
11+
type: 'String',
12+
},
13+
code: {
14+
description: 'MongoDB error code',
15+
type: 'Int',
16+
},
17+
});
18+
});
19+
}

src/errors/RuntimeError.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { SchemaComposer, ObjectTypeComposer } from 'graphql-compose';
2+
3+
export class RuntimeError extends Error {
4+
constructor(message: string) {
5+
super(message);
6+
(this as any).__proto__ = RuntimeError.prototype;
7+
}
8+
}
9+
10+
export function getRuntimeErrorOTC(schemaComposer: SchemaComposer<any>): ObjectTypeComposer {
11+
return schemaComposer.getOrCreateOTC('RuntimeError', (otc) => {
12+
otc.addFields({
13+
message: {
14+
description: 'Runtime error message',
15+
type: 'String',
16+
},
17+
});
18+
});
19+
}

src/errors/ValidationError.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { Error as MongooseError } from 'mongoose';
2+
import { SchemaComposer, ObjectTypeComposer } from 'graphql-compose';
3+
4+
interface Opts {
5+
/** Adds prefix to error `path` property. It's useful for createMany resolver which shows `idx` of broken record */
6+
pathPrefix?: string;
7+
}
8+
9+
export class ValidationError extends Error {
10+
public errors: Array<{
11+
path: string;
12+
message: string;
13+
value: any;
14+
}>;
15+
16+
constructor(data: MongooseError.ValidationError, opts?: Opts) {
17+
super(data.message);
18+
this.errors = [];
19+
Object.keys(data.errors).forEach((key) => {
20+
const e = data.errors[key];
21+
this.errors.push({
22+
path: opts?.pathPrefix ? `${opts?.pathPrefix}${e.path}` : e.path,
23+
message: e.message,
24+
value: e.value,
25+
});
26+
});
27+
28+
(this as any).__proto__ = ValidationError.prototype;
29+
}
30+
}
31+
32+
export function getValidationErrorOTC(schemaComposer: SchemaComposer<any>): ObjectTypeComposer {
33+
return schemaComposer.getOrCreateOTC('ValidationError', (otc) => {
34+
const ValidatorError = schemaComposer.getOrCreateOTC('ValidatorError', (otc) => {
35+
otc.addFields({
36+
message: {
37+
description: 'Validation error message',
38+
type: 'String',
39+
},
40+
path: {
41+
description: 'Source of the validation error from the model path',
42+
type: 'String',
43+
},
44+
value: {
45+
description: 'Field value which occurs the validation error',
46+
type: 'JSON',
47+
},
48+
});
49+
});
50+
51+
otc.addFields({
52+
message: {
53+
description: 'Combined error message from all validators',
54+
type: 'String',
55+
},
56+
errors: {
57+
description: 'List of validator errors',
58+
type: ValidatorError.NonNull.List,
59+
},
60+
});
61+
});
62+
}

src/errors/index.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { InterfaceTypeComposer, SchemaComposer } from 'graphql-compose';
2+
import { MongoError, getMongoErrorOTC } from './MongoError';
3+
import { ValidationError, getValidationErrorOTC } from './ValidationError';
4+
import { RuntimeError, getRuntimeErrorOTC } from './RuntimeError';
5+
6+
export { MongoError, ValidationError, RuntimeError };
7+
8+
export function getErrorInterface(schemaComposer: SchemaComposer<any>): InterfaceTypeComposer {
9+
const ErrorInterface = schemaComposer.getOrCreateIFTC('ErrorInterface', (iftc) => {
10+
iftc.addFields({
11+
message: {
12+
description: 'Generic error message',
13+
type: 'String',
14+
},
15+
});
16+
17+
const ValidationErrorOTC = getValidationErrorOTC(schemaComposer);
18+
const MongoErrorOTC = getMongoErrorOTC(schemaComposer);
19+
const RuntimeErrorOTC = getRuntimeErrorOTC(schemaComposer);
20+
21+
ValidationErrorOTC.addInterface(iftc);
22+
MongoErrorOTC.addInterface(iftc);
23+
RuntimeErrorOTC.addInterface(iftc);
24+
25+
schemaComposer.addSchemaMustHaveType(ValidationErrorOTC);
26+
schemaComposer.addSchemaMustHaveType(MongoErrorOTC);
27+
schemaComposer.addSchemaMustHaveType(RuntimeErrorOTC);
28+
29+
const ValidationErrorType = ValidationErrorOTC.getType();
30+
const MongoErrorType = MongoErrorOTC.getType();
31+
const RuntimeErrorType = RuntimeErrorOTC.getType();
32+
33+
iftc.setResolveType((value) => {
34+
switch (value?.name) {
35+
case 'ValidationError':
36+
return ValidationErrorType;
37+
case 'MongoError':
38+
return MongoErrorType;
39+
default:
40+
return RuntimeErrorType;
41+
}
42+
});
43+
});
44+
45+
return ErrorInterface;
46+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ export * from './composeWithMongoose';
88
export * from './composeWithMongooseDiscriminators';
99
export * from './fieldsConverter';
1010
export * from './resolvers';
11+
export * from './errors';
1112
export { GraphQLMongoID, GraphQLBSONDecimal };

src/resolvers/__tests__/createMany-test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ describe('createMany() ->', () => {
237237

238238
it('should have records field, NonNull List', () => {
239239
const resolver = createMany(UserModel, UserTC);
240-
expect(resolver.getOTC().getFieldTypeName('records')).toEqual('[User!]!');
240+
expect(resolver.getOTC().getFieldTypeName('records')).toEqual('[User!]');
241241
});
242242

243243
it('should have user.contacts.mail required field', () => {

src/resolvers/__tests__/createOne-test.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,21 @@ describe('createOne() ->', () => {
118118
error: true,
119119
},
120120
});
121-
// TODO: FIX error
122-
expect(result.error).toEqual(
123-
[
124-
{ message: 'Path `n` is required.', path: 'n', value: undefined },
125-
{ message: 'this is a validate message', path: 'valid', value: 'AlwaysFails' },
126-
][0] // <---- [0] remove after preparation correct ValidationErrorsType
121+
expect(result.error.message).toEqual(
122+
'User validation failed: n: Path `n` is required., valid: this is a validate message'
127123
);
124+
expect(result.error.errors).toEqual([
125+
{
126+
message: 'Path `n` is required.',
127+
path: 'n',
128+
value: undefined,
129+
},
130+
{
131+
message: 'this is a validate message',
132+
path: 'valid',
133+
value: 'AlwaysFails',
134+
},
135+
]);
128136
});
129137

130138
it('should throw GraphQLError if client does not request errors field in payload', async () => {

src/resolvers/__tests__/updateById-test.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,17 @@ describe('updateById() ->', () => {
132132
error: true,
133133
},
134134
});
135-
expect(result.error).toEqual({
136-
message: 'this is a validate message',
137-
path: 'valid',
138-
value: 'AlwaysFails',
139-
});
135+
136+
expect(result.error.message).toEqual(
137+
'User validation failed: valid: this is a validate message'
138+
);
139+
expect(result.error.errors).toEqual([
140+
{
141+
message: 'this is a validate message',
142+
path: 'valid',
143+
value: 'AlwaysFails',
144+
},
145+
]);
140146
});
141147

142148
it('should throw GraphQLError if client does not request errors field in payload', async () => {

src/resolvers/__tests__/updateOne-test.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,17 @@ describe('updateOne() ->', () => {
167167
error: true,
168168
},
169169
});
170-
expect(result.error).toEqual({
171-
message: 'this is a validate message',
172-
path: 'valid',
173-
value: 'AlwaysFails',
174-
});
170+
171+
expect(result.error.message).toEqual(
172+
'User validation failed: valid: this is a validate message'
173+
);
174+
expect(result.error.errors).toEqual([
175+
{
176+
message: 'this is a validate message',
177+
path: 'valid',
178+
value: 'AlwaysFails',
179+
},
180+
]);
175181
});
176182

177183
it('should throw GraphQLError if client does not request errors field in payload', async () => {

0 commit comments

Comments
 (0)