Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Hosted-services / AWS

Cron Expression Parser in AWS Lambda

3.00/5 (1 vote)
29 Aug 2021CPOL5 min read 6.2K   40  
Cron expression parser written in Node.js on AWS Lambda
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.

JavaScript
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:

JavaScript
switch(i) {
    // year
    case 6:
        if(m === "*") return Array.apply(null, Array(10)).map(function(e, i)
            { return ts.getFullYear() + i; }); // current and next 9 years
        if(!m.includes("-") && !m.includes(","))
             return [parseInt(m, 10)];         // single value
        return resolveList(m, i);              // list of years
    // month
    case 4:
        if(m === "*") return Array.apply(null,
            Array(12)).map(function(e, i) { return i; }); // return all months
        if(!m.includes("-") && !m.includes(",") && !isNaN(parseInt(m, 10)))
            return [parseInt(m, 10)-1];                   // single value numeric
        if(isNaN(parseInt(m, 10)) && month2num(m) != -1)
            return [month2num(m)-1];                      // single value string
        return resolveList(m, i).map(function(e,i)
            { return (e > 0 ? e-1 : e) }); // list of months
    // day of month
    case 3:
        if(m === "?") return undefined; // empty - use dw instead
        if(m === "*") return Array.apply(null, Array
            (new Date(yyyy, mm+1, 0).getDate())).map(function(e, i)
            { return i + 1; });         // return all days
        if(m.replace(/L|W/g,"").length != m.length)
            return null;                // last (week)day of month - skip,
                                        // resolve outside this function
        if(!m.includes("-") && !m.includes(","))
           return [parseInt(m, 10)];    // single value
        return resolveList(m, i);       // list of days
    // day of week
    case 5:
        if(m.replace(/L|#/g,"").length != m.length)
           return null;                    // just ignore special cases,
                                           // to be resolved outside this function
        if(m === "?") return undefined;    // empty - use dd instead
        if(m === "*") return Array.apply(null, Array(7)).map
           (function(e, i) { return i; }); // return all days
        if(!m.includes("-") && !m.includes(","))
           return [parseInt(m, 10)];       // single value numeric
        if(isNaN(parseInt(m, 10)) && day2num(m) != -1)
           return [day2num(m)];            // single value string
        return resolveList(m, i);          // list of days
    // hour
    case 2:
        if(m === "*") return Array.apply(null,
            Array(24)).map(function(e, i) { return i; }); // return all hours
        if(!m.includes("-") && !m.includes(",") && !m.includes("/"))
            return [parseInt(m, 10)];      // single value
        return resolveList(m, i);          // list of hours
    // min / sec
    case 1: case 0:
        if(m === "*") return Array.apply(null, Array(60)).map(function(e, i)
            { return i; });                // return all min/sec
        if(!m.includes("-") && !m.includes(",") && !m.includes("/"))
            return [parseInt(m, 10 )];     // single value
        return resolveList(m, i);          // list of min/sec
}

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:

JavaScript
if(m === "*") return Array.apply(null, Array(12)).map(function(e, i) 
              { return i; }); // return all months

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:

JavaScript
function resolveList(m, i) {
        var retValue = [];
        var msplit;
        var k;
        var limit;
        if(m.includes("-")) { // all in between
            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(",")) {         // all listed
            m.split(",").map(function(e) { // convert to int
                return parseInt(e, 10);
            }).forEach(k => {              // remove duplicates
                //console.log("currentValue=" + k + " ; retValue=" + retValue.toString());
                if(!retValue.includes(k)) retValue.push(k);
            });
            return retValue.sort();        // sort
            //m.split(",").forEach(k => retValue.push(parseInt(k)));
        }
        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;         // seconds/minutes
            if(i == 2) limit = 12;         // seconds/minutes
            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:

JavaScript
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--) 
            { // start from end of month and look for last specified weekday in that month
                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++) 
             { // start from beginning of the month and count (n) 
               // the occurences of specified weekday in that month
                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:

Image 1

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:

JavaScript
{
  "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:

Image 2

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".

Image 3

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.

Image 4

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:

HTML
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Cron parser</title>
    <script>
        // define the callAPI function that takes a first name and last name as parameters
        var callAPI = (key1)=>{
            // instantiate a headers object
            var myHeaders = new Headers();
            // add content type header to object
            myHeaders.append("Content-Type", "application/json");
            // using built in JSON utility package turn object 
            // to string and store in a variable
            var raw = JSON.stringify({"key1":key1});
            // create a JSON object with parameters for API call and store in a variable
            var requestOptions = {
                method: 'POST',
                headers: myHeaders,
                body: raw,
                redirect: 'follow'
            };
            // make API call with parameters and use promises to get response
            fetch("YOUR API INVOKE URL HERE", requestOptions)
            .then(response => response.text())
            .then(result => document.getElementById('result').innerHTML = 
                            result/*alert(JSON.parse(result).body)*/)
            .catch(error => document.getElementById('result').innerHTML = 
                            error/*console.log('error', 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.

Image 5

History

  • 29th August, 2021: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)