AWS SAM and Middy with Lambda Layers

In the last few months, I’ve been working with an AWS serverless architecture. One of the reasons that I’m writing this post is that during this experience, we needed to implement something that could be used as middleware. While researching how to write middlewares for Lambda Layers I found Middy and it seemed to be a good fit.

Lambda Layers

Lambda Layers were introduced in late 2018 as a way to share modules and dependencies across your lambdas. With layers, you can use libraries in your function without needing to include them in your deployment package, keeping them small.

There are different ways to use Lambda Layers. You can create a custom layer, or use a layer published by AWS Partners such IOPipe, which is an application operations platform that offers configurable and multi-dimensional alerting that can invoke Lambda functions.

AWS Lambda Limits

A function can use up to 5 layers at a time. The total unzipped size of the function and all layers can’t exceed the unzipped deployment package size limit of 250 MB. For more information, see AWS Lambda Limits.

Options for managing layers

Without entering in details, you have several options to manage a layer:

  • Through SAM using the default sam package/sam deploy,
  • With aws-cli
  • Manually in AWS console.

Middy

Middy is a very simple middleware engine. If you used web frameworks like Express, you will be familiar with Middy concepts.

Why use Lambda Layers and Middy?

With Lambda Layers and Middy, you can share custom middlewares to handle your application, throw custom errors, format responses, and even implement access control. I’ll share some examples below.

Middy implements the classic onion-like middleware pattern, with some peculiar details.

Middy Engine

Middlewares allow you to extract and generalize business logic to be shared across handlers, modifying the response and passing through or changing the execution flow.

Using Middy as middleware, you have three options to implement: before, after and onError.

Assuming 3 middlewares, they are executed in the following order:

  • middleware 1 (before)
  • middleware 2 (before)
  • middleware 3 (before)
  • handler
  • middleware 3 (after)
  • middleware 2 (after)
  • middleware 1 (after)

When an error is thrown, the execution flow is stopped and it always goes to the next middleware where the onError function is implemented.

Let’s get started

Prerequisites

You are creating an application to show or block vegetable products for company users. Users from vegetable-company will be granted permission, while users from other companies will be blocked through the middleware with a 401 response.

First of all, you should create a new application with SAM called aws-sam-middy-app.

sam init --runtime nodejs8.10 --name aws-sam-middy-app

The directory structure of your new application will look like this:

├── events
│   ├── event.json
├── hello-world
│   ├── app.js
│   ├── package.json
│   └── tests
├── README.md
└── template.yaml

Done! Now we need to change the AWS SAM template and folder structure to be able to use layers and install dependencies. AWS SAM templates are an extension of AWS CloudFormation templates.

The AWS SAM template file is a YAML or JSON configuration file that adheres to the open-source AWS Serverless Application Model specification. You use the template to declare all of the AWS resources that comprise your serverless application.

In template.yaml, we need to create a function to handle our products and create a new layer. Add the following code to the Resources section of the template:

Resources:
  ProductsFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: products/
      Handler: app.handler
      Layers:
          - !Ref CommonLayer
      Runtime: nodejs8.10
      Events:
        Products:
          Type: Api
          Properties:
            Path: /products
            Method: get
  CommonLayer:
        Type: AWS::Serverless::LayerVersion
        Properties:
            LayerName: sam-app-common-layer
            Description: Dependencies and middlewares for sam app
            ContentUri: common-layer/
            CompatibleRuntimes:
              - nodejs8.10

We’ve introduced the CommonLayer resource in the template, which defines the new layer and points to the common-layer folder as the source code of the layer. We’ve also added the ProductsFunction resource, and along with it a Layers section that hooks CommonLayer to ProductsFunction. We could declare more layers to extend the functionality of ProductsFunction.

folder structure

├── events
│   ├── event.json
├── common-layer
│   ├── package.json
│   ├── middy-wrapper.js
├── products
│   ├── app.js
│   └── tests
├── README.md
└── template.yaml

This is the final folder structure we are aiming for. Below we will guide you through creating the remaining files.

install middy dependency

mkdir common-layer
cd common-layer
npm init -y
npm install middy http-errors

With that final change, you have separated the application code from the dependencies. Try out the application and see if it still works. From the sam-app directory, run the following command:

sam local start-api

Middy Wrapper

With the application working and the layer properly configured, let’s create a Middy wrapper with default middlewares, which may be hooked to any lambda function.

// common-layer/middy-wrapper.js

const middy = require('middy');
const { httpSecurityHeaders, httpErrorHandler } = require('middy/middlewares');

module.exports = (handler) => {
  return middy(handler).use(httpSecurityHeaders())
                       .use(httpErrorHandler());
};

