Exploring - Using Express and Amazon Web Services

October 09, 2020

Express + AWS

šŸ—ŗ Exploring - Work in progress

Using Express to build out your API can be challenging; but integrating it with all of the products available from Amazon Web Services can feel impossible. This post focuses less on best practices for your API and more on reusable patterns to tie all of those products together. This is the second part in a series on building out a scalable website on AWS. In the first part we setup all of the infrastructure weā€™ll need using Terraform.

Choosing a framework

Before we even get started we need to decide what framework weā€™ll use to write our AWS Lambda function - if any. Out of the box AWS gives you a handler pattern that you can use to receive an incoming web request, perform some actions and return a response. For a web server this is really all we need. In fact, using this simple pattern is actually the recommended best practice.

AWS Lambda is a FaaS (Function as a Service) or ā€œserverlessā€ platform designed to execute discrete ā€œfunctionsā€ or handlers. The expectation is that each route on our website will execute a separate specific function inside Lambda. Each of these routes would be setup individually inside AWS API Gateway and point to their respective AWS Lambda functions. Weā€™ve setup our API Gateway to send all routes to a single function (site_api).

Why would we do this? What are the tradeoffs?

All routes pointing to a single function

  • When developing your API you want to be able to test it locally
  • When deploying you want shared dependencies to be updated simultaneously
  • Adding a new route to API Gateway and pointing to a new Lambda function is cumbersome
  • Changing API Gateway can be slow
  • Our function will have fewer cold-starts

Many routes pointing to separate functions

  • You might want to restrict deployments for specific parts of the site
  • You can change parts of the site without changing everything
  • A single mistake wonā€™t break the entire site
  • Deploying new functions will only cause cold-starts on part of the site
  • You want to use Lambda@Edge
  • Smaller functions take less time to upload and deploy
  • Smaller functions have a shorter cold-start time

In general, how you choose between these trade-offs is dictated by the site and team (and in some cases preferences). If you have a smaller site and smaller team then it is often better to favor simplicity. Choosing All routes pointing to a single function simplifies local development and deployment but increases risk by putting everything in one function. For example, if you are the only developer (or there are only a couple developers), then layering strict permissions about who can deploy specific parts of the site is overkill.

Whatā€™s a cold-start? Even though AWS Lambda runs functions ā€œon-demandā€ it still needs to load the code in order to execute it. If your code-base is large it will take a long time to load before your function can respond to requests. Because this can be slow, AWS tries to avoid doing it too often and keeps the code in a hot-cache for five minutes after it is loaded. If the function is used again, the timeout is restarted. You should try to avoid cold-starts as much as possible and you should try to minimize their impact as much as possible to keep your site snappy. Keeping all of your routes in a separate functions means that the code that needs to be loaded is smaller (and therefore faster to load). But pointing all of your routes to a single shared function means that the function will be used more frequently and will likely have fewer cold-starts.

As with most things, it depends.

For our API weā€™ll use a single function. This is almost always the simplest and easiest choice especially when getting started. As our site grows we can slowly transition from this model to a hybrid model with multiple functions and ultimately to individual functions for every route if needed.

Why Express?

Because weā€™re using a single function weā€™ll need to enable that function to handle routing for us (like a traditional web server). There are numerous frameworks that make it easy. Express is a ā€œFast, unopinionated, minimalist web framework for Node.jsā€. That sounds like what we want. We could choose a smaller framework like Micro from Vercel (used as part of Next.js) - but it is not typically used in serverless environments. We could also choose Sails.js, a much richer MVC framework written in JavaScript. In many cases this is the right choice, but for our purposes weā€™ll use Express as it is closer to the metal which makes it easier to follow the logic.

Because we are building an API there are a few other options available to us.

Getting started

In the previous post we created a very simple API function:

exports.handler = async (event, context) => {
  console.log('Event: ' + JSON.stringify(event))
  console.log('Context: ' + JSON.stringify(context))

  return {
    statusCode: 200,
    body: JSON.stringify({
      event: event,
      context: context,
    }),
  }
}

In the handler we logged information about the event and context and echoed back the result as JSON. You would never want a function like this in a production application, but this allowed us to quickly setup our API Gateway deployment, test AWS Lambda and make sure our logging worked.

To make our API weā€™ll need more code than this. Letā€™s create a new folder called api. I usually make this a sibling folder to the terraform folder we created previously.

mkdir api

Change to that folder:

cd api

Weā€™ll use npm to install and manage the packages for our API. Letā€™s set that up now:

npm init -y

This will generate a default package.json:

