Collaboration Store, Part LXIV

“Optimism is an occupational hazard of programming: feedback is the treatment.”
Kent Beck

Last time, we wrapped up the last of the refactoring for all of the features that push artifacts from one instance to another. Although that covers the majority of the REST API calls, there are still a few remaining functions that make REST API calls of their own, and we want to have those calls logged just like all of the others in the shared functions. The first of those is the getStoreInfo function in the CollaborationStoreUtils Script Include.

getStoreInfo: function(host) {
	var result = {};

	var request  = new sn_ws.RESTMessageV2();
	request.setHttpMethod('get');
	request.setEndpoint('https://' + host + '.service-now.com/api/x_11556_col_store/v1/info');
	var response = request.execute();
	result.responseCode = response.getStatusCode();
	if (response.haveError()) {
		result.error = response.getErrorMessage();
		result.errorCode = response.getErrorCode();
		result.body = response.getBody();
	} else if (result.responseCode == '200') {
		result.storeInfo = JSON.parse(response.getBody());
		if (result.storeInfo.result.status == 'success') {
			result.name = result.storeInfo.result.info.name;
			var csgu = new global.CollaborationStoreGlobalUtils();
			csgu.setProperty('x_11556_col_store.active_token', result.storeInfo.result.info.sys_id);
		} else {
			result.error = 'This instance is not a Host instance';
		}
	} else {
		result.error = 'Invalid HTTP Response Code: ' + result.responseCode;
		result.body = response.getBody();
	}

	return result;
}

It shouldn’t be too difficult to rework the code a little bit to adopt the standard result object that the logging function is expecting. The main problem with this particular function is timing: we need to pass the GlideRecord of the target instance to the logging function, but we are calling the getStoreInfo function so that we can get the data needed to create the GlideRecord for the Host instance. At the moment that we are making the call, the GlideRecord for the Host instance does not yet exist. Since we do not yet have a Host instance GlideRecord to pass, we will have to pass null, but we will also have to modify the logging function to handle that possibility. Here is the refactored getStoreInfo function:

getStoreInfo: function(host) {
	var result = {};

	result.url = 'https://' + host + '.service-now.com/api/x_11556_col_store/v1/info';
	result.method = 'GET';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	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();
	} else if (result.obj) {
		if (result.obj.result.status == 'success') {
			result.name = result.obj.result.info.name;
			var csgu = new global.CollaborationStoreGlobalUtils();
			csgu.setProperty('x_11556_col_store.active_token', result.obj.result.info.sys_id);
		} else {
			result.error = true;
			result.error_code = '99';
			result.error_message = 'This instance is not a Host instance';
		}
	}
	this.logRESTCall(null, result);

	return result;
}

To avoid a null pointer exception in the logging function, we need to add a check for the target instance GlideRecord before we attempt to snag its sys_id.

logRESTCall: function (targetGR, result, payload) {
	var logGR = new GlideRecord('x_11556_col_store_rest_api_log');
	if (targetGR) {
		logGR.instance = targetGR.getUniqueValue();
	}
	...
}

Finally, to correct the log records once the Host instance record has been created in the set-up process, we can call this simple function:

function fixLogRecords(targetGR) {
	var logGR = new GlideRecord('x_11556_col_store_rest_api_log');
	logGR.addQuery('instance', null);
	logGR.query();
	while (logGR.next()) {
		logGR.instance = targetGR.getUniqueValue();
		logGR.update();
	}
}

Basically, it just looks for any log records that do not have a target instance value and updates them with the new Host instance record’s sys_id. That should take care of that.

There is yet another REST API call made before the Host record is created and that one is in the registerWithHost function. Here is the current version:

registerWithHost: function(mbrGR) {
	var result = {};

	this.createUpdateWorker(mbrGR.getUniqueValue());
	var host = gs.getProperty('x_11556_col_store.host_instance');
	var token = gs.getProperty('x_11556_col_store.active_token');
	var payload = {};
	payload.sys_id = mbrGR.getUniqueValue();
	payload.name = mbrGR.getDisplayValue('name');
	payload.instance = mbrGR.getDisplayValue('instance');
	payload.email = mbrGR.getDisplayValue('email');
	payload.description = mbrGR.getDisplayValue('description');
	var request = new sn_ws.RESTMessageV2();
	request.setHttpMethod('post');
	request.setBasicAuth(this.WORKER_ROOT + host, token);
	request.setRequestHeader("Accept", "application/json");
	request.setEndpoint('https://' + host + '.service-now.com/api/x_11556_col_store/v1/register');
	request.setRequestBody(JSON.stringify(payload));
	var response = request.execute();
	result.responseCode = response.getStatusCode();
	result.bodyText = response.getBody();
	try {
		result.body = JSON.parse(response.getBody());
	} catch(e) {
		//
	}
	if (response.getErrorCode()) {
		result.error = response.getErrorMessage();
		result.errorCode = response.getErrorCode();
	} else if (result.responseCode != '202') {
		result.error = 'Invalid HTTP Response Code: ' + result.status;
	} else {
		mbrGR.accepted = new GlideDateTime();
		mbrGR.update();
	}

	return result;
}

Once again, we will need to rework this a little bit to adopt the standard result object that the logging function is expecting, and will have to pass null for the target instance GlideRecord, as that record has still not been created at this point in the process.

registerWithHost: function(mbrGR) {
	var result = {};

	this.createUpdateWorker(mbrGR.getUniqueValue());
	var payload = {};
	payload.sys_id = mbrGR.getUniqueValue();
	payload.name = mbrGR.getDisplayValue('name');
	payload.instance = mbrGR.getDisplayValue('instance');
	payload.email = mbrGR.getDisplayValue('email');
	payload.description = mbrGR.getDisplayValue('description');
	var host = gs.getProperty('x_11556_col_store.host_instance');
	result.url = 'https://' + host + '.service-now.com/api/x_11556_col_store/v1/register';
	result.method = 'POST';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + host, gs.getProperty('x_11556_col_store.active_token'));
	request.setRequestHeader("Accept", "application/json");
	request.setRequestBody(JSON.stringify(payload));
	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();
	} else if (result.status != '202') {
		result.error = true;
		result.error_code = result.status;
		result.error_message = 'Invalid HTTP Response Code: ' + result.status;
	} else {
		mbrGR.accepted = new GlideDateTime();
		mbrGR.update();
	}
	this.logRESTCall(null, result);

	return result;
}