You can see other middleware options here

Using default middlewares, you have added security headers and error handler, which will provide two layers to the application.

Http Security Headers
Applies best-practice security headers to responses. It’s a simplified port of HelmetJS

Http Error Hander
Automatically handles uncaught errors that contain the statusCode (number) and message (string) properties, and creates a proper HTTP response for them.

Creating a data file

Assuming that you need data to get products, we will create a vegetable-products.json inside of common-layer that will be useful during the next steps. Save it to common-layer/data/vegetable-products.json:

[{
  "id": 1,
  "title": "Asparagus",
  "price": 28.1
}, {
  "id": 2,
  "title": "Cabbage",
  "price": 29.45
}, {
  "id": 3,
  "title": "Pea",
  "price": 16.3
}, {
  "id": 4,
  "title": "Lettuce",
  "price": 17.11
}, {
  "id": 5,
  "title": "Parsley",
  "price": 5.25
}]

Lambda handler

Let’s create a handler that will be used to list all products with default middlewares wrapped with a middy-wrapper:

// products/app.js

const middy = require('/opt/middy-wrapper');
const products = require('/opt/data/vegetable-products.json');
const handler = async (event, context) => {
    try {
        return {
            'statusCode': 200,
            'body': JSON.stringify({
                products
            })
        }
    } catch (err) {
        console.log(err);
        return err;
    }
};

exports.handler = middy(handler);

It’s important to mention that when a Lambda function configured with a Lambda Layer is executed, AWS downloads any specified layers and extracts them to the /opt directory on the function execution environment. Each runtime then looks for a language-specific folder under the /opt directory.

Custom middleware

In this custom middleware, presuming that you are sending an authentication token in your request, here is a vegetable-company-allowed.js middleware that will grant access only to vegetable-company. No other company will be able to fetch the products. In this article, the authentication token will be the company name. Don’t try this in production!

// common-layer/vegetable-company-allowed.js

const createError = require("http-errors");

module.exports = () => {
  return {
    before: async handler => {
      const { event } = handler;
      const user = event.requestContext && event.requestContext.user;
      if (!user || user.company !== "vegetable-company")
        throw new createError.Unauthorized();
      return;
    }
  };
};

As mentioned before, here we are using httpErrorHandler to block access, which is created with http-errors

To finish the setup of your company filter, you need to hook the middleware to the main handler:

// products/app.js

const createError = require('/opt/node_modules/http-errors');
const middyWrapper = require('/opt/middy-wrapper');
const vegetableCompany= require('/opt/vegetable-company-allowed'); 
const products = require('/opt/data/vegetable-products.json');

const handler = async (event, context) => {
    try {
        return {
            'statusCode': 200,
            'body': JSON.stringify({
                products
            })
        }
    } catch (err) {
        console.log(err);
        throw new createError.badRequest();
    }
};

exports.handler = middyWrapper(handler)
                    .use(vegetableCompany());

With a simple import and by wrapping the handler with Middy and using the vegetableCompany middleware, we can share the same logic with other endpoints that need to verify if a company is allowed to access.

Testing

Time to spin it up and make sure that it works.

This can be tested using sam local invoke, which can be invoked with an event file payload or stdin payload. In the sam-app directory, run the following command:

# event file
sam local invoke "ProductsFunction" -e events/event.json

# stdin
echo '{"requestContext": {"user": {"company": "vegetable-company"}}}' | sam local invoke "ProductsFunction"

If you go with the event file option, events.json should have the following contents:

{"requestContext": {"user": {"company": "vegetable-company"}}}

About unit testing

You can test your custom middleware easily with code like this:

describe("when send an invalid user", () => {
  it("returns unauthorized", async () => {
    const handler = middy(async (event, context) => {});

    handler.use(vegetableCompany());

    const event = { requestContext: { user: { company: "fruit-company" } } };

    const { statusCode } = await handler(event, {});
    expect(statusCode).toEqual(401);
  });

  it("returns products", async () => {
    const handler = middy(async (event, context) => {});

    handler.use(vegetableCompany());

    const event = { requestContext: { user: { company: "vegetable-company" } } };

    const { statusCode, body } = await handler(event, {});
    expect(statusCode).toEqual(200);
    expect(body.products).toBeDefined();
  });
});

Afterwords

Lambda Layers is something relatively new in serverless architecture and it can be very useful in certain use cases, to make your code organized and capable of handling shared code with Middy in an easier way. Also, it can avoid some issues like different versions of dependencies.

Please, if you have questions, leave it up in the comments and check out the code for this example. Thank you.

References and useful links

We want to work with you. Check out our "What We Do" section!