Collaboration Store, Part XLIII

“You don’t start out writing good stuff. You start out writing crap and thinking it’s good stuff, and then gradually you get better at it. That’s why I say one of the most valuable traits is persistence.”
Octavia E. Butler

Last time, we built a function to send over an application record modeled after a similar function that we already had to send over and instance record. We can do the same thing now for a version record as well as the Update Set XML attachment. Since those are always inserts, we can skip the extra step of looking to see if the record is already there, and these functions will end looking an awful lot like the original function that sent over an instance record. Here is a completed function to send over a version record.

pushVersion: function(versionGR, targetGR, remoteAppId) {
	var result = {};

	var payload = {};
	payload.member_application = remoteAppId;
	payload.version = versionGR.getDisplayValue('version');
	payload.built_on = versionGR.getDisplayValue('built_on');
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application_version';
	result.method = 'POST';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader('Content-Type', 'application/json');
	request.setRequestHeader('Accept', 'application/json');
	request.setRequestBody(JSON.stringify(payload, null, '\t'));
	var response = request.execute();
	result.status = response.getStatusCode();
	result.body = response.getBody();
	if (result.body) {
		try {
			result.obj = JSON.parse(result.body);
		} catch (e) {
			result.parse_error = e.toString();
		}
	}
	result.error = response.haveError();
	if (result.error) {
		result.error_code = response.getErrorCode();
		result.error_message = response.getErrorMessage();
	}

	return result;
}

And here is the completed function for sending over the Update Set XML file that is attached to the version.

pushAttachment: function(attachmentGR, targetGR, remoteVerId) {
	var result = {};

	var gsa = new GlideSysAttachment();
	result.url = 'https://';
	result.url += targetGR.getDisplayValue('instance');
	result.url += '.service-now.com/api/now/attachment/file?table_name=x_11556_col_store_member_application_version&table_sys_id=';
	result.url += remoteVerId;
	result.url += '&file_name=';
	result.url += attachmentGR.getDisplayValue('file_name');
	result.method = 'POST';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader('Content-Type', attachmentGR.getDisplayValue('content_type'));
	request.setRequestHeader('Accept', 'application/json');
	request.setRequestBody(gsa.getContent(sysAttGR));
	var response = request.execute();
	result.status = response.getStatusCode();
	result.body = response.getBody();
	if (result.body) {
		try {
			result.obj = JSON.parse(result.body);
		} catch (e) {
			result.parse_error = e.toString();
		}
	}
	result.error = response.haveError();
	if (result.error) {
		result.error_code = response.getErrorCode();
		result.error_message = response.getErrorMessage();
	}

	return result;
}

All together, the four complementary functions in the CollaborationStoreUtils now look like this.

pushInstance: function(instanceGR, targetGR) {
	var result = {};

	var payload = {};
	payload.instance = instanceGR.getDisplayValue('instance');
	payload.name = instanceGR.getDisplayValue('name');
	payload.description = instanceGR.getDisplayValue('description');
	payload.email = instanceGR.getDisplayValue('email');
	payload.accepted = instanceGR.getDisplayValue('accepted');
	payload.active = instanceGR.getDisplayValue('active');
	payload.host = instanceGR.getDisplayValue('host');
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization';
	result.method = 'POST';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader('Content-Type', 'application/json');
	request.setRequestHeader('Accept', 'application/json');
	request.setRequestBody(JSON.stringify(payload, null, '\t'));
	var response = request.execute();
	result.status = response.getStatusCode();
	result.body = response.getBody();
	if (result.body) {
		try {
			result.obj = JSON.parse(result.body);
		} catch (e) {
			result.parse_error = e.toString();
		}
	}
	result.error = response.haveError();
	if (result.error) {
		result.error_code = response.getErrorCode();
		result.error_message = response.getErrorMessage();
	}

	return result;
},

pushApplication: function(applicationGR, targetGR, remoteSysId) {
	var result = {};
 
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=sys_id&sysparm_query=provider.instance%3D' + applicationGR.getDisplayValue('provider.instance') + '%5Ename%3D' + encodeURIComponent(applicationGR.getDisplayValue('name'));
	result.method = 'GET';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader('Content-Type', 'application/json');
	request.setRequestHeader('Accept', 'application/json');
	var response = request.execute();
	result.status = response.getStatusCode();
	result.body = response.getBody();
	if (result.body) {
		try {
			result.obj = JSON.parse(result.body);
		} catch (e) {
			result.parse_error = e.toString();
		}
	}
	result.error = response.haveError();
	if (result.error) {
		result.error_code = response.getErrorCode();
		result.error_message = response.getErrorMessage();
	}
	if (!result.error && !result.parse_error && result.status == 200) {
		var remoteAppId = '';
		if (result.obj.result && result.obj.result.length > 0) {
			remoteAppId = result.obj.result[0].sys_id;
		}
		result = {};
		var payload = {};
		payload.name = applicationGR.getDisplayValue('name');
		payload.description = applicationGR.getDisplayValue('description');
		payload.current_version = applicationGR.getDisplayValue('current_version');
		payload.active = 'true';
		if (remoteAppId > '') {
			result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application/' + remoteAppId;
			result.method = 'PUT';
			result.remoteAppId = remoteAppId;
		} else {
			result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application';
			result.method = 'POST';
			payload.provider = remoteSysId;
		}
		request  = new sn_ws.RESTMessageV2();
		request.setEndpoint(result.url);
		request.setHttpMethod(result.method);
		request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
		request.setRequestHeader('Content-Type', 'application/json');
		request.setRequestHeader('Accept', 'application/json');
		request.setRequestBody(JSON.stringify(payload, null, '\t'));
		response = request.execute();
		result.status = response.getStatusCode();
		result.body = response.getBody();
		if (result.body) {
			try {
				result.obj = JSON.parse(result.body);
			} catch (e) {
				result.parse_error = e.toString();
			}
		}
		result.error = response.haveError();
		if (result.error) {
			result.error_code = response.getErrorCode();
			result.error_message = response.getErrorMessage();
		}
	}
 
	return result;
},

pushVersion: function(versionGR, targetGR, remoteAppId) {
	var result = {};

	var payload = {};
	payload.member_application = remoteAppId;
	payload.version = versionGR.getDisplayValue('version');
	payload.built_on = versionGR.getDisplayValue('built_on');
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application_version';
	result.method = 'POST';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader('Content-Type', 'application/json');
	request.setRequestHeader('Accept', 'application/json');
	request.setRequestBody(JSON.stringify(payload, null, '\t'));
	var response = request.execute();
	result.status = response.getStatusCode();
	result.body = response.getBody();
	if (result.body) {
		try {
			result.obj = JSON.parse(result.body);
		} catch (e) {
			result.parse_error = e.toString();
		}
	}
	result.error = response.haveError();
	if (result.error) {
		result.error_code = response.getErrorCode();
		result.error_message = response.getErrorMessage();
	}

	return result;
},

pushAttachment: function(attachmentGR, targetGR, remoteVerId) {
	var result = {};

	var gsa = new GlideSysAttachment();
	result.url = 'https://';
	result.url += targetGR.getDisplayValue('instance');
	result.url += '.service-now.com/api/now/attachment/file?table_name=x_11556_col_store_member_application_version&table_sys_id=';
	result.url += remoteVerId;
	result.url += '&file_name=';
	result.url += attachmentGR.getDisplayValue('file_name');
	result.method = 'POST';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader('Content-Type', attachmentGR.getDisplayValue('content_type'));
	request.setRequestHeader('Accept', 'application/json');
	request.setRequestBody(gsa.getContent(sysAttGR));
	var response = request.execute();
	result.status = response.getStatusCode();
	result.body = response.getBody();
	if (result.body) {
		try {
			result.obj = JSON.parse(result.body);
		} catch (e) {
			result.parse_error = e.toString();
		}
	}
	result.error = response.haveError();
	if (result.error) {
		result.error_code = response.getErrorCode();
		result.error_message = response.getErrorMessage();
	}

	return result;
}