That should take care of all of the REST API calls in the CollaborationStoreUtils Script Include. There were never any REST API calls in the ApplicationInstaller Script Include, and we just removed all of the REST API calls in the ApplicationPublishers Script Include, but there are still some remaining in the InstanceSyncUtils, so we will need to take a look at those. That looks like a little bit of an effort, though, so let’s save that for our next installment.

Collaboration Store, Part LXIII

“One of my most productive days was throwing away 1,000 lines of code.”
Ken Thompson

Last time, we wrapped up the code to refactor the application publishing process. Now we need to do the same thing with the application distribution process, the process that runs on the Host instance to share recently published application versions to all of the other Client instances in the community. Currently, this involves three separate functions, publishNewVersion, publishVersionRecord, and publishVersionAttachment.

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.scope = mbrAppGR.getDisplayValue('scope');
					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);
	}
},

publishVersionRecord: function(targetInstance, token, versionGR, targetAppId, attachmentId) {
	var canContinue = true;
	var payload = {};
	payload.member_application = targetAppId;
	payload.version = versionGR.getDisplayValue('version');
	payload.built_on = versionGR.getDisplayValue('built_on');
	var request  = new sn_ws.RESTMessageV2();
	request.setBasicAuth(this.WORKER_ROOT + targetInstance, token);
	request.setRequestHeader("Accept", "application/json");
	request.setHttpMethod('post');
	request.setEndpoint('https://' + targetInstance + '.service-now.com/api/now/table/x_11556_col_store_member_application_version');
	request.setRequestBody(JSON.stringify(payload, null, '\t'));
	response = request.execute();
	if (response.haveError()) {
		gs.error('CollaborationStoreUtils.publishVersionRecord: Error returned from Target instance ' + targetInstance + ': ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
		canContinue = false;
	} else if (response.getStatusCode() != 201) {
		gs.error('CollaborationStoreUtils.publishVersionRecord: 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.publishVersionRecord: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + jsonString);
			canContinue = false;
		}
		if (canContinue) {
			targetVerId = jsonObject.result.sys_id;
			this.publishVersionAttachment(targetInstance, token, targetVerId, attachmentId);
		}
	}
},

publishVersionAttachment: function(targetInstance, token, targetVerId, attachmentId) {
	var gsa = new GlideSysAttachment();
	var sysAttGR = new GlideRecord('sys_attachment');
	if (sysAttGR.get(attachmentId)) {
		var url = 'https://';
		url += targetInstance;
		url += '.service-now.com/api/now/attachment/file?table_name=x_11556_col_store_member_application_version&table_sys_id=';
		url += targetVerId;
		url += '&file_name=';
		url += sysAttGR.getDisplayValue('file_name');
		var request  = new sn_ws.RESTMessageV2();
		request.setBasicAuth(this.WORKER_ROOT + targetInstance, token);
		request.setRequestHeader('Content-Type', sysAttGR.getDisplayValue('content_type'));
		request.setRequestHeader('Accept', 'application/json');
		request.setHttpMethod('post');
		request.setEndpoint(url);
		request.setRequestBody(gsa.getContent(sysAttGR));
		response = request.execute();
		if (response.haveError()) {
			gs.error('CollaborationStoreUtils.publishVersionAttachment: Error returned from Target instance ' + targetInstance + ': ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
		} else if (response.getStatusCode() != 201) {
			gs.error('CollaborationStoreUtils.publishVersionAttachment: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + response.getStatusCode());
		}
	} else {
		gs.error('CollaborationStoreUtils.publishVersionAttachment: Invalid attachment record sys_id: ' + attachmentId);
	}
}

The corresponding shared functions are pushApplication, pushVersion, and pushAttachment in the CollaborationStoreUtils Script Include. The pushApplication takes the application GlideRecord, the target instance GlideRecord, and the sys_id of the application providing instance on the target instance as arguments. The current function already has code to fetch the target instance GlideRecord and the version GlideRecord, and from the version GlideRecord, we can obtain the application GlideRecord. We also built a function earlier that fetched the sys_id of an instance record on the target instance, but that function assumed that you were looking for the local instance on the target instance. In this case, we are looking for the instance that provided the application, so if we want to leverage that function, we will have to alter it to accept the instance name as one of the arguments. Here is the start of the function as it stands right now.

getRemoteInstanceSysId: function(targetGR) {
	var sysId = '';

	var result = {};
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization?sysparm_fields=sys_id&sysparm_query=instance%3D' + gs.getProperty('instance_name');
	...
}

To add the ability to pass in the instance name, we can change that to this:

getRemoteInstanceSysId: function(targetGR, instanceName) {
	var sysId = '';

	var result = {};
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization?sysparm_fields=sys_id&sysparm_query=instance%3D' + instanceName;
	...
}

Then we just have to go back into the code and find where this function is called and add the appropriate instance name to the argument list. With that little modification out of the way, we should be able to gather up all of the required arguments to call the pushApplication function. That will make our replacement code look like this:

publishNewVersion: function(newVersion, targetInstance, attachmentId) {
	var targetGR = new GlideRecord('x_11556_col_store_member_organization');
	if (targetGR.get('instance', targetInstance)) {
		var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
		if (versionGR.get(newVersion)) {
			var applicationGR = versionGR.member_application.getRefRecord();
			var remoteSysId = this.getRemoteInstanceSysId(targetGR, applicationGR.getDisplayValue('provider.instance'));
			var result = this.pushApplication(applicationGR, targetGR, remoteSysId);
			if (result.error) {
				gs.error('CollaborationStoreUtils.publishNewVersion: Error returned from Target instance ' + targetInstance + ': ' + result.error_code + ' - ' + result.error_message);
			} else if (result.parse_error) {
				gs.error('CollaborationStoreUtils.publishNewVersion: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + result.body);
			} else if (result.status != 200 && result.status != 201) {
				gs.error('CollaborationStoreUtils.publishNewVersion: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + result.status);
			} else {
				var remoteAppId = result.obj.result.sys_id;
			}
		} else {
			gs.error('CollaborationStoreUtils.publishNewVersion: Version record not found: ' + newVersion);
		}
	} else {
		gs.error('CollaborationStoreUtils.publishNewVersion: Target instance record not found: ' + targetInstance);
	}
}

If all goes well, we will be able to obtain the sys_id of the transferred application record on the target system from the result object, which is the only other piece of data that we need in order to call the pushVersion function. In fact, we can make that call on the very next line and check the results right there.

var remoteAppId = result.obj.result.sys_id;
result = this.pushVersion(versionGR, targetGR, remoteAppId);
if (result.error) {
	gs.error('CollaborationStoreUtils.publishNewVersion: Error returned from Target instance ' + targetInstance + ': ' + result.error_code + ' - ' + result.error_message);
} else if (result.parse_error) {
	gs.error('CollaborationStoreUtils.publishNewVersion: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + result.body);
} else if (result.status != 200 && result.status != 201) {
	gs.error('CollaborationStoreUtils.publishNewVersion: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + result.status);
} else {
	var remoteVerId = result.obj.result.sys_id;
}

And again, if all goes well, we will be able to obtain the sys_id of the transferred version record on the target system from the result object, which is the other piece of data that we need in order to call the pushAttachment function. And once again, we can make that call on the very next line and check the results right there.

var remoteVerId = result.obj.result.sys_id;
result = this.pushAttachment(attachmentGR, targetGR, remoteVerId);
if (result.error) {
	gs.error('CollaborationStoreUtils.publishNewVersion: Error returned from Target instance ' + targetInstance + ': ' + result.error_code + ' - ' + result.error_message);
} else if (result.parse_error) {
	gs.error('CollaborationStoreUtils.publishNewVersion: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + result.body);
} else if (result.status != 200 && result.status != 201) {
	gs.error('CollaborationStoreUtils.publishNewVersion: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + result.status);
}

So putting it all together in this way, we can replace the original three functions with a single function that makes three calls to our shared functions. Once again, we have simplified things quite a bit and at the same time added functionality with the inclusion of the logo images and the REST API call logging. Here is the whole thing all put together:

publishNewVersion: function(newVersion, targetInstance, attachmentId) {
	var targetGR = new GlideRecord('x_11556_col_store_member_organization');
	if (targetGR.get('instance', targetInstance)) {
		var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
		if (versionGR.get(newVersion)) {
			var applicationGR = versionGR.member_application.getRefRecord();
			var remoteSysId = this.getRemoteInstanceSysId(targetGR, applicationGR.getDisplayValue('provider.instance'));
			var result = this.pushApplication(applicationGR, targetGR, remoteSysId);
			if (result.error) {
				gs.error('CollaborationStoreUtils.publishNewVersion: Error returned from Target instance ' + targetInstance + ': ' + result.error_code + ' - ' + result.error_message);
			} else if (result.parse_error) {
				gs.error('CollaborationStoreUtils.publishNewVersion: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + result.body);
			} else if (result.status != 200 && result.status != 201) {
				gs.error('CollaborationStoreUtils.publishNewVersion: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + result.status);
			} else {
				var remoteAppId = result.obj.result.sys_id;
				result = this.pushVersion(versionGR, targetGR, remoteAppId);
				if (result.error) {
					gs.error('CollaborationStoreUtils.publishNewVersion: Error returned from Target instance ' + targetInstance + ': ' + result.error_code + ' - ' + result.error_message);
				} else if (result.parse_error) {
					gs.error('CollaborationStoreUtils.publishNewVersion: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + result.body);
				} else if (result.status != 200 && result.status != 201) {
					gs.error('CollaborationStoreUtils.publishNewVersion: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + result.status);
				} else {
					var remoteVerId = result.obj.result.sys_id;
					result = this.pushAttachment(attachmentGR, targetGR, remoteVerId);
					if (result.error) {
						gs.error('CollaborationStoreUtils.publishNewVersion: Error returned from Target instance ' + targetInstance + ': ' + result.error_code + ' - ' + result.error_message);
					} else if (result.parse_error) {
						gs.error('CollaborationStoreUtils.publishNewVersion: Unparsable JSON string returned from Target instance ' + targetInstance + ': ' + result.body);
					} else if (result.status != 200 && result.status != 201) {
						gs.error('CollaborationStoreUtils.publishNewVersion: Invalid HTTP Response Code returned from Target instance ' + targetInstance + ': ' + result.status);
					}
				}
			}
		} else {
			gs.error('CollaborationStoreUtils.publishNewVersion: Version record not found: ' + newVersion);
		}
	} else {
		gs.error('CollaborationStoreUtils.publishNewVersion: Target instance record not found: ' + targetInstance);
	}
}

That should cover the application distribution process, which should complete the list of features that should be relying on the shared functions. The only thing left at this point would be to hunt down all of the other REST API calls and throw in a call to the new shared logging function. Maybe we will take a look at that next time out.

Collaboration Store, Part LXII

“Now I’m a pretty lazy person and am prepared to work quite hard in order to avoid work.”
Martin Fowler

Last time, we modified the processPhase5 function in the ApplicationPublisher Script Include to use the shared functions for making REST API calls instead of its own code. Now we need to continue with that work and do the same for the processPhase6 and processPhase7 functions. Here is the current script for the processPhase6 function.

processPhase6: function(answer) {
	var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
	if (versionGR.get(answer.versionId)) {
		var host = gs.getProperty('x_11556_col_store.host_instance');
		var token = gs.getProperty('x_11556_col_store.active_token');
		var payload = {};
		payload.member_application = answer.hostAppId;
		payload.version = versionGR.getDisplayValue('version');
		payload.built_on = versionGR.getDisplayValue('built_on');
		var request  = new sn_ws.RESTMessageV2();
		request.setBasicAuth(this.WORKER_ROOT + host, token);
		request.setRequestHeader("Accept", "application/json");
		request.setHttpMethod('post');
		request.setEndpoint('https://' + host + '.service-now.com/api/now/table/x_11556_col_store_member_application_version');
		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() != 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.hostVerId = jsonObject.result.sys_id;
			}
		}
	} else {
		answer = this.processError(answer, 'Invalid version record sys_id: ' + answer.versionId);
	}

	return answer;
}

The corresponding shared function is pushVersion, which takes as arguments the version GlideRecord, the target instance GlideRecord, and the sys_id of the application record on the target system. We are already fetching the version GlideRecord, we can use the function that we built last time to go get the target instance GlideRecord, and the sys_id of the application record on the target system was stored in the shared answer object in phase 5, so we should have everything that we need to invoke the appropriate shared function and check the results.

processPhase6: function(answer) {
	var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
	if (versionGR.get(answer.versionId)) {
		var targetGR = this.getHostInstanceGR();
		var csu = new CollaborationStoreUtils();
		var result = csu.pushVersion(versionGR, targetGR, answer.hostAppId);
		if (result.error) {
			answer = this.processError(answer, 'Error returned from Host instance: ' + result.error_code + ' - ' + result.error_message);
		} else if (result.parse_error) {
			answer = this.processError(answer, 'Unparsable JSON string returned from Host instance: ' + result.body);
		} else if (result.status != 200 && result.status != 201) {
			answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + result.status);
		} else {
			answer.hostVerId = result.obj.result.sys_id;
		}
	} else {
		answer = this.processError(answer, 'Invalid version record sys_id: ' + answer.versionId);
	}

	return answer;
}

That should take care of the processPhase6 function. Now, let’s take a look at that processPhase7 function.

processPhase7: function(answer) {
	var gsa = new GlideSysAttachment();
	var sysAttGR = new GlideRecord('sys_attachment');
	if (sysAttGR.get(answer.attachmentId)) {
		var host = gs.getProperty('x_11556_col_store.host_instance');
		var token = gs.getProperty('x_11556_col_store.active_token');
		var url = 'https://';
		url += host;
		url += '.service-now.com/api/now/attachment/file?table_name=x_11556_col_store_member_application_version&table_sys_id=';
		url += answer.hostVerId;
		url += '&file_name=';
		url += sysAttGR.getDisplayValue('file_name');
		var request  = new sn_ws.RESTMessageV2();
		request.setBasicAuth(this.WORKER_ROOT + host, token);
		request.setRequestHeader('Content-Type', sysAttGR.getDisplayValue('content_type'));
		request.setRequestHeader('Accept', 'application/json');
		request.setHttpMethod('post');
		request.setEndpoint(url);
		request.setRequestBody(gsa.getContent(sysAttGR));
		response = request.execute();
		if (response.haveError()) {
			answer = this.processError(answer, 'Error returned from Host instance: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
		} else if (response.getStatusCode() != 201) {
			answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + response.getStatusCode());
		}
	} else {
		answer = this.processError(answer, 'Invalid attachment record sys_id: ' + answer.attachmentId);
	}

	return answer;
}

The corresponding shared function is pushAttachment, which takes as arguments the attachment GlideRecord, the target instance GlideRecord, and the sys_id of the version record on the target system. Once again, we are already fetching the attachment GlideRecord, we can use the function that we built last time to go get the target instance GlideRecord, and the sys_id of the version record on the target system was stored in the shared answer object in phase 6, so once again we should have everything that we need to invoke the appropriate shared function and check the results.

processPhase7: function(answer) {
	var gsa = new GlideSysAttachment();
	var attachmentGR = new GlideRecord('sys_attachment');
	if (attachmentGR.get(answer.attachmentId)) {
		var targetGR = this.getHostInstanceGR();
		var csu = new CollaborationStoreUtils();
		var result = csu.pushAttachment(attachmentGR, targetGR, answer.hostVerId);
		if (result.error) {
			answer = this.processError(answer, 'Error returned from Host instance: ' + result.error_code + ' - ' + result.error_message);
		} else if (result.parse_error) {
			answer = this.processError(answer, 'Unparsable JSON string returned from Host instance: ' + result.body);
		} else if (result.status != 200 && result.status != 201) {
			answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + result.status);
		} else {
			answer.hostVerId = result.obj.result.sys_id;
		}
	} else {
		answer = this.processError(answer, 'Invalid attachment record sys_id: ' + answer.attachmentId);
	}

	return answer;
}

So that wraps up all of the refactoring for the application publishing process. All that is left to do now is to do the same thing for the application distribution process, where the Host instance sends out the artifacts for new application versions to all of the other Client instances in the community. We’ll jump right into that next time out.

Collaboration Store, Part LXI

“I think it is often easier to make progress on mega-ambitious dreams. Since no one else is crazy enough to do it, you have little competition.”
Larry Page

Last time, we wrapped up all of the code in the shared functions to include the logo images whenever an instance or application is transferred from one instance to another. Now we need to take a look at those places where the shared functions are not currently being used and replace the code in those existing functions with calls to the shared functions. This will not only consolidate the code and ensure that the logo images will be included, but since we also added a logging feature to the shared functions, it will also ensure that the REST API activity gets recorded.

Our ApplicationPublisher Script Include contains individual functions for the 7 independent phases of the application publishing process. The first four are all internal, but the last three move the artifacts from the Client instance to the the Host, so we will want to rework each one of those. Before we do that, though, we will want to snag the image from the sys_app record and attach it to our application record, which is something that we can do in Phase 2, once the application record is available. We can insert a line into this code:

mbrAppGR.setValue('name', sysAppGR.getValue('name'));
mbrAppGR.setValue('scope', sysAppGR.getValue('scope'));
mbrAppGR.setValue('description', sysAppGR.getValue('short_description'));
mbrAppGR.setValue('current_version', sysAppGR.getValue('version'));
mbrAppGR.setValue('active', true);
mbrAppGR.update();

… and make it look like this:

mbrAppGR.setValue('name', sysAppGR.getValue('name'));
mbrAppGR.setValue('scope', sysAppGR.getValue('scope'));
mbrAppGR.setValue('description', sysAppGR.getValue('short_description'));
mbrAppGR.setValue('current_version', sysAppGR.getValue('version'));
mbrAppGR.setValue('active', true);
if (sysAppGR.getValue('logo') && !mbrAppGR.getValue('logo')) {
	mbrAppGR.setValue('logo', this.copyLogoImage(answer));
}
mbrAppGR.update();

Of course, now that we have done that, we will need to build a new copyLogoImage function. We already have a function that copies an attachment (Phase 4), and we can steal most of the code from that guy.

processPhase4: function(answer) {
	var gsa = new GlideSysAttachment();
	var values = gsa.copy('sys_app', answer.appSysId, 'x_11556_col_store_member_application_version', answer.versionId);
	gsa.deleteAttachment(answer.attachmentId);
	if (values.length > 0) {
		var ids = values[values.length - 1].split(',');
		if (ids[1]) {
			answer.attachmentId = ids[1];
		} else {
			answer = this.processError(answer, 'Unrecognizable response from attachment copy: ' + values);
		}
	} else {
		answer = this.processError(answer, 'Unrecognizable response from attachment copy: ' + values);
	}

	return answer;
}

We want to return the attachment sys_id and not the answer object, and we need to swap out the table names, but other than that, it looks pretty similar:

copyLogoImage: function(answer) {
	var logoId = '';

	var gsa = new GlideSysAttachment();
	var values = gsa.copy('ZZ_YYsys_app', answer.appSysId, 'ZZ_YYx_11556_col_store_member_application', answer.mbrAppId);
	if (values.length > 0) {
		var ids = values[values.length - 1].split(',');
		if (ids[1]) {
			logoId = ids[1];
		} else {
			answer = this.processError(answer, 'Unrecognizable response from logo attachment copy: ' + values);
		}
	} else {
		answer = this.processError(answer, 'Unrecognizable response from logo attachment copy: ' + values);
	}

	return logoId;
}

That will get the logo image from the Scoped Application attached to our application record during the application publishing process. Now we need to take a look at those functions that send the local artifacts over to the Host instance and see if we can alter them to use the newly modified shared function. The first of such functions is processPhase5, and it moves over the application record.

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.scope = mbrAppGR.getDisplayValue('scope');
				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;
}

The corresponding shared function is pushApplication in the CollaborationStoreUtils Script Include, which takes the application GlideRecord, the target instance GlideRecord, and the sys_id of the local instance on the target instance as arguments. We already have the application GlideRecord, but will need to fetch the GlideRecord for the Host instance and grab the sys_id of local instance on the Host instance before we can make the call. Assuming that we can create a couple of functions to gather up the information that we need, we can reduce the new processPhase5 function to this:

processPhase5: function(answer) {
	var applicationGR = new GlideRecord('x_11556_col_store_member_application');
	if (applicationGR.get(answer.mbrAppId)) {
		var targetGR = this.getHostInstanceGR();
		var csu = new CollaborationStoreUtils();
		answer.hostInstanceId = csu.getRemoteInstanceSysId(targetGR);
		var result = csu.pushApplication(applicationGR, targetGR, answer.hostInstanceId);
		if (result.error) {
			answer = this.processError(answer, 'Error returned from Host instance: ' + result.error_code() + ' - ' + result.error_message);
		} else if (result.parse_error) {
			answer = this.processError(answer, 'Unparsable JSON string returned from Host instance: ' + result.body);
		} else if (result.status != 200 && result.status != 201) {
			answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + result.status);
		} else {
			answer.hostAppId = result.obj.result.sys_id;
		}
	} else {
		answer = this.processError(answer, 'Invalid Member Application sys_id: ' + answer.mbrAppId);
	}

	return answer;
}

That simplifies the code quite a bit, and yet we will be doing more work, as we will be moving over the logo image and also logging all the REST API calls. Much better. Of course we still need to build out those function to gather up the required arguments, but those should both be fairly straightforward. To fetch the Host instance GlideRecord, I kept the function in the ApplicationPublisher Script Include, but I put the other one in the main CollaborationStoreUtils Script Include, as that involves another REST API call to the Host instance, and I want to keep all of the functions that do that together in the same place. Here is the getHostInstanceGR function in the ApplicationPublisher Script Include:

getHostInstanceGR: function() {
	var instanceGR = new GlideRecord('x_11556_col_store_member_organization');
	instanceGR.get('instance', gs.getProperty('x_11556_col_store.host_instance'));
	return instanceGR;
}

And here is the getRemoteInstanceSysId function in the CollaborationStoreUtils Script Include:

getRemoteInstanceSysId: function(targetGR) {
	var sysId = '';

	var result = {};
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization?sysparm_fields=sys_id&sysparm_query=instance%3D' + gs.getProperty('instance_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();
	} else if (result.obj && result.obj.result && result.obj.result.length > 0) {
		sysId = result.obj.result[0].sys_id;
	}
	this.logRESTCall(targetGR, result);

	return sysId;
}

So that takes care of the first of the three functions that need to modified to use the shared REST API functions. Now we just need to do the same thing for the other two, processPhase6 and processPhase7. That should be a little simpler now that we have done the first one, but it’s still a bit of work, so let’s save all of that for our next installment.

Collaboration Store, Part LX

“Failure is simply the opportunity to begin again, this time more intelligently.”
Henry Ford

Last time, I had to confess that the code that I put out didn’t actually work. At the time, I had tried several things to make it work, but none of those were successful. Since then, I have tried quite a few other things, but none of those were successful, either. Eventually, I had to actually read the documentation, which helps quite a bit, but for some reason, always seems to be my tactic of last resort. Anyway, as it turns out, I only had to make one small change to get the logo image to actually appear on the other side intact. This line from my original attempt:

request.setRequestBody(gsa.getContentBase64(attachmentGR));

… just had to be changed to this:

request.setRequestBodyFromAttachment(attachmentGR.getUniqueValue());

The setRequestBodyFromAttachment method of the sn_ws.RESTMessageV2 object accepts the sys_id of the attachment as an argument and does all of the heavy lifting of building the request body from the attachment file. Once I replaced the setRequestBody method with the setRequestBodyFromAttachment method, everything worked great. So that takes care of that little problem. Now, where were we?

Now that we have a working function to push over the images for both instances and applications, we need to go into the functions that push over the instances and applications and add a call to this function. Here is the common function created to push over an instance.

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();
	}
	this.logRESTCall(targetGR, result, payload);

	return result;
}

As we did within the pushImageAttachment function, we can add an else to the if (result.error) condition and check to see if we need to send over the image. In this instance, not only do we need to make sure that the instance record was successfully sent over to the target instance, we also need to check if the instance record actually has a logo image. If it does, then we need to grab the image attachment record so that we can use it to make the call to the pushImageAttachment function.

result.error = response.haveError();
if (result.error) {
	result.error_code = response.getErrorCode();
	result.error_message = response.getErrorMessage();
} else {
	if (instanceGR.getValue('logo')) {
		if (result.status == '201' && result.obj) {
			var attachmentGR = new GlideRecord('sys_attachment');
			attachmentGR.get(instanceGR.getValue('logo'));
			this.pushImageAttachment(attachmentGR, targetGR, 'x_11556_col_store_member_organization', result.obj.result.sys_id);
		}
	}
}

For an application record, things get a little more complicated, as that can be either an insert or an update. Since the application record might already exist on the target system, not only do we need to make sure that the application has a logo image on the source instance, but we also need to check to make sure that the image doesn’t already exist on the target instance. Here is the code that I came up with to add to the pushApplication function.

result.error = response.haveError();
if (result.error) {
	result.error_code = response.getErrorCode();
	result.error_message = response.getErrorMessage();
} else {
	if (applicationGR.getValue('logo') > '') {
		if ((result.status == '200' || result.status == '201') && result.obj) {
			if (!result.obj.result.logo) {
				var attachmentGR = new GlideRecord('sys_attachment');
				attachmentGR.get(applicationGR.getValue('logo'));
				this.pushImageAttachment(attachmentGR, targetGR, 'x_11556_col_store_member_application', result.obj.result.sys_id);
			}
		}
	}
}

With those changes, pushing an instance record will also push over the instance’s logo image and pushing an application record will also push over that application’s logo image. At least, that will happen if you are using the shared functions built for that purpose. We are not yet using those everywhere, though, so now might be a good time to fix that. We built those functions so that they could be called from various places as needed, but we never went back and refactored the code to actually do that in all places. That sounds like a good project for our next installment.

Collaboration Store, Part LIX

“When something you make doesn’t work, it didn’t work, not you. You, you work. You keep trying.”
Zach Klein

Last time, we created a couple of new shared functions to send over a logo image and associate that image with its base record. Unfortunately, the function that sends over the image file doesn’t actually work. Yes, it creates an attachment record on the target system, and yes, that attachment gets linked to its base record, but the image itself does not come across correctly, and the resulting file is not a valid image file. Yes, I should have tested that before I stuck the code out there, but it all seemed as if it should work, so I just threw it out there without first giving it a try.

I tried a few things to get it to go, but none of them did the trick. I went back to the getContent method instead of getContentBase64, but that didn’t work, so I tried getContentStream, but that didn’t do it, either. Then I tried adding a Content-Transfer-Encoding: base64 header, but that didn’t help, no matter what method I used to snag the content. So, it’s back to the drawing board on that one to see if we can’t figure out how to get that working correctly.

In the meantime, I decided to start logging all of this REST API activity so that I would have some record of what’s been happening between the instances. I have long thought that there should be some form of activity log tracking all of the important things going on with the records, and I even built a table for that early on, but that table was never used. This time, though, I was looking for something specific to the REST API activity, which has a number of specific data points. So, I created a new table called REST API Log to start tracking every request and response.

New REST API Log table

Then I added the following function to create records in this new table.

logRESTCall: function (targetGR, result, payload) {
	var logGR = new GlideRecord('x_11556_col_store_rest_api_log');
	logGR.instance = targetGR.getUniqueValue();
	logGR.url = result.url;
	logGR.method = result.method;
	if (payload) {
		logGR.request_body = JSON.stringify(payload, null, '\t');
	}
	logGR.response_code = result.status;
	if (result.obj) {
		logGR.response_body =  JSON.stringify(result.obj, null, '\t');
	} else {
		logGR.response_body =  result.body;
	}
	logGR.error = result.error;
	logGR.error_code = result.error_code;
	logGR.error_message = result.error_message;
	logGR.parse_error = result.parse_error;
	logGR.insert();
}

Then, at the end of each common REST API function, I added this line right before the final return statement:

this.logRESTCall(targetGR, result, payload);

Now, not every REST API call in the system uses these common functions, but my intent is to go back and correct that wherever appropriate, so eventually that should cover most of them, and then I can see what I need to do with the rest to get that activity logged as well. But it’s a start, anyway.

So now I have to get busy figuring out how to get my logo image over to another instance successfully. I’m sure that there is a way to do that; I just haven’t figured it out yet. Hopefully, we can explain how that is done next time out.

Collaboration Store, Part LVIII

“Progress is not in enhancing what is, but in advancing toward what will be.”
Khalil Gibran

Last time, we laid out all of the work that will need to be done to incorporate the new logo fields into the various processes of our application. Now we need to get busy doing that work. To begin, we can create a common function to move a logo image from one instance to another. We already have a common function to move an XML Update Set attachment from one instance to another, so let’s take a quick look at that guy and see if there is anything there that we can salvage for our new purpose.

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(attachmentGR));
	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;
}

Since we want our new function to work for both instance logo images and application logo images, we will want to pass in both the table name and the table sys_id to our new function. Since the table name is part of the end point URL, we will want to change this:

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');

… to this:

result.url = 'https://';
result.url += targetGR.getDisplayValue('instance');
result.url += '.service-now.com/api/now/attachment/file?table_name=ZZ_YY';
result.url += tableName;
result.url += '&table_sys_id=';
result.url += tableSysId;
result.url += '&file_name=';
result.url += attachmentGR.getDisplayValue('file_name');

In addition to using the passed table name in the URL, we also prepend the string ZZ_YY to the value. This is a convention of the Now Platform to hide the attachment icon from the record for that image. When you manually add a logo image to a record and then go take a look at that image record in the sys_attachment table, you can see that the system has automatically prepended the ZZ_YY string to the table name. We want to our process to behave in the same manner, so we do that here as well.

The other difference between our logo image attachment and the XML Update Set attachment is that the XML content is in plain text and our image is stored in binary. Fortunately, the GlideSysAttachment object that we are using has a built-in way of handling that, so we just need to change this:

request.setRequestBody(gsa.getContent(attachmentGR));

… to this:

request.setRequestBody(gsa.getContentBase64(attachmentGR));

Other than these two changes, we should be able to use the rest of the original function intact. That makes our new function now look like this:

pushImageAttachment: function(attachmentGR, targetGR, tableName, tableSysId) {
	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=ZZ_YY';
	result.url += tableName;
	result.url += '&table_sys_id=';
	result.url += tableSysId;
	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.getContentBase64(attachmentGR));
	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;
}

That still does not complete the process, however. In addition to creating the attachment record linked to the base record, the logo field on the base record contains the sys_attachment record sys_id as the value. Once we create the sys_attachment record on the target system using the above function, we need to grab the resulting sys_id and update the logo field value on the base record. For that, we will need yet another function to make an additional REST API call to the target system to make that update. Since the new image field on both tables is named logo, we should again be able to create a single function that will work for both use cases. The payload that we will be sending just needs to contain the one value that we intend to update:

updateLogoField: function(attachmentId, targetGR, tableName, tableSysId) {
	var result = {};
 
	var payload = {};
	payload.logo = attachmentId;
	...
 
	return result;
}

For the URL, we will need to use both the passed table name and the passed table sys_id.

result.url = 'https://';
result.url += targetGR.getDisplayValue('instance');
result.url += '.service-now.com/api/now/table/';
result.url += tableName;
result.url += '/';
result.url += tableSysId;

… and the rest of the function is just our standard REST API call and result check:

updateLogoField: function(attachmentId, targetGR, tableName, tableSysId) {
	var result = {};
 
	var payload = {};
	payload.logo = attachmentId;
	result.url = 'https://';
	result.url += targetGR.getDisplayValue('instance');
	result.url += '.service-now.com/api/now/table/';
	result.url += tableName;
	result.url += '/';
	result.url += tableSysId;
	result.method = 'PUT';
	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;
}

Now all we have to do is call this function from our pushImageAttachment function, but only if all went well and the attachment was successfully sent over. We already have the targetGR, tableName, and tableSysId arguments available, but we will need to extract the remote system’s attachmentId from the response body of our call to send over the attachment. We are already checking for an error condition here:

result.error = response.haveError();
if (result.error) {
	result.error_code = response.getErrorCode();
	result.error_message = response.getErrorMessage();
}

… so we should be able to just add an else condition to that if statement to make the call.

result.error = response.haveError();
if (result.error) {
	result.error_code = response.getErrorCode();
	result.error_message = response.getErrorMessage();
} else {
	if (result.status == '201' && result.obj) {
		this.updateLogoField(result.obj.result.sys_id, targetGR, tableName, tableSysId);
	}
}

So now we have a common function that will send over the logo image for both instance records and application records, and then update the base record with attachment’s local sys_id. That’s a good start, but there is lots more to do, so we will keep plowing ahead next time out.

Collaboration Store, Part LVII

“Slow, steady progress is better than fast, daily excuses.”
Robin Sharma

It has been quite some time since we last dealt with this project, but now that our little side trip with the SNH Data Table collection has finally wrapped up, it’s time to get back into it. One of the things that I have always felt was missing in the work thus far was the logo images, both for the participating instances and the shared applications. To address that problem, I added a field called logo of type image to both the Member Organization and Member Application tables.

New Logo field added to the Member Organization table

When you add a value to an image field on a record, it creates a related record on the sys_attachment table to store the image. This record will then have to be moved from one instance to another as part of the instance registration, application publishing, and instance syncing processes. This will involve quite a bit of work, so let’s break it all down and make sure that we have covered everything before we start getting into the code.

The logo on the Member Organization record will be entered by the operator when the instance is defined. The logo on the Member Application record, on the other hand, will come from the logo associated with the Scoped Application. We will need to add logic to the application publishing process to snag the image from the Scoped Application record and add it to the application record. Once the images have been included in their respective records, we will then need to add logic to any process that moves those records from one instance to another. This includes Client instances sending artifacts over to the Host as well as Host instances sending artifacts out to the individual Clients. We already created a set of stock processes for moving individual artifacts from instance to instance when we built the periodic sync process, but we never went back and updated the original processes to use those new stock processes, so now would probably a good time to address that, since we will be working with that code anyway.

So, it would probably be a good idea to make a quick list of all of things that will need to be done to fully integrate the images into all of the various processes involved with the app. To help organize the list, let’s break it down by the major functions of the application:

Registration Process

The instance registration process is where you define your instance, so this process will need to include the ability to upload a logo to be associated with the instance. Additionally, once the image has been uploaded, the process that registers the instance with the Host will need to be modified to send the image over once the instance record has been established, and the process that shares the new instance with all of the other instances will need to do that as well.

Application Publishing Process

Publishing an application sends the application to the Host, which in turn sends it out to all of the other instances in the community. These processes will also have to be updated to send over the image associated with the app after the application record has been established on the target instance.

Application Installation Process

The installation process establishes the Scoped Application record on the installing instance, so this process will need to be modified to add the application’s logo image to that record once it has been created. This should be a fairly straightforward attachment copy, but making that copy needs to be part of the installation process.

Instance Sync Process

The instance sync process runs periodically to ensure that all of the Client instances are in sync with the Host instance, and it sends over any missing artifacts that are not already present on a Client. This process will need to be modified to send over an instance logo when it sends over an instance record and to send over an application logo whenever it sends over an application record.

None of these modifications should be too complicated or difficult, but the work still needs to be undertaken, completed, and tested. And, as mentioned earlier, this will also be an excellent opportunity to refactor the code to use the stock functions that were created when building the instance sync process, so that work will need to be completed as well. Even though there shouldn’t be anything here that should present much of challenge, there is still a lot to get done here, so let’s get right to it next time out.

Refactoring the SNH Data Table Widget, Part IV

“Testing is an infinite process of comparing the invisible to the ambiguous in order to avoid the unthinkable happening to the anonymous.”
James Bach

Last time, we tested a number of the features of the refactored SNH Data Table collection, but there is still much to do before we can bundle this all up into a new Update Set and stuff it out on Share. Now that we know that the initial version that I put out there is missing a critical component, I’d like to wrap this up and replace it with the refactored version, so let’s get to it.

To test the bulk actions and other clicks that do not result in navigating to a new portal page, I took my original Button Click Handler Example widget, renamed it the Table Click Handler Example widget, and then reworked it to handle all four of the clickable features, reference links, aggregate columns, buttons and icons, and bulk actions. Here is the new Client script for the repurposed widget:

function (spModal, $rootScope) {
	var c = this;
	var eventNames = {
		referenceClick: 'data_table.referenceClick',
		aggregateClick: 'data_table.aggregateClick',
		buttonClick: 'data_table.buttonClick',
		bulkAction: 'data_table.bulkAction'
	};

	$rootScope.$on(eventNames.referenceClick, function(e, parms) {
		displayClickDetails(eventNames.referenceClick, parms);
	});

	$rootScope.$on(eventNames.aggregateClick, function(e, parms) {
		displayClickDetails(eventNames.aggregateClick, parms);
	});

	$rootScope.$on(eventNames.buttonClick, function(e, parms) {
		displayClickDetails(eventNames.buttonClick, parms);
	});

	$rootScope.$on(eventNames.bulkAction, function(e, parms) {
		displayClickDetails(eventNames.bulkAction, parms);
	});

	function displayClickDetails(eventName, parms) {
		var html = '<div>'; 
		html += ' <table>\n';
		html += '  <tbody>\n';
		html += '   <tr>\n';
		html += '    <td class="text-primary">Event: &nbsp;</td>\n';
		html += '    <td>' + eventName + '</td>\n';
		html += '   </tr>\n';
		html += '   <tr>\n';
		html += '    <td class="text-primary">Table: &nbsp;</td>\n';
		html += '    <td>' + parms.table + '</td>\n';
		html += '   </tr>\n';
		html += '   <tr>\n';
		html += '    <td class="text-primary">Sys ID: &nbsp;</td>\n';
		html += '    <td>' + parms.sys_id + '</td>\n';
		html += '   </tr>\n';
		html += '   <tr>\n';
		html += '    <td class="text-primary">Config: &nbsp;</td>\n';
		html += '    <td><pre>' + JSON.stringify(parms.config, null, 4) + '</pre></td>\n';
		html += '   </tr>\n';
		html += '   <tr>\n';
		html += '    <td class="text-primary">Item(s): &nbsp;</td>\n';
		html += '    <td><pre>' + JSON.stringify(parms.record, null, 4) + '</pre></td>\n';
		html += '   </tr>\n';
		html += '  </tbody>\n';
		html += ' </table>\n';
		html += '</div>';
		spModal.alert(html);
	}
}

This will allow me to test a number of things, all with the same companion widget, which should save a little bit of time. The original button_test page has a number of options in the configuration, so let’s pull that guy up and click around and see what we can find out.

Original button_test page

One thing that I noticed right away was that the master checkbox was not working. It seems that when I rebuilt the core SNH Data Table widget from the latest version of the stock Data Table widget, I neglected to paste back in the following added function:

$scope.masterCheckBoxClick = function() {
	for (var i in c.data.list) {
		c.data.list[i].selected = c.data.master_checkbox;
	}
};

Putting that back solved that problem, but then I also discovered that I needed to add spModal to the function arguments so that the error message would come up when you tried to select a bulk action from the drop-down without selecting any of the records in the table. Once I got all that out of the way, I got back to testing the buttons and icons.

Clicking on the Status Check icon

So now when I click on a button or icon, the alert pops up with all of the information that is passed with the broadcast message, which gives you an idea of what you have to work with in your companion widget to take whatever action that you would like to take based on that information. We should be able to do the same thing when selecting one or more rows and then choosing a bulk action.

Selecting a bulk action

Looks like that is working as well, so I think things are looking pretty good at this point. There is another old test page that we can pull called snh_data_table that has a companion widget for one of the icons.

snh_data_table test page

Once again, we will need to update the Client script in the click handler widget to adapt to our new approach to click handling.

function(spModal, $rootScope) {
	var c = this;
	var eventNames = {
		referenceClick: 'data_table.referenceClick',
		aggregateClick: 'data_table.aggregateClick',
		buttonClick: 'data_table.buttonClick',
		bulkAction: 'data_table.bulkAction'
	};

	$rootScope.$on(eventNames.buttonClick, function(e, parms) {
		if (parms.config.name == 'icon') {
			spModal.open({widget: 'snh-user-profile', widgetInput: {sys_id: parms.sys_id}}).then(function() {
				//
			});
		}
	});
}

Since the button is configured to launch a new page and the icon is configured to pop up a modal dialog, we need to check to make sure that the button click is for the icon and not the button. Other than that, it is very similar to the other examples.

Clicking on the icon to bring up the modal dialog

So that works. Very nice. Obviously, there is a lot more that we could do here to check out every little thing, but I think that we have covered most of the high points, and given that the version that is out on Share right now contains a pretty significant flaw, I think I would like to roll the dice and toss this out there as is to resolve that issue. Hopefully, I will not miss any important artifacts this time! Here is the Update Set for those of you who would like to check it out. As always, please feel free to leave any feedback of any kind in the comments. Thanks!

SNH Data Table Widgets on Share, Corrected

“An essential part of being a humble programmer is realizing that whenever there’s a problem with the code you’ve written, it’s always your fault.”
Jeff Atwood

Well, it turns out that I missed a critical widget when I built my first Update Set to be posted out on Share the other day. The Table Selector widget is used in the Content Selector Configuration Editor to select a new table whenever you wanted to add a table to the configuration. Without that widget included in the Update Set, a blank modal pop-up comes up when you click on the Add a New Table button, which is obviously not helpful. I have added the widget to a corrected Update Set that I need to get out on the Share site, but I thought that I would post it here first to see if there was anything else that I needed to fix before I do that. Thanks for the feedback, by the way … it is always much appreciated.

By the way, I built the Update Set using the Add to Update Set Utility, which is an awesome tool that I cannot recommend highly enough. The only problem with that, of course, is that you have to remember to include all of your artifacts when you create an Update Set and start adding in all of the various parts and pieces for your project. If you are not careful, you can leave out an important piece, which I did, and that’s on me. Hopefully, this is the only one, but there is no guarantee …