{
  "name": "api",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

Next, weā€™ll install express:

npm install --save express

Weā€™ll need a few more packages right away:

npm install --save cors helmet body-parser cookie-parser http-errors

Weā€™ll also need some AWS and serverless specific packages:

npm install --save aws-sdk @vendia/serverless-express

TypeScript

I usually choose to use TypeScript for my projects. Though it isnā€™t required it makes some things easier (and some things much harder). It can be a tough trade-off.

npm install --save-dev typescript

We only need TypeScript support while developing. Weā€™ll convert the application to JavaScript when deploying. Install the following TypeScript dependencies:

npm install --save-dev ts-node-dev ts-loader

Weā€™ll need to setup our TypeScript configuration; create a new file called tsconfig.json:

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "esnext",
    "lib": ["es2015", "es2017"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitAny": true,
    "removeComments": false,
    "preserveConstEnums": true
  },
  "include": ["**/*.ts"],
  "exclude": ["node_modules"]
}

Next weā€™ll want to download type definitions for our dependencies:

npm install --save-dev \
  @types/node \
  @types/cors \
  @types/cookie-parser \
  @types/http-errors \

Weā€™ll want to add a few scripts to package.json to utilize these dependencies. The only script we need at the moment is build which will convert our TypeScript to JavaScript. We can remove the placeholder test script for now. While weā€™re developing our action weā€™ll need access to all of our projectā€™s dependencies; but when we release our action we donā€™t want to include the testing, linting and TypeScript dependencies. Because of this, the difference between dependencies and devDependencies is important (as well as --save versus --save-dev).

Add the following scripts to package.json

{
  "scripts": {
    "build": "tsc --noEmit",
  },
}

Keep it clean (optional)

Note: this section is not required to complete this tutorial; if you want to skip it feel free.

Everyone has different preferences when they edit code. Some prefer tabs over spaces. Some want two spaces instead of four. Some prefer semicolons and some donā€™t. It shouldnā€™t matter right? But it does. If editors are auto-formatting code based on user preferences it is important to make sure everyone has chosen the same set of defaults for that auto-formatting. This makes it easy to tell what changed between versions ā€“ even when different developers (with different preferences) have made changes.

For this reason weā€™ll setup a linter and code formatter for our code. Install eslint and prettier:

npm install --save-dev \
  eslint \
  @typescript-eslint/eslint-plugin \
  @typescript-eslint/parser \
  eslint-config-prettier \
  eslint-plugin-prettier \
  prettier

Now that we have the packages weā€™ll need to configure them in .eslintrc.json. Create that file in your project root folder:

{
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint", "prettier"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended"
  ],
  "rules": {
    "prettier/prettier": [
      "error",
      {
        "singleQuote": true,
        "trailingComma": "all",
        "bracketSpacing": true,
        "printWidth": 120,
        "tabWidth": 2,
        "semi": false
      }
    ]
  },
  "env": {
    "node": true,
    "es6": true
  },
  "parserOptions": {
    "ecmaVersion": 2018,
    "sourceType": "module"
  }
}

I wonā€™t go into too much detail here; there are better explanations to be found. This configuration does a few things:

  • Relies on the TypeScript ESlint parser with the prettier plugin - Iā€™ve found this works very well in VS Code.
  • The expected environment should include node - this will help eslint ignore missing declarations for things like process, module, etc.

If you need to ignore specific files when linting you can add them to .eslintignore. Because our setup doesnā€™t work well for JavaScript weā€™ll ignore all JavaScript files. Create .eslintignore in the project root folder:

*.js

If you are using VS Code, you can install the ESLint extension so you can see lint warnings in your editor. Additionally, we can add a lint item to the scripts node in package.json so that we can check for lint warnings when building and deploying:

  "scripts": {
    "build": "tsc --noEmit",
    "lint": "eslint . --ext .ts"
  },

With this in place we can run:

npm run lint

Wait, thereā€™s an error:

