Current Weather Feeding Messenger: Using AWS Lambda and Node.js
In this article will be designing a very interesting system. It is basically a weather SMS sender. It tells me the current temperature, cloudyness and humidity in a nicely formatted SMS message.
Will Utilize AWS services including SNS, Lambda and EventBridge. Will be using the Open Weather Service. I will document AWS API calls using the AWS CLI. All the Lambda code will be written in Node.js 14.x. Note that AWS is not technically free, especially sending SMS costs some money.
I expect you to know basic concepts of AWS, know what an API is, know basic Node.js to the extent of async/await and having fun.
The message I expect to get by SMS is formatted below in code.
The complete source code can be found below at GitHub: https://github.com/Morr0/Weather-Feeder
Notes on the API:
The reason I am using this particular weather provider is because I found it reliable and capable with good documentation. It requires signing up to use. Other do exist and need no signup.
You can learn and consume the API using your favourite tool, I used Postman to learn the response so I can know how to use it in Javascript. I am using the One Call Api. Pretty flexible.
The initial code:
Will initialize NPM or Yarn. Use one of the following, based on preference:
npm init -y
yarn init
Will create index.js. Will add the following environment variables I will use:
const {
WEATHER_API_KEY,
LATITUDE,
LONGITUDE,
SNS_TOPIC_ARN
} = process.env;
Please populate the variables in .env file or as command line arguments. I highly discourage hardcoding values, but feel free to do it if it can reduce rigour. This is obviously not a sensitive application. Note that I don’t have the SNS_TOPIC_ARN yet but will get it after writing the code.
const fetch = () => {
const httpRequest = require("http").request;
const options = {
host: "api.openweathermap.org",
path: `/data/2.5/onecall?lat=${LATITUDE}&lon=${LONGITUDE}&appid=${WEATHER_API_KEY}&units=metric&exclude=minutely,hourly`
};
let responseStr = "";
return new Promise((resolve, reject) => {
httpRequest(options, (response) => {
response.on("error", (e) => reject(e));
response.on("data", (chunk) => {
responseStr += chunk;
});
response.on("end", () => resolve(responseStr));
}).end();
});
}
This is basically the GET request I will call. Since on Lambda later on, you need to use layers if you want to use an external dependency. I basically wrote my abstraction to call and fetch the endpoint. This article by Node.js docs shows the code:
https://nodejs.dev/learn/making-http-requests-with-nodejs
Next will create the function that consumes the above to keep separation of concerns. The code is pretty explanatory. Note I am requesting the temperature in Celsius.
const getWeatherNow = async () => {
const responseStr = await fetch();
console.log(`Received data:
${responseStr}`);
const data = JSON.parse(responseStr);
console.log("\Logging current weather");
const now = `Now:
-Temperature: ${data.current.temp}C
-Feels Like: ${data.current.feels_like}C
-Humidity: ${data.current.humidity}%
-Cloudness: ${data.current.clouds}%`;
console.log(now);
return now;
}
Now will create the handler function, the same singature as Lambda function. It receives an event argument, that we don’t need here:
const handler = async function (event){
console.log(JSON.stringify(
{
latitude: LATITUDE,
longitude: LONGITUDE
}
));
const text = await getWeatherNow();
}
Will export the function so it can be used by another file and Lambda as well:
exports.handler = handler;
Now to test it locally, will make another file just for local testing local.js containing:
require("dotenv").config();
require("./index").handler();
Note I am calling the index.js’s handler. Also note I am using the dotenv package to load my environment variables from .env.
Now let’s install the needed packages to run:
npm i aws-sdk
npm i dotenv
And run:
node local
Creating SNS topic and adding subscribers:
Now you can create an SNS topic either using the CLI or the AWS console. I will show the commands I will use to create the topic:
aws sns create-topic --name <Name of SNS topic>
The docs for this: https://docs.aws.amazon.com/cli/latest/reference/sns/create-topic.html
Then will create an SMS subscriber, not if the number is inputted for the first time in your AWS account. You will get a confirmation message. The same is true for email.
aws sns subscribe \
--topic-arn <Arn of SNS> \
--protocol sms \
--notification-endpoint <Number starting in + then country code>
You need to confirm the subscription for it to receive. The docs for this: https://docs.aws.amazon.com/cli/latest/reference/sns/subscribe.html
For other SNS CLI docs refer to: https://docs.aws.amazon.com/cli/latest/reference/sns/index.html#cli-aws-sns
Wiring up the SNS publication in code:
Now we knocked down the SNS infrastructure. Let’s do the code.
Will use the aws-sdk package, note this is an exception package where it is included in AWS Lambda. It is the only package to be included by the system.
Will create a new function:
const publishToClient = async (text) => {
const aws = require("aws-sdk");
const snsClient = new aws.SNS();
console.log("Publishing to SNS");
await snsClient.publish({
TopicArn: SNS_TOPIC_ARN,
Message: text
}).promise();
console.log("Published to SNS");
};
And call it from the handler function defined above right after getting the API data:
await publishToClient(text);
Now the code for Lambda is done.
Now for testing, you may get an error if you have multiple profiles for AWS in your computer. Although the AWS SDK resolves the access key and secret, you will need to specify the specific region so it applies to correct profile if an error occurs calling for this. You can either hardcode the region or ask for it as a command line argument. I updated the whole of local.js to be:
require("dotenv").config();
const region = process.argv[2];
if (region){
const aws = require("aws-sdk");
aws.config.region = region;
require("./index").handler();
} else console.log("Please provide a region for AWS as argument");
To just make sure I don’t continue the application with error because this happened to me in the computer I am using.
Creating the IAM Roles and Permissions for Lambda:
Will now create the IAM role, note based on the AWS CLI, I have to create a role then in a separate call will add the permissions.
In order to define the role, will create a new JSON document called whatever you want. And use it to define what this role uses from AWS. I advise you to learn how roles work if you are interested at the link below: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html
The document:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
Now will make the API call using the CLI:
aws iam create-role --role-name <Name of IAM role> \
--assume-role-policy-document file://<Name of role document file>
The docs for this: https://docs.aws.amazon.com/cli/latest/reference/iam/create-role.html
Now I will declare the permissions this role will use in a separate JSON document as a policy:
{
"Version": "2012-10-17",
"Statement": {
"Effect": "Allow",
"Action": [
"sns:Publish"
],
"Resource": "arn:aws:sns:ap-southeast-2:472971161478:weather-topic"
}
}
A caveat, I am not giving permissions to push to CloudWatch Logs. If you are interested in the logs generated by the Lambda function, then you should add the statements for it. If so just rearrange the "Action" from object to an array like so:
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "*"
}
Making the API call:
aws iam put-role-policy --role-name <Name of IAM role above> \
--policy-name <Name of policy> --policy-document file://<Name of Policy document>
The docs for this: https://docs.aws.amazon.com/cli/latest/reference/iam/put-role-policy.html
This article goes into the rigour of how to create what I have done in this section: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-service.html#roles-creatingrole-service-cli
Creating The Lambda Function:
Now we can create the function given we created what we need.
In order to use the code, it has to be either from S3 or be uploaded. Now both ways need to be a ZIP format i.e. compressed. If you are using the AWS console, there is an editor there for Node.js and Python.
The fact you need to zip shows that you can include many files, even an entire full blown project. As well as NPM packages.
We will only use index.js. I zipped the file using the Powershell command:
Compress-Archive <Path of file> <Path and name of compressed ZIP>
7-Zip does the job.
So let’s issue the API call:
aws lambda create-function --function-name <Name of Lambda function> --runtime nodejs14.x --zip-file fileb://zipped-code.zip --handler index.handler --role arn:aws:iam::472971161478:role/weather-feeder-lambda-role --publish --timeout 10 \
--environment Variables="{WEATHER_API_KEY=<Api Key>,SNS_TOPIC_ARN=<Arn of SNS topic>,LATITUDE=<Your preffered Latitude>,LONGITUDE=<Your preffered Longitude>}"
This is quite a call! Never mind. Am passing all the environment variables together. Note the index.handler, this is really important. Let’s split that string by ., the first part is the index which is the file name of index.js without the extension. The second is the handler which is the exported function handler in index.js. So if you change file or function/method name please change accordingly. The docs for this:
https://docs.aws.amazon.com/cli/latest/reference/lambda/create-function.html
You can now see the lambda function existent in the console or in an API call:
aws lambda get-function --function-name <Name of Lambda function above>
The docs for this: https://docs.aws.amazon.com/cli/latest/reference/lambda/get-function.html
Now you can test. I highly encourage testing. Please check correct environment variables and API Key.
Creating the Cron job:
Now by far the easiest thing but most confusing is Cron Expressions. AWS has it’s own format mostly the same: https://docs.aws.amazon.com/lambda/latest/dg/services-cloudwatchevents-expressions.html
I won’t go explaining how Cron works, it is basically a statement that is used to describe timing. It is used for orchestrate batch jobs on the timing part. Here EventBridge will run the job. I will use this website to generate my expressions: https://crontab.cronhub.io/
To make it valid, just insert a ? before the last entry in Cron given the second last entry not used. The AWS article above shows an example in case I didn’t get the idea across.
The article above also shows you can use another function but I won’t use it here due to standardization of cron across the UNIX ecosystem (pretty much most of servers).
Calling the API will create a rule:
aws events put-rule --name <Name of cron job> --schedule-expression "cron(<Cron Expression goes here>)"
The docs for this: https://docs.aws.amazon.com/cli/latest/reference/events/put-rule.html
Now a rule on it’s own does nothing. So will link it to the Lambda function above:
aws events put-targets --rule <Name of cron job above> --targets "Id"="1","Arn"="<Arn of Lambda function above>"
The docs for this: https://docs.aws.amazon.com/cli/latest/reference/events/put-targets.html
That’s it.
Remarks:
Many different services were covered here. The external API I am consuming is very capable and I encourage to explore it and design the Lambda function to your needs. In fact, you can use anything to build the same feeding message. An idea would be if you have uneeded EC2 instances still running that cost money.
EventBridge is not the only way to issue Cron jobs, AWS’s flagship database DynamoDB has a Time To Live mean of accomplishing that. DynamoDB would be more helpful in a database environment. Note there is a limit to how many EventBridge rules exist per region. DynamoDB doesn’t have this limitation however in DynamoDB, events are not immediate, so the event may fire minutes over the due time.
Conclusion:
You know everything works when you receive an SMS/Email by SNS which was published by Lambda which was invoked by EventBridge which was made possible by you the reader.
Thanks for reading.