Scheduling Dynamics 365 workflows with Azure Functions and Node.js

Earlier this week I showed an easy way to integrate a Node.js application with Dynamics 365 using the Web API. Building on that example, I have created a scheduled workflow runner using Node.js and Azure Functions. Here's how I did it.

First, I created a workflow in Dynamics 365 that creates a note on an account record. The screenshots below shows what it looks like:

Demo workflow

Demo note

Next, I wrote Node.js code to do the following in an Azure Function.

  1. Request an OAuth token using a username and password.
  2. Query the Dynamics 365 Web API for accounts with names that start with the letter "F."
  3. Execute a workflow for each record that was retrieved in the previous step.

Most of this is regular Node.js, but there are a couple of nuances specific to Azure Functions. See the "Azure Functions NodeJS developer reference" for more information.

var https = require('https');

//set these values to retrieve the oauth token
//see http://alexanderdevelopment.net/post/2016/11/23/dynamics-365-and-node-js-integration-using-the-web-api/ for more details
var _crmorg = 'https://CRMORG...dynamics.crom';  
var _clientid = 'OAUTH CLIENT ID';  
var _username = 'CRM USERNAME';  
var _userpassword = 'CRM PASSWORD';  
var _tokenendpoint = 'OAUTH TOKEN ENDPOINT FROM EARLIER';

//set these values to query your crm data
var _apipath = '/api/data/v8.2'; //web api version  
var _workflowid = 'DC8519EC-F3CE-4BC9-BB79-DF2AD70217A1'; //guid for the workflow you want to execute  
var _crmwebapihost = 'XXXX.api.crm.dynamics.com'; //crm api url (without https://)  
var _crmwebapiquerypath = "/accounts?$select=name,accountid&$filter=startswith(name,'f')"; //web api query

var _counter = 0; //variable to keep track of how many records retrieved and workflows started

module.exports = function (context, myTimer) {  
    //remove https from _tokenendpoint url
    _tokenendpoint = _tokenendpoint.toLowerCase().replace('https://','');

    //get the authorization endpoint host name
    var authhost = _tokenendpoint.split('/')[0];

    //get the authorization endpoint path
    var authpath = '/' + _tokenendpoint.split('/').slice(1).join('/');

    //build the authorization request
    var reqstring = 'client_id='+_clientid;
    reqstring+='&resource='+encodeURIComponent(_crmorg);
    reqstring+='&username='+encodeURIComponent(_username);
    reqstring+='&password='+encodeURIComponent(_userpassword);
    reqstring+='&grant_type=password';

    //set the token request parameters
    var tokenrequestoptions = {
        host: authhost,
        path: authpath,
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            'Content-Length': Buffer.byteLength(reqstring)
        }
    };

    //make the token request
    context.log('starting token request');
    var tokenrequest = https.request(tokenrequestoptions, function(response) {
        //make an array to hold the response parts if we get multiple parts
        var responseparts = [];
        response.setEncoding('utf8');
        response.on('data', function(chunk) {
            //add each response chunk to the responseparts array for later
            responseparts.push(chunk);      
        });
        response.on('end', function(){
            //once we have all the response parts, concatenate the parts into a single string
            var completeresponse = responseparts.join('');
            //context.log('Response: ' + completeresponse);
            context.log('token response retrieved');

            //parse the response JSON
            var tokenresponse = JSON.parse(completeresponse);

            //extract the token
            var token = tokenresponse.access_token;
            //context.log(token);

            //pass the token to our data retrieval function
            getData(context, token);
        });
    });
    tokenrequest.on('error', function(e) {
        context.error(e);
        context.done();
    });

    //post the token request data
    tokenrequest.write(reqstring);

    //close the token request
    tokenrequest.end();
}

function getData(context, token){  
    //set the web api request headers
    var requestheaders = { 
        'Authorization': 'Bearer ' + token,
        'OData-MaxVersion': '4.0',
        'OData-Version': '4.0',
        'Accept': 'application/json',
        'Content-Type': 'application/json; charset=utf-8',
        'Prefer': 'odata.maxpagesize=500',
        'Prefer': 'odata.include-annotations=OData.Community.Display.V1.FormattedValue'
    };

    //set the crm request parameters
    var crmrequestoptions = {
        host: _crmwebapihost,
        path: _apipath+_crmwebapiquerypath,
        method: 'GET',
        headers: requestheaders
    };

    //make the web api request
    context.log('starting data request');
    var crmrequest = https.request(crmrequestoptions, function(response) {
        //make an array to hold the response parts if we get multiple parts
        var responseparts = [];
        response.setEncoding('utf8');
        response.on('data', function(chunk) {
            //add each response chunk to the responseparts array for later
            responseparts.push(chunk);      
        });
        response.on('end', function(){
            //once we have all the response parts, concatenate the parts into a single string
            var completeresponse = responseparts.join('');

            //parse the response JSON
            var collection = JSON.parse(completeresponse).value;

            //set counter length = number of records
            _counter = collection.length;

            //loop through the results and call the workflow for each one
            collection.forEach(function (row, i) {
                callWorkflow(context, token, row['accountid']);
            });
        });
    });
    crmrequest.on('error', function(e) {
        context.error(e);
        context.done();
    });
    //close the web api request
    crmrequest.end();
}

