How I made my own chat app with AWS

Krishnan V C
10 min readMay 7, 2024

Innovation in communication technologies has always greatly improved our quality of life, from old postal system to modern VR personas we tend to push the boundaries of ingenuity and connect people through technology.

One such major innovation is messaging apps aka chatting apps. We use it everyday but how exactly do they work?

Well I got curious and I decided to create my own chat application using AWS Serverless services like API gateway WebSocket API, AWS Lambda and DynamoDB. Let go ahead and understand how we can create our own chat app with AWS.

LinkedIn: https://in.linkedin.com/in/krishnan-v-c

Backend: https://github.com/KrishnanVC/AWS_ChatApp

User Interface: https://github.com/KrishnanVC/AWS_ChatApp_UI

Key concepts:

Let’s start with some basic components that you must be familiar with to truly understand what’s going on. If you are familiar with these concepts please feel free to skip this section.

WebSocket:

A WebSocket protocol is an networking protocol that provides two-way (full duplex) communication between two nodes (say browser and a server). It is useful when real-time communication and update is required. This makes it an ideal protocol for chat applications. Without WebSocket we may need to implement http polling which is not efficient.

API Gateway WebSocket API:

Amazon API Gateway is a fully managed service that makes it easy for developers to create, publish, maintain, monitor, and secure APIs at any scale. Using API Gateway, you can create RESTful APIs and WebSocket APIs that enable real-time two-way communication applications. You can create a WebSocket API as a stateful frontend for an AWS service (such as Lambda or DynamoDB) or for an HTTP endpoint.

In WebSocket API each message is routed based on an attribute of the JSON message we send to the API. The attribute of the JSON message is selected based on the routeSelectionExpression that you configure at the API level. The routeSelectionExpression takes in the JSON message and outputs a route key. Each route includes a route key, in your WebSocket API incoming JSON messages are directed to backend integrations based on routes that you configure.

For example, if your JSON messages contain an ‘action’ attribute, and you want to perform different actions based on this attribute, your route selection expression might be ${request.body.action}. Your routing table in the API Gateway would specify which action to perform by matching the value of the ‘action’ attribute against the custom route key values (like ‘send’) that you have defined in the table.

There are three predefined routes that can be used: $connect, $disconnect, and $default. In addition, you can create custom routes.

Serverless Application Model (SAM):

AWS SAM provide an easy way to work with AWS serverless services. It makes creating and deploying serverless application on AWS easier.

SAM can be broken into two parts:

  1. SAM CLI
  2. SAM Template

SAM CLI provides functionalities to deploy application, test function locally, get applications logs on the client and much more.

SAM template is a superset of CloudFormation that makes deployment of certain serverless services easier, imagine it to be a shorthand version of writing CloudFormation for certain serverless components. As SAM template is a superset of CloudFormation all your CloudFormation knowledge can be utilized when using SAM templates.

Let’s build our chat app:

Now that we are familiar with the key concepts lets dive straight into developing, starting with the architecture

Architecture:

ChatApp Architecture Diagram
Architecture Diagram

We use a serverless architecture to develop our chat application. The client makes connection request to the API Gateway WebSocket API with the user ID as a query string parameter. The API Gateway invokes the $connect lambda function as the connection is established. The Lambda function stores the user ID (from the query string) and connection ID (provided by API Gateway) to the DynamoDB table. When the connection disconnects the Gateway calls the $disconnect lambda function which removes the connection ID from the table.

The send messages lambda function receives the sender ID, the receiver ID and the message the function then retrieves the connection ID of the receiver from the DynamoDB and send the message through the connection (we can send data from the server to the client as it is a WebSocket API). The list users function provides the connected users list to the client.

For now please ignore the other components, they are for user authentication and authorization. I will make a separate blog going into detail about that.

Now we will get into the actual code.

1) SAM init:

Before we start make sure you have installed and configured AWS SAM cli, refer:

