Skip to content

Commit 03e8a02

Browse files
authored
Merge pull request #2 from Maklestiguan/improvement_suggestions
feat: new error log with id, now with tests! BREAKING CHANGE: logger.error() now logs object except of string message
2 parents 2d96fb3 + 4c967dd commit 03e8a02

12 files changed

+1647
-19926
lines changed

.eslintrc.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ module.exports = {
1212
'plugin:@typescript-eslint/eslint-recommended',
1313
'plugin:@typescript-eslint/recommended',
1414
'prettier',
15-
'prettier/@typescript-eslint',
1615
],
1716
root: true,
1817
env: {
@@ -111,6 +110,7 @@ module.exports = {
111110
},
112111
"ignorePatterns": [
113112
".eslintrc.js",
113+
"*.mocks.ts",
114114
'[0-9]*.ts' // not to lint migrations files, example: 152325322-create-db.ts
115115
],
116116
};

.github/workflows/pull-request.yaml

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ jobs:
1212
- uses: actions/setup-node@v2
1313
with:
1414
node-version: 14
15-
- run: npm ci
15+
- run: npm ci
16+
- run: npm test
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { Test, TestingModule } from '@nestjs/testing'
2+
import { BadRequestException, HttpStatus } from '@nestjs/common'
3+
4+
import { ResponseFilter } from './response.exception-filter'
5+
import { mockArgumentsHost, mockLogger } from '../test/__mocks__/common.mocks'
6+
7+
describe('Response exception filter suite', () => {
8+
let exceptionFilter: ResponseFilter
9+
10+
describe('when stacktrace enabled', () => {
11+
beforeEach(async () => {
12+
jest.clearAllMocks()
13+
const module: TestingModule = await Test.createTestingModule({
14+
providers: [
15+
{
16+
provide: ResponseFilter,
17+
useValue: new ResponseFilter(),
18+
},
19+
],
20+
})
21+
.setLogger(mockLogger)
22+
.compile()
23+
24+
exceptionFilter = module.get<ResponseFilter>(ResponseFilter)
25+
})
26+
27+
it('should be defined', () => {
28+
expect(exceptionFilter).toBeDefined()
29+
})
30+
31+
it('should have stack in error object', () => {
32+
const response = exceptionFilter.catch(
33+
new Error('random error'),
34+
mockArgumentsHost,
35+
)
36+
expect(response).toBeDefined()
37+
expect(response.data).toBeUndefined()
38+
expect(response.error).toBeDefined()
39+
expect(response.error.stack).toBeDefined()
40+
})
41+
})
42+
43+
describe('when stacktrace disabled | stacktrace does not matter', () => {
44+
beforeEach(async () => {
45+
jest.clearAllMocks()
46+
const module: TestingModule = await Test.createTestingModule({
47+
providers: [
48+
{
49+
provide: ResponseFilter,
50+
useValue: new ResponseFilter({ stack: false }),
51+
},
52+
],
53+
})
54+
.setLogger(mockLogger)
55+
.compile()
56+
57+
exceptionFilter = module.get<ResponseFilter>(ResponseFilter)
58+
})
59+
60+
it('should be defined', () => {
61+
expect(exceptionFilter).toBeDefined()
62+
})
63+
64+
it('should not have stack in error object', () => {
65+
const response = exceptionFilter.catch(
66+
new Error('random error'),
67+
mockArgumentsHost,
68+
)
69+
expect(response).toBeDefined()
70+
expect(response.data).toBeUndefined()
71+
expect(response.error).toBeDefined()
72+
expect(response.error.stack).toBeUndefined()
73+
})
74+
75+
it('should set status to INTERNAL_SERVER_ERROR if error is not instanceof HttpException', () => {
76+
const response = exceptionFilter.catch(
77+
new Error('I am not an HttpException'),
78+
mockArgumentsHost,
79+
)
80+
expect(response).toBeDefined()
81+
expect(response.data).toBeUndefined()
82+
expect(response.error).toBeDefined()
83+
expect(response.error.code).toEqual(
84+
HttpStatus.INTERNAL_SERVER_ERROR,
85+
)
86+
})
87+
88+
it('should set given status when error is instanceof HttpException', () => {
89+
const response = exceptionFilter.catch(
90+
new BadRequestException(
91+
'I am an HttpException with status code 400',
92+
),
93+
mockArgumentsHost,
94+
)
95+
expect(response).toBeDefined()
96+
expect(response.data).toBeUndefined()
97+
expect(response.error).toBeDefined()
98+
expect(response.error.code).toEqual(HttpStatus.BAD_REQUEST)
99+
})
100+
})
101+
})
+44-28
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,80 @@
1-
import { Catch, ArgumentsHost, Logger, HttpException } from '@nestjs/common'
1+
import {
2+
Catch,
3+
ArgumentsHost,
4+
Logger,
5+
HttpException,
6+
HttpStatus,
7+
} from '@nestjs/common'
28
import { v4 as uuid } from 'uuid'
39
import { GqlExceptionFilter } from '@nestjs/graphql'
4-
import { ResponseFilterConfig } from '../interfaces/response-filter-config.interface'
5-
import { ResponsePayload } from '../interfaces/response-payload.interface'
10+
import { ResponseFilterConfig, ResponsePayload, ErrorInfo } from '../interfaces'
11+
12+
const NO_DESCRIPTION = 'No description provided'
613

