Serverless API with TypeScript on AWS

Serverless API with TypeScript on AWS

There are many ways to stand up a REST API. Nearly every programming language provides a way for us to develop a simple web server, such as Express.js, Go Gin or Python Flask. However, with the advent of serverless computing, we need to rethink some of the fundamentals of how APIs are developed and deployed. Chiefly:

  • Traditionally APIs are packaged into one deliverable; serverless architecture encourages to divide solutions up into many smaller units that are packaged and deployed individually.
  • Traditionally APIs are stateful applications that are started and stopped infrequently; serverless architecture encourages to start up compute tasks only on demand.

In this article, I will describe a way to develop a serverless API in TypeScript. The motivation is to develop a solution that makes it very easy to get started - making compromises as required to not over-complicate the solution. Specifically, this TypeScript API will:

  • Use AWS HTTP Gateway to route HTTP requests to AWS Lambda functions
  • Develop handlers for HTTP routes in TypeScript
  • Deploy every handler in a separate Lambda for minimal cold-start times
  • Generate configuration for the API Gateway automatically by mapping .ts files in a src/routes folder to HTTP routes
  • Have all infrastructure defined in Terraform for easy extensibility and configuration

While it is actually quite quick to stand up an API using the techniques and template described in this article, there are a few moving parts involved in making a Serverless API work end-to-end. Therefore I will go through some background concepts in this article, to explain the different components of the solution.

TL;DR

If you just want to get a working API up and running and start coding your endpoints, I have put together and easy to use template. Feel free to download that template to get started:

πŸ”— Goldstack Serverless API Template

You can also explore the source code a complete project set up for the domain typescript-serverless-api.examples.dev.goldstack.party and adapt this to your needs:

πŸ”— GitHub TypeScript Serverless API Template

Overall Architecture

Let us start with a high level view of the solution:

Architecture

Clients such as mobile application or browser applications make HTTP request to an endpoint provided by the AWS API Gateway. This will be available under a domain such as api.mywebsite.com.

API Gateway defines a number of routes; e.g. /user or /message that clients can access via the API domain, e.g. https://api.mywebsite.com/user.

When HTTP requests for these endpoints are received, API Gateway will invoke Lambda functions for these endpoints. Each endpoint will have its own Lambda function. The Lambda functions will contain a handler defined in TypeScript. In this handler, we define the logic that will determine what response the server will provide.

In the following, I will provide further details for each of the components of the architecture.

AWS API Gateway HTTP API

For our solution we use the AWS API Gateway HTTP APIs and not the original AWS API Gateway REST API. The latter requires a lot more complex configuration and the new HTTP API does the job just fine for our purposes. To find out more about the differences between the HTTP API and the REST API, please see this page on the AWS documentation:

πŸ”— Choosing between HTTP APIs and REST APIs

Unfortunately the HTTP API has a very generic name that makes it difficult to search for.

Thus I would recommend to bookmark the following reference pages for digging deeper into any concepts touched upon here:

The HTTP API for our solution provides the following features:

  • Provide us with a public HTTP endpoint that clients can call
  • Route requests to Lambdas based on the request path

Defining an API Gateway using Terraform is actually quite simple, see api_gateway.tf:

resource "aws_apigatewayv2_api" "api" {
  name        = "lambda-api-gateway-${random_id.id.hex}"
  description = "API for Goldstack lambda deployment"
    protocol_type = "HTTP"
}

AWS generates HTTP endpoints for all HTTP APIs automatically. These use domains such as d58z7h24p0.execute-api.us-west-2.amazonaws.com. In most instances, we would want to replace this with our own custom domain names for instance api.mydomain.com. For this we need to configure AWS Route 53. The first thing we will need is a hosted zone. Please see this guide to see how to set up a hosted zone: Hosted Zone Configuration.

Once we have the id of our hosted zone, we can define an A alias to our API Gateway domain.tf#L47:

resource aws_route53_record a {
  type     = "A"
  name     = var.api_domain
  zone_id  = data.aws_route53_zone.main.zone_id

  alias {
    evaluate_target_health = false
    name                   = aws_apigatewayv2_domain_name.domain.domain_name_configuration[0].target_domain_name
    zone_id                = aws_apigatewayv2_domain_name.domain.domain_name_configuration[0].hosted_zone_id
  }
}

