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:
Next, I wrote Node.js code to do the following in an Azure Function.
- Request an OAuth token using a username and password.
- Query the Dynamics 365 Web API for accounts with names that start with the letter "F."
- 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.
- Create a new Function app via New->Compute->Function App.
- Set the app name, resource group, etc.
- Once the new Function app is provisioned, open it.
- Select "new function" on the left.
- Set language to "JavaScript" and scenario to "Core." Find the "TimerTrigger-JavaScript" template and select it.
- 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.
- 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.)
- On the "integrate" tab, you can modify the timer schedule. The schedule shown (0 */5 * * * *) will execute the function every five minutes.
- 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.
- This screenshot shows the process sessions for when the workflow was executed in Dynamics 365.
- This screenshot shows the note records that were created by the workflow.
A few notes/caveats:
- 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.
- 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.
- 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.