Skip to content

Commit

Permalink
Merge pull request #2 from Maklestiguan/improvement_suggestions
Browse files Browse the repository at this point in the history
feat: new error log with id, now with tests!

BREAKING CHANGE: logger.error() now logs object except of string message
  • Loading branch information
temarusanov authored Dec 28, 2021
2 parents 2d96fb3 + 4c967dd commit 03e8a02
Show file tree
Hide file tree
Showing 12 changed files with 1,647 additions and 19,926 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ module.exports = {
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
'prettier/@typescript-eslint',
],
root: true,
env: {
Expand Down Expand Up @@ -111,6 +110,7 @@ module.exports = {
},
"ignorePatterns": [
".eslintrc.js",
"*.mocks.ts",
'[0-9]*.ts' // not to lint migrations files, example: 152325322-create-db.ts
],
};
3 changes: 2 additions & 1 deletion .github/workflows/pull-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ jobs:
- uses: actions/setup-node@v2
with:
node-version: 14
- run: npm ci
- run: npm ci
- run: npm test
101 changes: 101 additions & 0 deletions lib/filters/response.exception-filter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Test, TestingModule } from '@nestjs/testing'
import { BadRequestException, HttpStatus } from '@nestjs/common'

import { ResponseFilter } from './response.exception-filter'
import { mockArgumentsHost, mockLogger } from '../test/__mocks__/common.mocks'

describe('Response exception filter suite', () => {
let exceptionFilter: ResponseFilter

describe('when stacktrace enabled', () => {
beforeEach(async () => {
jest.clearAllMocks()
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: ResponseFilter,
useValue: new ResponseFilter(),
},
],
})
.setLogger(mockLogger)
.compile()

exceptionFilter = module.get<ResponseFilter>(ResponseFilter)
})

it('should be defined', () => {
expect(exceptionFilter).toBeDefined()
})

it('should have stack in error object', () => {
const response = exceptionFilter.catch(
new Error('random error'),
mockArgumentsHost,
)
expect(response).toBeDefined()
expect(response.data).toBeUndefined()
expect(response.error).toBeDefined()
expect(response.error.stack).toBeDefined()
})
})

describe('when stacktrace disabled | stacktrace does not matter', () => {
beforeEach(async () => {
jest.clearAllMocks()
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: ResponseFilter,
useValue: new ResponseFilter({ stack: false }),
},
],
})
.setLogger(mockLogger)
.compile()

exceptionFilter = module.get<ResponseFilter>(ResponseFilter)
})

it('should be defined', () => {
expect(exceptionFilter).toBeDefined()
})

it('should not have stack in error object', () => {
const response = exceptionFilter.catch(
new Error('random error'),
mockArgumentsHost,
)
expect(response).toBeDefined()
expect(response.data).toBeUndefined()
expect(response.error).toBeDefined()
expect(response.error.stack).toBeUndefined()
})

it('should set status to INTERNAL_SERVER_ERROR if error is not instanceof HttpException', () => {
const response = exceptionFilter.catch(
new Error('I am not an HttpException'),
mockArgumentsHost,
)
expect(response).toBeDefined()
expect(response.data).toBeUndefined()
expect(response.error).toBeDefined()
expect(response.error.code).toEqual(
HttpStatus.INTERNAL_SERVER_ERROR,
)
})

it('should set given status when error is instanceof HttpException', () => {
const response = exceptionFilter.catch(
new BadRequestException(
'I am an HttpException with status code 400',
),
mockArgumentsHost,
)
expect(response).toBeDefined()
expect(response.data).toBeUndefined()
expect(response.error).toBeDefined()
expect(response.error.code).toEqual(HttpStatus.BAD_REQUEST)
})
})
})
72 changes: 44 additions & 28 deletions lib/filters/response.exception-filter.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,80 @@
import { Catch, ArgumentsHost, Logger, HttpException } from '@nestjs/common'
import {
Catch,
ArgumentsHost,
Logger,
HttpException,
HttpStatus,
} from '@nestjs/common'
import { v4 as uuid } from 'uuid'
import { GqlExceptionFilter } from '@nestjs/graphql'
import { ResponseFilterConfig } from '../interfaces/response-filter-config.interface'
import { ResponsePayload } from '../interfaces/response-payload.interface'
import { ResponseFilterConfig, ResponsePayload, ErrorInfo } from '../interfaces'

const NO_DESCRIPTION = 'No description provided'

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

constructor(private readonly _config: ResponseFilterConfig) {}
constructor(
private readonly _config: ResponseFilterConfig = { stack: true },
) {}

