I've been working closely with software infrastructure for a couple of years and I've gotten to enjoy the raw vibe of having to set up a server for a specific purpose or for a particular software. I like performing experiments on AWS and have learned to love using CloudFormation to configure infrastructure resources on AWS. I enjoy the concept of serverless and I've been taking it out for several spins.
For this article, I'll be exploring AWS Lambda functions using the AWS Serverless Application Model (SAM). When I learned about Lambda functions they blew my mind away and its possible applications were very fascinating but I quickly realized that it was hard to manage and deploy the code for lambda functions for a reasonably sized application because of the process of uploading zip files for application code and managing API Gateway configuration for the functions. Luckily, AWS understood this too and created the SAM service to solve this problem. SAM combines the best of Lambda, API Gateway, and CloudFormation together.
To explore this technology better, I'll be building a simple URL shortener with it.
Requirements
- An AWS account
- Node.js v12+
- AWS CLI
- AWS SAM CLI
- Docker
- DynamoDB
Design
Before we proceed to build this application, let's take a step back and try to visualize how our final result would work. We need a persistent store to record URLs with an id and we'll use DynamoDB to help with that. We need a REST API endpoint for submitting a URL and that endpoint should save it and return the id to access the shortened version with. Finally, we need a function to redirect users to their intended long URLs. This means we would need two endpoints/functions:
- POST /shorten
- GET /{id}
Setting up the project
To startup, we need to initiate a new project with SAM:
sam init
This should start an interactive session and we'll be using the "AWS Quick Start Templates" with the "nodejs12.xx" runtime and the "Quick Start: From Scratch" template. Our selections should look similar to this:
Feel free to name the project whatever you like on the "Project name" prompt.
Create a Dynamo DB Table
To create a Dynamo DB table, login to the AWS console, search for the DynamoDB service, and click "Create Table". Create a table called short-urls
with a url
primary key.
Please note that you can also create a DynamoDB table by specifying it as a Resource
in the template.yml
file using Cloudformation syntax but for this article, we'll be working with DynamoDB from the AWS console.
Create the Shorten URL function
Like we discussed above, to shorten the URL we would map that URL with an id and store both in our database.
To create unique short ids we'll be using the short-unique-id
npm package and we'll be using aws-sdk
to access DynamoDB. You can install both these packages by running:
npm i aws-sdk short-unique-id
In the src/handlers
directory, let's create a shorten-url.js
file and export the shortenUrl function. The function would take a URL from the request, create an id for the URL and store both the URL and the id in the database table. Here's how that looks:
const { default: ShortUniqueId } = require('short-unique-id');
const AWS = require("aws-sdk");
const uid = new ShortUniqueId();
const docClient = new AWS.DynamoDB.DocumentClient();
const TableName = "short-urls";
exports.shortenUrl = async (event) => {
const { url } = JSON.parse(event.body);
const params = {
TableName,
Item: {
url,
id: uid()
},
}
await docClient.put(params).promise()
return {
statusCode: 200,
body: JSON.stringify({
data: params.Item
})
}
};
Now, let's register this function in the template.yml
to an API path:
Resources:
ShortenUrl:
Type: AWS::Serverless::Function
Properties:
Handler: src/handlers/shorten-url.shortenUrl
Runtime: nodejs12.x
Timeout: 100
Events:
ShortenUrl:
Type: Api
Properties:
Path: /shorten
Method: post
Okay great, we can now test this by running
sam local start-api
You should be able to make a post request to localhost:3000/shorten
with a JSON request body like:
{
"url": "example.com/long/url/path"
}
Nice. We are not yet done with this though. Just like with any application that receives input from users we can't trust the input so we need to validate it.
Let's create a function to validate that a valid URL is passed.
const validateUrl = (url) => {
if (url === undefined) {
return {
statusCode: 400,
body: JSON.stringify({
errors: [
{
'field': 'url',
'message': 'Url is required'
}
]
})
}
}
try {
new URL(url);
} catch (_) {
return {
statusCode: 400,
body: JSON.stringify({
errors: [
{
'field': 'url',
'message': 'Please provide a valid url'
}
]
})
};
}
}
We also need to just return the URL details if it has already been shortened before. We'll use the getUrlIfExists
function to do that:
const getUrlIfExists = async (url) => {
try {
const existingUrl = await docClient.get({
TableName,
Key: {
url
}
}).promise();
return Object.keys(existingUrl).length !== 0 || existingUrl;
} catch (error) {
return false
}
}
We can now add these functions to the shortenUrl
handler function
exports.shortenUrl = async (event) => {
const { url } = JSON.parse(event.body);
const invalidUrlResponse = validateUrl(url);
if (invalidUrlResponse) {
return invalidUrlResponse
}
const existingUrl = await getUrlIfExists(url);
if (existingUrl && Object.keys(existingUrl).length !== 0) {
return {
statusCode: 200,
body: JSON.stringify({
data: existingUrl.Item
})
}
}
const params = {
TableName,
Item: {
url,
id: uid(),
visitCount: 0
},
}
await docClient.put(params).promise()
return {
statusCode: 200,
body: JSON.stringify({
data: params.Item
})
}
}
Okay, our shortenUrl
function is good to go!
Create the Redirect to Original URL Function
We've been able to register URLs and get IDs mapped to them. Next up we need a function to redirect to the original URLs when a path is visited with that ID. Before we do that, we need to create an index on the ID column on DynamoDB.
Once that's done, we can then create our redirectUrl
handler function in the src/handlers
directory.
const AWS = require("aws-sdk");
const docClient = new AWS.DynamoDB.DocumentClient();
const TableName = "short-urls";
exports.redirectUrl = async (event) => {
const { id } = event.pathParameters;
const queryResponse = await docClient.query({
TableName,
IndexName : 'id-index',
KeyConditionExpression: "id = :v_id",
ExpressionAttributeValues: {
":v_id": id
},
}).promise();
if (queryResponse.Items.length < 1) {
return jsonResponse({
status: 404,
body: {
message: 'Url not found'
}
})
}
return {
statusCode: 301,
headers: {
Location: queryResponse.Items[0].url,
}
}
};
Let's register this handler in the template.yml
file:
RedirectUrl:
Type: AWS::Serverless::Function
Properties:
Handler: src/handlers/redirect-url.redirectUrl
Runtime: nodejs12.x
Timeout: 100
Events:
ShortenUrl:
Type: Api
Properties:
Path: /{id}
Method: get
And Boom! We have a working URL shortener. Local invocations of SAM might be slow but that isn't the same performance you get once you deploy the functions.
Conclusion
I had fun doing this and I got to understand how to build applications using AWS SAM. I definitely see myself using it to solve problems in the future.