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 asrc/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:
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<APIGatewayProxyEventV2, any>;
// 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).
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 a Lambdas, APIs developed with this template should support Lambda cold starts in the single-digit milliseconds. See below an execution log for a Lambda cold start for an API from the example project.
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
- AWS API Gateway v2 (HTTP/Websocket) Terraform module - Provides a good integrated example of how to configure most aspects of a Lambda integration
- Provisioning AWS API Gateway using Terraform
- How to prevent AWS SAM from creating the default “Stage” in the API gateway stage - I came across a similar error but since I am using Terraform this was caused by the HTTP APIs quick create (see apigatewayv2_api#target)
- How to use source maps in AWS Lambda with Node.js - I used to use source-map-support before but this is no longer need in Node v12+
This article was originally published on the Full Stack Blog: Serverless API with TypeScript on AWS
Top comments (2)
"Traditionally APIs are stateful applications that are..."
You're talking about REST API endpoints here, right?
My understanding is that REST APIs are stateless. In REST (as a client-server architecture), the client has to provide all the information to the server in order to receive the right response. The server doesn't need to remember each client's previous requests. In other words, servers are stateless
Good point and thank you for your comment 🤎! 100% agree that REST APIs should be stateless. What I try to say here is that many legacy services that provide APIs do not react well to being restarted frequently.
So even though they provide a 'stateless API', the server applications themselves take a long time to start up - which is often linked to them having some kind of implicit state, such as DB connections, local caches, session management, etc.
Does that make sense? If so, I will try to rewrite that section to make it clearer - but if not, happy to discuss further first!