This is an example of a simple API on AWS Lambda, with full instructions on how to create a simple webpage and connect it to API, all in AWS console. The services used: AWS Lambda, AWS API Gateway, AWS Amplify. The code for the Lambda function is written in Node.js. It is a cron expression parser that gives you the next time a given cron expression will fire an event.
Introduction
This project was a bit of an exercise for me. I started learning beginner stuff in AWS recently, so I wanted to test out Lambda functions by creating a simple API. This article explains how to create an API function using AWS Lambda, a simple website using AWS Amplify, and connect those two by using AWS API Gateway.
The lambda function itself is a cron expression parser written in Node.js. It takes as argument a Cron expression, and it calculates and returns the next time this cron expression would fire an event. The cron parser uses RegEx expressions which are published and explained in this article.
Explaining the Code
I am using two different versions of Cron expression, a simple (POSIX) and an extended version (both versions are explained in this article.
The expression received through the API event is first tested against both expressions, and if matched, it is split into array to be parsed.
var matches_POSIX = regex_POSIX.exec(testexp);
var matches_EXT = regex_EXT.exec(testexp);
var arr = [];
if(matches_POSIX === null && matches_EXT === null) {
console.log("Expression does not match!!!");
return null;
}
else if(matches_POSIX !== null) {
console.log("Expression is POSIX");
arr.push("*");
matches_POSIX.forEach((m, i) => i != 0 ? arr.push(m) : null);
arr.push("*");
if(arr[3] != "?" && arr[5] == "*") arr[5] = "?";
if(arr[5] != "?" && arr[3] == "*") arr[3] = "?";
}
else {
console.log("Expression is EXTENDED");
matches_EXT.forEach((m, i) => i != 0 ? arr.push(m) : null);
}
The main function is the getNextTime
. It will go through all the elements of the array and call the resolve
function with appropriate arguments. It will also test the resolved values for errors, which is basically -1
value in the first element of the returned array.
The order of resolving is: year-month-weekday-day-hour-minute-second. The reason for this sort-of backwards approach is because, for example, to know how many days are in a month, we need to know which year it is, to know which weekday it is on a certain date, we need to know month and year, etc.
This approach is also used later on when we are determining the next moment in time when the cron event will be triggered, because we want to narrow the time window as we go along, by taking the closest possible date in the future; this will be explained more thoroughly in later text.
The Resolve Function
This function is divided into several parts depending on which part of the cron expression is being evaluated – which is passed through the second argument; the meaning of the argument value is explained in the comment above each case:
switch(i) {
case 6:
if(m === "*") return Array.apply(null, Array(10)).map(function(e, i)
{ return ts.getFullYear() + i; });
if(!m.includes("-") && !m.includes(","))
return [parseInt(m, 10)];
return resolveList(m, i);
case 4:
if(m === "*") return Array.apply(null,
Array(12)).map(function(e, i) { return i; });
if(!m.includes("-") && !m.includes(",") && !isNaN(parseInt(m, 10)))
return [parseInt(m, 10)-1];
if(isNaN(parseInt(m, 10)) && month2num(m) != -1)
return [month2num(m)-1];
return resolveList(m, i).map(function(e,i)
{ return (e > 0 ? e-1 : e) });
case 3:
if(m === "?") return undefined;
if(m === "*") return Array.apply(null, Array
(new Date(yyyy, mm+1, 0).getDate())).map(function(e, i)
{ return i + 1; });
if(m.replace(/L|W/g,"").length != m.length)
return null;
if(!m.includes("-") && !m.includes(","))
return [parseInt(m, 10)];
return resolveList(m, i);
case 5:
if(m.replace(/L|#/g,"").length != m.length)
return null;
if(m === "?") return undefined;
if(m === "*") return Array.apply(null, Array(7)).map
(function(e, i) { return i; });
if(!m.includes("-") && !m.includes(","))
return [parseInt(m, 10)];
if(isNaN(parseInt(m, 10)) && day2num(m) != -1)
return [day2num(m)];
return resolveList(m, i);
case 2:
if(m === "*") return Array.apply(null,
Array(24)).map(function(e, i) { return i; });
if(!m.includes("-") && !m.includes(",") && !m.includes("/"))
return [parseInt(m, 10)];
return resolveList(m, i);
case 1: case 0:
if(m === "*") return Array.apply(null, Array(60)).map(function(e, i)
{ return i; });
if(!m.includes("-") && !m.includes(",") && !m.includes("/"))
return [parseInt(m, 10 )];
return resolveList(m, i);
}
The resolve
function attempts to return an array of all the possible values for each part of cron (except the year; as years are infinite, the function will return maximum of 10 years into the future).
If a star (*
) is supplied, this means that all the values are possible, so an array is created first by using Array.apply
function, with null
as initial element value, and then the elements are set by using the map
function, for example 12 months:
if(m === "*") return Array.apply(null, Array(12)).map(function(e, i)
{ return i; });
The possible outcomes are either all the values (*), only a single value, or a list of values represented either as comma-separated list or as a range – this is resolved in a separate function called resolveList
:
function resolveList(m, i) {
var retValue = [];
var msplit;
var k;
var limit;
if(m.includes("-")) {
msplit = m.split("-").map(function(e) {
if(i == 4) e = month2num(e);
if(i == 5) e = day2num(e);
return parseInt(e, 10);
});
if (msplit[0] < msplit[1]) {
for(k = msplit[0]; k <= msplit[1]; k++) retValue.push(k);
}
else {
console.log("error: illegal expression " + m);
retValue.push(-1);
}
return retValue;
}
else if(m.includes(",")) {
m.split(",").map(function(e) {
return parseInt(e, 10);
}).forEach(k => {
if(!retValue.includes(k)) retValue.push(k);
});
return retValue.sort();
}
else if(m.includes("/")) {
msplit = m.split("/");
if(msplit[0] == "*") msplit[0] = "0";
msplit = msplit.map(function(e) {
return parseInt(e, 10);
});
if(i <= 1) limit = 60;
if(i == 2) limit = 12;
for(k = msplit[0] + msplit[1]; k <= limit;
k = k + msplit[1]) retValue.push(k == limit ? 0 : k);
}
return retValue;
}
Special Cases
All the special values, such as L, W or _#_ are resolved outside of the function resolve
, as they can only be resolved once we have already narrowed down the timeframe. They are resolved in functions specialDay
and specialDate
, which are called directly from the main function:
function specialDate(exp, mm, yyyy) {
if(exp == "L")
return new Date(yyyy, mm + 1, 0).getDate();
if(exp == "LW")
for(var i = new Date(yyyy, mm + 1, 0).getDate(); i > 0; i--) {
if([1, 2, 3, 4, 5].includes(new Date(yyyy, mm, i).getDay()))
return i;
}
if(exp.substring(0, 1) == "L-")
if(!isNaN(parseInt(exp.substring(2), 10)))
return new Date(yyyy, mm + 1, 0).getDate()+1 -
parseInt(exp.substring(2), 10);
}
function specialDay(exp, mm, yyyy) {
if(exp.includes("L"))
for(var i = new Date(yyyy, mm + 1, 0).getDate(); i > 0; i--)
{
if(parseInt(exp.substring(0,1),10) == new Date(yyyy, mm, i).getDay())
return i;
}
if(exp.includes("#")) {
var n = 0;
for(i = 1; i <= new Date(yyyy, mm + 1, 0).getDate(); i++)
{
if(parseInt(exp.substring(0,1),10) == new Date(yyyy, mm, i).getDay()) {
n++;
if(n == parseInt(exp.substring(2,3),10)) return i;
i = i+6;
}
}
}
return undefined;
}
AWS Setup
AWS Lambda
Go to AWS Lambda and click on "Create function".
Choose "Author from scratch", enter function name, i.e., "cron-parse
", under "Runtime" choose "Node.js" and click on "Create function".
You will get a screen with "Function overview" and "Code Source" sections:
Paste your code under "index.js" and click "Deploy".
Under "Test" dropdown (arrow menu), click on "Configure test event". With this, you can specify a test expression (JSON) and see the result output. You can put in for example:
{
"key1": "5/15 12-15 3-5 ? 7,5,3 WED-FRI 2021-2022"
}
Name your event, let's say test1
, and press "Create" button.
Then, when you click on the "Test" button, you should get this output:
AWS API Gateway
Go to AWS API Gateway and click on "Create API".
Click on "Build" under "Rest API".
Choose "REST – New API". Enter a name, for example cronAPI
.
Under "Endpoint Type", choose "Edge optimized".
Click on "Create API".
Under "Resources", go to "Actions" menu and choose "Create method".
In created method, choose "POST" and click on the confirmation mark.
Choose "Lambda function" as "Integration type" (make sure that you are using the same region as in which you created your Lambda function), write in the name of your Lambda function (cron-parse
) and click "Save" and then "OK".
Go to "Actions" menu again, and choose "Enable CORS", then click "Enable CORS and replace existing..."
From "Actions" menu, choose "Deploy API", "Deployment stage" = "New stage", enter stage name (for example: dev
), and click on "Deploy".
Copy the "Invoke URL" from the top of the page – you will need it later.
AWS Amplify
Go to AWS Amplify, go all the way down and click on "Get Started" in the "Develop" block.
Enter app name, for example "Cron parser". Confirm deployment and relax and enjoy while AWS does everything for you in the background.
Go to "Frontend environments" tab, choose "Deploy without Git provider" and click on "Connect branch".
On your computer (locally), create a simple HTML file called "index.html" using this code:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Cron parser</title>
<script>
var callAPI = (key1)=>{
var myHeaders = new Headers();
myHeaders.append("Content-Type", "application/json");
var raw = JSON.stringify({"key1":key1});
var requestOptions = {
method: 'POST',
headers: myHeaders,
body: raw,
redirect: 'follow'
};
fetch("YOUR API INVOKE URL HERE", requestOptions)
.then(response => response.text())
.then(result => document.getElementById('result').innerHTML =
result)
.catch(error => document.getElementById('result').innerHTML =
error);
}
</script>
</head>
<body>
<label for="incron">Enter Cron expression here </label>
<input type="text" name="incron" id="incron">
<button type="button" onclick="callAPI(document.getElementById('incron').value)">
Submit</button><br/><hr/><br/>
<label for="result">Result: </label><div name="result" id="result"></div>
</body>
</html>
Replace the "YOUR API INVOKE URL HERE" with the URL you saved from the previous step.
Save the "index.html" and then ZIP (compress) it (you can name the ZIP whatever you like).
Now go back to the AWS console (your Amplify app), and click on "Choose files", then open the zip containing the "index.html". Save and deploy.
That's it. Now you can use the URL under "Domain" to open your HTML webpage. Enter a cron expression to test it and click Submit, your API should return a value under Result.
History
- 29th August, 2021: Initial version