Build a Serverless API with AWS Gateway and Lambda

APIs are a crucial part of any web application and there are different techniques for development and design. Serverless is one approach gaining popularity, because of its cost-efficiency, scalability and relative simplicity. As a leading serverless provider, Amazon Web Services (AWS) has made a huge contribution to the world of serverless development, and in this article, we will explain general API implementation concepts using AWS Lambda and other AWS services.
Why AWS Lambda?

AWS Lambda is an AWS service that is responsible for running particular functions in response to particular triggers — events happening in the application. Those triggers could be HTTP calls; events from other AWS services like S3, Kinesis, or SNS; or just recurrent scheduled events. Functions are executed in some type of ephemeral containers, which are fully provisioned and scaled by AWS, so the development team can focus more on the code and functionality than on infrastructure.
Another attractive feature is the pay-as-you-go payment model, where you are charged only for the total execution time of your functions and do not pay for idle time. Of course, like any other service, Lambda has limits and is sometimes not suitable for certain tasks — such as very long-running jobs, heavy computing jobs, or processes that require control over the execution environment. However, AWS Lambda usually works perfectly for implementing APIs.
The Role of API Gateway
AWS API Gateway is a service allowing developers to create and manage HTTP endpoints, map them to particular AWS resources, and configure custom domains, authorizing mechanisms, caching and other features. API Gateway is the fundamental part of serverless API, because it is responsible for the connection between a defined API and the function handling requests to that API.
HTTP APIs
As mentioned, API Gateway includes a lot of functionality and integrations. At some point, though, Amazon realized that serverless developers usually do not require all of those features, but instead need a general simplification of the implementation process. That is probably why in late 2019, AWS announced the new HTTP APIs, a lite version of API Gateway, which dramatically simplifies the developer experience and provides better performance and lower costs for serverless APIs. Although it is simple, HTTP APIs still support such important features as configuring CORS for all endpoints, JWT integration, custom domains and VPC connections.
Understanding Serverless API Concepts
In order to easily understand the main concepts of serverless API implementation, we’ll build a very minimalistic example of a simple “virtual whiteboard” application, consisting of two simple endpoints: POST for writing messages on a whiteboard, and GET for fetching the three most recent messages. We will also consider other possible features — like path parameters, CORS, and authorizers — but we’ll keep the final implementation simple and clear to read.
AWS DynamoDB
We will make our project completely serverless, by using AWS DynamoDB for storing messages. This database corresponds to serverless principles, is easy to use, and offers a pay-per-request model that is really cost-effective. DynamoDB is a NoSQL key-value database offered by AWS, where your data is stored across AWS servers and fully managed by Amazon.
AWS Serverless Application Model
In order to continue further implementation, you’ll need an AWS account and AWS Serverless Application Model (SAM) installed and configured. SAM is a tool for creating, updating, and managing serverless applications and all the resources needed for the application to operate. With AWS SAM, you don’t need to create every single service manually via web console, but just to describe all the things needed in the special template file.
After you’ve installed the CLI, navigate to the directory you are going to work in and run this command:
1 |
$ sam init -r nodejs12.x -n whiteboard |
Initializing new project
Select the first option, then select “Quick Start from Scratch.” This will create a “whiteboard” directory with a minimum of setup files inside.
Define the Required Resources Needed
First, open the template.yml file and remove everything below the “Resources” section. Before moving to the API itself, let’s create the secondary resources. Define a DynamoDB table where messages will be stored:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
Resources: BoardMessagesTable: Type: AWS::DynamoDB::Table Properties: TableName: board-messages-table AttributeDefinitions: - AttributeName: partKey AttributeType: S - AttributeName: createdAt AttributeType: N KeySchema: - AttributeName: partKey KeyType: HASH - AttributeName: createdAt KeyType: RANGE ProvisionedThroughput: ReadCapacityUnits: 5 WriteCapacityUnits: 5 |
Declaring DynamoDB table
The code above will tell AWS to create a DynamoDB table, where attribute “partKey” will be a partition key that is the same for all records and “createdAt” will be a range key, allowing further sorting by timestamp. We may also add other keys and values into the records, but you are not required to define those.
Now, in the same file, just below the previous definition, declare the HTTP API to which all future endpoints and functions will be related.
1 2 3 4 5 |
BoardHttpApi: Type: AWS::Serverless::HttpApi Properties: StageName: Test CorsConfiguration: True |
Declaring HTTP API
The definition is very small and simple since we just included the stage name and CORS configuration, which are not actually required either. This illustrates how simple and clean API creation can be. However, there are many possible properties to add, such as a reference to authorization function, definition of the domain to use, logging settings, and others.
Define API Handlers Functions
Finally, when we have the API defined, let’s also declare two functions connected to its particular endpoints.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
PostMessageFunction: Type: AWS::Serverless::Function Properties: Handler: src/handlers/postMessage.handler Runtime: nodejs12.x MemorySize: 128 Timeout: 5 Events: PostMessage: Type: HttpApi Properties: ApiId: !Ref BoardHttpApi Method: POST Path: /messages Policies: - AmazonDynamoDBFullAccess GetMessagesFunction: Type: AWS::Serverless::Function Properties: Handler: src/handlers/getMessages.handler Runtime: nodejs12.x MemorySize: 128 Timeout: 5 Events: GetMessages: Type: HttpApi Properties: ApiId: !Ref BoardHttpApi Method: GET Path: /messages Policies: - AmazonDynamoDBFullAccess |
Declaring handlers for POST and GET requests
The above code is quite self-descriptive: two functions, one of which will be invoked upon a POST request to the “/messages” path, and the other of which will be invoked upon a GET request to the same path. Both functions have a capacity of 128 MB RAM and a five-second timeout. The functions’ code is found in the postMessage.js and getMessage.js files under the /src/handlers/ directory. We are going to create those right now. (Note that we’ve provided full access to the DynamoDB in the “Policies” section of each function, just to make things easier.) In a real project, you should consider providing more granular access.
Coding the Functions
Navigate to the /src/handlers directory and create files there with the following content:
postMessage.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
const AWS = require('aws-sdk'); const dynamodb = new AWS.DynamoDB(); exports.handler = async (event) => { const { body } = event; try { const { author, text } = JSON.parse(body); if (!author || !text) { return { statusCode: 403, body: 'author and text are required!' } } await dynamodb.putItem({ TableName: 'board-messages-table', Item: { msgId: { S: 'board' }, author: { S: author }, text: { S: text }, createdAt: { N: String(Date.now()) } // still expects string! } }).promise(); return { statusCode: 200, body: 'Message posted on board!', } } catch (err) { return { statusCode: 500, body: 'Something went wrong :(', } } }; |
POST request handler’s code
This function will run in response to POST requests and will parse the author and text of the message from the request body and save that data into the database. It also fills the “partKey” attribute with the same value for all records. Although usually this is not a good practice, it is completely fine for this example, as it allows you to sort by range key among all items with the same partition key. Note that DynamoDB always expects string data to be saved, even if the type of attribute is a number.
getMessages.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
const AWS = require('aws-sdk'); const dynamodb = new AWS.DynamoDB(); exports.handler = async () => { try { const result = await dynamodb.query({ TableName: 'board-messages-table', KeyConditionExpression: 'partKey = :partKey', ScanIndexForward: false, Limit: 3, ExpressionAttributeValues: {':partKey': { S: 'board'}} }).promise(); return { statusCode: 200, headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(result.Items), } } catch (err) { console.log(err); return { statusCode: 500, body: 'Something went wrong :(', } } }; |
GET request handler’s code
In this function we first get records with “partKey” equal to “board,” then use “ScanIndexForward” set to “false” to sort messages so that the most recent is first, and finally we use the “Limit” property to limit results to three messages.
Deployment
Deployment with AWS SAM is easy and can be done with a single command and a few inputs. Navigate to the root directory of the project and run the following command:
1 |
$ sam deploy --guided |
Deployment command
You will then be asked to enter the name of your app and the AWS region to use. You’ll also need to confirm some actions:
After you’ve completed all the confirmations, deployment will start, and you’ll see all the resources being created. This takes about a minute or less.
When the process is finished, open the AWS web console in your browser, navigate to API Gateway service, find the newly created API, and copy the URL to the root endpoint of your API.
Testing the API
Let’s create a few messages on the board using the default “curl” tool. Use the following command, but replace placeholders with your own data.
1 |
curl -d '{"author":"name", "text":"Message text"}' -H "Content-Type: application/json" -X POST https://your-api-id.execute-api.your-region.amazonaws.com/test/messages |
Performing POST request with curl
Send a few different requests with different messages. If everything is OK, you’ll see “Message posted on board!” in the console without any errors.
In order to fetch the last messages, run an even shorter command:
1 |
curl https://your-api-id.execute-api.your-region.amazonaws.com/test/messages |
Performing GET request with curl
Congratulations! You’ve just built a simple HTTP API with AWS Lambda and AWS SAM. Of course, in a real project you would use additional features and configurations, but the principles remain the same: define resources, define configurations, write the code, and run deploy.
Connect Thundra Monitoring
It’s very good practice to have monitoring set up — especially for serverless applications, as they are really tricky to debug and trace.
Connect Thundra monitoring to the newly created Lambda functions (see the quick start guide). Once you’ve connected Thundra, you’ll need to instrument the “postMessage” and “getMessages” Lambda functions in order to see detailed information about every single invocation and have a global picture of your application.
Select functions in the list and click the “Instrument” button, then confirm instrumenting by clicking “OK.”
Try to make a few other requests to your API, then return to the Thundra dashboard, click on the function’s name, and select any of the invocations in the invocations list. You’ll see details about timing, performance, function inputs and outputs, etc. This can be extremely useful in debugging APIs in real-life projects.
If you or your company use serverless in complex projects with different AWS or third-party services integrated, you should definitely consider using the unique tracing feature, which will dramatically simplify application troubleshooting and debugging.
Are HTTP APIs Worth Using?
HTTP APIs plus AWS Lambda is a great way to build performant and cost-effective APIs. Although it is actually a lite version of API Gateway REST APIs, it still provides all the needed functionality and covers 90% of developers’ needs. HTTP APIs don’t support some useful features, like caching, schema validation and response transformations. Caching, though, is probably not something you’ll need, as HTTP APIs are much faster then the old REST APIs, and validation and transformations can be done at the function’s code level.
If your development team has no other reason to refuse to use HTTP APIs, you may confidently proceed to development with this great feature. Usually, the journey of a serverless transaction starts with an API call and it can be daunting to trace an asynchronous flow of events. Thundra comes to the rescue for such issues, with its end-to-end distributed tracing feature. Thundra is free up to 250K requests per month, which can be quite useful for small projects or startups.
If you’d like to gain full observability through serverless APIs, sign up for Thundra today.