Developing and maintaining APIs is an essential part of creating any modern application. Both engineers and stakeholders want the development process to be quick and effective, have a reasonable cost, and deliver reliable products.

All that is achievable with a contemporary serverless approach. In this article, you’ll learn how to build an API fast and easily using Node.js, AWS Lambda, and AWS CDK.

The Serverless Concept

Long story short, serverless is a way of developing the backend part of an application without being responsible for managing the actual servers. Instead, cloud providers take responsibility for configuring and launching the machines, their security, scalability, load balancing, etc.

There are two types of serverless: Backend as a service (BaaS), where developers are provided with ready-to-use third-party services (like authentication and storage), and function as a service (FaaS), where developers are able to upload their own code, which then runs in the provider's environment.

One of the key features of serverless is following a pay-as-you-go model, meaning you’re charged only for the resources your application actually utilizes. For example, in FaaS, you pay only for the total execution time of your code. So, let’s say during a month, your function’s code ran for 350 seconds in total; you would then pay only for these 350 seconds.

AWS Lambda Functions

AWS Lambda is a FaaS solution provided by Amazon Web Services. It allows developers to run their code in stateless ephemeral containers, while the provisioning of containers and autoscaling is handled by AWS itself. Developers can upload small pieces of code as a single function written in their favorite language; on the other hand, they are not limited to this and can upload the backend code for an entire application.

Lambda executes functions only when needed, in response to different events occurring in the application (for example, an HTTP request) or events coming from other AWS services involved in your implementation.

Is AWS Lambda Good for API Development?

An API call is usually a stateless request-response mechanism, and its handling is not a long-running process. This makes Lambda a perfect choice for implementing APIs. Moreover, AWS Lambda is a very cost-effective service. Since your bill depends on the number of function invocations, execution time, and performance settings selected, you will not pay for any idle time of your backend, like in traditional architectures.

At the same time, your application will never go down in case of a huge spike in traffic to your API because AWS takes care of autoscaling. However, it is worth mentioning that scaling here means launching new containers for executing lambda’s code and this process is very quick but not immediate and can cause a noticeable delay, called cold start, between event and actual execution. This is really important for performance critical APIs, but fortunately, there are ways to optimize Lambdas and minimize or even avoid cold starts by using warming mechanisms or enabling provisioned concurrency feature provided by AWS (not free).

The Lambda Trilogy

There are three popular approaches to organizing code and distributing functionality of your API among Lambda functions: single-purpose function, fat lambda, and lambda-lith.

Let’s say we need to develop a backend consisting of two CRUD APIs: one for user and one for product services.

If we decide to use the single purpose function approach, we will need to create as many separate Lambda functions as we have methods or commands in our API. For example: create user, get user, delete user, create product, etc. each has its own Lambda function.

In the case where we select the fat lambda approach, we’ll group functionality in a way (for example, per service) so that only two Lambda functions need to be implemented: One is responsible for handling all requests to the user service, while the other handles those to the product service.

Finally, the lambda-lith approach entails us having a single Lambda function that handles all the requests to the application. This is usually an option if you want to quickly migrate an existing application to Lambda.

Every approach has its pros and cons, and there is no universally accepted correct variant, but for the purpose of this article, we’ll be using the single purpose approach.

Prepare to Code

Your main task in this exercise is to develop an API for creating, reading, and deleting text files in an AWS S3 bucket. In the end, you will have three Lambda functions, which are triggered by HTTP requests arriving via AWS API Gateway.

You will deploy both the code and infrastructure using the AWS Cloud Development Kit (AWS CDK), a tool for managing cloud applications described in detail in another article from our blog.

In order to start, you’ll need an active AWS account and Node.js installed on your computer.

If you’ve ever used AWS CLI, you probably already know how to get and set the security credentials you need to interact with AWS services from your local machine. If you’re doing this for the first time, make sure to use the AWS guide on basic configuration.

Open the terminal and run this command to install CDK:

$ npm install -g aws-cdk@2.x

Then, bootstrap CDK to make sure it’s ready for use on your AWS account:

$ cdk bootstrap aws://YOUR_ACCOUNT_NUMBER/REGION

Finally, create a folder named my-serverless-api, navigate inside of it, and initialize a new CDK project using the terminal:

$ cdk init app --language typescript

Defining the Infrastructure of the App

