Using ReScript to build applications on AWS Lambda
Tags: aws, rescript, node, tutorial
Published: 2021-08-26 12:06:13

*(This was originally posted at my development blog on [chrisarmstrong.dev](https://www.chrisarmstrong.dev). Check it out for more AWS and DynamoDB related articles!)*

[Rescript](https://www.rescript-lang.org) provides a type-safe, compile-to-JS language that delivers blazingly fast compile times with efficient and readable JavaScript output without sacrificing usability.

It's most often used to build front-end applications with React but can also be used to build type-safe applications with NodeJS.  

In this tutorial I'll show you how to get up and running quickly with an AWS Lambda application in ReScript. I assume you have some knowledge of ReScript and building for AWS, but in any case I'll explain what is happening as I go through.

[The full code accompanying this article can be found on GitHub](https://github.com/chris-armstrong/rescript-lambda-example)

## Prerequisites

To begin, you'll need to have installed:
* [NodeJS](https://www.NodeJS.org): this can be [installed directly](https://nodejs.org/en/download/https://nodejs.org/en/download/) or using a tool like [nvm](https://github.com/nvm-sh/nvm#install--update-scripthttps://github.com/nvm-sh/nvm#install--update-script) to manage multiple NodeJS installations. I've assumed version 14.x here which is the latest stable version
* [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html): Servlerless Application Model (SAM) is one of the various AWS native ways you can develop serverless
applications. 

You'll also need to have an AWS account and have [configured SAM with with your AWS credentials](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-getting-started-set-up-credentials.html).

## Set up project

1. In a new directory, run `npm init` to generate a `package.json`:

    ```bash
    mkdir rescript-lambda-example
    cd rescript-lambda-example
    npm init -y
    ```

2. Next, install some of the modules we will need:
    
    ```bash
    npm i rescript esbuild npm-run-all
    ```

    ReScript is installed as an `npm` module. In addition, we'll be using `esbuild` to bundle our code and `npm-run-all` to coordinate our shell scripts.

## API Handler

AWS Lambda API handers for NodeJS consist of a JavaScript file that exports a function of the form:

```JavaScript

export const handler = async (event, context) => {
  // some handler code
}
```

where the `event` is structured based on the Lambda trigger, and the `context` provides context information
about the Lambda runtime environment (such as the request ID and authentication information for API Gateway
contexts). 

Our Lambda will be used with API Gateway, which has [events structured like this](https://github.com/awsdocs/aws-lambda-developer-guide/blob/main/sample-apps/nodejs-apig/event.json):

```json
{
  "body": "{\"name": \"Chris\"}",
  "isBase64Encoded": false,
  "headers": {
    "content-type": "application/json",
    "accept": "*/*",
  },
}
```

We will build a simple hello-world handler that accepts a JSON-encoded body containing an object with a single `name` field, and echo it back in the response to the HTTP client.

### The Code

First, we define the structure of our incoming request body and outgoing response body and serialisation functions for them.

**`src/NameMessage.res`**
```rescript
/**
 * The type of the incoming body
 */
type nameMessage = {name: option<string>}

/**
 * The type of the response body
 */
type nameResponse = {message: string}

/**
 * Decode a JSON-encoded string using JSON.parse()
 * and assume it is the nameMessage type
 */
@scope("JSON")
@val 
external parseNameMessage: string => nameMessage = "parse"

/*
 * Stringify the name response message
 */
@scope("JSON") 
@val 
external stringifyResponse: nameResponse => string = "stringify"
```

These serialisers are just `JSON.parse` (mapped as `parseNameMessage`) and `JSON.stringify` (as `stringifyResponse`). These aren't particularly type safe, and as you'll see, we can easily cause runtime errors as they effectively cast whatever input the user provides into the `nameMessage` structure.

We'll see how we can build type-safe encoders/decoders in a future post, but for now, this wrapping the `JSON` API directly us get started quickly with our example.

Next, we'll define the structure of the API Gateway event and the expected response from the Lambda event handler:

**`src/ApiGateway.res`**
```rescript
/*
 * An API Gateway Lambda Event.
 *
 * (with a simplified interface with just what
 * we need for this example)
 */
type awsAPIGatewayEvent = {
  body: option<string>,
  isBase64Encoded: bool
}

/**
  * An APIGateway response type. In this we
  * seralise the response body and specify the
  * status code (with any headers, if necessary)
  */
type awsAPIGatewayResponse = {
  body: string,
  headers: Js.Dict.t<string>,
  statusCode: int,
}
```

These are based on the [API Gateway Event](https://github.com/awsdocs/aws-lambda-developer-guide/blob/main/sample-apps/nodejs-apig/event.json) structure we showed before.

Next, we'll define some wrappers around NodeJS `Buffer.from()` and `Buffer.prototype.toString()` (which we'll need to decode base64-encoded API Gateway bodies).

Lastly, we use the above two files and define our handler:

**`src/Hello.res`**
```rescript
open Belt.Option
open ApiGateway
open NameMessage

let handler = (event: awsAPIGatewayEvent): Js.Promise.t<awsAPIGatewayResponse> => {
    Js.log2("body", isNone(event.body))
  // Convert body to a UTF-8 string if base64-encoded
  let body =
    event.body
    ->map(body =>
      event.isBase64Encoded
        ? body->Buffer.from(~encoding=#base64)->Buffer.toString(~encoding=#utf8)
        : body
    )
    ->flatMap(body => body != "" ? Some(body) : None)

  // Parse the event body string using JSON.parse
  let message = map(body, parseNameMessage)

  // Extract the name field
  let name = flatMap(message, message => message.name)

  // Construct the response body
  let responseBody = {
      message: `Hello, ${getWithDefault(name, "there")}!`,
  }

  let response = {
    statusCode: 200,
    headers: Js.Dict.fromArray([("content-type", "application/json")]),
    body: stringifyResponse(responseBody),
  }
  Js.Promise.resolve(response)
}
```

Our handler starts by decoding the body (if it happens to be base64-encoded) and checking if it is an empty string (making it `None` if it is to short-circuit the remaining code).

It then uses `parseNameMessage` to parse the JSON-encoded body string, and then extracts the `name` property.

(Note that we use `map` and `flatMap` (from `Belt.Option`) throughout, as the `event.body` value is optional.)

Lastly, we construct the response body object, and then stringify it as part of the API Gateway response (which
also includes the `Content-Type: application/json` header and `200` status code).

Note that if the `name` value is not specified (i.e. it is the special value `None`), we use `there` as the default mapping. This is to account for when a body is not provided by the caller.

## Configuration

We need to configure two aspects of this project - the Rescript build and the serverless application deployment.

### Building your code with Rescript

The Rescript build is mostly boilerplate, but we'll cover it here to identify the main customisation points.

The configuration I demonstrate uses a bundler, which most consider unnecessary for server-side nodejs code, but which is a good idea in AWS Lambda environments as it both drastically reduces your Lambda start up time and the time it takes to upload your code for deployment. It can also help improve the performance of your Lambda overall as there is far fewer filesystem reads from loading the required source files.

The bundler I've chosen is [esbuild](https://esbuild.github.io/), which is a great complement to ReScript as it is also incredibly fast.

**`bsconfig.json`**

Create a new file called `bsconfig.json` in the main project directory and fill it as follows:

```json
{
  "name": "rescript-lambda-example",
  "sources": { "dir": "src" },
  "package-specs": {
    "module": "es6-global"
  }
}
```

Breaking it down:

- *name* - this the name of the project, which is unused here because we a compiling an application, but in the case of libraries, it sets the namespace
- *sources* - set the source directory (this can also be an array when there are multiple source directories)
- *package-specs.module* - set the output to ES6 modules using import/export style, instead of the default ES5/commonjs output. This ensures that the bundler (esbuild) can tree-shake out unused functions from our imports.

**`package.json`**

In the package.json, add the following new scripts:

```json
  "name": "rescript-lambda-example",
  "scripts": {
    "build": "run-s build:rescript build:bundle",
    "build:rescript": "rescript build",
    "build:bundle": "esbuild --outdir=build --target=node14 --platform=node lib/js/src/Hello.js",

    "develop": "run-p develop:rescript develop:bundle",
    "develop:rescript": "npm run build:rescript -- -w",
    "develop:bundle": "npm run build:bundle -- --watch"
}
```
    
We've added two main scripts: `build`, which does a full build and bundle (for CI/CD and deployment), and `develop`, which uses a watch mode to continuously rebuild and rebundle. `npm-run-all` has been used to delegate to two other `npm` scripts and run them sequentially (`run-s`) or in parallel (`run-p`) as needed.

Both scripts run by first using `rescript` to compile the code (which is output to `lib/js`) and then bundle using `esbuild` which outputs to the `build` directory.

The ReScript documentation has more information on [configuring `bsconfig.json`](https://rescript-lang.org/docs/manual/latest/build-configuration) and the `rescript --help` and `esbuild --help` will tell you all you need to understand their respective options.

### Setting up a deployment template

SAM uses an extension of [AWS CloudFormation Templates](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-reference.html) for its configuration.

Create a new file called `template.yaml` in the root project directory with the following declarations:

```yaml
Transform: AWS::Serverless-2016-10-31

Resources:
  IndexHandler:
    Type: AWS::Serverless::Function
    Properties:
      Handler: Hello.handler
      CodeUri: build
      Runtime: nodejs14.x
      Events:
        ApiEvent:
          Type: HttpApi
          Properties:
            Method: POST
            Path: /hello
Outputs:
  ApiGatewayURL:
    Value: !GetAtt ServerlessHttpApi.ApiEndpoint
    Export:
      Name: !Sub "${AWS::StackName}-url"
```

This short template:

1. Generates a Lambda function from our `build/Hello.js` file (specifying the entry point as the `handler` function)
2. Adds a `HttpApi` event trigger to the function for path `/hello` and HTTP method `POST`
3. Implicitly generates an AWS API Gateway HTTP API with default deployment and stage (based on the `HttpApi` event - [the logical ID of the API is `ServerlessHttpApi`](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-specification-generated-resources-function.html#sam-specification-generated-resources-function-httpapi))

## Test Handler Locally

We can test our handler locally by using the `sam local` command (you will need to have Docker installed for this to work):

```bash
# Build the application
> npm run build
# Start a local HTTP server
> sam local start-api
```

SAM should spin up a HTTP server on `localhost:3000`. We can run a quick testing using `curl` in another terminal session:

```bash
# Send a payload to curl (the POST HTTP method is implied)
> curl http://localhost:3000/hello --data '{"name": "Chris"}'
{"message":"Hello, Chris!"}%
```

*Note that the first time you run this may take a while, as SAM will need to download and build a Docker image to run your Lambda within.*

We can also see what happens when we don't provide a request body:
```bash
> curl http://localhost:3000/hello -XPOST
{"message":"Hello, there!"}% 
``` 

We can also show how the `JSON.parse` wrapper is just as un-type-safe as JavaScript:
```bash
# Send a payload to curl (the POST HTTP method is implied)
> curl http://localhost:3000/hello --data '{"name": {}}'
{"message":"Hello, [object Object]!"}%
```

## Deploy to the environment

The first step is packaging, which uploads your code and transforms the template to refer to its S3 location.

```bash
sam package \
  --resolve-s3 \
  --output-template-file template.deploy.yaml
```

Once that is done, we can run the deployment command. This will create a new CloudFormation stack containing your Lambda and API Gateway.

```bash
sam deploy \
  --template-file template.deploy.yaml \
  --resolve-s3 \
  --stack-name rescript-example \
  --capabilities CAPABILITY_IAM
```

The output should contain something like the following, which identifies the URL where your API is deployed:

```
CloudFormation outputs from deployed stack
---------------------------------------------------------------------------------------------------------------------------------------------------------
Outputs                                                                                                                                                 
---------------------------------------------------------------------------------------------------------------------------------------------------------
Key                 ApiGatewayURL                                                                                                                       
Description         -                                                                                                                                   
Value               https://2uhonte28e.execute-api.us-east-1.amazonaws.com                                                                         
---------------------------------------------------------------------------------------------------------------------------------------------------------
```

We can then use curl again to test our deployed API to verify it is working correctly:

```bash
> curl https://2uhonte28e.execute-api.us-east-1.amazonaws.com/hello -XPOST --data '{"name": "World"}'
{"message":"Hello, World!"}% 
``` 

## Next Steps

In a future post, we'll look at:
* Improve JSON handling
* Add multiple endpoints
* Make AWS API calls