Oops! Something went wrong! :(

ESLint: 7.24.0

No files matching the pattern "." were found.
Please check for typing mistakes in the pattern.

We havenā€™t written any TypeScript to lint yet.

Prettier & .prettierrc

Prettier works to auto-format your code based on a shared configuration. If you are using VSCode you can install the prettier extension and it will auto-format your code every time you save. The configuration is setup in the .prettierrc file. Create .prettierrc. I tend to use the following setup:

{
  "endOfLine": "lf",
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "all",
  "bracketSpacing": false,
  "jsxBracketSameLine": true,
  "printWidth": 120
}

You might have different preferences in your project. Thatā€™s fine, so long as all of the developers working on the code agree. For more information on the available options, view the Prettier docs.

There are some files in our project that we shouldnā€™t (or donā€™t want to) prettify. The reasons we might not want to run Prettier vary but include: different formatting preferences, external libraries, generated files, frequency of change, or speed. Luckily we can tell Prettier to ignore files. Add a .prettierignore file:

package.json
package-lock.json
node_modules

Time to stop configuring and start writing code.

Building the Express API

Express applications can be very simple - all of the logic can be contained in a single file. As your project becomes more complex this can become very confusing. Because of this, weā€™ll use files and folders that help us keep the project organized:

api/
  |- .env
  |- src/
  |  |- app.ts
  |  |- env.ts
  |  |- lambda.ts
  |  |- local.ts
  |  |- middleware/
  |  |  |- error.ts
  |  |  |- not-found.ts
  |  |- routes/
  |  |  |- index.ts
  |  |  |- ok.ts

Main Application

Even though it is not required, it is common to put the main application logic in a file called app.ts (or app.js if you are not using TypeScript). Thatā€™s the name the Express application generator chooses (which we are not using). In our case weā€™ll want to store all of the code for our application in a src folder to keep things organized. Create src/app.ts and copy the following:

import express from 'express'
import cookieParser from 'cookie-parser'
import cors from 'cors'
import helmet from 'helmet'
import router from './routes'

import { errorMiddleware } from './middleware/error'
import { notFoundMiddleware } from './middleware/not-found'

// Create the Express app
const app = express()

// Setup middleware for our API
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.use(cookieParser())
app.use(helmet())
app.use(cors({ credentials: true }))

// The aws-serverless-express library creates a server and listens on a Unix
// Domain Socket for you, so you don't need the usual call to app.listen.
app.use('/', router)

// If the user tries to access any path which doesn't exist, and there was no error, return a 404
app.use(notFoundMiddleware)

// Error handling middleware should be loaded after the loading the routes
app.use(errorMiddleware)

// Export your express server so it can be reused depending on how the server is run.
export default app

After creating the default express application we begin using specific middleware. Express applications receive a request (containing params and other information) and return a response (in our case, containing JSON). The middleware pattern allows us to treat this request-response cycle as a pipeline using specific functions:

const customMiddleware = (request: Request, response: Response, next: NextFunction) {
  console.log('Time:', Date.now())
  next()
})

The customMiddleware function receives the request and response objects and can modify them before allowing the middleware pipeline to move to the next middleware function.

When setting up our application weā€™ve specified that our application should use a common set of middleware:

  • express.json(): allows our application to handle JSON requests
  • express.urlencoded({ extended: true }): allows our application to handle URL encoded parameters
  • cookieParser(): parses any cookie headers sent to our application
  • helmet(): introduces a set of security-related middleware including default content security policies
  • cors({ credentials: true }): allows our API to be called from other domains by returning correct Cross-Origin Resource Sharing responses

The routes - or paths - that your API handles can also be treated as middleware. Weā€™ll store those in a separate folder so that we can keep the logic separate. For now, weā€™ve included them in our application in the correct order. We want to process all of our routes after our security middleware and serverless middleware and before any of our error handling middleware.

Our error handling middleware comes last so that we can catch any errors that happen within our routes (or security middleware). We havenā€™t written our notFoundMiddleware or errorMiddleware yet.

Finally, we export the application. This isnā€™t strictly necessary; but it will simplify our tests later.

Middleware

We havenā€™t written any middleware yet - letā€™s do that now. Create a new folder in the src directory called middleware. Then create a new file called src/middleware/not-found.ts:

import { Request, Response, NextFunction } from 'express'
import createError from 'http-errors'

export const notFoundMiddleware = (_request: Request, _response: Response, next: NextFunction): void => {
  next(createError(404))
}

This is a very simple function that creates a 404 not found error if no response has been returned by a previous route middleware.Why do the _request and _response parameters start with an underscore (_)? One of our linter rules states that there can be no-unused-vars. Unfortunately we need to include them so that we can access the third parameter: next. By adding an _ to the beginning of the parameter name it indicates that we won't use that parameter and the linter can safely ignore it.

Next, create a new file called src/middleware/error.ts:

import { HttpError } from 'http-errors'
import { Request, Response, NextFunction } from 'express'

// Error handler (must have all four parameters)
export const errorMiddleware = (
  error: HttpError,
  _request: Request,
  response: Response,
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  _next: NextFunction,
): void => {
  const message = (error.expose ? error.message : undefined) || 'Internal Server Error'
  response.status(error.statusCode || 500)
  response.json({ error: message, statusCode: error.statusCode || 500 })
}

This middleware is slightly different - when creating an error middleware you must include all four parameters in the function signature for Express to use it. We have to include the fourth parameter even though we wonā€™t use it. Because of this we have to disable the no-unused-vars linter warning for the last parameter (even though it starts with an underscore).

In the handler we attempt to respond with information about the error that occurred. If there is an exception within one of our routes, Express will catch the exception and funnel it through the errorMiddleware.

Routes

We havenā€™t written any route-handling code for our Express application. Like our middleware, weā€™ll organize our route logic in separate files within a src/routes folder. Weā€™ll create a default src/routes/index.ts to manage all of the routes. Create src/routes/index.ts:

import { Router } from 'express'
import okRoutes from './ok'

const router = Router()

router.use('/ok', okRoutes)

export default router

