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
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
}
AWS Lambda Handyman accpest both class-validator
classes as zod
parsable classes.
import 'reflect-metadata'
class CustomBodyType {
@IsEmail()
email: string
}
class AccountDelete {
@Handler()
static async handle(@Body() { email }: CustomBodyType) {
await deleteAccount(email)
return ok()
}
}
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
- We import
reflect-metadata
- We create a class with the shape we expect
CustomBodyType
- We decorate the properties we want validated with any of
the decorators of class-validator
e.g.
@IsEmail()
- We create a class that would hold our handler method, in this case
AccountDeleteHandler
andstatic async handle(){}
- We decorate
handle()
with the@Handler()
decorator - We decorate the method's parameter with
@Body()
and cast it to the expected shape i.e.CustomBodyType
- We can readily use the automatically validated method parameter, in this case the
@Body() { email }: CustomBodyType
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 })
}
}
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:
- Validation and injection of method parameters, decorated with @Paths(), @Body() ,@Queries() parameters
- Injection of method parameters, decorated with @Event() and Ctx()
- Out of the box error handling and custom error handling via throwing HttpError
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
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.
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:
- Decorate the type with a class-transformer decorator
class PathType {
@Type(() => Number) // π Decorator from `class-transformer`
@IsInt()
intParam: number
}
- 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
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()
Injects the APIGatewayProxyEventBase<T>
object, passed on to the function at runtime.
class AccountDelete {
@Handler()
static async handle(@Event() evt) {}
}
Injects the Context
object, passed on to the function at runtime.
class AccountDelete {
@Handler()
static async handle(@Ctx() context) {}
}
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) {}
}
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) {}
}
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) {}
}
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) {}
}
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)
- Documentation
- add optional example
- http responses
- http errors
- Linting
- add team to collaborators