Search code examples
aws-lambdanestjsserverless

How to use enhancers (pipes, guards, interceptors, etc) with Nestjs Standalone app


The Nestjs module system is great, but I'm struggling to figure out how to take full advantage of it in a Serverless setting.

I like the approach of writing my domain logic in *.service.ts files, while using *.controller.ts files to take care of non-business related tasks such as validating an HTTP request body and converting to a DTO before invoking methods in a service.

I found the section on Serverless in the nestjs docs and determined that for my specific use-case, I need to use the "standalone application feature".

I created a sample nestjs app here to illustrate my problem.

The sample app has a simple add() function to add two numbers. I use class-validator for validation on the AddDto class.

// add.dto.ts
import { IsNumber } from 'class-validator'

export class AddDto {
    @IsNumber()
    public a: number;
        
    @IsNumber()
    public b: number;
}

And then, via some Nestjs magic, I am able to get built-in validation using the AddDto inside my controller by doing the following:

// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Use `ValidationPipe()` for auto-validation in controllers
  app.useGlobalPipes(
    new ValidationPipe({ transform: true })
  )

  await app.listen(3000);
}


// app.controller.ts
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Post('add')
  add(@Body() dto: AddDto): number {
    // Request body gets auto validated and converted
    // to an instance of `AddDto`, sweet!
    return this.appService.add(dto.a, dto.b);
  }
}

// app.service.ts
@Injectable()
export class AppService {
  add(a: number, b: number): number {
      return a + b
  }
}

So far, so good. The problem now arises when using this in AWS with a Lambda function, namely:

  • I want to re-use the business logic in app.service.ts
  • I want to re-use built in validation that happens when making an HTTP request to the app, such as in the example above.
  • I want to use the standalone app feature so I don't have to spin up an entire nest server in Lambda

The docs hint on this being a problem:

Be aware that NestFactory.createApplicationContext does not wrap controller methods with enhancers (guard, interceptors, etc.). For this, you must use the NestFactory.create method.

For example, I have a lambda that receives messages from AWS EventBridge. Here's a snippet from the sample app:

// standalone-app.ts
interface IAddCommand {
  a: number;
  b: number;
}

export const handler = async (
  event: EventBridgeEvent<'AddCommand', IAddCommand>,
  context: any
) => {
  const appContext = await NestFactory.createApplicationContext(AppModule);
  const appService = appContext.get(AppService);
  const { a, b } = event.detail;
  const sum = appService.add(a, b)
  // do work on `sum`, like cache the result, etc...
  return sum
};

// lambda-handler.js
const { handler } = require('./dist/standalone-app')

handler({
  detail: {
   a: "1", // is a string, should be a number
   b: "2" // is a string, should be a number
  }
})
  .then(console.log) // <--- prints out "12" ("1" + "2") instead of "3" (1 + 2)

I don't get "free" validation of the event's payload in event.detail like I do with @Body() dto: AddDto when making a HTTP POST request to /add. Preferentially, the code would throw a validation error in the above example. Instead, I get an answer of "12" -- a false positive.


Hopefully, this illustrates the crux of my problem. I still want to validate the payload of the event before calling appService.add(a, b), but I don't want to write custom validation logic that already exists on the controller in app.controller.ts.

Ideas? Anyone else run into this before?


Solution

  • It occurred to me while writing this behemoth of a question that I can simply use class-validator and class-transformer in my Lambda handler.

    import { validateOrReject } from 'class-validator'
    import { plainToClass } from 'class-transformer'
    import { AddDto } from 'src/dto/add.dto'
    
    export const handler = async (event: any, context: any) => {
      const appContext = await NestFactory.createApplicationContext(AppModule);
      const appService = appContext.get(AppService);
    
      const data = getPayloadFromEvent(event)
    
      // Convert raw data to a DTO
      const dto: AddDto = plainToClass(AddDto, data)
    
      // Validate it!
      await validateOrReject(dto)
    
      const sum = appService.add(dto.a, dto.b)
      // do work on `sum`...
    }
    

    It's not as "free" as using app.useGlobalPipes(new ValidationPipe()), but only involves a few extra lines of code.