To start, we create and export a basic express router. For now weā€™ve included a single route handler that responds to /ok. Weā€™ll be adding more routes soon.Why did we choose to make an API endpoint for /ok instead of the root route / to test things? Because we've setup our API Gateway as a proxy for /{proxy+}, the root route / won't ever be passed to our Express server (it must have an additional path part of the route). If you attempt to fetch the root route (for example, in a browser) you'll see {'message':"Missing Authentication Token"} regardless of how your Express server is setup. We might have also chosen /ping which is commonly used for health checks. Unfortunately this route is reserved by API Gateway and returns the health status of the gateway itself and will also never be passed to the Express server.

Next, we need to create the ok route handler. Create src/routes/ok.ts:

import { Router } from 'express'
import { getCurrentInvoke } from '@vendia/serverless-express'

const router = Router()

router.get('/', (_request, response) => {

  console.log('GET ok')

  const { event, context } = getCurrentInvoke()
  console.log('Serverless Event: ' + JSON.stringify(event))
  console.log('Serverless Context: ' + JSON.stringify(context))

  response.json({
    ok: 'ok',
  })
})

export default router

Our ok router handles a GET request and returns a simple JSON response. We also log some information about the request. In general, you wouldnā€™t want to use this in a production application as you might accidentally log private information. For now, it will help us see how the server is working.

Setting up the Lambda handler function

Notice that we arenā€™t calling app.listen in app.ts as we normally would in an Express application. When our server is running within AWS lambda, we donā€™t want to listen on a port to receive requests; instead weā€™ll build a custom AWS handler function and pass the request to our Express server directly.

Create a new file called src/lambda.ts:

import serverlessExpress from '@vendia/serverless-express'
import app from './app'

exports.handler = serverlessExpress({ app })

Weā€™ll configure our AWS Lambda function to call this handler which will then pass the request to our Express application. Even if we add more routes to our Express application, we wonā€™t need to change this simple handler.

Setting up a local server for development

Weā€™ve setup our Express app and added a Lambda handler wrapper for it for production, but it is still difficult for us to test our application locally. Create a file called src/local.ts:

import './env'
import app from './app'

const port = process.env['PORT'] || 4000

app.listen(port)
console.log(`Listening on http://localhost:${port}`)

Like lambda.ts, this wrapper is very simple. It loads our configuration and sets the application to listen on a port (by default, port 4000). When running in development mode weā€™ll need to manage our environment variables manually. Add the following dependency:

npm install --save-dev dotenv

Notice that weā€™re saving this as a development dependency. We wonā€™t need these for our Lambda function and we want to keep it as small as possible. Next create a new file called env.ts:

import dotenv from 'dotenv'

dotenv.config()

export default dotenv

This will automatically load a file called .env where we can keep local environment variables. For now, the only local environment variable we want to set is the NODE_ENV. Create a file called .env in the project root folder:

NODE_ENV=development

Weā€™ll add more variables to our environment later, but for now this is all we need.

To use local.ts, weā€™ll change package.json so that the start script loads it when starting in development mode:

{
  "scripts": {
    // ...
    "start": "ts-node-dev --respawn --pretty --transpile-only src/local.ts"
  },
}

Notice that we execute local.ts using ts-node-dev. This will watch our project folder for changes and reload and restart our server automatically while recompiling our TypeScript.

You can start the server by running:

npm start

You should see (your version numbers might be different):

[INFO] 07:53:57 ts-node-dev ver. 1.1.6 (using ts-node ver. 9.1.1, typescript ver. 4.2.4)
Listening on http://localhost:4000

If you open http://localhost:4000/ok you should see:

{"ok": "ok"}

Not very exciting content, but this is our first JSON API response!

Save our progress

Weā€™ll want to use version control to keep track of our changes. Weā€™ll use git:

git init

Ignore some things

Letā€™s make sure to ignore our environment variables (which may include sensitive secrets) and some of the files weā€™ve generated. Create a .gitignore file in the project root folder:

