Blog For Devs

Contact form with Lambda and SES

author Dumitru GlavanDumitru Glavan (@doomhz)

AWS Lambda functions are extremely efficient at handling small, focused tasks. If you manage to break your entire application in small services then you don’t have to bother maintaining and scaling virtual instances. Having a couple of functions that run on-demand is a lot cheaper than paying for a EC2 instance that has to run indefinitely.

I’ve built a small project (a Lambda function on NodeJS) as a boilerplate for deploying your future Lambda functions on AWS. The job of this function is to handle contact form submissions and send them by email to the site owner. It uses Amazon SES as an email provider and APIGateway to handle requests. You can find the code on GitHub. Please follow the setup and installation steps from the README file.

Lambda handler

That’s the main file/function where your code goes. It can be invoked directly (from aws-cli, aws-sdk, S3/SNS/SQS… AWS events) or by the APIGateway with extra request data.

/**
* event - holds all the Lambda headers and request info
* context - has information about the Lambda runtime
* callback - optional function to be called after the code execution,
* i.e. callback(error, stringResponse)
*/
function handler (event, context, callback) {
  console.log('Lambda params:', JSON.stringify(event))

  if (event.httpMethod) {
    // It's an APIGateway event
    const route = `${event.httpMethod}_${data.resource}`
    switch (route) {
      case 'POST_/ContactForm':
        ...
        break
      default:
        callback()
    }
  } else if (event.task) {
    // It's a Lambda task with direct invocation
    switch (event.task) {
      case 'CONTACT':
        ...
        break
      default:
        callback()
    }
  } else {
    // Catch any other request
    callback()
  }
}

The first event parameter holds the data sent along with the request or the direct invocation. The easiest way to differentiate between the two invocation types is to check if there is some APIGateway specific data set on the event. The HTTP method set by the APIGateway makes the difference.

When invoking our Lambda function directly, it’s recommended to set an extra property on the event that holds the task name (i.e. event.task).

Lambda event data

APIGateway has a pre-defined standard of passing along the data to a Lambda handler. This data is parsed on every invocation. The request body parameters come in as a stringified JSON.

/**
* Extract event info from Lambda APIGateway request
*/
function parseApiGatewayEventData (event) {
  const body = {}
  try {
    Object.assign(body, event.body)
  } catch (e) {
    console.warn(`Could not parse Lambda body params: ${event.body}`)
  }
  return {
    headers: event.headers,
    path: event.resource,
    method: event.httpMethod,
    queryParams: event.queryStringParameters,
    pathParams: event.pathParameters,
    bodyParams: body
  }
}

In case of a direct invocation, the data comes in as a JSON object.

Lambda response

APIGateway expects a special response format. Mostly, you’ll need to set headers, a status code and a JSON body response. It’s worth noticing that the response body must be a stringified JSON, otherwise APIGateway will generate an Internal error.

/**
* Handle a Lambda API Gateway response,
* convert the result to string
*/
function respondToLambdaRoute (callback, response, error){
  const statusCode = error ? 500 : 200
  if (error) {
    response = typeof error === Error ? error.message : error
  }
  try {
    response = JSON.stringify(response)
  } catch (e) {
    response = `${response}`
  }
  // Add cross-origin headers to the response to support Ajax requests
  const apiGatewayResponse = {
    statusCode,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Headers': '*',
      'Access-Control-Allow-Methods': 'POST, GET, PUT, DELETE, OPTIONS',
      'Content-Type': 'application/json'
    },
    body: response
  }
  callback(null, apiGatewayResponse)
}

Responding to a direct invocation is as easy as sending a string back.

/**
* Handle a Lambda task response,
* convert the result to string
*/
function respondToLambdaTask (callback, response, error){
  try {
    response = JSON.stringify(response)
  } catch (e) {
    response = `${response}`
  }
  callback(error, response)
}

The response is a stringified JSON most of the times.

Compiling the Lambda code

Unfortunately, AWS Lambda supports only Node version 4.3 at this time. This means that you don’t have all the goodies available in the new Javascript. This can be easily fixed by setting up Babel and compiling the code to “old” Javascript before packaging and uploading to Lambda. Enabling Babel env presets with fast-async and add-module-exports will let you write shiny code that still works on Lambda with Node 4.3.

#!/usr/bin/env bash

# Compile JS to support Node 4.3 on Lambda,
# prepare a deployable codebase

rm -rf ./build
babel src --out-dir build --copy-files
cp ./package.json ./build/
cd ./build
NODE_ENV=production npm install
rm package.json

It’s important to have a script that uploads the packaged code to Lambda, otherwise you’ll spend a lot of time managing the deployment instead of writing code.

#!/usr/bin/env bash

# Upload the build to Lambda
# through aws-cli

FUNCTION_NAME="ContactForm"
LAMBDA_CODE_PATH=./build
ZIP_CODE_FILE=$FUNCTION_NAME.zip

npm install
npm run build
cd $LAMBDA_CODE_PATH
rm $ZIP_CODE_FILE
zip -r -D $ZIP_CODE_FILE *

aws lambda update-function-code \
  --function-name $FUNCTION_NAME \
  --zip-file fileb://$ZIP_CODE_FILE

For this script to run, aws-cli must be installed globally on your OS.

Dependencies

babel-polyfill goes as a production dependency, otherwise new JS features like async/await or import/export won’t work. aws-sdk can be installed as a dev dependency only, since the module and the access keys are globally available inside the Lambda function. This will free up some space on Lambda since the code base limit is set to 50MB.

{
  ...
  "dependencies": {
    "babel-polyfill": "^6.23.0",
    "handlebars": "^4.0.6"
  },
  "devDependencies": {
    "aws-sdk": "^2.23.0",
    "babel-cli": "^6.23.0",
    "babel-plugin-add-module-exports": "^0.2.1",
    "babel-preset-env": "^1.1.8",
    "babel-register": "^6.23.0",
    "fast-async": "^6.2.1",
    "lodash": "^4.17.4",
    "mocha": "^3.2.0",
    "should": "^11.2.0",
    "sinon": "^1.17.7"
  }
  ...
}

The rest of the Babel dependencies are not needed in production.

The end

Hopefully, this small tutorial helps you master the Lambda power a lot faster. Please read the official docs on setting up Lambda and APIGateway before playing with functions. More info on how to setup SES can be found here. Check out the full example on GitHub.