We further want to ensure users can call our API using a secure HTTP connection (https://). For this we can use a free certificate from AWS Certificate manager, that can also be defined in Terraform, domain.tf#L10:

resource "aws_acm_certificate" "wildcard" {

  domain_name               = var.api_domain
  subject_alternative_names = ["*.${var.api_domain}"]
  validation_method         = "DNS"

  tags = {
    ManagedBy = "goldstack-terraform"
    Changed   = formatdate("YYYY-MM-DD hh:mm ZZZ", timestamp())
  }

  lifecycle {
    ignore_changes = [tags]
  }
}

We define the routes for the API using a Terraform variable defined in goldstack.json#L11. Please see the next section on more details of how this is generated.

{
  "lambdas": {
    "$default": {
      "function_name": "serverless-api-default_gateway_lambda_2281"
    },
    "ANY /admin/{proxy+}": {
      "function_name": "serverless-api-admin-_proxy__"
    },
    "ANY /cart/{sessionId}/items": {
      "function_name": "serverless-api-cart-_sessionId_-items"
    },
    "ANY /echo": {
      "function_name": "serverless-api-echo"
    },
    "ANY /order/{id}": {
      "function_name": "serverless-api-order-_id_"
    },
    "ANY /user": {
      "function_name": "serverless-api-user-index_root_lambda_4423"
    },
    "ANY /user/{userId}": {
      "function_name": "serverless-api-user-_userId_"
    }
  }
}

TypeScript Handlers in Lambdas

The API Gateway HTTP API provides an HTTP endpoint but does not contain any logic defining what responses should be sent back to the client.

For this, we need to define Lambdas and within these Lambdas handler functions that contain some logic defined in TypeScript building the responses.

A handler function is a simple JavaScript function. For more information on the TypeScript types used in the method declaration see TypeScript Types for AWS Lambda. Here an example for a simple handler function (src/routes/echo.ts):

import { Handler, APIGatewayProxyEventV2 } from 'aws-lambda';

type ProxyHandler = Handler;

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const handler: ProxyHandler = async (event, context) => {
  const message = event.queryStringParameters?.message || 'no message';

  return {
    message: `${message}`,
  };
};

Here we return a simple JSON object that AWS Gateway will automatically transform into a HTTP response using the correct response code (200) and content type (application/json). To test the response of this endpoint, try opening:

https://typescript-serverless-api.examples.dev.goldstack.party/echo?message=TypeScript

To run this code, we need to define the infrastructure for a Lambda to contain the code. For this we can use the Terraform resource aws_lambda_function, see lambda_routes#L13:

resource "aws_lambda_function" "this" {
  for_each  = var.lambdas

  function_name =  lookup(each.value, "function_name", null)

  filename = data.archive_file.empty_lambda.output_path

  handler = "lambda.handler"
  runtime = "nodejs12.x"

  memory_size = 2048
  timeout = 27 # default Gateway timeout is 29 s

  role = aws_iam_role.lambda_exec.arn
}

There is more than one way to link a Lambda function to an API gateway, see Choose an API Gateway API integration type on the AWS documentation.

In our case, we choose a AWS_PROXY integration, see lambda_routes.tf#L52

resource "aws_apigatewayv2_integration" "this" {
  for_each         = var.lambdas
  api_id           = aws_apigatewayv2_api.api.id
  integration_type = "AWS_PROXY"

  payload_format_version    = "2.0"
  connection_type           = "INTERNET"
  description               = "Dynamic lambda integration"
  integration_method        = "POST"
  integration_uri           = aws_lambda_function.this[each.key].invoke_arn
}

Note the integration method here is "POST" but this is related to how the API Gateway invokes the Lambda function. The HTTP methods that the gateway will serve are defined as part of the routes.

There are a number of other things we need to configure to get our Lambdas working. For a full reference, please see the files lambda_routes.tf and lambda_shared.tf

Route Configuration

After having configured the API Gateway, the Lambda infrastructure and the integration between Gateway and Lambda, we still need to configure what paths in the API are linked to which Lambda functions.

This can be achieved in Terraform using the aws_apigatewayv2_route resource, see lambda_routes.tf#L43:

resource "aws_apigatewayv2_route" "this" {
  for_each  = var.lambdas

  api_id    = aws_apigatewayv2_api.api.id
  route_key = each.key

  target    = "integrations/${aws_apigatewayv2_integration.this[each.key].id}"
}

The route_key here determines which request path in the API this resource applies to (see Working with routes for HTTP APIs). The target defined the Lambda we want to call.

Since creating and maintaining these route definitions can be quite cumbersome, I have developed a small utility that scans a folder (src/routes) for .ts files and ensures that Terraform creates routes for all handlers defined in these folders. Find the source code for this utility in collectLambdasFromFiles.ts. For information on the rules used to map file names to routes in the API, please see Defining Routes.

See below the routes defined in the example project (src/routes).

API routes

While it is possible to get the dynamic routing working using the npm module @goldstack/utils-aws-lamda, it is much easier to do so using the Serverless API template. Alternatively it is also possible to set up routes manually using the aws_apigatewayv2_route Terraform resource.

Conclusion

In a previous article I described how to configure AWS Lambda to deploy an Express.js server. When I put the project for that article together, I used the REST API in API Gateway. AWS has since releases the new HTTP API for API Gateway. I found that while creating the template for this article, that new HTTP API is faster, cheaper and easier to configure.

Further, after implementing a few APIs with the Express.js template, I realised using Express.js to do the routing for a Lambda function may not be the best way to go about things; especially since the size of the Lambdas I had to deploy was often easily above 1 MB in size. This resulted in rather long cold start times (~1 s).

I have thus created this new template from the ground up to enable developing APIs that are optimised for serverless deployments. Depending on the dependencies used in the Lambdas, APIs developed with this template should support cold starts in the single-digit milliseconds. See below an execution log for a Lambda cold start for an API from the example project.

Lambda cold start times

Note here that the most time is consumed by Init Duration: 132.36 ms which relates to AWS setting up some basic infrastructure for the Lambda.

Overall it took me a fair amount of time to get a basic API up and running and I hope that this blog post, the example project and template can help others to start working on their API quicker, rather than having to mock around with AWS and TypeScript configuration. Please feel free to head over to the Goldstack GitHub project if you encounter any issues or have ideas for further improvement.

Further Reading