Looking at them all side by side now, it appears that we could consolidate them even further by creating a single function that accepted a URL and a method, and maybe a payload, as that seems to be the only difference between them. I’m not really up for that at the moment, but it looks as if we could actually make that work one day when we had nothing better to do. For now, though, let’s just leave things as they are and get back to our InstanceSyncUtils and finish building out the code that will sync the version and attachment records in the same way that we did for the instances and the applications. These will utilize our new push functions when needed, but we still need to build out the logic that does the compare and determines what, if anything, needs to be sent over.

As you may recall, our strategy is to loop through the instances, and for each instance, loop through the applications provided by that instance. Similarly, for each application, we will loop through each version of that application, and for each version, we will make sure that there is an associated Update Set XML file attached. Whenever we find anything missing, we will utilize the appropriate push function above to send over the missing artifact. Since we have already done that for instances and applications, it should be relatively straightforward to copy that existing code and modify it as needed for the different types of records involved. Still, it is a little bit of work, so let’s save that for our next installment.

Collaboration Store, Part XLII

“You don’t get results by focusing on results. You get results by focusing on the actions that produce results.”
Mike Hawkins

Last time out, we threw together the code that will sync up the list of apps for a particular instance on the Host with the list of apps for that instance on the remote Client. We stopped short of building out the code that will send the missing applications over, though, and today we need to wrap that up. Those of you following along at home will recall that we already have a couple of functions that do that, and we really don’t want to create yet another one, so we need to see if we can somehow collapse those two into one that can serve the needs of all three use cases. The basic premise on the application updates is to first look and see if the application is already present on the target instance, and if so, update it; otherwise, insert it. Both of our existing functions take that approach, so we will want to maintain that functionality in the new shared version.

The other thing that we wanted to do was to model the existing publishApplication function’s approach to handling the interactions between the instance making the call and the instance that is being called. Basically, the shared code just makes the calls and returns the results in an object that can be evaluated by the caller. The shared code takes no action on any errors that might be encountered; it simply reports back to the calling module everything that happened. It is up to the caller to take whatever action might be necessary to respond to any issues reported. We will want to maintain that approach in our new function as well.

So, just as with the existing publishApplication function, we start out by creating the result object that we will be returning to the caller.

pushApplication: function(applicationGR, targetGR, remoteSysId) {
	var result = {};
 
	...
 
	return result;
}

Something that was not in the original version of the publishApplication function was the insertion of the URL and the method used in the result object. It occurred to me that this might be useful information to the caller, so I decided to add it into this one, and I think I will go back and add that into the original as well. For fetching the applications, which is the next thing that we need to do, that looks like this:

result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=sys_id&sysparm_query=provider.instance%3D' + applicationGR.getDisplayValue('provider.instance') + '%5Ename%3D' + encodeURIComponent(applicationGR.getDisplayValue('name'));
result.method = 'GET';

Once we have set the value of those two properties in the result object, then we can use them to build our request object.

var request = new sn_ws.RESTMessageV2();
request.setEndpoint(result.url);
request.setHttpMethod(result.method);

And before we pull the trigger, we also have to set up the standard authentication and content headers.

request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
request.setRequestHeader('Content-Type', 'application/json');
request.setRequestHeader('Accept', 'application/json');

Once our request object is fully configured, then we just need to pull the trigger and complete our result object with the data and/or errors returned.

var response = request.execute();
result.status = response.getStatusCode();
result.body = response.getBody();
if (result.body) {
	try {
		result.obj = JSON.parse(result.body);
	} catch (e) {
		result.parse_error = e.toString();
	}
}
result.error = response.haveError();
if (result.error) {
	result.error_code = response.getErrorCode();
	result.error_message = response.getErrorMessage();
}

At this point, if there was any kind of error, then we just want to return that back to the caller so that they can take whatever action is appropriate for that particular context. Assuming everything worked as it should, though, the next thing that we are going to want to do is to see if the application was returned in the array of JSON objects sent back in response, and if so, snag the sys_id from the data.

if (!result.error && !result.parse_error && result.status == 200) {
	var remoteAppId = '';
	if (result.obj.result && result.obj.result.length > 0) {
		remoteAppId = result.obj.result[0].sys_id;
	}
	...
}

At this point, we are about to make an entirely new request of the target instance, so we want to reset the result object back to an empty object and start the building process all over again from scratch. The fetching of the application was successful (even if the application was not found on the target instance, finding that out was a success), so we do not need to retain anything from that activity other than the application’s sys_id on the target instance, if it happens to be there. Once we reset the result object, we can start building our payload to send over from the data on the application record.

result = {};
var payload = {};
payload.name = applicationGR.getDisplayValue('name');
payload.description = applicationGR.getDisplayValue('description');
payload.current_version = applicationGR.getDisplayValue('current_version');
payload.active = 'true';

At this point, we need to determine if we are doing an insert or an update based on the presence of a remote sys_id for this application.

if (remoteAppId > '') {
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application/' + remoteAppId;
	result.method = 'PUT';
	result.remoteAppId = remoteAppId;
} else {
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application';
	result.method = 'POST';
	payload.provider = remoteSysId;
}

Now we build our new request object, once again using the result.url and result.method along with all of the other standard elements of our other requests. Also, since this request will be shipping data over to the target instance, we will need to set the request body with the stringified payload.

request  = new sn_ws.RESTMessageV2();
request.setEndpoint(result.url);
request.setHttpMethod(result.method);
request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
request.setRequestHeader('Content-Type', 'application/json');
request.setRequestHeader('Accept', 'application/json');
request.setRequestBody(JSON.stringify(payload, null, '\t'));

Then, as before, we need to execute the request and populate our result object with the response.

response = request.execute();
result.status = response.getStatusCode();
result.body = response.getBody();
if (result.body) {
	try {
		result.obj = JSON.parse(result.body);
	} catch (e) {
		result.parse_error = e.toString();
	}
}
result.error = response.haveError();
if (result.error) {
	result.error_code = response.getErrorCode();
	result.error_message = response.getErrorMessage();
}

Once again, we do not take any action in response to any of these potential errors; we simply report them back to the caller in the result object and let them decide what, if anything, they want to do about it. These functions are not built to take action directly. They simply Observe and Report.

All together, this new function looks like this:

pushApplication: function(applicationGR, targetGR, remoteSysId) {
	var result = {};
 
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=sys_id&sysparm_query=provider.instance%3D' + applicationGR.getDisplayValue('provider.instance') + '%5Ename%3D' + encodeURIComponent(applicationGR.getDisplayValue('name'));
	result.method = 'GET';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader('Content-Type', 'application/json');
	request.setRequestHeader('Accept', 'application/json');
	var response = request.execute();
	result.status = response.getStatusCode();
	result.body = response.getBody();
	if (result.body) {
		try {
			result.obj = JSON.parse(result.body);
		} catch (e) {
			result.parse_error = e.toString();
		}
	}
	result.error = response.haveError();
	if (result.error) {
		result.error_code = response.getErrorCode();
		result.error_message = response.getErrorMessage();
	}
	if (!result.error && !result.parse_error && result.status == 200) {
		var remoteAppId = '';
		if (result.obj.result && result.obj.result.length > 0) {
			remoteAppId = result.obj.result[0].sys_id;
		}
		result = {};
		var payload = {};
		payload.name = applicationGR.getDisplayValue('name');
		payload.description = applicationGR.getDisplayValue('description');
		payload.current_version = applicationGR.getDisplayValue('current_version');
		payload.active = 'true';
		if (remoteAppId > '') {
			result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application/' + remoteAppId;
			result.method = 'PUT';
			result.remoteAppId = remoteAppId;
		} else {
			result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application';
			result.method = 'POST';
			payload.provider = remoteSysId;
		}
		request  = new sn_ws.RESTMessageV2();
		request.setEndpoint(result.url);
		request.setHttpMethod(result.method);
		request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
		request.setRequestHeader('Content-Type', 'application/json');
		request.setRequestHeader('Accept', 'application/json');
		request.setRequestBody(JSON.stringify(payload, null, '\t'));
		response = request.execute();
		result.status = response.getStatusCode();
		result.body = response.getBody();
		if (result.body) {
			try {
				result.obj = JSON.parse(result.body);
			} catch (e) {
				result.parse_error = e.toString();
			}
		}
		result.error = response.haveError();
		if (result.error) {
			result.error_code = response.getErrorCode();
			result.error_message = response.getErrorMessage();
		}
	}
 
	return result;
}

