Cómo escribir código Typescript orientado a objetos para AWS Lambda

If you want to see this post in English, click here.

Han pasado un par de meses desde que entré en el aterrador mundo de AWS Lambda. En ese momento, solo tenía alrededor de tres meses de experiencia laboral, la mayoría de los cuales los pasé rezando para que siempre hubiera suficiente documentación para ayudarme a resolver cualquier problema. Si estás familiarizado con AWS Lambda, espero que te hayas reído un poco.

Y, si no estás familiarizado, ¡has llegado al lugar correcto! Condensé mi pequeña experiencia en esta publicación para comprarte un par de días más de tranquilidad. Aquí encontrarás una guía desde la descarga de todo lo relacionado con Serverless hasta la creación de una clase TypeScript que reutiliza el código en sus métodos handler.

¡Disfrutá!

Instalación

Para este tutorial, vamos a utilizar el framework Serverless. Antes de escribir esta publicación, nunca había buscado realmente si hay alternativas a él (además de codificar directamente en el panel de AWS Lambda), y aún ahora no encontré nada que llamara mi atención. Este framework ha cubierto todo lo que hemos necesitado para mi proyecto.

Los primeros dos pasos del pequeño tutorial en la página del Serverless Framework son el lugar perfecto para comenzar.

# Step 1. Install serverless globally
$ npm install serverless -g

# Step 2. Create a serverless function
$ serverless create --template hello-world

Una vez que ejecutes el comando serverless create, notarás que aparecen algunos archivos en tu directorio. Los revisaremos en un minuto. Primero, hay un par de cosas más que necesitarás instalar:

  • Serverless Offline Plugin. Esto hará que el Paso 3 del tutorial del Framework Serverless sea innecesario por ahora, ya que nos permitirá ejecutar nuestra aplicación localmente.

Como queremos usar TypeScript, también necesitaremos estas dependencias:

  • Typings for AWS Lambda: normalmente, cuando incorporas una biblioteca de JS en un proyecto TypeScript, también necesitarás tipos adecuados para todas las clases, interfaces y métodos que la biblioteca proporciona. Este es el caso con AWS. No solo tendrás acceso a mucha información sobre las cosas que usas, sino que no tendrás que recurrir a ignorar errores de tipo con @ts-ignore como yo hice.
  • Serverless Plugin Typescript: en circunstancias normales, tendríamos que transpilar nuestro código TypeScript a JavaScript para que nuestra aplicación pueda ejecutarlo. Esto implica recordar transpilar cada vez que cambias algo. Este plugin hace que nuestros archivos TypeScript sean legibles por el Framework Serverless, lo que nos ahorra pequeños dolores de cabeza.

Ahora es momento de revisar esos archivos.

El Archivo Serverless

El archivo serverless.yml básicamente sirve como los papeles de identificación de nuestra aplicación: su nombre, el proveedor que utiliza (AWS Lambda en nuestro caso), las funciones que contiene… todo estará registrado aquí. La mayor parte del tiempo que pases jugando con este archivo será para agregar más funciones. Pero, por ahora, la función helloWorld es suficiente.

serverless. Read the docs
# https://serverless.com/framework/docs/

# Serverless.yml is the configuration the CLI
# uses to deploy your code to your provider of choice

# The `service` block is the name of the service
service: serverless-hello-world

# The `provider` block defines where your service will be deployed
provider:
  name: aws
  runtime: nodejs6.10

# The `functions` block defines what code to deploy
functions:
  helloWorld:
    handler: handler.helloWorld
    # The `events` block defines how to trigger the handler.helloWorld code
    events:
      - http:
          path: hello-world
          method: get
          cors: true
Primera mirada a serverless.yml

Una cosa a tener en claro es que este archivo es independiente del paradigma. Este es un tutorial orientado a objetos, pero eso no significa que tengamos que cambiar la forma en que se escribe este archivo. Piénsalo como la interfaz que AWS Lambda espera de nuestro código.

El Archivo handler