We can start a project using SAM init, follow the prompts that it gives you and it will provide a baseline project. Tweak the project to your needs and once done run SAM build to package the application for deployment. Refer the link below for more information refer:

Once done run the following command:

> sam init

2) SAM Template:

Now lets code the above architecture using SAM template. In your working directory the template.yaml file is where you will add the SAM template.

Let go over each component starting with..

DynamoDB:

ConnectionTable:
Type: AWS::Serverless::SimpleTable
Properties:
PrimaryKey:
Name: id
Type: String
ProvisionedThroughput:
ReadCapacityUnits: 2
WriteCapacityUnits: 2

This uses SAM template convention here you can alter the read and write capacity units based on your workload.

API Gateway:

MyApi:
Type: AWS::ApiGatewayV2::Api
Properties:
Description: My API Gateway
DisableExecuteApiEndpoint: false
Name: chat-app
ProtocolType: WEBSOCKET
RouteSelectionExpression: $request.body.action
Tags:
Project: chat-app

This follows regular CloudFormation templating style as SAM template no longer supports WebSocket API. As SAM template is an superset of the CloudFormation we freely use CloudFormation template components inside SAM templates.

Deployment:

A Deployment is a point-in-time snapshot of your API Gateway API. To be available for clients to use, the deployment must be associated with one or more API stages.

MyDeployment:
Type: "AWS::ApiGatewayV2::Deployment"
DependsOn:
- MyConnectRoute
- MySendMessageRoute
- MyDisconnectRoute
Properties:
Description: My deployment
ApiId: !Ref MyApi

Stage:

A Stage is a logical reference to a lifecycle state of your API (for example, ‘dev’, ‘prod’, ‘beta’, ‘v2’). API stages are identified by API ID and stage name.

MyStage:
Type: "AWS::ApiGatewayV2::Stage"
Properties:
StageName: Prod
Description: Production Stage
ApiId: !Ref MyApi
DeploymentId: !Ref MyDeployment

Function:

This is where we will define out lambda functions. The workflow for integration all the four functions to the API Gateway is almost identical thus we will go into detail about one of the function the other three functions are available in the GitHub

First define the function

SendMessageFn:
Type: AWS::Serverless::Function
Properties:
Handler: src/handlers/send_message.lambda_handler
Runtime: python3.11
Architectures:
- x86_64
MemorySize: 128
Timeout: 100
Description: Sends Messages
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref ConnectionTable
- Statement:
- Effect: Allow
Action:
- "execute-api:ManageConnections"
Resource:
- !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${MyApi}/*"
Environment:
Variables:
TABLE_NAME: !Ref ConnectionTable

In SAM template we can add permission to the function directly using the Policies attributes, here we provide the function permission to access the DynamoDB table and to send message to the user. We pass the table name as an environment variable to the function.

Function Integration with the API Gateway:

In order to integrate the function to the API Gateway we need three components, one is the route to which we need to integrate the function (remember the route key and routeSelectionExpression we talked about earlier), second we need an integration that defines how to integrate the lambda function to the route especially the integration type, finally we need to provide permission to the route to invoke the function.

The other three routes follow identical flow.

MySendMessageRoute:
Type: AWS::ApiGatewayV2::Route
Properties:
ApiId: !Ref MyApi
RouteKey: "send"
AuthorizationType: NONE
Target: !Sub "integrations/${SendMessageIntegration}"

SendMessageIntegration:
Type: AWS::ApiGatewayV2::Integration
Properties:
ApiId: !Ref MyApi
Description: Send Message Integration
IntegrationType: AWS_PROXY
IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${SendMessageFn.Arn}/invocations

SendMessagePermission:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !Ref SendMessageFn
Principal: apigateway.amazonaws.com
SourceArn: !Sub "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${MyApi}/*/send"

3) Lambda functions:

