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 XXVI

“It’s not foresight or hindsight we need. We need sight, plain and simple. We need to see what is right in front of us.”
Gordon Atkinson

Last time, we completed the final step in the publication process for apps published on a Host instance. For those apps published on a Client instance, however, there is still more work to do. Everything that we created locally on the instance where the app is being published needs to be transferred over to the Host. To do that, we can utilize the built-in REST API.

The first thing that we will want to do is to fetch the GlideRecord for the app to obtain all of the data to send over. As we have done in other steps, we will check to make sure that we have obtained the record and report an error if we did not.

var mbrAppGR = new GlideRecord('x_11556_col_store_member_application');
if (mbrAppGR.get(answer.mbrAppId)) {
	...
} else {
	answer = this.processError(answer, 'Invalid Member Application sys_id: ' + answer.appSysId);
}

The next thing that we will want to do is to check to see if the app exists on the Host. Although we have already determined whether or not this app is new to the local instance earlier in the process, we will still want to double check to make sure that there isn’t already a version sitting on the Host for some unknown reason. If we find it, we will want to update it; otherwise, we will want to add it. To find the app on the remote Host, we will use both the name of the app and the name of the instance as query arguments (other instances may have published an app with the same name, but we would not want to update any of those). That’s a fairly standard REST API HTTP get operation.

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') {
	...
} else {
	answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + response.getStatusCode());
}

The HTTP response code should be 200 whether or not any records were returned. To determine if there is a record present on the Host instance, we need to parse the returned JSON string and check the size of the array of records returned.

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 we are updating an existing record, the HTTP Method will be a PUT and the URL will include the sys_id of the record. If we are inserting a new record, then the HTTP Method will be a POST. Other than that, the data that we will be sending to the Host instance will be virtually the same, so we can start to build that up before we make the determination as to which method we will use.

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

Now we can check the size of the array of records returned and handle the things that will be different depending on whether or not this is a new record on the Host.

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

Now that we have everything all set up, all this is left to do is to execute the method and check the results.

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;
	}
}

If all goes well, the application record will now be present on the Host instance, which will only leave us with two more things to do, add a new version record to the Host instance and then attach the Update Set XML to the version record. Here is the full function for this step in its entirety.

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;
},

Next time, we will code out the insertion of the version record, which should be very similar to this step, although it should be a little simpler since we do not have to check to see if a record exists or not. Version records are always new records for each new version published. That should simplify things quite a bit.

Collaboration Store, Part XII

“The slogan ‘press on’ has solved and always will solve the problems of the human race.”
Calvin Coolidge

In the previous installment in this series, we created a new Scripted REST API Resource and referenced another nonexistent function in our Script Include. Now it is time to create that function, which will perform some of the work required to register a new client instance and then hand off the remaining tasks to an asynchronous Subflow so that the function can return the results without waiting for all of the other instances to be notified of the new client instance. The only thing to be done in the function will be to insert the new client instance into the database and kick off the Subflow. But before we do that, we need to first check to make sure that the Client has not already registered with the Host.

var result = {body: {error: {}, status: 'failure'}};

var mbrGR = new GlideRecord('x_11556_col_store_member_organization');
if (mbrGR.get('instance', data.instance)) {
	result.status = 400;
	result.body.error.message = 'Duplicate registration error';
	result.body.error.detail = 'This instance has already been registered with this store.';
} else {
	...

As we did before, we construct our result object with the expectation of failure, since there are more error conditions than the one successful path through the logic. In the case of an instance that has already been registered, we respond with a 400 Bad Request HTTP Response Code and accompanying error details. If the instance is not already in the database, then we attempt to insert it.

mbrGR.initialize();
mbrGR.name = data.name;
mbrGR.instance = data.instance;
mbrGR.email = data.email;
mbrGR.description = data.description;
mbrGR.token = data.sys_id;
mbrGR.active = true;
mbrGR.host = false;
mbrGR.accepted = new GlideDateTime();
if (mbrGR.insert()) {
	result.status = 202;
	delete result.body.error;
	result.body.info = {};
	result.body.info.message = 'Registration complete';
	result.body.info.detail = 'This instance has been successfully registered with this store.';
	result.body.status = 'success';
	...

If the new record was inserted successfully, then we response with a 202 Accepted HTTP Response Code, indicating that the registration was accepted, but the complete registration process (notifying all of the other instances) is not yet complete. At this point, all we have left to do is to initiate the Subflow to handle the rest of the process. We haven’t built the Subflow just yet, but for the purposes of this exercise, we can just assume that it is out there and then we can build it out later. There a couple of different ways to launch an asynchronous Subflow in the background, the original way, and the newer, preferred method. Both methods use the Scripted Flow API. Here is the way that we used to do this:

sn_fd.FlowAPI.startSubflow('New_Collaboration_Store_Instance', {new_instance: data.instance});

… and here is way that ServiceNow would like you to do it now:

sn_fd.FlowAPI.getRunner()
	.subflow('New_Collaboration_Store_Instance')
	.inBackground()
	.withInputs({new_instance: data.instance})
	.run();

Right now, both methods will work, and I’m still using the older, simpler way, but one day I’m going to need to switch over.

There should never be a problem inserting the new record, but just in case, we make that a conditional, and if for some reason it fails, we respond with a 500 Internal Server Error HTTP Response Code.

result.status = 500;
result.body.error.message = 'Internal server error';
result.body.error.detail = 'There was a problem processing this registration request.';

That’s it for all of the little parts and pieces. Here is the entire function, all put together.

processRegistrationRequest: function(data) {
	var result = {body: {error: {}, status: 'failure'}};

	var mbrGR = new GlideRecord('x_11556_col_store_member_organization');
	if (mbrGR.get('instance', data.instance)) {
		result.status = 400;
		result.body.error.message = 'Duplicate registration error';
		result.body.error.detail = 'This instance has already been registered with this store.';
	} else {
		mbrGR.initialize();
		mbrGR.name = data.name;
		mbrGR.instance = data.instance;
		mbrGR.email = data.email;
		mbrGR.description = data.description;
		mbrGR.token = data.sys_id;
		mbrGR.active = true;
		mbrGR.host = false;
		mbrGR.accepted = new GlideDateTime();
		if (mbrGR.insert()) {
			result.status = 202;
			delete result.body.error;
			result.body.info = {};
			result.body.info.message = 'Registration complete';
			result.body.info.detail = 'This instance has been successfully registered with this store.';
			result.body.status = 'success';
			sn_fd.FlowAPI.startSubflow('New_Collaboration_Store_Instance', {new_instance: data.instance});
		} else {
			result.status = 500;
			result.body.error.message = 'Internal server error';
			result.body.error.detail = 'There was a problem processing this registration request.';
		}
	}

	return result;
}

Now we have completed the nonexistent function that was referenced in the REST API Resource, but we have also now referenced a new nonexistent Subflow that we will need to build out before things are complete. That sounds like a good subject for our next installment.