# Ignore built releases
dist/*

# =========================
# Node.js-Specific Ignores
# =========================

# Ignore any localstack configuration
.localstack*

# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage

# nyc test coverage
.nyc_output

# Ignore test runner output
__tests__/runner/*

# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# Typescript v1 declaration files
typings/

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variables file
.env

# =========================
# Operating System Files
# =========================

# OSX
# =========================

.DS_Store
.AppleDouble
.LSOverride

# Thumbnails
._*

# Files that might appear on external disk
.Spotlight-V100
.Trashes

# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk

# Windows
# =========================

# Windows image file caches
Thumbs.db
ehthumbs.db

# Folder config file
Desktop.ini

# Recycle Bin used on file shares
$RECYCLE.BIN/

# Windows Installer files
*.cab
*.msi
*.msm
*.msp

# Windows shortcuts
*.lnk

Commit

To add all of the files and commit them to the repository weā€™ll use GitHub Desktop:

Create a commit titled 'Initial commit'

Deploying

Weā€™ll use the terrform setup from the previous post to deploy our Express application to AWS Lambda. To do this weā€™ll need to do the following:

  • Install only the production dependencies
  • Build and compress the release and copy them to the terraform folder
  • Use terraform to deploy our changes

Install only the production dependencies

While working locally, weā€™ve been using some development packages. These arenā€™t needed for our production application so weā€™ll want to remove them (to make our build smaller and startup time faster). If youā€™re running your server locally, stop it (use Ctrl+C).

Next, letā€™s prune the dependencies to only production packages:

npm prune --production

Build and compress the release

Weā€™ll need to package all of the files into a release for deployment. There are a few ways to do this, each with tradeoffs. To start, weā€™ll simply compress all of the source code and dependencies into a single zip file. This is far less efficient and will include many files our production application wonā€™t need - but it will make the process simpler and easier to debug.

Add the zip script to your package.json:

  ...
  "scripts": {
    "zip": "zip -r ../terraform/api.zip *.js package* node_modules"
  },
  ...

This creates a file called api.zip in the terraform folder. If youā€™ve named your folders differently (or put them in different locations) you may need to change this.

Run the command:

npm run zip

The zip file is created but it is very large: 8.0M. Weā€™ll want to make this smaller. Re-install your development dependencies:

npm install

Instead of including everything, we should build our release using a tool like Webpack. Webpack allows you to transpile and minify your source code into a single JavaScript file; bundling all of the dependencies in the most efficient way. It will also save the hassle of pruning and re-installing dependencies. Letā€™s install it:

npm install --save-dev webpack webpack-cli

Next, weā€™ll want to create a configuration for Webpack. For now, weā€™ll focus only on the production build that we plan to upload to AWS. Create a file called webpack.config.js in the project root folder:

const path = require('path')

module.exports = {
  entry: './src/lambda.ts',
  output: {
    library: 'api',
    libraryTarget: 'umd',
    umdNamedDefine: true,
    path: path.resolve(__dirname, 'dist'),
    filename: 'api.js'
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js'],
    modules: [
      'node_modules'
    ]
  },
  target: 'node',
  mode: 'production',
  module: {
    rules: [{
      // all files with a `.ts` or `.tsx` extension will be handled by `ts-loader`
      test: /\.tsx?$/,
      use: [{
        loader: 'ts-loader',
      }]
    }]
  }
}

This configuration tells Webpack to start with ./src/lambda.ts and export a bundled release called dist/api.js which will be used as an API and has a Universal Module Declaration (our exported handler function). Weā€™ve set our target to node meaning we plan to run the generated file using Node (we donā€™t plan to run it in a browser).

To run webpack we need to add another script to our package.json:

  ...
  "scripts": {
    "build": "tsc --noEmit && webpack"
  },
  ...

Run it:

npm run build

You should see:

asset api.js 587 KiB [compared for emit] [minimized] (name: main) 1 related asset
runtime modules 211 bytes 2 modules
modules by path ./node_modules/ 769 KiB
  javascript modules 510 KiB 85 modules
  json modules 258 KiB
    modules by path ./node_modules/iconv-lite/encodings/tables/*.json 86.7 KiB 8 modules
    3 modules
modules by path ./src/ 4.18 KiB
  modules by path ./src/*.ts 2.45 KiB 2 modules
  modules by path ./src/routes/*.ts 760 bytes
    ./src/routes/index.ts 395 bytes [built] [code generated]
    ./src/routes/ok.ts 365 bytes [built] [code generated]
  modules by path ./src/middleware/*.ts 1010 bytes
    ./src/middleware/error.ts 546 bytes [built] [code generated]
    ./src/middleware/not-found.ts 466 bytes [built] [code generated]
14 modules

WARNING in ./node_modules/express/lib/view.js 81:13-25
Critical dependency: the request of a dependency is an expression
 @ ./node_modules/express/lib/application.js 22:11-28
 @ ./node_modules/express/lib/express.js 18:12-36
 @ ./node_modules/express/index.js 11:0-41
 @ ./src/app.ts 6:34-52
 @ ./src/lambda.ts 8:30-46

1 warning has detailed information that is not shown.
Use 'stats.errorDetails: true' resp. '--stats-error-details' to show it.

webpack 5.34.0 compiled with 1 warning in 4393 ms

The compiled size is now 769 KiB! That is much smaller than 8 MB. Unfortunately, you may also see a Critical Dependency warningMore information about this error can be found on GitHub.. Why are we getting this warning? Express loads its view engines using a dynamic require:

var fn = require(mod).__express

Webpack isnā€™t sure how to handle this; but luckily we donā€™t really care because weā€™re not using any view engines. Because of this we can just ignore the warning. If, like me, this bothers you then there are a few options:

  1. Exclude all nodeExternals - this will prevent Webpack from bundling any of the node_modules in our build
  2. Exclude express - this will prevent Webpack from bundling the express package in our build
  3. Silence the warning

The first option makes the build incredibly small (less than 1 Kib), but unfortunately doesnā€™t work at all. Because weā€™re running on AWS Lambda we need the node modules to be bundled. This also means option 2 wonā€™t work. Our only option is to silence the warning. Change webpack.config.js:

const path = require('path')

module.exports = {
  entry: './src/lambda.ts',
  output: {
    library: 'api',
    libraryTarget: 'umd',
    umdNamedDefine: true,
    path: path.resolve(__dirname, 'dist'),
    filename: 'api.js'
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js'],
    modules: [
      'node_modules'
    ]
  },
  target: 'node',
  mode: 'production',
  ignoreWarnings: [/^(?!CriticalDependenciesWarning$)/],
  module: {
    rules: [{
      // all files with a `.ts` or `.tsx` extension will be handled by `ts-loader`
      test: /\.tsx?$/,
      use: [{
        loader: 'ts-loader',
      }]
    }]
  }
}

Letā€™s change our zip script in package.json to use this new file. Also, weā€™ll add a new release script that combines the build and zip scripts:

  "scripts": {
    ...
    "zip": "cd dist; zip -r ../../terraform/api.zip api.js",
    "release": "npm run build && npm run zip"
    ...
  },
  ...

Run the release:

npm run release

Our compressed api.zip is only 235 KiB.

Remove the extracted comments

In newer versions of webpack the comments will be automatically extracted and placed in a LICENSE.txt file. If you want to avoid this you can turn off comment extraction in your webpack.config.js:

const path = require('path')
const TerserPlugin = require("terser-webpack-plugin")

module.exports = {
  entry: './src/lambda.ts',
  output: {
    library: 'api',
    libraryTarget: 'umd',
    umdNamedDefine: true,
    path: path.resolve(__dirname, 'dist'),
    filename: 'api.js'
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js'],
    modules: [
      'node_modules'
    ]
  },
  target: 'node',
  mode: 'production',
  ignoreWarnings: [/^(?!CriticalDependenciesWarning$)/],
  module: {
    rules: [{
      // all files with a `.ts` or `.tsx` extension will be handled by `ts-loader`
      test: /\.tsx?$/,
      use: [{
        loader: 'ts-loader',
      }]
    }]
  },
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          format: {
            comments: false,
          },
        },
        extractComments: false,
      }),
    ],
  }
}

Use terraform to deploy our changes

Now that we have prepared the release we should be able to apply our changes using terraform. Check the plan:

terraform plan

You should see the modified hash of the source code:

...
 ~ source_code_hash = "PM7qAOa3MhcDW7/4fxKHxkSe5c6c3DyElx5iTGb8bPY=" -> "APdymEzpkA8Gm1BqKtSRnr/4O5Xl0gZBJHknkbA4LYE="
...

Apply the changes:

terraform apply

And answer yes. Once complete, open your API in a browser: https://api.example.com/ok (replacing example with your domain name). You should see:

{"ok": "ok"}

If you donā€™t see ā€œokā€ you might instead see:

{'message':"Missing Authentication Token"}

There are several reasons you might see this:

  • You attempted to navigate to the root URL (which isnā€™t configured)
  • Your integration or options integration is configured incorrectly
  • The Lambda function is failing (and errors are configured incorrectly)
  • The integration is setup to require authorization and there is no authorized user.

In most cases the problem is required authorization. In fact, if youā€™ve followed the previous tutorial, you should see this error. Feel free to continue (weā€™ll setup user authorization in the next post). If you want to remove the user authorization requirement, revisit the site_api_method definition. Change AWS_IAM to NONE and apply the changes. After a few minutes you should be able to see "ok" response.

Invoking the function directly

We could also invoke our function directly (without opening a browser and sending a request through the API Gateway). We can do this using awscli. Run the following (changing the profile name example to the name of your awscli profile)It's easy to forget to add the cli-binary-format parameter. If you leave it off you'll see an error: "An error occurred (InvalidRequestContentException) when calling the Invoke operation: Could not parse request body into json: Unexpected character ('Ā­' (code 173)): expected a valid value (number, String, array, object, 'true', 'false' or 'null') at [Source: (byte[])"ļæ½ļæ½(ļæ½ļæ½ļæ½ļæ½ļæ½ļæ½/ļæ½jļæ½ļæ½Hmļæ½ļæ½ļæ½DŪ”Ü”yļæ½^ļæ½ļæ½(ļæ½×§ļæ½Ü©y ļæ½iļæ½'ļæ½*'ļæ½;(ļæ½ļæ½Zļæ½Ē­Q1|"; line: 1, column: 2]". You can make this parameter the default in your ~/.aws/config. For more information, see the AWS documentation.:

aws lambda invoke \
  --profile example \
  --cli-binary-format raw-in-base64-out \
  --function-name site_lambda_function \
  --payload '{
    "resource": "/{proxy+}",
    "path": "/ok",
    "httpMethod": "GET",
    "body": "{\"\"}",
    "headers": "{\"content-type\":\"application/json; charset=UTF-8\"}"
  }' /dev/stdout

You should see:

{"statusCode":200,"body":"{\"ok\":\"ok\"}","headers":{"x-powered-by":"Express","access-control-allow-origin":"*","access-control-allow-credentials":"true","content-type":"application/json; charset=utf-8","content-length":"11","etag":"W/\"b-2F/2BWc0KYbtLqL5U2Kv5B6uQUQ\"","date":"Thu, 08 Oct 2020 03:25:{4 GMT","connection":"close"},"isBase64Encoded":false}
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}

This is really helpful when debugging API Gateway configuration and request payload problems.

If you want to view the Lambda function on AWS itself, you should be able to log in and go to https://console.aws.amazon.com/lambda/home?region=us-east-1#/functions/site_lambda_function?tab=configuration. From here you can quickly get to the CloudWatch logs to see any information youā€™ve logged.

Save the change and create a new release:

npm run release

Next, weā€™ll need to deploy the change using Terraform. Letā€™s add a deploy script to our package.json:

  "scripts": {
    "build": "tsc --noEmit && webpack",
    "lint": "eslint . --ext .ts",
    "start": "ts-node-dev --respawn --pretty --transpile-only src/local.ts",
    "zip": "cd dist; zip -r ../../terraform/api.zip api.js",
    "release": "npm run build && npm run zip",
    "deploy": "cd ../terraform; terraform apply --auto-approve"
  },

Now run:

npm run deploy

You should see Terraform run. Go to your ok route in a browser again; wait a few seconds and then open the CloudWatch logs by clicking the View logs in CloudWatch button (or, if youā€™ve closely followed my setup: here).

CloudWatch logs showing the API invocation

Testing

With all of our setup, deploying is fairly quick and easy. However, weā€™ll move much faster if we can test all of our code before deploying. We can do this manually using our local development server but it is very good to also have automated tests. Letā€™s setup Jest. As with all of the other choices weā€™ve had to make, there are lots of choices for our testing framework. I tend to use Jest because Iā€™ve always used it and it is easy for me to understand.

Letā€™s install the jest and supertest packages:

npm install --save-dev jest supertest

Weā€™ll also want to add the TypeScript support:

npm install --save-dev ts-jest @types/jest @types/supertest

Now that we have the packages weā€™ll need to add jest to the env in .eslintrc.json:

  ...
  "env": {
    "node": true,
    "jest": true,
    "es6": true
  },
  ...
}

Weā€™ll also need to add a test script in our package.json:

  "scripts": {
    "build": "tsc --noEmit && webpack",
    "lint": "eslint . --ext .ts",
    "start": "ts-node-dev --respawn --pretty --transpile-only src/local.ts",
    "zip": "cd dist; zip -r ../../terraform/api.zip api.js",
    "release": "npm run build && npm run zip",
    "deploy": "cd ../terraform; terraform apply --auto-approve",
    "test": "tsc --noEmit && jest"
  },

The test script will compile our TypeScript (which will check the types) and then run jest to run all of our tests. For this to work weā€™ll have to add another configuration file to the root of our project. Create jest.config.js:

module.exports = {
  clearMocks: true,
  moduleFileExtensions: ['js', 'ts'],
  testEnvironment: 'node',
  testMatch: ['**/*.test.ts'],
  transform: {
    '^.+\\.ts$': 'ts-jest',
  },
  verbose: true,
}