Este es el manejador predeterminado que el Framework Serverless crea para nosotros. Hay dos cosas sobre él que vale la pena notar:

  • Recibe un evento, un contexto y una devolución de llamada.
  • Devuelve un objeto con un número de statusCode, un objeto headers y un string body.
'use strict';

module.exports.helloWorld = (event, context, callback) => {
    const response = {
        statusCode: 200,
        headers: {
            'Access-Control-Allow-Origin': '*', // Required for CORS support to work
        },
        body: JSON.stringify({
            message: 'Go Serverless v1.0! Your function executed successfully!',
            input: event,
        }),
    };
    
    callback(null, response);
};
La función predeterminada
import {APIGatewayEvent, APIGatewayProxyResult, Context} from "aws-lambda";

export async function helloWorld(event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> {
    return {
        statusCode: 200,
        headers: {
            'Access-Control-Allow-Origin': '*',
        },
        body: JSON.stringify({
            message: 'Go Typescript',
            input: event
        })
    }
}
La misma función en Typescript

Escribí una versión muy similar en TypeScript. Con estas dos, una al lado de la otra, quiero mostrarte un par de cosas. Primero, debes haber notado que el parámetro callback ya no está. Resulta que AWS Lambda lo llama por nosotros implícitamente, así que no hay necesidad de que lo incluyamos nosotros mismos. Y, en segundo lugar, los tipos involucrados.

Notarás que mi evento es un APIGatewayEvent. El noventa por ciento de las funciones que he escrito tienen un evento de este tipo. Esto significa que AWS Lambda solo triggereará esta función cuando se haga una solicitud HTTP a ella. Específicamente, una solicitud GET al path hello-world, que es el especificado para esta función en el archivo serverless.yml.

Eventualmente, puedes usar otros tipos de eventos, como SNSEvent1 o S3Event2, dependiendo de caso.

El tipo de retorno de la función también está sujeto a cambios. Estoy devolviendo un APIGatewayProxyResult, pero con funciones SNS, por ejemplo, tienes que devolver void3.

Ahora que se eliminó el callback, la firma y el tipo de retorno permanecerán iguales durante toda la publicación, así que quiero aprovechar esta oportunidad para escribir una pequeña prueba.

Un solo test

Voy a usar Jest para esto, solo porque creo que el nombre del framework es genial.

import {helloWorld} from "./handler";
import {APIGatewayEvent, Context} from "aws-lambda";

test('helloWorld handler returns a 200 statusCode and an inputted event', async () => {
    const event = {
        body: 'Test Body'
    } as APIGatewayEvent
    const context = {} as Context
    
    const result = await helloWorld(event, context)
    const parsedResultBody = JSON.parse(result.body)
    
    expect(result.statusCode).toBe(200)
    expect(parsedResultBody.input).toStrictEqual(event)
})

Este test es en el que nos basaremos para transformar nuestra función manejadora en una clase. No importa lo que hagamos, deberíamos terminar con algo que haga que esta prueba también pase.

Construyendo nuestra clase

Independientemente del enfoque que elijamos para estructurar nuestro código, ya sea funcional u orientado a objetos, es inevitable que exportemos algo al final, de lo contrario, nuestro archivo Serverless no podrá usarlo. En este nuevo mundo donde todos nuestros métodos necesitan una instancia de Handler para ser llamados, tenemos que proporcionar dicha instancia lista en todo momento. La mejor manera que hemos encontrado para hacer esto es declarar una constante handler como única instancia de nuestra clase Handler. Luego declaramos constantes exportadas; cada una de ellas contendrá un método de la instancia handler.

import {APIGatewayEvent, APIGatewayProxyResult, Context} from "aws-lambda";

class Handler {

    public async helloWorld(event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> {
        return {
            statusCode: 200,
            headers: {
                'Access-Control-Allow-Origin': '*',
            },
            body: JSON.stringify({
                message: 'Go Typescript!',
                input: event
            })
        }
    }
}

export const handler = new Handler()
export const helloWorld = handler.helloWorld
Es tan fácil como eso… por ahora.

El test pasa y todo está bien. Podemos servir la aplicación y veremos que funciona sin modificar el archivo Serverless, tal como te dije.

Pero nuestra clase está un poco falta de contenido. No estamos viendo cómo podemos aprovechar esta estructura para reutilizar código aún. Así que vamos a agregar otro método handler:

import {APIGatewayEvent, APIGatewayProxyResult, Context} from "aws-lambda";

class Handler {

    public async helloWorld(event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> {
        return {
            statusCode: 200,
            headers: {
                'Access-Control-Allow-Origin': '*',
            },
            body: JSON.stringify({
                message: 'Hello world',
                input: event
            })
        }
    }
    
    public async goodbyeWorld(event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> {
        return {
            statusCode: 200,
            headers: {
                'Access-Control-Allow-Origin': '*',
            },
            body: JSON.stringify({
                message: 'Goodbye world!',
                input: event
            })
        }
    }
}

export const handler = new Handler()

export const helloWorld = handler.helloWorld
export const goodbyeWorld = handler.goodbyeWorld

Este nuevo método manejador con un nombre espeluznante servirá muy bien para ilustrar mi punto. Devuelve exactamente la misma respuesta, excepto por el mensaje.

Al mirar ambos métodos uno al lado del otro, no tiene mucho sentido repetir la mayoría del objeto de respuesta. Podemos mejorar esto creando un objeto ResponseBuilder que nos dé la respuesta deseada dependiendo del mensaje que queremos mostrar.

import {APIGatewayEvent} from "aws-lambda";

export class ResponseBuilder {

    public getResponse(message: string, event: APIGatewayEvent) {
        return {
            statusCode: 200,
            headers: {
                'Access-Control-Allow-Origin': '*',
            },
            body: JSON.stringify({
                message: message,
                input: event
            })
        }
    }
}               
El evento que devolvemos como entrada también puede variar.
import {APIGatewayEven, APIGatewayProxyResult, Context} from "aws-lambda";
import {ResponseBuilder} from "./ResponseBuilder";

class Handler {
    private responseBuilder: ResponseBuilder
    
    constructor() {
        this.responseBuilder = new ResponseBuilder()
    }
    
    public async helloWorld(event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> {
        return this.responseBuilder.getResponse('Hello world', event)
    }
    
    public async goodbyeWorld(event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> {
        return this.responseBuilder.getResponse('Goodbye world', event)
    }
}

export const handler = new Handler()

export const helloWorld = handler.helloWorld
export const goodbyeWorld = handler.goodbyeWorld

¡El código se ve mucho más bonito ahora! Sin embargo, hay un pequeño problema. Lo descubrirás al ejecutar tu prueba helloWorld:

Enlace de Contexto

Si llamas a un método de instancia dentro de un handler, es probable que obtengas un error como el de arriba. Es un efecto secundario bien conocido de pasar funciones como valor. Debido a que se están utilizando en aislamiento a través del enlace dinámico, pierden el contexto de llamada. Afortunadamente, esto se puede solucionar fácilmente con el enlace de contexto:

export const handler = new Handler()

export const helloWorld = handler.helloWorld.bind(handler)

Con esto, básicamente le estamos diciendo a nuestro método que cada vez que this aparezca en el código, debería referirse a la instancia que hemos definido. Esto garantizará que siempre estén asociados al contexto correcto.

public helloWorld = async (event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> => {
        return this.responseBuilder.getResponse('Hello world', event)
    }
    
    public goodbyeWorld = async (event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> => {
        return this.responseBuilder.getResponse('Goodbye world', event)
    }

Y eso es todo. Cualquier cosa que construyas no debería darte más problemas que este pequeño ejemplo. Como regalo de despedida de esta publicación, te dejo una guía para descubrir cómo desplegar tu aplicación.

¡Gracias por leer!


Referencias:

1. SNSEvent: https://docs.aws.amazon.com/lambda/latest/dg/with-sns-example.html.

2. S3Event: https://docs.aws.amazon.com/lambda/latest/dg/with-s3.html.

3. Returning void on SNS functions.