Skip to content

Commit

Permalink
add error management for failed Validation Pipes (#169)
Browse files Browse the repository at this point in the history
* add error management for failed Validation Pipes

* format

* test exception on ping controller

* registered exception filters to policy-engine

* format

* Update apps/armory/src/shared/filter/application-exception.filter.ts

Co-authored-by: William Calderipe <wcalderipe@gmail.com>

* ported filter test file

---------

Co-authored-by: William Calderipe <wcalderipe@gmail.com>
  • Loading branch information
Ptroger and wcalderipe authored Mar 21, 2024
1 parent da31f53 commit 2d0a9d1
Show file tree
Hide file tree
Showing 7 changed files with 236 additions and 2 deletions.
7 changes: 6 additions & 1 deletion apps/armory/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { lastValueFrom, map, of, switchMap } from 'rxjs'
import { Config } from './armory.config'
import { ArmoryModule } from './armory.module'
import { ApplicationExceptionFilter } from './shared/filter/application-exception.filter'
import { HttpExceptionFilter } from './shared/filter/http-exception.filter'
import { ZodExceptionFilter } from './shared/filter/zod-exception.filter'

/**
Expand Down Expand Up @@ -64,7 +65,11 @@ const withGlobalInterceptors = (app: INestApplication): INestApplication => {
const withGlobalFilters =
(configService: ConfigService<Config, true>) =>
(app: INestApplication): INestApplication => {
app.useGlobalFilters(new ApplicationExceptionFilter(configService), new ZodExceptionFilter(configService))
app.useGlobalFilters(
new ApplicationExceptionFilter(configService),
new ZodExceptionFilter(configService),
new HttpExceptionFilter(configService)
)

return app
}
Expand Down
36 changes: 36 additions & 0 deletions apps/armory/src/shared/filter/http-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { Response } from 'express'
import { Config, Env } from '../../armory.config'

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
private logger = new Logger(HttpExceptionFilter.name)

constructor(private configService: ConfigService<Config, true>) {}

catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp()
const response = ctx.getResponse<Response>()
const status = exception.getStatus()

const isProduction = this.configService.get('env') === Env.PRODUCTION

this.logger.error(exception)

response.status(status).json(
isProduction
? {
statusCode: status,
message: exception.message,
response: exception.getResponse()
}
: {
statusCode: status,
message: exception.message,
response: exception.getResponse(),
stack: exception.stack
}
)
}
}
1 change: 0 additions & 1 deletion apps/policy-engine/src/engine/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export class AppController {
this.logger.log({
message: 'Received ping'
})

return 'pong'
}

Expand Down
19 changes: 19 additions & 0 deletions apps/policy-engine/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { ConfigService } from '@nestjs/config'
import { NestFactory } from '@nestjs/core'
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
import { lastValueFrom, map, of, switchMap } from 'rxjs'
import { Config } from './policy-engine.config'
import { PolicyEngineModule } from './policy-engine.module'
import { ApplicationExceptionFilter } from './shared/filter/application-exception.filter'
import { HttpExceptionFilter } from './shared/filter/http-exception.filter'

/**
* Adds Swagger documentation to the application.
Expand Down Expand Up @@ -37,6 +40,21 @@ const withGlobalPipes = (app: INestApplication): INestApplication => {
return app
}

/**
* Adds a global exception filter to the application.
*
* @param app - The Nest application instance.
* @param configService - The configuration service instance.
* @returns The modified Nest application instance.
*/
const withGlobalFilters =
(configService: ConfigService<Config, true>) =>
(app: INestApplication): INestApplication => {
app.useGlobalFilters(new HttpExceptionFilter(configService), new ApplicationExceptionFilter(configService))

return app
}

async function bootstrap() {
const logger = new Logger('PolicyEngineBootstrap')
const application = await NestFactory.create(PolicyEngineModule, { bodyParser: true })
Expand All @@ -51,6 +69,7 @@ async function bootstrap() {
of(application).pipe(
map(withSwagger),
map(withGlobalPipes),
map(withGlobalFilters(configService)),
switchMap((app) => app.listen(port))
)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { ArgumentsHost, HttpStatus } from '@nestjs/common'
import { HttpArgumentsHost } from '@nestjs/common/interfaces'
import { ConfigService } from '@nestjs/config'
import { Response } from 'express'
import { mock } from 'jest-mock-extended'
import { Config, Env } from '../../../../policy-engine.config'
import { ApplicationException } from '../../../exception/application.exception'
import { ApplicationExceptionFilter } from '../../application-exception.filter'

describe(ApplicationExceptionFilter.name, () => {
const exception = new ApplicationException({
message: 'Test application exception filter',
suggestedHttpStatusCode: HttpStatus.INTERNAL_SERVER_ERROR,
context: {
additional: 'information',
to: 'debug'
}
})

const buildArgumentsHostMock = (): [ArgumentsHost, jest.Mock, jest.Mock] => {
const jsonMock = jest.fn()
const statusMock = jest.fn().mockReturnValue(
mock<Response>({
json: jsonMock
})
)

const host = mock<ArgumentsHost>({
switchToHttp: jest.fn().mockReturnValue(
mock<HttpArgumentsHost>({
getResponse: jest.fn().mockReturnValue(
mock<Response>({
status: statusMock
})
)
})
)
})

return [host, statusMock, jsonMock]
}

const buildConfigServiceMock = (env: Env) =>
mock<ConfigService<Config, true>>({
get: jest.fn().mockReturnValue(env)
})

describe('catch', () => {
describe('when environment is production', () => {
it('responds with exception status and short message', () => {
const filter = new ApplicationExceptionFilter(buildConfigServiceMock(Env.PRODUCTION))
const [host, statusMock, jsonMock] = buildArgumentsHostMock()

filter.catch(exception, host)

expect(statusMock).toHaveBeenCalledWith(exception.getStatus())
expect(jsonMock).toHaveBeenCalledWith({
statusCode: exception.getStatus(),
message: exception.message,
context: exception.context
})
})
})

describe('when environment is not production', () => {
it('responds with exception status and complete message', () => {
const filter = new ApplicationExceptionFilter(buildConfigServiceMock(Env.DEVELOPMENT))
const [host, statusMock, jsonMock] = buildArgumentsHostMock()

filter.catch(exception, host)

expect(statusMock).toHaveBeenCalledWith(exception.getStatus())
expect(jsonMock).toHaveBeenCalledWith({
statusCode: exception.getStatus(),
message: exception.message,
context: exception.context,
stack: exception.stack
})
})
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ArgumentsHost, Catch, ExceptionFilter, LogLevel, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { Response } from 'express'
import { Config, Env } from '../../policy-engine.config'
import { ApplicationException } from '../../shared/exception/application.exception'

@Catch(ApplicationException)
export class ApplicationExceptionFilter implements ExceptionFilter {
private logger = new Logger(ApplicationExceptionFilter.name)

constructor(private configService: ConfigService<Config, true>) {}

catch(exception: ApplicationException, host: ArgumentsHost) {
const ctx = host.switchToHttp()
const response = ctx.getResponse<Response>()
const status = exception.getStatus()
const isProduction = this.configService.get('env') === Env.PRODUCTION

this.log(exception)

response.status(status).json(
isProduction
? {
statusCode: status,
message: exception.message,
context: exception.context
}
: {
statusCode: status,
message: exception.message,
context: exception.context,
stack: exception.stack,
...(exception.origin && { origin: exception.origin })
}
)
}

// TODO (@wcalderipe, 16/01/24): Unit test the logging logic. For that, we
// must inject the logger in the constructor via dependency injection.
private log(exception: ApplicationException) {
const level: LogLevel = exception.getStatus() >= 500 ? 'error' : 'warn'

if (this.logger[level]) {
this.logger[level](exception.message, {
status: exception.getStatus(),
context: exception.context,
stacktrace: exception.stack,
origin: exception.origin
})
}
}
}
41 changes: 41 additions & 0 deletions apps/policy-engine/src/shared/filter/http-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { Response } from 'express'
import { Config, Env } from '../../policy-engine.config'

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
private logger = new Logger(HttpExceptionFilter.name)

constructor(private configService: ConfigService<Config, true>) {}

catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp()
const response = ctx.getResponse<Response>()
const status = exception.getStatus()

const isProduction = this.configService.get('env') === Env.PRODUCTION

this.logger.error({
message: exception.message,
stack: exception.stack,
response: exception.getResponse(),
statusCode: status
})

response.status(status).json(
isProduction
? {
statusCode: status,
message: exception.message,
response: exception.getResponse()
}
: {
statusCode: status,
message: exception.message,
response: exception.getResponse(),
stack: exception.stack
}
)
}
}

0 comments on commit 2d0a9d1

Please sign in to comment.