Open the my-serverless-api-stack.ts file in the root of your project. This is a place where you need to define all of your infrastructure components, like an S3 bucket, an API gateway, and Lambdas.

On top of the file, in the import section, add the following line:

import * as cdk from 'aws-cdk-lib';

Now, in the constructor of the MyServerlessApiStack class, define an S3 bucket:

const myBucket = new cdk.aws_s3.Bucket(this, 'texts-bucket', {
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
})

The aws_s3.Bucket here is one of many CDK building blocks named [constructs](https://docs.aws.amazon.com/cdk/v2/guide/constructs.html) that are used to define all of your infrastructure components. When you’re creating constructs in code, you usually need to pass three parameters: context, identifier, and properties.

Now, right below the bucket, define the first Lambda function, which will be responsible for creating text files in S3:

const textCreateHandler = new cdk.aws_lambda_nodejs.NodejsFunction(
  this,
  'text-create',
  {
    timeout: cdk.Duration.seconds(5),
    memorySize: 1024,
    entry: 'code/text-create/index.ts',
    environment: {
      BUCKET_NAME: myBucket.bucketName,
    },
  }
)
myBucket.grantWrite(textCreateHandler)

Note, at the moment, you’re not writing the code that will be executed in Lambda; instead, you’re writing code that will tell AWS to create a Lambda function configured in accordance with properties you passed in a given construct. Also, as you can see in the last line of the above code snippet, we tell AWS to grant this Lambda write access to our bucket. The environment property can pass variables that will be available at your function’s runtime, which is really useful. For example, in our case, we pass the name of the S3 bucket and have no need to hardcode it elsewhere.

The entry property is a path to the source code of your function, which we will write soon, but for now, let’s define two more Lambda functions right after the first one:

const textReadHandler = new cdk.aws_lambda_nodejs.NodejsFunction(
  this,
  'text-read',
  {
    timeout: cdk.Duration.seconds(5),
    memorySize: 1024,
    entry: 'code/text-read/index.ts',
    environment: {
      BUCKET_NAME: myBucket.bucketName,
    },
  }
)
myBucket.grantRead(textReadHandler)

const textDeleteHandler = new cdk.aws_lambda_nodejs.NodejsFunction(
  this,
  'text-delete',
  {
    timeout: cdk.Duration.seconds(5),
    memorySize: 1024,
    entry: 'code/text-delete/index.ts',
    environment: {
      BUCKET_NAME: myBucket.bucketName,
    },
  }
)
myBucket.grantDelete(textDeleteHandler)

Finally, let’s add an API gateway-related resource and attach our Lambda functions to the corresponding routes and HTTP methods:

const api = new cdk.aws_apigateway.RestApi(this, 'api')
const texts = api.root.addResource('texts')
texts.addMethod(
  'POST',
  new cdk.aws_apigateway.LambdaIntegration(textCreateHandler)
)

const text = texts.addResource('{id}')
text.addMethod('GET', new cdk.aws_apigateway.LambdaIntegration(textReadHandler))
text.addMethod(
  'DELETE',
  new cdk.aws_apigateway.LambdaIntegration(textDeleteHandler)
)

Coding Lambdas

In the root of your project, create a folder named code, then create three folders inside of it: text-create, text-read and text-delete. Now, create an index.ts file in each of these three folders.

Since the functionality of our app is very simple, all the logic of each Lambda function can be placed in a single file. In a real-life project, you’d probably still have entry index.ts files as well as plenty of subfolders, utilities, shared code, modules, and maybe even a package.json file for each Lambda function.

A great feature of the aws_lambda_nodejs.NodejsFunction construct we use here is that, during the deployment of your function, it will transpile TypeScript code; resolve all the dependencies; and perform tree shaking, minification, and bundling. It does use an esbuild bundler under the hood, so we need to install that. Also, since we need to interact with AWS S3, it makes sense to install the S3 client as well:

$ npm install esbuild @aws-sdk/client-s3

Paste the following code into your text-create/index.ts file:

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'

const s3Client = new S3Client({})
export const handler = async (event: any) => {
  const { id, content } = JSON.parse(event.body || '{}')
  if (!id || !content) {
    return { statusCode: 400, error: 'Validation failed' }
  }

  const command = new PutObjectCommand({
    Key: `${id}.txt`,
    ContentType: 'text/plain',
    Bucket: process.env.BUCKET_NAME,
    Body: content,
  })

  try {
    await s3Client.send(command)
  } catch (err) {
    return { statusCode: 500, body: 'Oops' }
  }

  return { statusCode: 200, body: `${id}.txt created!` }
}

And here is the code for the text-read/index.ts file:

import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'
import { Readable } from 'stream'

const s3Client = new S3Client({})
export const handler = async (event: any) => {
  const { id } = event.pathParameters

  const command = new GetObjectCommand({
    Key: `${id}.txt`,
    Bucket: process.env.BUCKET_NAME,
  })

  try {
    const { Body } = await s3Client.send(command)
    const content = await streamToString(Body)
    return { statusCode: 200, body: content }
  } catch (err) {
    return { statusCode: 500, body: 'Oops' }
  }
}
// function for convert response from S3 into string
async function streamToString(stream: Readable): Promise<string> {
  return await new Promise((resolve, reject) => {
    const chunks: Uint8Array[] = []
    stream.on('data', (chunk) => chunks.push(chunk))
    stream.on('error', reject)
    stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
  })
}

Finally, here is the last bit, the code for text-delete/index.ts:

import { S3Client, DeleteObjectCommand } from '@aws-sdk/client-s3'

const s3Client = new S3Client({})
export const handler = async (event: any) => {
  const { id } = event.pathParameters

  const command = new DeleteObjectCommand({
    Key: `${id}.txt`,
    Bucket: process.env.BUCKET_NAME,
  })

  try {
    await s3Client.send(command)
  } catch (err) {
    return { statusCode: 500, body: 'Oops' }
  }

  return { statusCode: 200, body: `${id}.txt deleted` }
}

As you can see, each function takes the event object carrying all the data of the incoming request, which triggers the Lambda function. Then, the function interacts with the S3 bucket and returns the result in the form of an object consisting of the status code and body.

Deploy and Test

In order to deploy your application, run the following command from the projects root folder:

$ cdk deploy

CDK checks all the architecture you defined, prepares a list of updates needed for your AWS account, and then asks you to confirm deployment.

Your application will be deployed in a few minutes, after which you will see a prettified result message in the terminal similar to this:

MyServerlessApiStack: creating CloudFormation changeset...

 ✅  MyServerlessApiStack

✨  Deployment time: 98.06s

Outputs:
MyServerlessApiStack.apiEndpoint9349E63C = https://XXXXXXXXX.execute-api.REGION.amazonaws.com/prod/
Stack ARN:
arn:aws:cloudformation:REGION:4789XXXXXXXX:stack/MyServerlessApiStack/8dc5ba80-c711-11ec-8f10-XXXXXXXXX

✨  Total time: 106.06s

There’s an endpoint URL in that message, so you can use it to test the application. But make sure to attach texts or texts/{id} depending on what request you’re going to send.

For example, let’s create a mytext file with the content Hello World! using the command line:

$ curl -X POST -d '{"id":"mytext", "content":"Hello World!"}' https://XXXXXXXXX.execute-api.REGION.amazonaws.com/prod/texts

You should see a message about a successful creation.

Next, navigate to the S3 bucket using the AWS web console and see your file there, or you can try to read the file using your API and the command line:

$ curl https://XXXXXXXXX.execute-api.REGION.amazonaws.com/prod/texts/mytext

You should see Hello World as a response, as that was the exact text we used in the previous request.

You can use the delete method to remove the file from the S3 bucket:

$ curl -X "DELETE" https://XXXXXXXXX.execute-api.REGION.amazonaws.com/prod/texts/mytext

And if you want to clean up all the created resources from your AWS account, you can easily do so using the following command:

$ cdk destroy

Congratulations! You’ve got the basic knowledge needed to develop and deploy an API with Node.js, AWS Lambda, and AWS CDK.

Conclusion

AWS Lambda is a very suitable computing service for building APIs for various applications and is extremely cost-effective for short-running workloads like handling HTTP requests. Of course, the application you wrote today is far from a production-ready real-life project, as it misses many essential things like validation, authentication, proper error handling, etc. But all that stuff can be built on top of the fundamental principles you learned about in this article. Continue getting familiar with serverless, its related services, and tools, and you’ll definitely fall in love with this kind of backend architecture.

Contact us