(This was originally posted at my development blog on chrisarmstrong.dev. Check it out for more AWS and DynamoDB related articles!)
Rescript 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
Prerequisites
To begin, you'll need to have installed:
- NodeJS: this can be installed directly or using a tool like nvm to manage multiple NodeJS installations. I've assumed version 14.x here which is the latest stable version
- AWS SAM CLI: 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.
Set up project
-
In a new directory, run
npm init
to generate apackage.json
:
mkdir rescript-lambda-example cd rescript-lambda-example npm init -y
-
Next, install some of the modules we will need:
npm i rescript esbuild npm-run-all
ReScript is installed as an
npm
module. In addition, we'll be usingesbuild
to bundle our code andnpm-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:
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:
{
"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
/**
* 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
/*
* 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 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
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, 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:
{
"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:
"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
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 for its configuration.
Create a new file called template.yaml
in the root project directory with the following declarations:
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:
- Generates a Lambda function from our
build/Hello.js
file (specifying the entry point as thehandler
function) - Adds a
HttpApi
event trigger to the function for path/hello
and HTTP methodPOST
- Implicitly generates an AWS API Gateway HTTP API with default deployment and stage (based on the
HttpApi
event - the logical ID of the API isServerlessHttpApi
)
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):
# 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:
# 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:
> 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:
# 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.
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.
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:
> 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
Top comments (0)