This configuration is pretty simple and will allow us to find any tests in our project and run them. Letā€™s try it:

npm test

Whoops, we have an error! We havenā€™t written any tests:

No tests found, exiting with code 1

Letā€™s add a test. There are a few ways that you can organize your tests. I prefer to keep my tests alongside the code they are testing. Create a new file called src/routes/ok.test.ts:

import request from 'supertest'
import app from '../app'

describe('GET /ok', () => {
  it('returns ok as json', async () => {
    const response = await request(app).get('/ok')
    expect(response.statusCode).toBe(200)
    expect(response.headers['content-type']).toBe('application/json; charset=utf-8')
    expect(response.body.ok).toBe('ok')
  })
})

This is a very simple test: it uses supertest to generate a request (and uses await to wait for the async call) and sets expectations on the response. Letā€™s run this:

npm test

You should see:

 PASS  src/routes/ok.test.ts
  GET /ok
    āœ“ returns ok as json (43 ms)

  console.log
    GET ok

      at src/routes/ok.ts:7:11

  console.log
    Serverless Event: undefined

      at src/routes/ok.ts:10:11

  console.log
    Serverless Context: undefined

      at src/routes/ok.ts:11:11

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.862 s, estimated 1 s
Ran all test suites.

The test passed! There are a couple of extraneous logs in the output. Also, there is no event or context available within our test environment so they are undefined. For now, thatā€™s fine we can mock those later.

