A Practical Guide to Building a Real-Time Chat Application on AWS Using API Gateway and Serverless Architecture
In today's digital age, real-time communication has become essential for businesses to provide immediate support and enhance customer engagement. Building a robust, scalable, and cost-effective chat application can be a complex task, but with AWS's powerful services, it becomes significantly more manageable. This lab will guide you through the process of creating a real-time single-page chat application using AWS API Gateway, AWS Lambda, and DynamoDB. This architecture leverages the benefits of serverless computing, ensuring your application scales automatically with demand and minimizes operational overhead.
In this tutorial, we will build a real-time chat application for a "fictional" company named Practical Cloud. The application will allow customers to communicate with support agents in real-time. We will use AWS services to handle WebSocket connections, process messages, and manage connection states.
Here's a high-level overview of the components and their roles:
N.B: We will not be implementing these additional steps in this project.
This Customer Support Chat System not only demonstrates the capabilities of a serverless architecture on AWS but also addresses a real-world need, providing immediate and scalable support to customers.
By the end of this tutorial, you will have a fully functional real-time chat application that leverages AWS's serverless architecture for scalability, reliability, and cost efficiency. Let's dive into the step-by-step process of building this application.
WebSocketConnections
with a primary key connectionId
(String).aws dynamodb create-table \
--table-name WebSocketConnections \
--attribute-definitions \
AttributeName=connectionId,AttributeType=S \
--key-schema \
AttributeName=connectionId,KeyType=HASH \
--provisioned-throughput \
ReadCapacityUnits=5,WriteCapacityUnits=5
ChatHistory
with a primary key of ChatRoomId
.aws dynamodb create-table \
--table-name ChatHistory \
--attribute-definitions \
AttributeName=ChatRoomId,AttributeType=S \
AttributeName=Timestamp,AttributeType=S \
--key-schema \
AttributeName=ChatRoomId,KeyType=HASH \
AttributeName=Timestamp,KeyType=RANGE \
--provisioned-throughput \
ReadCapacityUnits=5,WriteCapacityUnits=5
The SNS topic in this architecture is used to send notifications to support agents whenever a new chat message is received, ensuring they are alerted promptly for timely responses.
aws sns create-topic --name NewChatMessages
3.1: Connection handler:
The connection_handler
function is responsible for managing new WebSocket connections. When a client establishes a connection to the WebSocket API, this function is triggered. pecifically, it:
On the Lambda console, click on "Create Function" > "Authour from scartch" > "Python 3.12"
Enter the following code:
connection_handler:
import boto3
import os
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['TABLE_NAME'])
def lambda_handler(event, context):
connection_id = event['requestContext']['connectionId']
table.put_item(Item={'connectionId': connection_id})
return {'statusCode': 200}
Update the permission to be able to put items into dynamodb table.
Click on "Configuration" > "Permissions". Click on the role > "Add permissions" > "Create inline policy".
Add the following policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Statement1",
"Effect": "Allow",
"Action": [
"DynamoDB:PutItem"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:123456789102:table/WebSocketConnections"
]
}
]
}
Replace "region" and "accountid" values as appropriate.
Also, click on "Configuration" > "Envronmental variables" > "Edit" > "Add Environmental variables".
TABLE_NAME = WebSocketConnections
The connectionId
is generated automatically by the API Gateway when a client connects to the WebSocket API. It is a unique identifier assigned to each WebSocket connection, which ensures that the server can distinguish between different clients.
connectionId
is Generated and UsedconnectionId
for that connection. This connectionId
is used to identify and manage the connection throughout its lifecycle.connectionId
to Lambda Function:connectionId
is included in the event data that the API Gateway passes to the Lambda function handling the connection request. Specifically, it is available in event['requestContext']['connectionId']
.By using the connection ID generated by API Gateway, the chat application can ensure that messages are correctly routed to the connected clients (both customers and support agents) based on their unique session identifiers. Note also that we are using a Cognito authorizer for this impolementation. If we were to use a Lambda authorizer, the connection_handler function will contain authorization logic. I have previously written about how to implement Cognito authorizers and Lambda authorizers.
3.2: Message handler:
The message_handler
function processes incoming chat messages from clients. It broadcasts these messages to all connected clients, stores them in a DynamoDB table for chat history, and sends notifications to support agents via SNS.
message_handler:
import boto3
import os
import json
from datetime import datetime
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['TABLE_NAME'])
history_table = dynamodb.Table(os.environ['CHAT_HISTORY_TABLE'])
client = boto3.client('apigatewaymanagementapi', endpoint_url=os.environ['APIG_ENDPOINT'])
sns = boto3.client('sns')
sns_topic_arn = os.environ['SNS_TOPIC_ARN']
def lambda_handler(event, context):
print(f'Received Event: {event}')
print(f'Request Context: {event['requestContext']}')
connection_id = event['requestContext']['connectionId']
# principal_id = event['requestContext']['authorizer']['principalId']
message_data = json.loads(event['body'])
message = message_data['message']
# sender = message_data.get('sender', principal_id)
sender = message_data.get('sender')
chat_room_id = message_data.get('chatRoomId', 'default')
timestamp = datetime.utcnow().isoformat()
# Broadcast message to all connected clients
connections = table.scan()['Items']
for conn in connections:
if conn['connectionId'] != connection_id:
try:
client.post_to_connection(
ConnectionId=conn['connectionId'],
Data=json.dumps({'sender': sender, 'message': message, 'timestamp': timestamp})
)
except client.exceptions.GoneException:
table.delete_item(Key={'connectionId': conn['connectionId']})
# Store message in ChatHistory table
history_table.put_item(Item={
'ChatRoomId': chat_room_id,
'Timestamp': timestamp,
'Message': message,
'Sender': sender
})
# Publish to SNS Topic for support agent notification
sns.publish(
TopicArn=sns_topic_arn,
Message=json.dumps({'sender': sender, 'message': message, 'timestamp': timestamp}),
Subject='New Chat Message'
)
return {'statusCode': 200, 'body': 'Message sent.'}
The function receives a message from a client, processes it, broadcasts it to other clients, stores it in DynamoDB, and sends notifications to support agents.
We will come back later to add the environmental variables configuration. But we must immediately update the function's policies.
Add the following inline policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": [
"sns:Publish",
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:Scan"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:123456789012:table/WebSocketConnections",
"arn:aws:dynamodb:us-east-1:123456789012:table/ChatHistory",
"arn:aws:sns:us-east-1:1234567891012:NewChatMessages"
]
}
]
}
Replace the "AccountId" values as appropriate.
3.3: Disconnection Handler:
The disconnection_handler
function handles the disconnection of WebSocket clients. When a client disconnects, this function is triggered to remove the client's connection ID from the DynamoDB table, ensuring the list of active connections remains accurate.
disconnection_handler:
import boto3
import os
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['TABLE_NAME'])
def lambda_handler(event, context):
connection_id = event['requestContext']['connectionId']
table.delete_item(Key={'connectionId': connection_id})
return {'statusCode': 200}
Click on "Configuration" > "Environmental variables".
TABLE_NAME = WebSocketConnections.
Click on "Configuration" > "permissions". Click on the role. Add the following inline policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": "dynamodb:DeleteItem",
"Resource": "arn:aws:dynamodb:us-east-1:538578370232:table/WebSocketConnections"
}
]
}
4.1: Create IAM Role for API Gateway
Firstly, we must create the necessary IAM role that allows the API Gateway to invoke our lambda functions.
Click on "Role" > "create role". Select API Gateway as the use case.
Click on "Save".
Now select the role, click "Edit" > "Attach Policies' > "Inline Policy"
Paste in the following:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": [
"arn:aws:lambda:us-east-1:123456789012:function:message_handler",
"arn:aws:lambda:us-east-1:123456789012:function:connection_handler",
"arn:aws:lambda:us-east-1:123456789012:function:disconnection_handler"
]
}
]
}
Make sure to replace "AccountID" values. click on "Save".
4.2 Create WebSocket API
The API Gateway in this project facilitates real-time communication by managing WebSocket connections. It routes connection, disconnection, and message events to the appropriate Lambda functions. This allows for seamless interaction between clients and the backend services, enabling real-time chat functionality.
Open the API Gateway Console.
Create a new WebSocket API.
For the "Route selection expression" in your API Gateway WebSocket API, you should specify how API Gateway determines which route to call when it receives a message. A common expression is to use a field in the JSON payload sent by the client. This expression tells API Gateway to look for an action
field in the message body sent by the client to determine the appropriate route.
request.body.action
Define routes:
$connect
: Integrate with the Connection Handler.
$disconnect
: Integrate with the Disconnection Handler.
$default
: Integrate with the Message Handler.
4.3 Deploy the API
dev
).Click on "Create".
4.4: Attach IAM Role to Routes:
For each of the routes, we'll edit the "Integration Requests" tab to add our IAM Role.
Repeat same steps for $disconnect
and sendMessage
routes.
4.5: Redeploy the API
Click on "Deploy API". Select the "dev" stage and deploy.
nmessage_handler
functionOn the lambda function console, select the message_handler
function. Click on "Configuration" > "Environmental variables" > "Edit".
Enter the values for:
The real API Gateway endpoint for your WebSocket API is the WebSocket URL.
We have completed the necessary steps for building the backend. Let us test first to see that our implementation so far is working before proceeding to set up the frontend.
We shall be using wscat to test this out.
npm install -g wscat
wscat -c wss://YOUR-API-ENDPOINT
# E.g: wscat -c wss://1zsei3ob91.execute-api.us-east-1.amazonaws.com/dev/
{ "sender": "JohnDoe", "action": "sendMessage", "message": "Hello, this is a test message!" }
Press Enter to send the message.
Head over to your DynamoDB table to confirm if messages are being persisted:
You can also check CloudWatch for function logs to ensure everything performed as expected.
In this project, we successfully built a real-time single-page chat application on AWS using API Gateway and a serverless architecture. By leveraging AWS services such as API Gateway WebSocket, Lambda functions, DynamoDB, and SNS, we created a scalable and efficient solution for real-time communication.
We began by setting up the necessary AWS resources, including DynamoDB tables for connection information and chat history, and an API Gateway for managing WebSocket connections. We developed Lambda functions to handle connections, messages, and disconnections, ensuring seamless interaction between clients.
Our Lambda functions utilized the API Gateway Management API to broadcast messages to all connected clients and stored chat messages in DynamoDB for persistence. Additionally, we configured an SNS topic to send push notifications to support agents about new incoming chats, ensuring timely responses.
Throughout the process, we focused on creating a robust, scalable, and maintainable architecture. This project demonstrates the power and flexibility of AWS serverless services in building real-time applications, providing a solid foundation for further enhancements and scaling as needed.
By following these steps and leveraging AWS's serverless capabilities, you can build efficient, scalable, and cost-effective real-time applications suitable for various use cases, including customer support, live updates, and collaborative tools.
You can go ahead and build a react frontend for the chat application. Also, you could build a static website hosted on Amazon S3 for the frontend.
You can add authentication to your application by incorporating Amazon Cognito. Also, you could implement custom authorization logic using Lambda Authorizers.
Happy Clouding!!!
Did you like this post?
If you did, please buy me coffee 😊