We define four functions one for $connect, one for $disconnect and two custom routes one for our custom route ‘send’ that will handle sending and receiving messages and another ‘users’ that will provide a list of connected users.

Save the function in the src/handlers directory as referenced in the SAM template.

The connection function is given below:

We get a name from the user in the connection request and add it alongside the connection id in a DynamoDB table.

import logging
import json
import os
import boto3
from botocore.exceptions import ClientError

logger = logging.getLogger()
logger.setLevel(logging.DEBUG)

def connect(connection_id, user_id):
table_name: str = os.environ["TABLE_NAME"]
table = boto3.resource("dynamodb").Table(table_name)
try:
table.put_item(Item={"connection_id": connection_id, "id": user_id})
logger.info(f"Added connection {connection_id} for user {user_id}.")
return {
"statusCode": 200,
"body": json.dumps({
"message": "Connection successful",
"connection_id": connection_id
})
}
except ClientError as e:
logger.exception(
f"Couldn't add connection {connection_id} for user {user_id} due to error {e}"
)
return {
"statusCode": 500,
"body": json.dumps({
"message": "Connection unsuccessful",
})
}

def lambda_handler(event, context):
logger.info(event)
connection_id: str = event["requestContext"]["connectionId"]
user_id: str = event["queryStringParameters"]["name"]
return connect(connection_id, user_id)

The disconnection function is given below:

We get the connection id and remove it from the table DynamoDB table

import logging
import os
import json
import boto3
from boto3.dynamodb.conditions import Attr
from botocore.exceptions import ClientError

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def disconnect(connection_id):
table_name: str = os.environ["TABLE_NAME"]
table = boto3.resource("dynamodb").Table(table_name)
try:
item = table.scan(
FilterExpression=Attr("connection_id").eq(connection_id)
)["Items"]
user_id: str = item[0]["id"]

table.update_item(
Key={"id": user_id},
UpdateExpression="SET connection_id=:empty_string",
ExpressionAttributeValues={
":empty_string": ""
}
)
logger.info(f"Connection disconnected for connection: {connection_id}.")
return {
"statusCode": 200,
"body": json.dumps({
"message": "Disconnection successful",
})
}
except ClientError as e:
logger.exception(
f"Couldn't disconnect connection {connection_id} due to error {e}"
)
return {
"statusCode": 500,
"body": json.dumps({
"message": "Disconnection unsuccessful",
})
}

def lambda_handler(event, context):
connection_id: str = event["requestContext"]["connectionId"]
return disconnect(connection_id)

The SendMessage function is given below:

In this function we get the user id, recipient id and the message. Based on the recipient’s id we retrieve their connection id from the DynamoDB table and send the message.

import os
import logging
import json
import boto3
from botocore.exceptions import ClientError

logger = logging.getLogger()
logger.setLevel(logging.DEBUG)

def send_message(client, connection_id, user_id, send_to, message):
table_name: str = os.environ["TABLE_NAME"]
table = boto3.resource("dynamodb").Table(table_name)
try:
item: str = table.get_item(Key={"id": send_to})["Item"]
other_connection_id: str = item["connection_id"]
logger.info(f"Found the connection ID: {connection_id} for user ID: {send_to}")
except ClientError:
logger.exception(f"Couldn't get connection ID for user ID: {send_to}")
return {
"statusCode": "500",
"body": "Unable to send the message"
}
try:
data = {
"sent_by": user_id,
"message": message
}
data = json.dumps(data)
data = str.encode(data)
response = client.post_to_connection(
Data=data,
ConnectionId=other_connection_id
)
logger.info(f"Posted message to connection {other_connection_id}, got response {response}.")
return {
"statusCode": 200,
"body": "Message sent successfully"
}
except ClientError:
logger.exception(f"Couldn't post to connection ID: {other_connection_id}.")
return {
"statusCode": "500",
"body": "Unable to send the message"
}

