Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add error management for failed Validation Pipes #169

Merged
merged 9 commits into from
Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
const status = exception.getStatus()
const isProduction = this.configService.get('env') === Env.PRODUCTION

console.log('### ApplicationExceptionFilter.catch() ###')
Ptroger marked this conversation as resolved.
Show resolved Hide resolved
this.log(exception)

response.status(status).json(
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))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Not an action]

When setting global filters in your bootstrap, think of them as a framework version of Node's unhandledRejection. They act as a final catch before things blow up.

The side effect of including global filters in the bootstrap is that if you assemble an application using a module, it won't have the filter registered. An example of assembling the application by module is in tests where we don't need the server running. In other words, none of the E2E tests will have this filter, which can lead to confusion.

What you're essentially saying is: "I want my SERVER to have these filters," but you're not adding them to your business logic unless its public interface is a server.

That's why some modules, like the OrchestrationModule, set the same filters as a dependency.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, good lesson here ! Ty


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,52 @@
import { ArgumentsHost, Catch, ExceptionFilter, LogLevel, Logger } from '@nestjs/common'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, port the test file from the Armory to the Policy Engine project as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

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
}
)
}
}
Loading