function callWorkflow(context, token, entityid){  
    var crmwebapiworkflowpath = _apipath + "/workflows("+_workflowid+")/Microsoft.Dynamics.CRM.ExecuteWorkflow";

    //set the web api request headers
    var requestheaders = { 
        'Authorization': 'Bearer ' + token,
        'OData-MaxVersion': '4.0',
        'OData-Version': '4.0',
        'Accept': 'application/json',
        'Content-Type': 'application/json; charset=utf-8'
    };

    //set the crm request parameters
    var crmrequestoptions = {
        host: _crmwebapihost,
        path: crmwebapiworkflowpath,
        method: 'POST',
        headers: requestheaders
    };

    //create an object to post to the executeworkflow action
    var reqobj = {};
    reqobj["EntityId"] = entityid;

    //turn it into a string
    var reqjson = JSON.stringify(reqobj);

    //calculate the length to set the content-length header
    crmrequestoptions.headers['Content-Length'] = Buffer.byteLength(reqjson);

    //make the web api request
    context.log('starting workflow request for ' + entityid);
    var crmrequest = https.request(crmrequestoptions, function(response) {
        //make an array to hold the response parts if we get multiple parts
        var responseparts = [];
        response.setEncoding('utf8');
        response.on('data', function(chunk) {
            //add each response chunk to the responseparts array for later
            responseparts.push(chunk);      
        });
        response.on('end', function(){
            //once we have all the response parts, concatenate the parts into a single string
            var completeresponse = responseparts.join('');
            context.log('success ' + entityid);

            //decrement the counter
            _counter = _counter-1;

            //if nothing is left to start, we are done
            if(_counter==0){
                context.log('all workflows started');
                context.done();
            }
        });
    });
    crmrequest.on('error', function(e) {
        context.error(e);
        context.done();
    });
    crmrequest.write(reqjson);

    //close the web api request
    crmrequest.end();
}

Then in the Azure Portal, I configured an Azure Function app to query accounts and execute the workflow every five minutes. Here are the detailed steps to replicate that.

  1. Create a new Function app via New->Compute->Function App. Create new Function app
  2. Set the app name, resource group, etc. Set Function app name, etc.
  3. Once the new Function app is provisioned, open it. Open Function app
  4. Select "new function" on the left. Create new function
  5. Set language to "JavaScript" and scenario to "Core." Find the "TimerTrigger-JavaScript" template and select it. Select JavaScript timer trigger template
  6. Give your function a name and set the schedule options. The schedule value is a CRON expression that includes six fields: {second} {minute} {hour} {day} {month} {day of the week}. You can accept the default value of every five minutes and change it later. Click "create" to create the new function. Complete function creation
  7. Copy the Node.js code from above and paste it into the editor window. Set any specifics relative to your Dynamics 365 organization, and click save. (You can also use Git for deploying your code, but that's beyond the scope of today's post.) Write Node.js code
  8. On the "integrate" tab, you can modify the timer schedule. The schedule shown (0 */5 * * * *) will execute the function every five minutes. Set timer schedule
  9. The function will automatically execute at the next fifth minute, and the invocation log is available on the monitor tab. Selecting a specific invocation row shows detailed logging output on the right. Invocation logs
  10. This screenshot shows the process sessions for when the workflow was executed in Dynamics 365. Process sessions
  11. This screenshot shows the note records that were created by the workflow. Generated notes

A few notes/caveats:

  1. My Node.js code has hardly any error handling right now. If the workflow execution call returns an error, the Node.js code will not recognize it as an error.
  2. My CRM record retrieval is set to retrieve a maximum of 500 records. You would need to modify the Web API request logic to handle more.
  3. Per the "Best Practices for Azure Functions" guide:

    Assume your function could encounter an exception at any time. Design your functions with the ability to continue from a previous fail point during the next execution.

    This means you should put logic in your workflow to make sure that duplicate executions are avoided (unless that's what you intend to happen).

This sample just scratches the surface of what's possible with Azure Functions and Dynamics 365, and I'm looking forward to working with Azure Functions more in the future. Have you looked at Azure Functions yet? What do you think? Please let me know in the comments.

comments powered by Disqus