def lambda_handler(event, context):
domain: str = event["requestContext"]["domainName"]
stage: str = event["requestContext"]["stage"]
client = boto3.client(
'apigatewaymanagementapi',
endpoint_url= f"https://{domain}/{stage}"
)
connection_id: str = event["requestContext"]["connectionId"]
body = json.loads(event["body"])
message: str = body["message"]
send_to: str = body["send_to"]
user_id: str = body["sent_by"]

return send_message(client, connection_id, user_id, send_to, message)

The List users function is given below:

In this function we retrieve the users and their connection status from the table and send it to the client.

import os
import logging
import json
import boto3
from botocore.exceptions import ClientError

logger = logging.getLogger()
logger.setLevel(logging.DEBUG)

def list_users(client, connection_id):
table_name: str = os.environ["TABLE_NAME"]
table = boto3.resource("dynamodb").Table(table_name)
try:
response = table.scan()
items = response["Items"]
logger.info(f"Got all items")
contacts = []

for item in items:
status = item["connection_id"] != ""
contacts.append({
"name": item["id"],
"status": status
})
data = {
"action": "users",
"contacts": contacts
}
data = json.dumps(data)
data = str.encode(data)
response = client.post_to_connection(
Data=data,
ConnectionId=connection_id
)
return {
"statusCode": 200,
"body": "Contacts sent successfully"
}
except ClientError:
logger.exception(f"Could'nt get all the item")
return {
"statusCode": "500",
"body": "Unable to send the contacts"
}

def lambda_handler(event, context):
logger.info(event)
connection_id: str = event["requestContext"]["connectionId"]
domain: str = event["requestContext"]["domainName"]
stage: str = event["requestContext"]["stage"]
client = boto3.client(
'apigatewaymanagementapi',
endpoint_url= f"https://{domain}/{stage}"
)
return list_users(client, connection_id)

Now lets see the SAM template that makes up the architecture

4) Build and Deploy:

Once you have the template and code ready we can deploy it to AWS using SAM CLI.

First verify that the template.yaml file is in the correct format using the following command:

> sam validate

To deploy it we need to first build it, we can use SAM CLI to build the application. Run the command:

> sam build

Once the application is built we can deploy it using the following command:

> sam deploy

Make sure you have your AWS cli configured with credentials for a user with permission required to deploy all the above components.

SAM deploy command will deploy the application to the cloud, SAM deploy uploads the package to S3 and transforms the SAM template to CloudFormation template and deploys the stack.

5) Test:

After deploying the WebSocket API we can test it using the command line tool wscat.

Open two command prompt and run the following two commands on it

> wscat -c <websocket_api_url>?name=<user_name_1>

In the second command prompt

> wscat -c <websocket_api_url>?name=<user_name_2>

If everything worked properly you should get an command line with ‘>’ symbol

Now in order to communicate provide the following command in the command prompt one

> {"action":"send", "message":"Hello World!", "send_to":<name_2>, "send_by":<name_1>}

You should see the message appear in command prompt two

Now to get the logs of the lambda functions use the following SAM command:

> sam logs -n SendMessageFn --stack-name <stack_name> --tail

6) User Interface:

User interface is built using React. The User interface is not the focus of this blog but if you are interested get out the GitHub repo linked at the bottom of the article.

Conclusion:

Hope you have gotten a good idea for how to deploy a chat application on AWS. This is just the bare minimum, we can do a lot more on top of it, like providing support for multimedia, message reactions, groups and much more.

One of the key aspect of any application is authentication and authorization. With our current code anyone can connect to our API, which is not good. In the next blog I will detail on how we can harden our application’s security using AWS services such as Cognito User Pool and Lambda Authorizer.

See ya there !

Links:

LinkedIn: https://in.linkedin.com/in/krishnan-v-c

Backend: https://github.com/KrishnanVC/AWS_ChatApp

User Interface: https://github.com/KrishnanVC/AWS_ChatApp_UI

--

--