Working with authenticated requests

Looking closely at the logs you might have noticed the requestContext node in the apiGateway object that was logged:

        ...
        "requestContext": {
            "resourceId": "9df9s7",
            "resourcePath": "/{proxy+}",
            "httpMethod": "GET",
            "extendedRequestId": "p9d45s2gIAMD9yg=",
            "requestTime": "11/Oct/2020:11:37:39 +0000",
            "path": "/ok",
            "accountId": "15635678299",
            "protocol": "HTTP/1.1",
            "stage": "production",
            "domainPrefix": "api",
            "requestTimeEpoch": 1602416259517,
            "requestId": "629ceff-03d3-4939-b008-a9920072979e",
            "identity": {
                "cognitoIdentityPoolId": null,
                "accountId": null,
                "cognitoIdentityId": null,
                "caller": null,
                "sourceIp": "111.222.333.444",
                "principalOrgId": null,
                "accessKey": null,
                "cognitoAuthenticationType": null,
                "cognitoAuthenticationProvider": null,
                "userArn": null,
                "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36",
                "user": null
            },
            "domainName": "api.example.com",
            "apiId": "9sa8ucem6g"
        },
        ...

The requestContext has a lot of useful information but most importantly it has an identity node representing the userā€™s AWS Cognito identity. In this case everything is null. Thatā€™s because we didnā€™t include any information about our identity pool when making the request. Weā€™ll learn more about this when we begin building the front end.

