Skip to content

growx-tech/aws-lambda-handyman

Repository files navigation

AWS Lambda Handyman

AWS Lambda TypeScript validation made easy πŸ„ ...️and some other things

class BodyType {
  @IsEmail()
  email: string
}

class SpamBot {
  @Handler()
  static async handle(@Body() { email }: BodyType) {
    await sendSpam(email) // ->   οΈπŸ‘† I'm validated
    return ok()
  }
}

export const handler = SpamBot.handle

npm-downloads npm-version npm bundle size Test, Build and Publish coverage

Growy Logo

Table of Contents

Installation

First off we need to install the package

npm i aws-lambda-handyman

Since we use class-validator and class-transformer under the hood we need to install them for their decorators. We also use reflect-metadata

npm i class-transformer class-validator reflect-metadata

Next we need to enable these options in our .tsconfig file

{
  "experimentalDecorators": true,
  "emitDecoratorMetadata": true
}

Basic Usage

AWS Lambda Handyman accpest both class-validator classes as zod parsable classes.

class-validator

import 'reflect-metadata'

class CustomBodyType {
  @IsEmail()
  email: string
}

class AccountDelete {
  @Handler()
  static async handle(@Body() { email }: CustomBodyType) {
    await deleteAccount(email)
    return ok()
  }
}

Zod

const CustomBodySchema = z.object({
  email: z.string().email()
})

class CustomBodyType {
  constructor(input: z.input<typeof CustomBodySchema>) {
    Object.assign(this, CustomBodyType.parse(input))
  }

  // Requires a static parse method
  static parse(input: unknown) {
    return new CustomBody(input as z.input<typeof CustomBodySchema>)
  }
}

class AccountDelete {
  @Handler()
  static async handle(@Body() { email }: CustomBodyType) {
    await deleteAccount(email)
    return ok()
  }
}

export const handler = AccountDelete.handle

Let's break it down.

  1. We import reflect-metadata
  2. We create a class with the shape we expect CustomBodyType
  3. We decorate the properties we want validated with any of the decorators of class-validator e.g. @IsEmail()
  4. We create a class that would hold our handler method, in this case AccountDeleteHandler and static async handle(){}
  5. We decorate handle() with the @Handler() decorator
  6. We decorate the method's parameter with @Body() and cast it to the expected shape i.e. CustomBodyType
  7. We can readily use the automatically validated method parameter, in this case the @Body() { email }: CustomBodyType

Decorators can be mixed and matched:

class KitchenSink {
  @Handler()
  static async handle(
    @Body() body: BodyType,
    @Event() evt: APIGatewayProxyEventBase<T>,
    @Paths() paths: PathsType,
    @Ctx() ctx: Context,
    @Queries() queries: QueriesType
  ) {
    return ok({ body, paths, queries, evt, ctx })
  }
}

Decorators

Method Decorators

@Handler()

This decorator needs to be applied to the handler of our http event. The handler function needs to be async or needs to return a Promise.

class AccountDelete {
  @Handler()
  static async handle() {}
}

When applied, @Handler() enables the following:

  1. Validation and injection of method parameters, decorated with @Paths(), @Body() ,@Queries() parameters
  2. Injection of method parameters, decorated with @Event() and Ctx()
  3. Out of the box error handling and custom error handling via throwing HttpError

@Handler(options?: TransformValidateOptions)

Since the aws-lambda-handyman uses class-transformer and class-validator, you can pass options to the @Handler that would be applied to the transformation and validation of the decorated method property.

import { ValidatorOptions } from 'class-validator/types/validation/ValidatorOptions'
import { ClassTransformOptions } from 'class-transformer/types/interfaces'

export type TransformValidateOptions = ValidatorOptions & ClassTransformOptions

Validation and Injection

Behind the scenes AWS Lambda Handyman uses class-validator for validation, so if any validation goes wrong we simply return a 400 with the concatenated constraints of the ValidationError[] :

class BodyType {
  @IsEmail()
  userEmail: string
  @IsInt({ message: 'My Custom error message πŸ₯Έ' })
  myInt: number
}

class SpamBot {
  @Handler()
  static async handle(@Body() { userEmail, myInt }: BodyType) {}
}

So if the preceding handler gets called with anything other than a body, with the following shape:

{
  "userEmail": "[email protected]",
  "myInt": 4321
}

The following response is sent:

HTTP/1.1 400 Bad Request
content-type: application/json; charset=utf-8

{
    "message":"userEmail must be an email. My Custom error message πŸ₯Έ."
}

If the incoming request is correct, the decorated property is injected into the method parameter and is ready for use.

Validation Caveats

By default, Path and Query parameters come in as strings, so if you try to do something like:

class PathType {
  @IsInt()
  intParam: number
}

class HandlerTest {
  @Handler()
  static async handle(@Paths() paths: PathType) {}
}