catch(exception: Error, host: ArgumentsHost): ResponsePayload<unknown> {
catch(exception: Error, _host: ArgumentsHost): ResponsePayload<unknown> {
const id = uuid()
const code =
exception instanceof HttpException ? exception.getStatus() : 500
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR

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

const { stack } = this._config

const stackMessage =
stack === undefined || stack === true ? exception.stack : undefined
const stackMessage = stack ? exception.stack : undefined

this._logger.error(`${message} (${description})`, exception.stack)
this._logger.error(
{
id,
message,
description,
},
exception.stack,
)

return {
error: {
id: uuid(),
code: code,
id,
code,
message,
description,
stack: stackMessage,
},
}
}

private _getErrorInfo(exception: Error): {
message: string
description: string
} {
/**
* @summary Retrieves `message` and `description` that were originally sent to NestJS' `HttpException` constructor
* @param exception caught in `ResponseFilter`
* @returns `message` and `description` fields wrapped in object
*/
private _getErrorInfo(exception: Error): ErrorInfo {
const errorResponse =
exception instanceof HttpException
? (exception.getResponse() as any)
: null
exception instanceof HttpException ? exception.getResponse() : {}

if (typeof errorResponse === 'string' || errorResponse === null) {
if (typeof errorResponse === 'string') {
return {
message: exception.name,
description: exception.message as string,
description: exception.message,
}
}

return {
message:
'message' in errorResponse
? errorResponse['message']
: exception.name,
description:
'description' in errorResponse
? errorResponse['description']
: 'No description provided',
message: errorResponse.hasOwnProperty('message')
? errorResponse['message']
: exception.name,
description: errorResponse.hasOwnProperty('description')
? errorResponse['description']
: NO_DESCRIPTION,
}
}
}
4 changes: 4 additions & 0 deletions lib/interfaces/error-info.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface ErrorInfo {
message: string
description: string
}
1 change: 1 addition & 0 deletions lib/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './response-error.interface'
export * from './response-payload.interface'
export * from './response-filter-config.interface'
export * from './error-info.interface'
4 changes: 2 additions & 2 deletions lib/interfaces/response-error.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ export interface ResponseError {
id: string
code: number
message: string
description?: string
stack?: unknown
description: string
stack?: string
}
31 changes: 31 additions & 0 deletions lib/test/__mocks__/common.mocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export const mockLogger = {
log: (message: any, context?: string) => {},
error: (message: any, trace?: string, context?: string) => {},
warn: (message: any, context?: string) => {},
debug: (message: any, context?: string) => {},
verbose: (message: any, context?: string) => {},
}

export const mockJson = jest.fn()

export const mockStatus = jest.fn().mockImplementation(() => ({
json: mockJson,
}))

export const mockGetResponse = jest.fn().mockImplementation(() => ({
status: mockStatus,
}))

export const mockHttpArgumentsHost = jest.fn().mockImplementation(() => ({
getResponse: mockGetResponse,
getRequest: jest.fn(),
}))

export const mockArgumentsHost = {
switchToHttp: mockHttpArgumentsHost,
getArgByIndex: jest.fn(),
getArgs: jest.fn(),
getType: jest.fn(),
switchToRpc: jest.fn(),
switchToWs: jest.fn(),
}
4 changes: 2 additions & 2 deletions lib/types/response-error.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ export class ResponseErrorType implements ResponseError {
@Field()
message: string

@Field({ nullable: true })
description?: string
@Field()
description: string

@Field({ nullable: true })
stack?: string
Expand Down
16 changes: 15 additions & 1 deletion lib/types/responses-payload.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,26 @@ import { ResponseError } from '../interfaces/response-error.interface'
import { ResponsesPayload } from '../interfaces/responses-payload.interface'
import { ResponseErrorType } from './response-error.type'

export type NullableListOptions = { nullable: true | 'itemsAndList' }

/**
*
* @param classRef Initial class with `@ObjectType` decorator
* @param options nullability options, returning `[Type]!` or `[Type!]!` is disallowed due to nature of response structure -
* `data` field will always be (`GraphQL`-wise) null when error is thrown
*
* You can provide `true` (default) to generate `[Type!]` definition
* or `'itemsAndList'` to generate `[Type]` definition in resulting `.graphql` schema file
*
* @see NullableListOptions
*/
export const ResponsesPayloadType = <T>(
classRef: Type<T>,
options: NullableListOptions = { nullable: true },
): Type<ResponsesPayload<T>> => {
@ObjectType('ResponsesPayload', { isAbstract: true })
class ResponsesPayloadType implements ResponsesPayload<T> {
@Field(() => [classRef], { nullable: true })
@Field(() => [classRef], { nullable: options.nullable })
data?: T[]

@Field(() => ResponseErrorType, { nullable: true })
Expand Down
Loading

0 comments on commit 03e8a02

Please sign in to comment.