This should work now for our purpose, but if we want to also utilize this new function for the other two original purposes, we will need to refactor that code to call this function and then evaluate the response. Those actually work as they are right at the moment, so there is no rush to jump in and do that right now, but we don’t want to forget to take care of that one day, if for no other reason than to reduce the lines of code to be maintained.

That about wraps things up for the application records. Next time, we will see if we can do the same thing for the version records, and then we will jump into the Update Set attachments and that ought to wrap up this little side trip of creative avoidance that we took on so that we could ignore the issues with the application publishing for a while. One day, we need to get back into building out that third primary leg of this little stool, but now that we have headed down this intentional detour, we should finish this up first before we jump back onto the main road.

Collaboration Store, Part XLI

“If opportunity doesn’t knock, build a door.”
Milton Berle

Last time out we started work on the Script Include function that will sync up the data on a Client instance with that of the Host instance. We stopped short of consolidating a couple of cloned functions into a single shared function, but before we get to that effort, we need to add the code that syncs up the application table data. We already wrote the code that syncs up the instance table data, and since this operation is essentially the same process, we should be able lift most of the code from what we have already done.

As we did with the instances, we will need to gather up all of the applications on the Host associated with the instance being processed. This is basically the same process that we went through with the instance table, and the code will be very much the same except for the table and the query.

var applicationList = [];
var applicationGR = new GlideRecord('x_11556_col_store_member_application');
applicationGR.addQuery('provider.name', thisInstance);
applicationGR.query();
while (applicationGR.next()) {
	applicationList.push(applicationGR.getDisplayValue('name'));
}

We will also need to contact the Client instance and fetch all of the applications associated with the current instance being processed that are present on that system. Once again, we can turn to the REST API Explorer to generate an end point URL, and other than the actual URL, the request object needed for fetching the applications is again basically the same as that which we put together to fetch the instances from the Client.