It would return an error. See Error Handling

Because aws-lambda-handyman uses class-transformer, this issue can be solved in several ways:

  1. Decorate the type with a class-transformer decorator
class PathType {
  @Type(() => Number) // πŸ‘ˆ Decorator from `class-transformer`
  @IsInt()
  intParam: number
}
  1. Enable enableImplicitConversion in @Handler(options)
class HandlerTest {
  @Handler({ enableImplicitConversion: true }) // πŸ‘ˆ
  static async handle(@Paths() paths: PathType) {}
}

Both approaches work in 99% of the time, but sometimes they don't. For example when calling:

/path?myBool=true

/path?myBool=false

/path?myBool=123

/path?myBool=1

/path?myBool=0

with

class QueryTypes {
  @IsBoolean()
  myBool: boolean
}

class HandlerTest {
  @Handler({ enableImplicitConversion: true })
  static async handle(@Queries() { myBool }: QueryTypes) {
    //            myBool is 'true'   πŸ‘†
  }
}

myBool would have the value of true. Why this happens is explained here : Class Transformer Issue 626 because of the implementation of MDN Boolean

We can fix this in the way described in Class Transformer Issue 626, or we could use @TransformBoolean like so:

class QueryTypes {
  @TransformBoolean() // πŸ‘ˆ use this πŸŽƒ
  @IsBoolean()
  myBool: boolean
}

class HandlerTest {
  @Handler()
  static async handle(@Queries() { myBool }: QueryTypes) {}
}

So when we call the handler with the previous example we get this:

/path?myBool=true πŸ‘‰ myBool = 'true'

/path?myBool=false πŸ‘‰ myBool = 'false'

/path?myBool=123 πŸ‘‰ Validation error

/path?myBool=1 πŸ‘‰ Validation error

/path?myBool=0 πŸ‘‰ Validation error

Error handling

Methods, decorated with @Handler have automatic error handling. I.e. if an error gets thrown inside the method it gets wrapped with a http response by default

class SpamBot {
  @Handler()
  static async handle() {
    throw new Error("I've fallen... and I can't get up 🐸")
  }
}

Returns:

HTTP/1.1 500 Internal Server Error
content-type: application/json; charset=utf-8

{
    "message": "I've fallen... and I can't get up 🐸"
}

We could further instrument this by throwing an HttpError() , allowing us to specify the response's message and response code:

class SpamBot {
  @Handler()
  static async handle() {
    throw new HttpError(501, 'Oopsie Doopsie 🐸')
  }
}

Which returns:

HTTP/1.1 501 Not Implemented
content-type: application/json; charset=utf-8

{
    "message": "Oopsie Doopsie 🐸"
}

You could also extend HttpError for commonly occurring error types like in DynamoError()

Function Param Decorators

@Event()

Injects the APIGatewayProxyEventBase<T> object, passed on to the function at runtime.

class AccountDelete {
  @Handler()
  static async handle(@Event() evt) {}
}

@Ctx()

Injects the Context object, passed on to the function at runtime.

class AccountDelete {
  @Handler()
  static async handle(@Ctx() context) {}
}

@Paths()

Validates the http event's path parameters and injects them into the decorated method parameter.

For example a handler, attached to the path /cars/{color} ,would look like so:

class PathType {
  @IsHexColor()
  color: string
}

class CarFetch {
  @Handler()
  static async handle(@Paths() paths: PathType) {}
}

@Body()

Validates the http event's body and injects them it into the decorated method parameter.

class BodyType {
  @IsSemVer()
  appVersion: string
}

class CarHandler {
  @Handler()
  static async handle(@Body() paths: BodyType) {}
}

@Queries()

Validates the http event's query parameters and injects them into the decorated method parameter.

For example making a http request like this /inflated?balloonId={someUUID} would be handled like this:

class QueriesType {
  @IsUUID()
  balloonId: string
}

class IsBalloonInflated {
  @Handler()
  static async handle(@Queries() queries: QueriesType) {}
}

@Headers()

Validates the http event's headers and injects them into the decorated method parameter.

For example making a http request with headers ["authorization" = "Bearer XYZ"] would be handled like this:

class HeadersType {
  @IsString()
  @IsNotEmpty()
  authoriation: string
}

class IsBalloonInflated {
  @Handler()
  static async handle(@Headers() { authoriation }: HeadersType) {}
}

Transformer Decorators

@TransformBoolean()

HttpErrors

HttpError

DynamoError

HttpResponses

response(code: number, body?: object)

ok(body?: object)

created(body?: object)

badRequest(body?: object)

unauthorized(body?: object)

notFound(body?: object)

imaTeapot(body?: object)

internalServerError(body?: object)

TODO

  • Documentation
    • add optional example
    • http responses
    • http errors
  • Linting
  • add team to collaborators

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 4

  •  
  •  
  •  
  •