714
@Catch()
815
export class ResponseFilter implements GqlExceptionFilter {
916
private _logger = new Logger(ResponseFilter.name)
1017

11-
constructor(private readonly _config: ResponseFilterConfig) {}
18+
constructor(
19+
private readonly _config: ResponseFilterConfig = { stack: true },
20+
) {}
1221

13-
catch(exception: Error, host: ArgumentsHost): ResponsePayload<unknown> {
22+
catch(exception: Error, _host: ArgumentsHost): ResponsePayload<unknown> {
23+
const id = uuid()
1424
const code =
15-
exception instanceof HttpException ? exception.getStatus() : 500
25+
exception instanceof HttpException
26+
? exception.getStatus()
27+
: HttpStatus.INTERNAL_SERVER_ERROR
1628

1729
const { message, description } = this._getErrorInfo(exception)
1830

1931
const { stack } = this._config
2032

21-
const stackMessage =
22-
stack === undefined || stack === true ? exception.stack : undefined
33+
const stackMessage = stack ? exception.stack : undefined
2334

24-
this._logger.error(`${message} (${description})`, exception.stack)
35+
this._logger.error(
36+
{
37+
id,
38+
message,
39+
description,
40+
},
41+
exception.stack,
42+
)
2543

2644
return {
2745
error: {
28-
id: uuid(),
29-
code: code,
46+
id,
47+
code,
3048
message,
3149
description,
3250
stack: stackMessage,
3351
},
3452
}
3553
}
3654

37-
private _getErrorInfo(exception: Error): {
38-
message: string
39-
description: string
40-
} {
55+
/**
56+
* @summary Retrieves `message` and `description` that were originally sent to NestJS' `HttpException` constructor
57+
* @param exception caught in `ResponseFilter`
58+
* @returns `message` and `description` fields wrapped in object
59+
*/
60+
private _getErrorInfo(exception: Error): ErrorInfo {
4161
const errorResponse =
42-
exception instanceof HttpException
43-
? (exception.getResponse() as any)
44-
: null
62+
exception instanceof HttpException ? exception.getResponse() : {}
4563

46-
if (typeof errorResponse === 'string' || errorResponse === null) {
64+
if (typeof errorResponse === 'string') {
4765
return {
4866
message: exception.name,
49-
description: exception.message as string,
67+
description: exception.message,
5068
}
5169
}
5270

5371
return {
54-
message:
55-
'message' in errorResponse
56-
? errorResponse['message']
57-
: exception.name,
58-
description:
59-
'description' in errorResponse
60-
? errorResponse['description']
61-
: 'No description provided',
72+
message: errorResponse.hasOwnProperty('message')
73+
? errorResponse['message']
74+
: exception.name,
75+
description: errorResponse.hasOwnProperty('description')
76+
? errorResponse['description']
77+
: NO_DESCRIPTION,
6278
}
6379
}
6480
}
+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface ErrorInfo {
2+
message: string
3+
description: string
4+
}

lib/interfaces/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './response-error.interface'
22
export * from './response-payload.interface'
33
export * from './response-filter-config.interface'
4+
export * from './error-info.interface'

lib/interfaces/response-error.interface.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ export interface ResponseError {
22
id: string
33
code: number
44
message: string
5-
description?: string
6-
stack?: unknown
5+
description: string
6+
stack?: string
77
}

lib/test/__mocks__/common.mocks.ts

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export const mockLogger = {
2+
log: (message: any, context?: string) => {},
3+
error: (message: any, trace?: string, context?: string) => {},
4+
warn: (message: any, context?: string) => {},
5+
debug: (message: any, context?: string) => {},
6+
verbose: (message: any, context?: string) => {},
7+
}
8+
9+
export const mockJson = jest.fn()
10+
11+
export const mockStatus = jest.fn().mockImplementation(() => ({
12+
json: mockJson,
13+
}))
14+
15+
export const mockGetResponse = jest.fn().mockImplementation(() => ({
16+
status: mockStatus,
17+
}))
18+
19+
export const mockHttpArgumentsHost = jest.fn().mockImplementation(() => ({
20+
getResponse: mockGetResponse,
21+
getRequest: jest.fn(),
22+
}))
23+
24+
export const mockArgumentsHost = {
25+
switchToHttp: mockHttpArgumentsHost,
26+
getArgByIndex: jest.fn(),
27+
getArgs: jest.fn(),
28+
getType: jest.fn(),
29+
switchToRpc: jest.fn(),
30+
switchToWs: jest.fn(),
31+
}

lib/types/response-error.type.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ export class ResponseErrorType implements ResponseError {
1212
@Field()
1313
message: string
1414

15-
@Field({ nullable: true })
16-
description?: string
15+
@Field()
16+
description: string
1717

1818
@Field({ nullable: true })
1919
stack?: string

lib/types/responses-payload.type.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,26 @@ import { ResponseError } from '../interfaces/response-error.interface'
44
import { ResponsesPayload } from '../interfaces/responses-payload.interface'
55
import { ResponseErrorType } from './response-error.type'
66

7+
export type NullableListOptions = { nullable: true | 'itemsAndList' }
8+
9+
/**
10+
*
11+
* @param classRef Initial class with `@ObjectType` decorator
12+
* @param options nullability options, returning `[Type]!` or `[Type!]!` is disallowed due to nature of response structure -
13+
* `data` field will always be (`GraphQL`-wise) null when error is thrown
14+
*
15+
* You can provide `true` (default) to generate `[Type!]` definition
16+
* or `'itemsAndList'` to generate `[Type]` definition in resulting `.graphql` schema file
17+
*
18+
* @see NullableListOptions
19+
*/
720
export const ResponsesPayloadType = <T>(
821
classRef: Type<T>,
22+
options: NullableListOptions = { nullable: true },
923
): Type<ResponsesPayload<T>> => {
1024
@ObjectType('ResponsesPayload', { isAbstract: true })
1125
class ResponsesPayloadType implements ResponsesPayload<T> {
12-
@Field(() => [classRef], { nullable: true })
26+
@Field(() => [classRef], { nullable: options.nullable })
1327
data?: T[]
1428

1529
@Field(() => ResponseErrorType, { nullable: true })

0 commit comments

Comments
 (0)