var request  = new sn_ws.RESTMessageV2();
request.setHttpMethod('get');
request.setBasicAuth(this.CSU.WORKER_ROOT + instance, token);
request.setRequestHeader("Accept", "application/json");
request.setEndpoint('https://' + instance + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=name%2Csys_id&sysparm_query=provider%3D' + remoteSysId);

Now that we have build our request object, we need to execute the request and check the results just as we did when gathered up the Client’s list of instances.

var response = request.execute();
if (response.haveError()) {
	gs.error('InstanceSyncUtils.syncApplications - Error returned from attempt to fetch application list: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
} else if (response.getStatusCode() == '200') {
	var jsonString = response.getBody();
	var jsonObject = {};
	try {
		jsonObject = JSON.parse(jsonString);
	} catch (e) {
		gs.error('InstanceSyncUtils.syncApplications - Unparsable JSON string returned from attempt to fetch application list: ' + jsonString);
	}
	if (Array.isArray(jsonObject.result)) {
		...
	} else {
		gs.error('InstanceSyncUtils.syncApplications - Invalid response body returned from attempt to fetch application list: ' + response.getBody());
	}
} else {
	gs.error('InstanceSyncUtils.syncApplications - Invalid HTTP response code returned from attempt to fetch application list: ' + response.getStatusCode());
}

Assuming that all went well with gathering up all of the data on the Host and the Client, the next thing that we need to do is loop through all of the applications found on the Host for this instance and see if they are present on the Client as well. If not, we will need to push them over, which is where we will get into that code that we cloned that needs to be refactored into a single function so that we can have one process that can serve the two original purposes and our new purpose as well.

for (var i=0; i<applicationList.length; i++) {
	var thisApplication = applicationList[i];
	var remoteAppId = '';
	for (var j=0; j<jsonObject.result.length && remoteAppId == ''; j++) {
		if (jsonObject.result[j].name == thisApplication) {
			remoteAppId = jsonObject.result[j].sys_id;
		}
	}
	if (remoteAppId == '') {
		remoteAppId = this.sendApplication(targetGR, thisApplication, thisInstance, remoteSysId);
	}
	this.syncVersions(targetGR, thisApplication, remoteAppId);
}

This code references two functions that we have not yet created, sendApplication and syncVersions. We should be able to cut and paste the sendInstance function that we created earlier to build the sendApplication function with just a few minor modifications. Similarly, we should be able to clone the syncApplications function to serve as the initial basis of the syncVersions function. At this point, we are assuming that we have collapsed our two cloned functions that send applications over into a single function in the CollaborationStoreUtils Script Include modeled after the publishInstance function that we were using to send over an instance record, and we are assuming that it will return the very same type of object in response.

sendApplication: function(targetGR, thisApplication, thisInstance, remoteSysId) {
	var sysId = '';
	var applicationGR = new GlideRecord('x_11556_col_store_member_application');
	applicationGR.addQuery('name', thisApplication);
	applicationGR.addQuery('provider.name', thisInstance);
	applicationGR.query();
	if (applicationGR.next()) {
		var result = this.CSU.publishApplication(applicationGR, targetGR, remoteSysId);
		if (result.error) {
			gs.error('InstanceSyncUtils.sendApplication - Error occurred attempting to push application ' + thisApplication + ' : ' + result.errorCode + '; ' + result.errorMessage);
		} else if (result.status != 200) {
			gs.error('InstanceSyncUtils.sendApplication - Invalid HTTP response code returned from attempt to push application ' + thisApplication + ' : ' + result.status);
		} else if (!result.obj) {
			gs.error('InstanceSyncUtils.sendApplication - Invalid response body returned from attempt to push application ' + thisApplication + ' : ' + result.body);
		} else {
			sysId = result.obj.sys_id;
		}
	}
	return sysId;
}

Of course, for this to work, we actually have to create a function in CollaborationStoreUtils called publishApplication, but we already have two functions that do that very thing and a publishInstance function to use as a model. It shouldn’t take all that much to collapse all of that together into a single function that will work for everyone, so let’s make that the focus of our next installment.

Collaboration Store, Part XL

“If one does not know to which port one is sailing, no wind is favorable.”
Lucius Annaeus Seneca

Last time, we started building out the Script Include function that will do all of the heavy lifting for the process of periodically syncing up all of the Client instances with the Host. The next piece of code that we were going to tackle was the process of sending over a missing instance record to the Client. At the time, I was thinking that I had a couple of different versions of the code to do that already built out, one being cloned off of the other, but as it turns out, that is true for the application records, the version records, and the version attachments, but not the instance records. Currently, there is only one function that pushes over an instance record, and that function is called publishInstance and is found in the CollaborationStoreUtils Script Include.

publishInstance: function(instanceGR, targetGR) {
	var result = {};

	var payload = {};
	payload.instance = instanceGR.getDisplayValue('instance');
	payload.name = instanceGR.getDisplayValue('name');
	payload.description = instanceGR.getDisplayValue('description');
	payload.email = instanceGR.getDisplayValue('email');
	payload.accepted = instanceGR.getDisplayValue('accepted');
	payload.active = instanceGR.getDisplayValue('active');
	payload.host = instanceGR.getDisplayValue('host');
	var url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(url);
	request.setHttpMethod('POST');
	request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader('Content-Type', 'application/json');
	request.setRequestHeader('Accept', 'application/json');
	request.setRequestBody(JSON.stringify(payload, null, '\t'));
	var response = request.execute();
	result.status = response.getStatusCode();
	result.body = response.getBody();
	if (result.body) {
		try {
			result.obj = JSON.parse(result.body);
		} catch (e) {
			result.parse_error = e.toString();
		}
	}
	result.error = response.haveError();
	if (result.error) {
		result.error_code = response.getErrorCode();
		result.error_message = response.getErrorMessage();
	}

	return result;
}

It takes two arguments, the instanceGR and the targetGR. In the current version of our new code, we do not have a GlideRecord for either the instance to be sent over or the instance to which the record is to be sent, but with a little refactoring, we can make that happen. To start with, I changed this code:

var instance = '';
var token = '';
var instanceList = [];
instanceGR = new GlideRecord('x_11556_col_store_member_organization');
instanceGR.addQuery('active', true);
instanceGR.query();
while (instanceGR.next()) {
    instanceList.push(instanceGR.getDisplayValue('name'));
    if (instanceGR.getUniqueValue() == instanceId) {
        instance = instanceGR.getDisplayValue('name');
        token = instanceGR.getDisplayValue('token');
    }
}

… to this:

var targetGR = new GlideRecord('x_11556_col_store_member_organization');
targetGR.get(instanceId);
var instance = targetGR.getDisplayValue('name');
var token = targetGR.getDisplayValue('token');
var instanceList = [];
var instanceGR = new GlideRecord('x_11556_col_store_member_organization');
instanceGR.addQuery('active', true);
instanceGR.query();
while (instanceGR.next()) {
	instanceList.push(instanceGR.getDisplayValue('name'));
}

This still gets me the target instance name and token as it did earlier, but it also gets me a GlideRecord for the target instance as well.

Then I changed this line:

remoteSysId = this.sendInstance(instance, token, thisInstance);

… to this:

remoteSysId = this.sendInstance(targetGR, thisInstance);

That took care of the refactoring. Now all I needed to do was to fetch a GlideRecord for the instance to be sent over, and then I could call the existing function in CollaborationStoreUtils and check the response.

sendInstance: function(targetGR, thisInstance) {
	var sysId = '';
	var instanceGR = new GlideRecord('x_11556_col_store_member_organization');
	if (instanceGR.get('name', thisInstance)) {
		var result = this.CSU.publishInstance(instanceGR, targetGR);
		if (result.error) {
			gs.error('InstanceSyncUtils.sendInstance - Error occurred attempting to push instance ' + thisInstance + ' : ' + result.errorCode + '; ' + result.errorMessage);
		} else if (result.status != 200) {
			gs.error('InstanceSyncUtils.sendInstance - Invalid HTTP response code returned from attempt to push instance ' + thisInstance + ' : ' + result.status);
		} else if (!result.obj) {
			gs.error('InstanceSyncUtils.sendInstance - Invalid response body returned from attempt to push instance ' + thisInstance + ' : ' + result.body);
		} else {
			sysId = result.obj.sys_id;
		}
	}
	return sysId;
}

Of course, I haven’t actually tested any of this as yet, but it looks like it ought to work out OK. If not, well then we will make a few adjustments.

The next thing on the list, then, is to build out the other referenced function that does not yet exist, the syncApplications function. Here is where we will eventually run into the need for that code that was cloned earlier, so we will want to clean all of that up finally, and the publishInstance function actually looks like a good model to use for the way in which it works, sending back all of the good and bad results in an object that can be evaluated by the caller where actions can be taken that are appropriate for the specific context.

Here is the original version of the code that sends over an application:

processPhase5: function(answer) {
	var mbrAppGR = new GlideRecord('x_11556_col_store_member_application');
	if (mbrAppGR.get(answer.mbrAppId)) {
		var host = gs.getProperty('x_11556_col_store.host_instance');
		var token = gs.getProperty('x_11556_col_store.active_token');
		var thisInstance = gs.getProperty('instance_name');
		var request  = new sn_ws.RESTMessageV2();
		request.setHttpMethod('get');
		request.setBasicAuth(this.WORKER_ROOT + host, token);
		request.setRequestHeader("Accept", "application/json");
		request.setEndpoint('https://' + host + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=sys_id&sysparm_query=provider.instance%3D' + thisInstance + '%5Ename%3D' + encodeURIComponent(mbrAppGR.getDisplayValue('name')));
		var response = request.execute();
		if (response.haveError()) {
			answer = this.processError(answer, 'Error returned from Host instance: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
		} else if (response.getStatusCode() == '200') {
			var jsonString = response.getBody();
			var jsonObject = {};
			try {
				jsonObject = JSON.parse(jsonString);
			} catch (e) {
				answer = this.processError(answer, 'Unparsable JSON string returned from Host instance: ' + jsonString);
			}
			if (!answer.error) {
				var payload = {};
				payload.name = mbrAppGR.getDisplayValue('name');
				payload.description = mbrAppGR.getDisplayValue('description');
				payload.current_version = mbrAppGR.getDisplayValue('current_version');
				payload.active = 'true';
				request  = new sn_ws.RESTMessageV2();
				request.setBasicAuth(this.WORKER_ROOT + host, token);
				request.setRequestHeader("Accept", "application/json");
				if (jsonObject.result && jsonObject.result.length > 0) {
					answer.hostAppId = jsonObject.result[0].sys_id;
					request.setHttpMethod('put');
					request.setEndpoint('https://' + host + '.service-now.com/api/now/table/x_11556_col_store_member_application/' + answer.hostAppId);
				} else {
					request.setHttpMethod('post');
					request.setEndpoint('https://' + host + '.service-now.com/api/now/table/x_11556_col_store_member_application');
					payload.provider = thisInstance;
				}
				request.setRequestBody(JSON.stringify(payload, null, '\t'));
				response = request.execute();
				if (response.haveError()) {
					answer = this.processError(answer, 'Error returned from Host instance: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
				} else if (response.getStatusCode() != 200 && response.getStatusCode() != 201) {
					answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + response.getStatusCode());
				} else {
					jsonString = response.getBody();
					jsonObject = {};
					try {
						jsonObject = JSON.parse(jsonString);
					} catch (e) {
						answer = this.processError(answer, 'Unparsable JSON string returned from Host instance: ' + jsonString);
					}
					if (!answer.error) {
						answer.hostAppId = jsonObject.result.sys_id;
					}
				}
			}
		} else {
			answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + response.getStatusCode());
		}
	} else {
		answer = this.processError(answer, 'Invalid Member Application sys_id: ' + answer.appSysId);
	}

	return answer;
}

… and here is the other version that was cloned and hacked up based on the original:

publishNewVersion: function(newVersion, targetInstance, attachmentId) {
	var targetGR = new GlideRecord('x_11556_col_store_member_organization');
	if (targetGR.get('instance', targetInstance)) {
		var token = targetGR.getDisplayValue('token');
		var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
		if (versionGR.get(newVersion)) {
			var canContinue = true;
			var targetAppId = '';
			var mbrAppGR = versionGR.member_application.getRefRecord();
			var request  = new sn_ws.RESTMessageV2();
			request.setHttpMethod('get');
			request.setBasicAuth(this.WORKER_ROOT + targetInstance, token);
			request.setRequestHeader("Accept", "application/json");
			request.setEndpoint('https://' + targetInstance + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=sys_id&sysparm_query=provider.instance%3D' + mbrAppGR.getDisplayValue('provider.instance') + '%5Ename%3D' + encodeURIComponent(mbrAppGR.getDisplayValue('name')));
			var response = request.execute();
			if (response.haveError()) {
				gs.error('CollaborationStoreUtils.publishNewVersion: Error returned from Target instance ' + targetInstance + ': ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
				canContinue = false;
			} else if (response.getStatusCode() == '200') {
				var jsonString = response.getBody();
				var jsonObject = {};
				try {
					jsonObject = JSON.parse(jsonString);
				} catch (e) {
					gs.error('CollaborationStoreUtils.publishNewVersion: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + jsonString);
					canContinue = false;
				}
				if (canContinue) {
					var payload = {};
					payload.name = mbrAppGR.getDisplayValue('name');
					payload.description = mbrAppGR.getDisplayValue('description');
					payload.current_version = mbrAppGR.getDisplayValue('current_version');
					payload.active = 'true';
					request  = new sn_ws.RESTMessageV2();
					request.setBasicAuth(this.WORKER_ROOT + targetInstance, token);
					request.setRequestHeader("Accept", "application/json");
					if (jsonObject.result && jsonObject.result.length > 0) {
						targetAppId = jsonObject.result[0].sys_id;
						request.setHttpMethod('put');
						request.setEndpoint('https://' + targetInstance + '.service-now.com/api/now/table/x_11556_col_store_member_application/' + targetAppId);
					} else {
						request.setHttpMethod('post');
						request.setEndpoint('https://' + targetInstance + '.service-now.com/api/now/table/x_11556_col_store_member_application');
						payload.provider = mbrAppGR.getDisplayValue('provider.instance');
					}
					request.setRequestBody(JSON.stringify(payload, null, '\t'));
					response = request.execute();
					if (response.haveError()) {
						gs.error('CollaborationStoreUtils.publishNewVersion: Error returned from Target instance ' + targetInstance + ': ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
						canContinue = false;
					} else if (response.getStatusCode() != 200 && response.getStatusCode() != 201) {
						gs.error('CollaborationStoreUtils.publishNewVersion: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + response.getStatusCode());
						canContinue = false;
					} else {
						jsonString = response.getBody();
						jsonObject = {};
						try {
							jsonObject = JSON.parse(jsonString);
						} catch (e) {
							gs.error('CollaborationStoreUtils.publishNewVersion: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + jsonString);
							canContinue = false;
						}
						if (canContinue) {
							targetAppId = jsonObject.result.sys_id;
						}
					}
				}
			} else {
				gs.error('CollaborationStoreUtils.publishNewVersion: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + response.getStatusCode());
			}
			if (canContinue) {
				this.publishVersionRecord(targetInstance, token, versionGR, targetAppId, attachmentId);
			}
		} else {
			gs.error('CollaborationStoreUtils.publishNewVersion: Version record not found: ' + newVersion);
		}
	} else {
		gs.error('CollaborationStoreUtils.publishNewVersion: Target instance record not found: ' + targetInstance);
	}
}

Both functions accomplish the same thing, first checking to see if the application is already present on the remote instance, and then either adding it or updating it depending on whether or not it was already there. This is what we would want to do as well, so it seems as if we could collapse these two into a single, reusable function that would server our current purpose and these other two use cases as well. We could adopt the result object approach from the above publishInstance function and return the same form of object from our new function and then each user of the function could make their own choices as to what to do with the information returned. This is something that I have wanted to do for a while now, but it looks like it might be a little bit of work and some more refactoring of existing code, so let’s save all of that for our next installment.

Collaboration Store, Part XXXIX

“It’s not that I’m so smart, it’s just that I stay with problems longer.”
Albert Einstein

Last time, we built the Flow that will call our Script Include function to update all of the instances in the community that might be missing any artifacts. Now we need to get started on the actual function that will check in with each instance, gather its inventory of artifacts, compare it to that of the Host, and send over any missing items. As with most things, there are a number of ways to go about this, but my plan is to go through the instances first, and then work through the applications for each instance as each instance is being processed. Similarly, my intent is to work through the application versions as each application is being processed. To begin, we will need to fetch all of the instances found on the Host and stuff them into an array.

var instance = '';
var token = '';
var instanceList = [];
instanceGR = new GlideRecord('x_11556_col_store_member_organization');
instanceGR.addQuery('active', true);
instanceGR.query();
while (instanceGR.next()) {
	instanceList.push(instanceGR.getDisplayValue('name'));
	if (instanceGR.getUniqueValue() == instanceId) {
		instance = instanceGR.getDisplayValue('name');
		token = instanceGR.getDisplayValue('token');
	}
}

The other thing that this code accomplishes is to gather up the name and credentials for the target instance. The sys_id of the instance is passed to the function from the Flow, but to make the REST API calls, we need the actual name of the instance and the token. We could read that record directly before we started everything, but since we were spinning through the instance records anyway, I decided to just check each one and grab the data when we happened to be processing that particular instance. Of course, once we do that we need to check to make sure that we actually came across an instance with that sys_id before we go any further.

if (instance > '' && token > '') {
	this.fetchInstanceList(instance, token, instanceList);
} else {
	gs.error('InstanceSyncUtils.syncInstance - Requested instance not on file: ' + instanceId);
}

Assuming that we did come across the instance being synced, we now need to contact that instance and gather up all of the instances present on that system. For that, we can call on our old friend sn_ws.RESTMessageV2. But before we do that, we can use the REST API Explorer to come up with an end point URL that will get us the results that we need. We want to gather up all of the active instances from the Client instance, but we will only need a couple of the fields for our purpose here. We can specify those in the sysparm_fields parameter. I also like to alter the sysparm_limit parameter from the default of 1 to the alternative of 10, just to get more than one result in the output to help verify the query.

Using the REST API Explorer to generate an end point URL

Once we have entered all of the appropriate parameters, we can hit the Send button, which will produce the URL and also display some sample results in the Response Body section. After stripping off the server portion and removing the limitation parameter, we are left with the following value to use as our end point:

/api/now/table/x_11556_col_store_member_organization?sysparm_query=active%3Dtrue&sysparm_fields=name%2Csys_id

With our end point URL in hand, we can now build the request object.

fetchInstanceList: function(instance, token, instanceList) {
	var request = new sn_ws.RESTMessageV2();
	request.setHttpMethod('get');
	request.setBasicAuth(this.WORKER_ROOT + instance, token);
	request.setRequestHeader("Accept", "application/json");
	request.setEndpoint('https://' + instance + '.service-now.com/api/now/table/x_11556_col_store_member_organization?sysparm_query=active%3Dtrue&sysparm_fields=name%2Csys_id');
	...
}

Once the request object has been built, we can execute the request and check the results.

var response = request.execute();
if (response.haveError()) {
	gs.error('InstanceSyncUtils.syncInstance - Error returned from attempt to fetch instance list: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
} else if (response.getStatusCode() == '200') {
	var jsonString = response.getBody();
	var jsonObject = {};
	try {
		jsonObject = JSON.parse(jsonString);
	} catch (e) {
		gs.error('InstanceSyncUtils.syncInstance - Unparsable JSON string returned from attempt to fetch instance list: ' + jsonString);
	}
	if (Array.isArray(jsonObject.result)) {
		...
	} else {
		gs.error('InstanceSyncUtils.syncInstance - Invalid response body returned from attempt to fetch instance list: ' + response.getStatusCode());
	}
} else {
	gs.error('InstanceSyncUtils.syncInstance - Invalid HTTP response code returned from attempt to fetch instance list: ' + response.getStatusCode());
}

Finally, if all has gone well, we can loop through the Host’s list of instances and then for every instance on the Host list, see if we can find it on the Client instance list. If we do not find it, then we need to push it over. Either way, once that has been checked and corrected if necessary, the next thing to do will be to check all of the applications present for that instance from the Host list.

for (var i=0; i<instanceList.length; i++) {
	var thisInstance = instanceList[i];
	var remoteSysId = '';
	for (var j=0; j<jsonObject.result.length && remoteSysId == ''; j++) {
		if (jsonObject.result[j].name == thisInstance) {
			remoteSysId = jsonObject.result[j].sys_id;
		}
	}
	if (remoteSysId == '') {
		remoteSysId = this.sendInstance(instance, token, thisInstance.name);
	}
	this.syncApplications(instance, token, thisInstance, remoteSysId);
}

We already have code to send over an instance record. In fact, we have already cloned that code and have a second version for another purpose. Rather than clone that code yet a third time, it’s long past the point for all of that to be consolidated into a single function that can work for any and all purposes. That seems like a bit of work, though, so let’s stop here and save that for our next time out.

Collaboration Store, Part XXXVIII

“You don’t have to be good to start … you just have to start to be good!”
Joe Sabah

Last time out we really did not accomplish anything of any consequence, but today let’s see if we can actually make some progress on something of real value. We need to create a process that will run every so often and check to make sure that all of the instances in the community have all of the latest content. We can use the Flow Designer to create and schedule a flow that will run daily on the Host instance and compare the artifacts present on each Client instance with the artifacts present on the Host, and then send over any missing items that never made it over originally for whatever reason. Doing it this way will eliminate the need to track and record errors when they happen, since we will just compare the Client inventory with that of the Host and correct any discrepancies. We don’t really need to know what failed or why.

We could make all of the REST API calls to the Client instances using the Integration Hub, but since that is a collection of optional products, not every instance will have all of those components installed and we would not want to make that a prerequisite for the product. To make this work on any ServiceNow configuration, we will want to roll our own calls via Javascript, and we will just use the Flow Designer to schedule a simple flow that loops through all of the Client instance records and then calls a Script Action that will do all of the heavy lifting. Before we build the Action though, we will want to build a Script Include function that can be called in the Action. Since this will be a significant script, we should probably create a new Script Include specific to this purpose rather than adding a number of new functions to any of our existing Script Includes. We can call our new Script Include InstanceSyncUtils and create a simple function called syncInstance with a single argument of the sys_id of the record for the instance to be synced. For now, we can just stub out the function with a simple logging statement and then circle back later to fill in the details. Right now, we just want to have something to call in our Flow Designer Action.

New Script Include function to be called in our new Action

With that out of the way, we can now fire up the Flow Designer and navigate to New -> Action. We will call our new Action Sync Instance and configure a single Input called Instance ID.

New Sync Instance Action with a single Input

For the Script step, we will just pass the Input to our new Script Include function:

(function execute(inputs, outputs) {
	var isu = new InstanceSyncUtils();
	isu.syncInstance(inputs.instance_id);
})(inputs, outputs);

There is currently nothing returned by the function, so there is no need to define any Outputs. At this point, all we need to do is to Save and Publish the Action before turning our attentions to the actual Flow itself.

To build a Flow, go back to the Home Page of the Flow Designer and select New -> Flow. On the resulting pop-up Flow Properties panel, enter all of the details for the Flow and set the Run As value to System User.

New Flow Properties

Since we want this Flow to run on its own every day, we can set the Trigger to Daily, and the Time to 12:30, which should run it in the middle of the lunch hour in the Host time zone, when most instances should be available.

Flow Trigger configuration

Since we only want this Flow to run on the Host instance, the first thing that we need to do is to create a Flow Variable that indicates whether or not this instance is the Host. To set the value of the variable, we add a Set Flow Variables step that uses the following script to determine if this instance is the Host based on System Properties.

return gs.getProperty('instance_name') == gs.getProperty('x_11556_col_store.host_instance');

The next step then will be a conditional that checks the value of the new Flow Variable.

Making sure that this is the Host instance

Failing this test will terminate the Flow, but if this is the Host instance, then the next thing that we need to do is gather up all of the Client instance records and then loop through them and call our new Action for each one in turn.

Find all records where Active is true and Host is false

Inside the following For Each Item loop, we can then invoke our new Action, passing in the sys_id of the current record.

Calling our new Action inside the For Each Item loop

That completes the work on the Flow, and all we need to do now is to Save and Activate the Flow. At this point, the Flow will kick off every day at 12:30 local time, but it won’t really do much except put a few entries in the System Log. The real work will be done in the Script Include, and since that’s a major effort in and of itself, we’ll leave that for our next exciting episode.

Collaboration Store, Part XXXVII

“Stay committed to your decisions, but stay flexible in your approach.”
Tony Robbins

After abandoning my earlier plans to jump into the application publishing process, I started to take a look at the missing error recovery needs for all of these inter-instance interactions. One thing that I had always planned to do at some point was to create an activity log of all transactions between instances. My idea was not to sync the logs across all instances, but to have a record in each instance of the things that went on with that particular instance. On the Host instance, such a log could provide the basis for some form of periodic recovery process that scanned the log for failed transactions and then made another attempt to put the transaction through again. After giving that some thought, though, I decided that a better approach would be to just scan each instance for instances, applications, and versions, and then attempt to push over anything that was missing compared to what was present on the Host. I still want to build an activity log at some point, but I don’t think that I want to use it as a basis for error recovery. I think it would be more straightforward to just compare the lists of records on each instance with the master lists on the Host and then try to correct any deficiencies.

That’s the plan today, anyway, but plans do have a way of changing over time, so who knows how things will come out once I start putting all of the pieces together. Right now, though, this seems like the better way to go.

One little thing that have been wanting to do for some time was to add another field to the version record to indicate the version of ServiceNow that was used to build the Update Set. There is a System Property that contains this information, so I all I really needed to do was to add the field and then add a line of code to the version record creation function to pull that value out of the property and stuff it into the new field. The name of the property is glide.buildtag.last, and the name of the field that I added is built_on.

New built_on field added to the version record

Once I added the new field to the version record, I opened up the ApplicationPublisher Script Include, which creates the version record, and added the following line to the function that builds the record:

versionGR.setValue('built_on', gs.getProperty('glide.buildtag.last'));

I also had to modify the function that sent the version record over to other instances to pass on the new value. Since I copied that code instead of consolidating the logic into a single, reusable function, I had to do that in two places, in the ApplicationPublisher and then again in the CollaborationStoreUtils (I really need to collapse that code into a single set of functions that will for both cases). In both places, I added the following line:

payload.built_on = versionGR.getDisplayValue('built_on');

This was not any kind of a major change, but it was something that I had been meaning to do for a long time, and so while I was in looking at the code, I decided to just go ahead and do it. Next time, we will focus on some real work to start building out some kind of error recovery process so that we can ensure that all of the instances in the community are always kept up to date, even if they miss an update in real time for whatever reason.

Collaboration Store, Part XXXVI

“The definition of flexibility is being constantly open to the fact that you might be on the wrong track.”
Brian Tracy

Although it is long past time to start some serious work on the third major component of this little(?) project, the application installation process, I have been rather hesitant to officially kick that off. Last time, we addressed the last of the reported defects in the first two processes, the initial set-up and the application publishing process. Now, it would seem, would be the time to jump into wrestling with that last remaining primary function and put that to bed before turning our attentions to the list of secondary features that will complete the initial release of the full product. At least, that would appear to be the next logical step.

The reason for my reluctance, however, is that I have taken a cursory look at the various approaches available to accomplish my goal, and quite frankly, I don’t really like any of them. When we converted our Update Set to XML, we were able to fish out enough parts and pieces from the stock product to cobble together a reasonable solution with a minimal amount of questionable hackery. To go in the other direction, to convert the XML back to an Update Set, the only stock component that appears to provide this functionality is bound tightly with the process of uploading a local file from the user’s file system. The /upload.do page, or more specifically, the /sys_upload.do process to which the form on that page is posted, handles both the importing of the XML file and the conversion of that file to an Update Set. There is no way to extract the process that turns the XML into an Update Set, since everything is encapsulated into that one all-encompassing process. For our purposes, we do not have a local file on the user’s machine to upload; our file is an attachment already present on the server, so invoking this process, which seems the only way to go, involves much more than we really need.

To invoke the one and only process that I have found (so far!) to make this conversion, then, we will have to post a multi-part form to /sys_upload.do that includes our XML data along with all of the other fields, headers, and cookies expected by the server-side form processor. On the server side, we should be able to accomplish this with an outbound REST message, or on the client side, we should be able to do this by somehow sending our XML instead of a local file when submitting the form. Each approach has its own merits, but they also each have their own issues, and no matter which way you go, it’s a rather complicated mess.

The Server Side Approach

Posting a multi-part form on the server side is actually relatively simple as far as the form data goes. We can construct a valid body for a standard multipart/form-data POST using our XML data and related information and then send it out using an outbound REST message. That’s the easy part. We just need to add an appropriate Content-Type header, including some random boundary value:

Content-Type: multipart/form-data; boundary=somerandomvalue

Then we can build up the body by including all of the hidden fields on the form, and then add our XML in a file segment that would look something like this:

------------somerandomvalue
Content-Disposition: form-data; name="attachFile"; filename="app_name_v1.0.xml"
Content-Type: application/xml

<... insert XML data here ...>

------------somerandomvalue--

In addition to the XML file component, you would also need to send all of the other expected form field values, some of which are preloaded on the form when it is delivered. To obtain those values, you would have to first issue an HTTP GET request of the /upload.do page and pick those values out of the resulting HTML. This can be accomplished with a little regex magic and the Javascript string .match() method. Here is a simple function to which you can pass the HTML and a pattern to return the value found in the HTML based on the pattern:

function extractValue(html, pattern) {
	var response = '';
	var result = html.match(pattern);
	if (result.length > 1) {
		response = result[1];
	}
	return response;
}

For example, one of the form fields found on the /upload.do page is sysparm_ck. The INPUT element for this hidden field looks like this:

<input name="sysparm_ck" id="sysparm_ck" type="hidden" value="68fa4eee2fa401104425fcecf699b646939f52c6787c23fff22b124fcf58f713235b7478"></input>

To snag the value of that field, you would just pass the HTML for the page and the following pattern to our extractValue function:

id="sysparm_ck" type="hidden" value="(*.?)"

Once you have obtained the value, you can use it to build another “part” in the multi-part form body:

------------somerandomvalue
Content-Disposition: form-data; name="sysparm_ck"

68fa4eee2fa401104425fcecf699b646939f52c6787c23fff22b124fcf58f713235b7478

------------somerandomvalue

All of that is pretty easy to do, and would work great except for one thing: this POST would only be accepted as part of an authenticated session, and cannot just be sent in on its own. Theoretically, we could create an authenticated session by doing a GET of the /login.do page and then a POST of some authoritative user’s credentials, but that would mean knowing and sending the username and password of a powerful user, which is a dangerous thing with which to start getting involved. For that reason, and that reason alone, this does not seem to be a good way to go.

The Client Side Approach

On the client side, you are already involved in an authenticated session, so that’s not any kind of an issue at all. What you do not have is the XML, so to do anything on the client side, we will first need to create some kind of GlideAjax service that will deliver the XML over to the client. Once we have the XML that we would like to upload in place of the normal local file, we will have to perform some kind of magic trick to update the form on the page with our data in the place of a file from the local computer. To do that, we will have to either create our own copy of the /upload.do page or add a global script that will only run on that page, and only if there is some kind of URL parameter indicating that this is one of our processes and not just a normal user-initiated upload. We did this once before with a global script that only ran on the email client, so I know that I can run a conditional script on a stock page if I do not create a page of my own, but the trick will be getting the XML data to be sent back to the server with the rest of the input form.

After nosing around a bit for available options, it appears that you might be able to leverage the DataTransfer object to build a fake file, link it to the form, and then submit the form using something like this:

function uploadXML(xml, fileName) {
	var fileList = new DataTransfer();
	fileList.items.add(new File(xml, fileName));
	document.getElementById('attachFile').files = fileList.files;
	document.forms[0].submit();
}

Of course, there will be a lot more to it than that, as you will need to get a handle on the Update Set created and then try to Preview and Commit it programmatically as well, but this looks like a possibility. Still, you have to move the entire Update Set XML all the way down to the client just to push it all the way back up to the server again, which seems like quite a waste. Plus, with any client-side functionality, there is always the browser compatibility issues that would all need to be tested and resolved. Maybe this would work, but I still don’t like it. It seems like quite a bit of complexity and more than a few opportunities for things to go South. I’m still holding out hope that there is a better way.

Now what?

So … given that I don’t like any of the choices that I have come up with so far, I have decided to set that particular task aside for now in the hopes that a better alternative will come to me before I invest too much effort into a solution with which I am not all that thrilled. There is no shortage of things to do here, so my plan is to just focus on other issues and then circle back to this particular effort when a better idea reveals itself, or I run out of other things to do. Technically, once you have obtained the XML for a particular version from the Host, you can still manually install it by downloading the attachment yourself and importing like any other XML Update Set. That’s not really how I intend all of this to function, but it does work, so it should be OK to set this aside for a time.

Next time, then, instead of forging ahead with this third major component as I had originally planned, I will pick something else out of the pile and we will dig into that instead.

Collaboration Store, Part XXXV

“The good news about computers is that they do what you tell them to do. The bad news is that they do what you tell them to do.”
James Barton

Well, the test results are starting to trickle in, and one of the issues looks rather important, so we need to take a look at that before we jump into our next major effort. The problem with the scoped System Properties came up earlier, and I thought that I had found a way to deal with the issue, but obviously there is still a problem where the initial set-up is concerned. Basically, if you are not in the Collaboration Store scope, then the set-up fails because the updates to the scoped System Properties are not allowed. My idea of moving the update logic to a global component did not resolve this issue, as the problem is related to the active scope of the user rather than the scope of the component.

So, it seems that the answer to the problem is to ensure that the user is in the correct scope before allowing them proceed with the set-up. Fortunately, there is already an area in ServiceNow where that very check is made, and there is even an option in the error message to switch over to the correct scope. You can see that when you bring up a scoped app and you are not in the scope of that application.

Sample error message for incorrect scope on the scoped application form

So it would seem that we could snag the HTML for that message and throw it up in the top of the HTML for the initial set-up widget with some kind of ng-hide so that it only appears if you are in the wrong scope. The first thing that we would need to do, then, is to figure out how to detect the user’s scope and then set up some boolean variable in the widget to indicate whether or not the user is in the correct scope to proceed with the set-up. Looking at the GlideSession API, it seems like the getCurrentApplicationId() method is just what we need. So I added this line to the top of the server script of the widget:

data.validScope = (gs.getCurrentApplicationId() == '5b9c19242f6030104425fcecf699b6ec');

Then, to prevent the user from submitting the form when in the wrong scope, I modified the ng-disabled tag on all of the submit buttons from this:

ng-disabled="!(form1.$valid)"

… to this:

ng-disabled="!(form1.$valid) || !c.data.validScope"

Now all I needed to do was to grab a copy of the HTML source from the application form and paste it in at the top of the HTML for the widget and see if the link would still work. Unfortunately, the link in the existing code relies on the GlideURL object, which is not available in Service Portal widgets, so we are going to have to hack that up a little bit to get around that problem. Here is the existing onclick script:

window.location.href=new GlideURL('change_current_app.do').addParam('app_id', '5b9c19242f6030104425fcecf699b6ec').addParam('referrer', window.location.pathname + window.location.search + window.location.hash).getURL();

Basically, this code just builds a URL, so it seems as if we could just go ahead and build the URL manually and hard-code the link. After doing a little tinkering around to see just what the URL actually was, I came up with this value for our circumstances:

change_current_app.do?app_id=5b9c19242f6030104425fcecf699b6ec&referrer=%24sp.do%3Fid%3Dcollaboration_store_setup

So, my final HTML, including the ng-hide based on my new widget variable, turned out to be this:

<div id="nav_message" class="outputmsg_nav" ng-hide="c.data.validScope">
  <img role="presentation" src="images/icon_nav_info.png">
  <span class="outputmsg_nav_inner">
    &nbsp;
    The <strong>Collaboration Store Set-up</strong> cannot be completed because <strong>Collaboration Store</strong> is not selected in your application picker.
    <a onclick="window.location.href='change_current_app.do?app_id=5b9c19242f6030104425fcecf699b6ec&referrer=%24sp.do%3Fid%3Dcollaboration_store_setup'">
      Switch to <strong>Collaboration Store</strong>
    </a>.
  </span>
</div>

Now all I needed to do was to bring up the set-up widget while in the wrong scope and see how it looked and how the new link worked out once I gave it a try.

Initial set-up screen with error message and link when in the incorrect scope

With the hard-coded link in the onclick function, everything seems to work as intended. The user is placed in the correct scope, the form is reloaded, and the error message goes away. This should finally resolve this annoying issue once and for all (let’s hope!).

The other issue that was reported was that the provider field on the application record was not populated when publishing a new version of an application. It was not clear from the comment whether the missing data was on the Client instance, the Host instance, or both, but I was unable to recreate any of those conditions in any of my testing. I am going to need a little more detail on this one before I can address it, so if anyone encounters this error in any of their testing, please leave a detailed message in the comments so that we can get it resolved.

There is still the potential for more feedback to come, but this particular issue seems to be rather critical, so it’s time to release a new Update Set. While I am at it, I think I will take a different approach on the global components and make an actual Update Set for that as well instead of just exporting the XML for the one global Script Include. This way, I can also include the app’s logo image, which is always missing from the XML generated for a scoped application. Here are the two new Update Sets:

As always, all feedback is welcome, and not just to report issues. If you give this a try and everything actually works, I would love to hear about that as well. Next time out, we will either deal with any more issues to come to light, or we will jump into the next big challenge, which will be to install an app published to the store.

Special Note to Testers

Set-up Process – If you want to test the set-up process, you will need to set up the Host instance first. You cannot set up a Client instance until there is a valid Host instance, as the Client instance set-up process requires access to a valid Host instance. What to look for: check to make sure that all instances in the community have the same list of instances; select Collaboration Store -> Member Organizations to pull up the list of instances in each instance to verify that all instances have the exact same list of all instances in the community.

Application Publishing Process – If you want to test the application publishing process, you will need to go through the set-up process first, and then you can publish an application to the store. This can be done with a single Host instance, but to fully test all of the functionality, you will need at least two Client instances in addition to the Host. What to look for: once you publish the application, check to make sure that all instances in the community have the newly published version of the application, including the XML Update Set attachment; select Collaboration Store -> Member Organizations to pull up the list of instances, then check the Related List of applications under the appropriate instance for the application, and then click on the app to check the Related List of versions under the application.

Application Installation Process – If you want to test the application installation process, you have jumped ahead of the class, as that portion of the app has not yet been developed. However, even though the development of the official installation process has not even been started, if you really want to install an app that has been shared with your instance, you can always download the XML attachment on the version record, import it back into your instance, and go through the normal XML Update Set installation process that you would go through for any other imported Update Set. But again, that’s getting a little ahead of where things stand right at the moment with project’s development.

One More Thing

As mentioned earlier, there is currently no error recovery built into the system at this time. This is definitely something near the top of the we-really-need-to-do-this list, but it is not there right now. What that means is that if you are doing any kind of testing and one or more of the instances in your community is off-line or unavailable for some reason, things will fail and those instances will not get the needed updates. One day we will definitely need to fix that, but for now, if that happens, that’s not a bug in the software to be reported; it’s to be expected until we build in some kind of error recovery into the product.

Also, if you end up testing things and don’t have any issues to report, then by all means, report that, too! You don’t have to have encounter a problem to post your results. If everything worked out as expected, we would definitely love to hear that as well. As always, all feedback of any kind is very much appreciated.

Collaboration Store, Part XXXIV

“The key is not to prioritize what’s on your schedule, but to schedule your priorities.”
Stephen Covey

So far, we have completed the first two of the three primary components of the project, the initial set-up process and the application publication process. The last of the three major pieces will be the process that will allow you to install an application obtained from the store. Before we dive straight into that, though, we should pause to take a quick look at what we have, and what still needs to be done in order to make this a viable product. At this point, you can install all of the prerequisites and then grab the latest Update Set, install it, and go through the set-up process to create either a Host or Client instance. Once you get through all of that, you are ready to publish any local Scoped Application to the store, which will then be shared with all other instances in your Collaboration Store community.

What you cannot do, just yet, is to find an application published to the store by some other instance and install it on your own instance. That’s the missing third leg of the stool that we will need to take on next. But that is not all that is left to be done. Once we get the basics to work, there are quite a number of other things to address before one could consider this to be truly usable. Some things are just annoyances, but others are definite features that you would have to consider essential for a complete product.

Speaking of annoyances, one of the things that I really don’t like is that when you publish an app to XML for distribution, the resulting Update Set XML does not include the app’s logo image. Clearly it is a part of the app, and if you push an app to an internal store and pull it down into another instance, it comes complete with the logo, so why they left that out of the XML is a mystery to me. I don’t like that for a couple of reasons: 1) when you pull down the XML for this app, you do not get the logo, and 2) when we use the XML to publish an app to the store, the logo is missing there as well. I have seen people complain about this, but I have not, as yet, seen a solution. I would really like to address that, both for my own apps as well as for the process that we are using in this one.

Speaking of logos, another feature that I would like to have is to provide the ability for each instance to have its own distinctive logo image, so that everything from that particular instance could be tagged with that image as a way to visually identify where the app originated. That’s not a critical feature, which is why I did not include it initially, but it has always been something that I felt should be a part of the process, particularly when you start thinking about ways to browse the store and find what you are looking for. That’s definitely on the We-will-get-around-to-it-one-day list.

Browsing the store is another thing that will need some attention at some point. Right now, we just want to prove that we can set-up the app, publish an application, and install an application published by someone else. Those are the fundamental elements of the app. But once we get all of that worked out, being able to hunt through the store to find what you want will be another important capability to build out. We’re not done with the fundamentals just yet, so we don’t want to put too much energy into that issue right at the moment, but at some point, we will need to create a user-friendly way to easily find what you need.

That, of course, leads into things like searchable keywords, tags, user ratings, reviews, and the like, but now is not the time to head down that rabbit hole. Still, there are a lot of possibilities here, and this could turn into a life-long project in and of itself. That’s probably not a good thing, though!

Anyway, we won’t get anything done if we don’t focus, so we need to stay on task and figure out the application installation process. Once again, there are several options available, but the nicest one seems to be the process that you go through to install an app from an internal store. That’s basically a one-click operation and then the app is installed. Unfortunately, that particular page is neither Jelly nor AngularJS, so you can’t just peek under the hood and see the magic like you can with so many other things on the Now Platform. Another option would be to hack up a copy of the Import XML Action on the Update Set list page to push in the attached XML from a published app version, but that only takes things so far; you still have to Preview the Update Set, resolve any issues, and then manually issue the Commit. It would be much nicer if we could just push a button and have the app installation process run in the background and notify you when it was completed. Obviously, we have some work to do here to come up with the best way to go about this, and we had better figure that out relatively soon. Next time, if we are not dealing with test results from the last release, we will need to start building this out.