Skip to content

Commit e8d2cfb

Browse files
authored
feat: document the errors that each endpoint throws in Swagger and Apollo (#1353)
* feat: document errors in swagger per endpoint * fix: address code review feedback * refactor: code improvements * fix: additional code improvements based on code review feedback * feat: document errors in apollo server playground * fix: comment out log to avoid clutter
1 parent 32fc092 commit e8d2cfb

File tree

15 files changed

+265
-69
lines changed

15 files changed

+265
-69
lines changed

libraries/grpc-sdk/src/interfaces/Route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { allowedTypes, ConduitModel, TYPE } from './Model.js';
22
import { Indexable } from './Indexable.js';
3+
import { ModuleErrorDefinition } from '@conduitplatform/module-tools';
34

45
export interface ConduitRouteParameters {
56
params?: Indexable;
@@ -61,6 +62,7 @@ export interface ConduitRouteOptions {
6162
description?: string;
6263
middlewares?: string[];
6364
cacheControl?: string;
65+
errors?: ModuleErrorDefinition[];
6466
}
6567

6668
export interface ConduitRouteObject {

libraries/hermes/src/GraphQl/GraphQL.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,14 @@ export class GraphQLController extends ConduitRouter {
177177

178178
let description = '';
179179
if (input.description) {
180-
description = `""" ${input.description} """ `;
180+
description = `""" ${input.description}`;
181+
if (Array.isArray(input.errors) && input.errors.length > 0) {
182+
description += `\n\nPossible errors:\n\n`;
183+
input.errors.forEach((err: { conduitCode: string; description: string }) => {
184+
description += `- ${err.conduitCode}: ${err.description}\n\n`;
185+
});
186+
}
187+
description += ' """ ';
181188
}
182189

183190
const finalName = description + name + params + ':' + returnType;
Lines changed: 32 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,44 @@
11
import { ConduitGrpcSdk, ConduitError } from '@conduitplatform/grpc-sdk';
22
import { GraphQLError } from 'graphql';
3+
import { mapGrpcErrorToHttp } from '../../Rest/util.js';
34

45
export const errorHandler = (err: Error | ConduitError | any) => {
5-
ConduitGrpcSdk.Logger.error(err);
66
if (err.hasOwnProperty('status')) {
7+
ConduitGrpcSdk.Logger.error(err);
78
throw new GraphQLError(err.message, {
89
extensions: { code: (err as ConduitError).status.toString() },
910
originalError: err,
1011
});
11-
} else if (err.hasOwnProperty('code')) {
12-
switch (err.code) {
13-
case 3:
14-
throw new GraphQLError(err.details, {
15-
extensions: { code: '400' },
16-
originalError: err,
17-
});
18-
case 5:
19-
throw new GraphQLError(err.details, {
20-
extensions: { code: '404' },
21-
originalError: err,
22-
});
23-
case 7:
24-
throw new GraphQLError(err.details, {
25-
extensions: { code: '403' },
26-
originalError: err,
27-
});
28-
case 16:
29-
throw new GraphQLError(err.details, {
30-
extensions: { code: '401' },
31-
originalError: err,
32-
});
33-
default:
34-
throw new GraphQLError(err.details, {
35-
extensions: { code: '500' },
36-
originalError: err,
37-
});
12+
}
13+
if (err.hasOwnProperty('code')) {
14+
const { status } = mapGrpcErrorToHttp(err.code);
15+
let parsed: { message: string; conduitCode: string } | null = null;
16+
try {
17+
parsed = JSON.parse(err.details);
18+
} catch (e) {
19+
// The below line is commented out to avoid cluttering the logs since most errors will not have a parsable details field.
20+
// console.warn('Error parsing details:', e);
21+
}
22+
if (parsed && typeof parsed === 'object') {
23+
ConduitGrpcSdk.Logger.error(parsed.message);
24+
throw new GraphQLError(parsed.message, {
25+
extensions: {
26+
code: status,
27+
conduitCode: parsed.conduitCode,
28+
},
29+
originalError: err,
30+
});
31+
} else {
32+
ConduitGrpcSdk.Logger.error(err);
33+
throw new GraphQLError(err.details, {
34+
extensions: { code: status },
35+
originalError: err,
36+
});
3837
}
39-
} else {
40-
throw new GraphQLError(err.message, {
41-
extensions: { code: '500' },
42-
originalError: err,
43-
});
4438
}
39+
ConduitGrpcSdk.Logger.error(err);
40+
throw new GraphQLError(err.message, {
41+
extensions: { code: '500' },
42+
originalError: err,
43+
});
4544
};

libraries/hermes/src/Rest/Rest.ts

Lines changed: 26 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
Router,
88
} from 'express';
99
import { SwaggerGenerator } from './Swagger.js';
10-
import { extractRequestData, validateParams } from './util.js';
10+
import { extractRequestData, mapGrpcErrorToHttp, validateParams } from './util.js';
1111
import { createHashKey, extractCaching } from '../cache.utils.js';
1212
import { ConduitRouter } from '../Router.js';
1313
import {
@@ -241,45 +241,41 @@ export class RestController extends ConduitRouter {
241241

242242
handleError(res: Response): (err: Error | ConduitError) => void {
243243
return (err: Error | ConduitError | any) => {
244-
ConduitGrpcSdk.Logger.error(err);
245244
if (err.hasOwnProperty('status')) {
245+
ConduitGrpcSdk.Logger.error(err);
246246
return res.status((err as ConduitError).status).json({
247247
name: err.name,
248248
status: (err as ConduitError).status,
249249
message: err.message,
250250
});
251251
}
252252
if (err.hasOwnProperty('code')) {
253-
let statusCode: number;
254-
let name: string;
255-
switch (err.code) {
256-
case 3:
257-
name = 'INVALID_ARGUMENTS';
258-
statusCode = 400;
259-
break;
260-
case 5:
261-
name = 'NOT_FOUND';
262-
statusCode = 404;
263-
break;
264-
case 7:
265-
name = 'FORBIDDEN';
266-
statusCode = 403;
267-
break;
268-
case 16:
269-
name = 'UNAUTHORIZED';
270-
statusCode = 401;
271-
break;
272-
default:
273-
name = 'INTERNAL_SERVER_ERROR';
274-
statusCode = 500;
275-
break;
253+
const { status, name } = mapGrpcErrorToHttp(err.code);
254+
let parsed: { message: string; conduitCode: string } | null = null;
255+
try {
256+
parsed = JSON.parse(err.details);
257+
} catch (e) {
258+
// The below line is commented out to avoid cluttering the logs since most errors will not have a parsable details field.
259+
// console.warn('Error parsing details:', e);
260+
}
261+
if (parsed && typeof parsed === 'object') {
262+
ConduitGrpcSdk.Logger.error(parsed.message);
263+
return res.status(status).json({
264+
name,
265+
status,
266+
message: parsed.message,
267+
conduitCode: parsed.conduitCode,
268+
});
269+
} else {
270+
ConduitGrpcSdk.Logger.error(err);
271+
return res.status(status).json({
272+
name,
273+
status,
274+
message: err.details,
275+
});
276276
}
277-
return res.status(statusCode).json({
278-
name,
279-
status: statusCode,
280-
message: err.details,
281-
});
282277
}
278+
ConduitGrpcSdk.Logger.error(err);
283279
res.status(500).json({
284280
name: 'INTERNAL_SERVER_ERROR',
285281
status: 500,

libraries/hermes/src/Rest/Swagger.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ import { SwaggerRouterMetadata } from '../types/index.js';
55
import { ConduitRoute } from '../classes/index.js';
66
import { importDbTypes } from '../utils/types.js';
77
import { processSwaggerParams } from './SimpleTypeParamUtils.js';
8+
import { mapGrpcErrorToHttp } from './util.js';
9+
10+
interface SwaggerExample {
11+
name: string;
12+
message: string;
13+
conduitCode: string;
14+
}
15+
16+
interface SwaggerResponseContent {
17+
schema: { $ref: string };
18+
example?: SwaggerExample;
19+
examples?: Record<string, { value: SwaggerExample }>;
20+
}
821

922
export class SwaggerGenerator {
1023
private _swaggerDoc: Indexable;
@@ -31,6 +44,24 @@ export class SwaggerGenerator {
3144
ModelId: {
3245
type: 'string',
3346
},
47+
ErrorResponse: {
48+
type: 'object',
49+
properties: {
50+
name: {
51+
type: 'string',
52+
description: 'HTTP error name',
53+
},
54+
message: {
55+
type: 'string',
56+
description: 'Error message',
57+
},
58+
conduitCode: {
59+
type: 'string',
60+
description: 'Conduit internal error code',
61+
},
62+
},
63+
required: ['httpCode', 'conduitCode', 'description'],
64+
},
3465
},
3566
securitySchemes: this._routerMetadata.securitySchemes,
3667
},
@@ -152,6 +183,70 @@ export class SwaggerGenerator {
152183
this._swaggerDoc.paths[path] = {};
153184
this._swaggerDoc.paths[path][method] = routeDoc;
154185
}
186+
187+
const errors = route.input.errors || [];
188+
const errorGroups: Record<
189+
string,
190+
Record<
191+
string,
192+
{ name: string; message: string; conduitCode: string; description: string }[]
193+
>
194+
> = {};
195+
for (const error of errors) {
196+
const { conduitCode, grpcCode, message, description } = error;
197+
const { name, status } = mapGrpcErrorToHttp(grpcCode);
198+
if (!errorGroups[status]) errorGroups[status] = {};
199+
if (!errorGroups[status][conduitCode]) errorGroups[status][conduitCode] = [];
200+
errorGroups[status][conduitCode].push({
201+
name,
202+
message,
203+
conduitCode,
204+
description,
205+
});
206+
}
207+
208+
for (const status in errorGroups) {
209+
const allExamples: {
210+
name: string;
211+
message: string;
212+
conduitCode: string;
213+
description: string;
214+
}[] = [];
215+
for (const conduitCode in errorGroups[status]) {
216+
allExamples.push(...errorGroups[status][conduitCode]);
217+
}
218+
const responseContent: { 'application/json': SwaggerResponseContent } = {
219+
'application/json': {
220+
schema: {
221+
$ref: '#/components/schemas/ErrorResponse',
222+
},
223+
},
224+
};
225+
if (allExamples.length === 1) {
226+
responseContent['application/json']['example'] = {
227+
name: allExamples[0].name,
228+
message: allExamples[0].message,
229+
conduitCode: allExamples[0].conduitCode,
230+
};
231+
} else if (allExamples.length > 1) {
232+
responseContent['application/json']['examples'] = {};
233+
allExamples.forEach(example => {
234+
const { name, message, conduitCode, description } = example;
235+
if (responseContent['application/json']['examples']) {
236+
responseContent['application/json']['examples'][description] = {
237+
value: {
238+
name,
239+
message,
240+
conduitCode,
241+
},
242+
};
243+
}
244+
});
245+
}
246+
routeDoc.responses[status] = {
247+
content: responseContent,
248+
};
249+
}
155250
}
156251

157252
private _extractMethod(action: string) {

libraries/hermes/src/Rest/util.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,24 @@ function validateType(
229229
function isValidDate(date: Date): boolean {
230230
return !isNaN(date.getHours());
231231
}
232+
233+
export function mapGrpcErrorToHttp(gRPCErrorCode: number): {
234+
name: string;
235+
status: number;
236+
} {
237+
switch (gRPCErrorCode) {
238+
case 3:
239+
return { name: 'INVALID_ARGUMENTS', status: 400 };
240+
case 5:
241+
return { name: 'NOT_FOUND', status: 404 };
242+
// TODO: Enable this case once conflict error handling is implemented. Currently commented out to avoid introducing a breaking change.
243+
// case 6:
244+
// return { name: 'CONFLICT', status: 409 };
245+
case 7:
246+
return { name: 'FORBIDDEN', status: 403 };
247+
case 16:
248+
return { name: 'UNAUTHORIZED', status: 401 };
249+
default:
250+
return { name: 'INTERNAL_SERVER_ERROR', status: 500 };
251+
}
252+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { GrpcError } from '@conduitplatform/grpc-sdk';
2+
import { ModuleErrorDefinition } from '../interfaces';
3+
4+
export class ModuleError extends GrpcError {
5+
debugLogInfo?: string;
6+
7+
constructor(errorDefinition: ModuleErrorDefinition, debugLogInfo?: string) {
8+
const { grpcCode, conduitCode, message } = errorDefinition;
9+
super(
10+
grpcCode,
11+
JSON.stringify({
12+
message,
13+
conduitCode: conduitCode,
14+
}),
15+
);
16+
if (debugLogInfo) this.debugLogInfo = debugLogInfo;
17+
}
18+
}

libraries/module-tools/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from './classes/index.js';
44
export * from './helpers/index.js';
55
export * from './routing/index.js';
66
export * from './utilities/index.js';
7+
export * from './classes/ModuleError.js';
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export interface ModuleErrorDefinition {
2+
grpcCode: number;
3+
conduitCode: string;
4+
message: string;
5+
description: string;
6+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './ConduitService.js';
22
export * from './ModuleLifecycleStage.js';
3+
export * from './ModuleErrorDefinition.js';

0 commit comments

Comments
 (0)