Storing and retrieving items from the database

As weā€™ve seen with the other parts of our server there are lots of choices available for storing and retrieving data. When setting up our database using Terraform we chose DynamoDB mostly because of cost. Working with DynamoDB is very different from working in a traditional relational database like MySQL or Postgres. Weā€™ve made some strong choices about how the data should be arranged in our DynamoDB instance - these choices will give us decent flexibility while allowing us to keep our costs (read unit capacity) very low. In many cases - if you arenā€™t optimizing for cost and scale - there are simpler configurations. For me, I rely on this pattern regardless of scale to keep from having to make new decisions.

For context, the Dynamo Paper from 2007 lays out the fundamental aspects of Amazonā€™s highly available key-value store. You donā€™t need to read the paper to use DynamoDB but if you are looking to better understand its origin and foundation the paper can be a good read. There are also two fantastic resources about the data access patterns I use:

Again, you donā€™t need to read these but they offer deeper insight than I cover. For me, these took multiple tries to fully comprehend.

Localstack

Although it is possible to setup a temporary DynamoDB on AWS for testing purposes, it is very cumbersome. Luckily, it is possible to run a local DynamoDB for development using Localstack. Localstack utilizes Docker to create watered-down versions of many AWS products. For our purposes, the Community Edition (free and Open Source) should work fine.

To install localstack you need use pip (the package installer for Python) and run:

pip install localstack

If you get a permission error you can try:

pip install --user localstack

If you donā€™t have the pip command, try pip3 (the Python 3 version) instead.

Normally you would start Localstack using localstack start to run the default stack. You can configure which services run and where the data is stored using environment variables. Weā€™ll use the following services:

  • S3
  • DynamoDB
  • SES
  • Lambda

Weā€™ll want to persist our data in between localstack restarts. To do this we actually need to point our data directory to the our computerā€™s /tmp directory. Why? Localstack relies on docker which mounts a shared folder in the /tmp directory by default. Weā€™ll need to store our data within that mounted folder.

Weā€™ll also need to set directory for Localstackā€™s temporary storage. Weā€™ll use a directory called .localstack in our projectā€™s root folder.

This can be a lot to remember; letā€™s add another script to our package.json:

  scripts: {
    ...
    "localstack": "SERVICES=s3,dynamodb,ses,lambda DEBUG=1 DATA_DIR=/tmp/localstack/data TMPDIR=.localstack localstack start"
    ...
  }

Notice that we also set localstack to run in debug mode for better output.

Before we can run the command weā€™ll want setup which ports each of the services will use. To do this add the following your .env file:

LOCALSTACK_HOSTNAME=0.0.0.0
AWS_DYNAMODB_ENDPOINT=http://$LOCALSTACK_HOSTNAME:4566
AWS_ACCESS_KEY_ID=test
AWS_SECRET_ACCESS_KEY=test
AWS_REGION=us-east-1

With this in place you should be able to run:

npm run localstack

Sending email

Working locally

Testing

References

https://auth0.com/blog/node-js-and-typescript-tutorial-build-a-crud-api/

api/
  |
  |- .env
  |- .env.sample
  |- .eslintignore
  |- .eslintrc.json
  |- .gitignore
  |- .node-version
  |- .prettierrc
  |- package-lock.json
  |- package.json
  |- tsconfig.json
  |- webpack.config.json
  |- src/
  |  |- data/
  |  |- lib/
  |  |  |- http-exception.ts
  |  |- mailers/
  |  |- middleware/
  |  |  |- error.ts
  |  |  |- not-found.ts
  |  |- routes/
  |  |  |- index.ts
  |  |  |- ok.ts
  |  |- views/
  |  |  |- (empty)

Comments

Thanks for reading ā¤ļø ā€“ comment by replying to the issue for this post. Thereā€™s no comments yet; you could be the first.


Thereā€™s more to read

Looking for more long-form posts? Here ya go...