“I find that the best way to do things is to constantly move forward and to never doubt anything and keep moving forward.” — John Frusciante
Last time, we wrapped up the initial testing of our first table and related form, so now it is time to move on to the other tables on our list. The next table that we will want to deal with is the table that contains the details for each run of the review process, which we will call Review Execution.
The first field that we will want to have is a reference back to the associated configuration record. We will call that one configuration. Then we will need a short and long description field, the run date, start and end times, some counters, a current state, and a completion code.
Once we save all of the fields, we can pull up the form and arrange everything to suit our needs.
One last thing that we will want to do with this table is to set up a couple of state values, one for running and one for completed, and set the default value to running so that whenever a new record is created, it is automatically set to the running state.
That should take care of the Review Execution table. Now we will need to build tables for every notice that comes out of the review process and every item that appears on each notice. Let’s start with the notice table first, which we can call Review Notice.
This table will have several reference fields, the first one being a link back to the associated execution. Other references will link to the email that was sent out, the recipient of the email, and the user who responded to the notice.
With that completed, we can then lay out the form in the way that we would like it to appear.
The last table that we will need to build before we can start looking at the actual process of sending out the notices is the table of items associated with each notice. We will call that one Review Notice Item.
This one will contain reference fields as well, including a reference to the associated notice, but the reference to the actual item being reviewed will be a little bit different. Because we are setting this up as a generic process that can review virtually any item, for the link to the item we will be using a Document ID field for the reference. More on that a little later, but for now, here are the fields for this table.
Both Reference type fields and Document ID type fields contain sys_ids, but on a reference field, the table containing the record with that sys_id is defined as a part of the field definition. On a Document ID field, the record could potentially be on any table in the system, so you need a second, dependent field of type Table to specify which table contains the record referenced. Fortunately for our purposes, the field does not need to be on the same table as the one that contains the Document ID. The table field that we need is actually a part of the Review Configuration where we define what items are to be reviewed. To set that up, we need to go into the field definition and select that field in the Dependent field section of the form.
With that out of the way, we can once again lay out the fields as we like on the associated form.
That should be all we need to get started on building the actual process that performs the review. There will eventually be one or more tables yet to define, but we can save that until they are needed. For now, let’s set that aside so that next time we can start working on the actual review process itself.
“Beginning in itself has no value; it is an end which makes beginning meaningful; we must end what we begun.” — Amit Kalantri
Last time, we added the Requested Item table to our Service Account dashboard so that we could see the pending requests, but we left off with a field name error and the desire to add a few item variables to the table using some Scripted Value Columns. Today, we will fix up that little error, and add some columns to both tables, hopefully wrapping things up, at least for this version of the dashboard.
In our field list for the new table, we had included the field name opened, when in actuality, the correct field name for the opened date/time is opened_at. That’s an easy fix, and now our field list looks like this:
number,opened_at,request.requested_for,stage
While we are in the configuration updating field lists, let’s also add the new link to the original request to the field list for the Service Account table, which will now look like this:
Also, since that new column will be a link to the sc_req_item table, let’s map that table to the ticket page by adding a new entry to the reference map.
That should take care of the errors and oversights. Now let’s take a look at adding some item variables to the pending request view. We put some catalog item variables on an example table not too long ago, so let’s just follow that same approach and maybe steal a little code from that guy so that we don’t end up reinventing an existing wheel. Here is the script that we built for that exercise.
var ScriptedCatalogValueProvider = Class.create();
ScriptedCatalogValueProvider.prototype = {
initialize: function() {
},
questionMap: {
cpu: 'e46305fbc0a8010a01f7d51642fd6737',
memory: 'e463064ac0a8010a01f7d516207cd5ab',
drive: 'e4630669c0a8010a01f7d51690673603',
os: 'e4630688c0a8010a01f7d516f68c1504'
},
getScriptedValue: function(item, config) {
var response = '';
var column = config.name;
if (this.questionMap[column]) {
response = this.getVariableValue(this.questionMap[column], item.sys_id);
}
return response;
},
getVariableValue: function(questionId, itemId) {
var response = '';
var mtomGR = new GlideRecord('sc_item_option_mtom');
mtomGR.addQuery('request_item', itemId);
mtomGR.addQuery('sc_item_option.item_option_new', questionId);
mtomGR.query();
if (mtomGR.next()) {
var value = mtomGR.getDisplayValue('sc_item_option.value');
if (value) {
response = this.getDisplayValue(questionId, value);
}
}
return response;
},
getDisplayValue: function(questionId, value) {
var response = '';
var choiceGR = new GlideRecord('question_choice');
choiceGR.addQuery('question', questionId);
choiceGR.addQuery('value', value);
choiceGR.query();
if (choiceGR.next()) {
response = choiceGR.getDisplayValue('text');
}
return response;
},
type: 'ScriptedCatalogValueProvider'
};
We can make a copy of this script and call ours ServiceAccountDashboardValueProvider. Most of this appears to be salvageable, but we will want to build our own questionMap using the columns that we will want to use for our use case. To find the sys_ids for the variables that we will want to use, we can pull up the Catalog Item to get to the list of variables, and then pull up each variable and use the context menu to snag the sys_id for each one.
Once we gather up all of the sys_ids, we will have a new map that looks like this:
That should be enough to make things work; however, in our case the types of variables involved will return the display value directly, so we do not need to go through that secondary process to look up the display value from the value. We can simply delete that unneeded function and return the value directly in this instance. That will make our new script look like this:
var ServiceAccountDashboardValueProvider = Class.create();
ServiceAccountDashboardValueProvider.prototype = {
initialize: function() {
},
questionMap: {
account_id: '59fe77a4971311100362bfb6f053afcc',
type: 'f98b24a4971711100362bfb6f053afa0',
group: '3d4fbba4971311100362bfb6f053afe3'
},
getScriptedValue: function(item, config) {
var response = '';
var column = config.name;
if (this.questionMap[column]) {
response = this.getVariableValue(this.questionMap[column], item.sys_id);
}
return response;
},
getVariableValue: function(questionId, itemId) {
var response = '';
var mtomGR = new GlideRecord('sc_item_option_mtom');
mtomGR.addQuery('request_item', itemId);
mtomGR.addQuery('sc_item_option.item_option_new', questionId);
mtomGR.query();
if (mtomGR.next()) {
response = mtomGR.getDisplayValue('sc_item_option.value');
}
return response;
},
type: 'ServiceAccountDashboardValueProvider'
};
Now all we need to do is to pull up the dashboard under the new configuration and see how it all looks. First, let’s take a look at the new column that we added for the original request.
There is only data there for the most recent test, but that’s just because that field did not exist on the table until recently. Now let’s click on the Pending state and see how our item variables came out.
Very nice! OK, I think that about does it for this version of the sample dashboard. There is still some work that we could do on the Fulfiller perspective, and it might be nice to add an Admin perspective that showed everything, but since this is just an example of what might be done, I will leave that as an exercise for those who might want to play around with things a bit. Next time, let’s take a look at what now have up to this point, and at what there might be left to do before we can wrap this one up and call it done.
“Never discourage anyone who continually makes progress, no matter how slow.” — Plato
Last time, we built out a fulfillment Subflow for one of our two example Service Account types, so now we can build the primary Flow that will call that Subflow and do all of the other work required to fulfill the request. Although you can configure a Flow to call a Subflow using the Flow Designer, you have to specify the Subflow during the development process. In our case, the Subflow that we will want to use will be dependent on the type of Service Account requested, so we will not know which Subflow will need to be called until execution time. Ideally, we would want to look the type requested, read the record for that type to get the Subflow, and then execute the Subflow specified in the type record. Since there doesn’t seem to be a way to do that out of the box, we will need to build out a custom Action to make the Subflow call via script. I created a simple Action called Create Service Account that takes the name of the Subflow and the Requested Item record as input and returns the same outputs as our fulfillment Subflows. The script for that Action looks like this:
(function execute(inputs, outputs) {
try {
        var result = sn_fd.FlowAPI.getRunner()
            .subflow(inputs.subflow)
            .inForeground()
            .withInputs({requested_item: inputs.requested_item})
            .run();
var returned = result.getOutputs();
for (var name in returned) {
outputs[name] = returned[name];
}
    } catch (e) {
        outputs.success = false;
outputs.failure_reason = 'Subflow execution failed with error: ' + e.getMessage();
    }
})(inputs, outputs);
All it does is launch the Subflow with the Requested Item record passed and returns whatever is returned by the called Subflow. This will essentially perform the same function as the Call Subflow action, but with the added benefit of allowing us to pass in the name of the Subflow to be called rather than have it hard-coded in the Flow.
Now that we have the ability to call a configured Subflow, we can jump back into the App Engine Studio and build out the primary Flow. On the dashboard for our application, we can scroll down to the Logic and automation section and then click on the Add button right after the section header.
Once you click on the Add button, a selection list appears, with Flow being the first option.
On this screen, we simply select Flow from the available options, which takes us to the next screen.
On this screen we enter Service Account Request Fulfillment in both the Name and the Description fields and then click on the Continue button.
The next screen just comes up long enough for the basic Flow to be created, after which we are brought to the successful completion screen.
At this point, the Flow now exists, but it has no steps, so we will want to click on that Edit this flow button to start building out the logic of the Flow. We will select Service Catalog as the trigger, and as we did with our sample Subflow, the first thing that we will want to do is to gather up the variable values from the Requested Item.
This time, we will need the type, the responsible_group, and the account_id from the request. The next thing that we will want to do is to read the Service Account Type record for the requested type, so we will select Look Up Record for our action.
We are looking for the record where the Name field matches the type selected on the Catalog Request. Once we have the variable values from the request and the matching type record, we can call our type-specific Subflow using the Action that we created for that purpose.
Now we need to check to make sure that the account was created, so we add an If condition based on the success flag returned by the Subflow.
If the account was created successfully, we will want to create a record for the account in our Service Account table, but before we can do that we need to fetch the record for the responsible group from the sys_user_group table. We have the name of the group from the catalog item variables, but we need the sys_id of the record to populate the Service Account record. We can do this with another Look Up Record action, searching the table for a record with the same name.
With the user group record now in hand, we have enough information to create the new record in our Service Account table.
Once we have created a record for the new Service Account, we will want to inform the user that the account has been created and is available for use. I took a short-cut here and just stubbed out a simple email, but ideally you would want to use a mail template and include some boiler-plate verbiage about company policies on the use of Service Accounts, security concerns, and related information on the owner’s responsibilities. All of that would ultimately be up to anyone attempting to implement such as system, and has little relevance to the workings of the process, so I will leave that to others and just include something simple as an example.
The password for the account should be sent out in a separate email, but before we do that, let’s go ahead and close the Requested Item now that the account has been created and the requested notified. To do that, we will select Update Record for our action and then drag in the pill for the Requested Item record, which will then populate the Table value.
Now all that is left to do is to send the password for the account to the requester. I took another short-cut here in that the only contents of the body of the email is the password itself with no other information, but again, this is only a sample. An encrypted email from a template would obviously be preferable here, but this at least provides a placeholder for performing this task in a much better way.
That completes the process for a successful account creation, but if the account could not be created for any reason, we still need to close out the Requested Item with that information. To do that, we will add an Else condition to our If and then insert one more step under the Else.
And that’s all there is to that. Now that we have our completed Flow, we can go back into our Catalog Item and specify this Flow for fulfillment. Once that has been done, we can finally request the item to generate a Requested Item record that we can use to test all of this out. That will probably be a little bit of an adventure, so let’s save all of that for our next time out.
“Walk that walk and go forward all the time. Don’t just talk that talk, walk it and go forward. Also, the walk didn’t have to be long strides; baby steps counted too. Go forward.” — Chris Gardner
Last time, we added some code to our storefront to launch the application installation process. Today, we want to build a detail screen to show all of the detailed information for a specific app. Although we could create a new UI Page or Portal Page for that, and have the tile click branch to that page, it seems as if it would be better if we just had a simple modal pop-up screen so that we could remain on the main shopping experience page after closing the modal dialog. To begin, let’s just create a simple Service Portalwidget that we can call up using spModal. We can call our widget Application Details and give it an ID of cs-application-details.
We have already gathered up the application data in the main widget, so we could simply pass everything that we have over to the pop-up widget; however, that would make the pop-up widget highly dependent on the main widget, which is something that I try to avoid. I think the better approach will be to pass in the sys_id of the app and let the pop-up widget fetch its own data from the database. For that, we can cut and paste most of the code that we already have in the main widget.
(function() {
if (input) {
data.sysId = input.sys_id;
data.record = {};
var appGR = new GlideRecord('x_11556_col_store_member_application');
appGR.query();
if (appGR.get(data.sysId)) {
var item = {};
data.record.name = appGR.getDisplayValue('name');
data.record.description = appGR.getDisplayValue('description');
data.record.logo = appGR.getValue('logo');
data.record.version = appGR.getDisplayValue('current_version');
data.record.provider = appGR.getDisplayValue('provider.name');
data.record.providerLogo = appGR.provider.getRefRecord().getValue('logo');
data.record.local = appGR.getDisplayValue('provider.instance') == gs.getProperty('instance_name');
data.record.state = 0;
if (appGR.getValue('application')) {
data.record.state = 1;
data.record.installedVersion = appGR.getDisplayValue('application.version');
if (data.record.version == data.record.installedVersion) {
data.record.state = 2;
}
}
if (!data.record.local && data.record.state != 2) {
data.record.attachmentId = getAttachmentId(data.record.sys_id, data.record.version);
}
}
}
function getAttachmentId(applicationId, version) {
var attachmentId = '';
var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
versionGR.addQuery('member_application', applicationId);
versionGR.addQuery('version', version);
versionGR.query();
if (versionGR.next()) {
var attachmentGR = new GlideRecord('sys_attachment');
attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
attachmentGR.addQuery('table_sys_id', versionGR.getUniqueValue());
attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
attachmentGR.query();
if (attachmentGR.next()) {
attachmentId = attachmentGR.getUniqueValue();
}
}
return attachmentId;
}
})();
Before we get too excited about formatting all of this data for display, let’s just throw a single value onto the display and see if we can get the mechanics of bringing up the widget all working. Here is some simple HTML to get things started.
<div>
<h3>{{c.data.record.name}}</h3>
</div>
That should be enough to get things going, so let’s pop back over to the main widget and see if we can set up a function in the Client script to call up this widget.
That should be enough to be able to open up the store and give things the old college try.
Not bad. OK, now that we have the basic mechanics working, we need to design the layout of the pop-up and also add whatever functionality we might want such as links to any forms or actions. At this point in the process, we have not added that much in the way of extra data. There are no categories or keywords or comments or user ratings or statistics or much of anything else in the way of interesting information outside of the name and description of the application. Some or all of that may come in some future version, but for now, about the best we can do to add detail would be to add the version history and to throw in a few useful links.
The above example is for an app pulled down from the store. For local apps that have been pushed up to the store, the look would be similar, but with a few differences.
For the local application, we should probably continue with the modified background, just to be consistent, but you get the idea. To pull this off, we will have to fetch more data from the database, and work out all of the associated HTML. Once that is done, that should be good enough to push out a new version so that folks can try it all out at home. That’s still a bit of work, so let’s deal with all of that next time out.
“You just have to keep driving down the road. It’s going to bend and curve and you’ll speed up and slow down, but the road keeps going.” — Ellen DeGeneres
Last time, we got the ability to toggle between the card/tile view and table view working as it should, and we made an initial stab at making the local apps look a little different from the apps that could be or have been pulled down from the Host. Now we need to figure out what we want to happen when the operator clicks on any of the links that are present on the tiles or table rows. Currently, the main portion of the tile is clickable, and in the footer, the version number could be clickable as well and could potentially launch the install process. In fact, let’s take a look at that first.
Right now, if you want to install a version of a store application, you go to the version record and click on the Install button. Let’s take a quick peek at that UI Action and see how that works.
var attachmentGR = new GlideRecord('sys_attachment');
attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
attachmentGR.addQuery('table_sys_id', current.sys_id);
attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
attachmentGR.query();
if (attachmentGR.next()) {
action.setRedirectURL('/upload.do?attachment_id=' + attachmentGR.getUniqueValue());
} else {
gs.addErrorMessage('No Update Set XML file found attached to this version');
}
Basically, it just links to the stock upload.do page (which we hacked up a bit sometime back) with the attachment sys_id as a parameter. Assuming that we had the attachment sys_id included along with the rest of the row data, we could simply build an anchor tag to launch the install such as the following.
To get the attachment sys_id, we could steal some of the UI Action code above to build a function for that purpose.
function getAttachmentId(applicationId, version) {
var attachmentId = '';
var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
versionGR.addQuery('member_application', applicationId);
versionGR.addQuery('version', version);
versionGR.query();
if (versionGR.next()) {
var attachmentGR = new GlideRecord('sys_attachment');
attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
attachmentGR.addQuery('table_sys_id', versionGR.getUniqueValue());
attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
attachmentGR.query();
if (attachmentGR.next()) {
attachmentId = attachmentGR.getUniqueValue();
}
}
return attachmentId;
}
For local apps, or apps that are already installed and up to date, there is no action to take. But for apps that have not been installed, or those that have, but are not on the latest version, a link to the install process would be appropriate. Two mutually exclusive tags would cover both cases.
To test this out, we will need to publish an app on our Host instance and bring up the store on our Client instance to see how this looks.
That takes care of the install link. For the main action when clicking on the tile itself (or on the app name in the table view), we should have some way of displaying all of the details of the app. We could link to the existing form for the application table, but we might want something a little more formatted, and maybe even just a pop-up so that you don’t actually leave the shopping experience. Let’s see if we can throw something like that together next time out.
“Mistakes should be examined, learned from, and discarded; not dwelled upon and stored.” — Tim Fargo
Last time, we attempted to solve the problem of the instance logo image not being captured during the initial set-up process. Although the modifications that we made resolved the problem for a Host instance set-up, there is still a problem with the Client instances, as there is still no code in the set-up process that sends the logo image over the Host during registration. The periodic instance sync process won’t resolve that issue, either, as that compares what the Host has with what the Clients have, and the Host was never sent the image. We need to add some additional logic to send over the image when the Client is first registered with the Host. Here is the relevant code from the set-up widget:
All we are doing here is sending over the basic information about the Client for the instance record on the Host, and then creating a record in our own instance table for the Host instance. There is no attempt to send over the associated logo image. We should be able to add a little something right after we fix the REST API log records that were created before the Host instance record was built.
if (logoId) {
var attachmentGR = new GlideRecord('sys_attachment');
if (attachmentGR.get(logoId)) {
csu.pushImageAttachment(attachmentGR, mbrGR, 'x_11556_col_store_member_organization', resp.obj.result.info.sys_id);
}
}
There are a couple of issues with this code, however. The first problem is that we are reusing the instance GlideRecord for both the Client instance as well as the Host instance, so if we want the logo sys_id from the Client instance, we need to grab that and save it before we initialize the record and start building the record for the Host instance.
var logoId = mbrGR.getValue('logo');
mbrGR.initialize();
mbrGR.instance = input.store_info.instance;
mbrGR.accepted = input.store_info.accepted;
mbrGR.description = input.store_info.description;
mbrGR.name = input.store_info.name;
mbrGR.email = input.store_info.email;
mbrGR.token = input.store_info.sys_id;
mbrGR.active = true;
mbrGR.host = true;
mbrGR.insert();
fixLogRecords(mbrGR);
if (logoId) {
var attachmentGR = new GlideRecord('sys_attachment');
if (attachmentGR.get(logoId)) {
csu.pushImageAttachment(attachmentGR, mbrGR, 'x_11556_col_store_member_organization', resp.obj.result.info.sys_id);
}
}
The other problem is that we need the sys_id of the Client instance record on the Host system, and the current registration process does not send back the sys_id of the instance record created during registration. We have to have that so that we can attach the logo image to that record, so we will need to go into the function that processes the registration request and add that data point to the response.
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.info.sys_id = mbrGR.getUniqueValue();
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;
}
That should do it. Now, not only are we able to capture the logo image during the initial set-up process, if the instance is a Client instance, we also send that logo image over to the Host so that it can be distributed to all of the other Client instances. Of course, now we need a new Update Set that includes all of these changes, so here you go:
Same rules apply as before; this is a drop-in replacement for any of the previous 0.7.x version. More information on previewing, committing, and testing can be found here and here and here. And as always, feedback of any kind in the comments section is welcome, encouraged, and very much appreciated. Any and all information on your experiences, positive, negative, or otherwise, would be very welcome, and will give us a little something to review next time out.
Note to testers: On this version, it might be worthwhile to delete all of the instance records on your Host instance and have all of your Client instances re-register using a logo image to make sure all of this works. If all goes well, the logo images for all instances and all apps should appear on all instances. If you run into any issues, please report them in the comments, and if everything works out, please let us know that as well — thanks!
“Do not be too timid and squeamish about your actions. All life is an experiment. The more experiments you make the better.” — Ralph Waldo Emerson
Last time, we used the new scripted value columns feature to create columns from the comments and work notes of an Incident. Today we are going to do something similar, but instead of working with journal entries, we will try something with the optional variables that can be associated with Service Catalog items. For our demonstration, we will use the Executive Desktop catalog item, which has a number of defined ordering options.
Let’s see if we can’t create a list of all orders for this item, and include some of the catalog item variables on the list. To begin, let’s make another copy of our example scripted value provider and call it ScriptedCatalogValueProvider.
Let’s also make a copy of our last configuration script and change things over from the Incident table to the Requested Item table, and define some scripted value columns for some of the catalog item variables associated with this item.
Finally, let’s make a copy of our last test page and call it scripted_value_test_2, and then edit the widget options to use our new configuration file. Now let’s jump out to the Service Portal and pull up the page and see what we have so far.
So far, so good. Everything seems to work, but of course all we have in our new columns is the random numbers that we threw in there for demonstration purposes earlier. But that just means that all we have left to do now is to figure out what kind of script we need to pull out the actual variable values that are associated with each catalog item order. To begin, let’s map our variable names, which are also our column names, to their catalog item variable (question) sys_ids.
This will allow us to use some common code for all of the columns by passing in the correct sys_id for the catalog item variable associated with that column.
getScriptedValue: function(item, config) {
var response = '';
var column = config.name;
if (this.questionMap[column]) {
response = this.getVariableValue(this.questionMap[column], item.sys_id);
}
return response;
}
Now we need to build the getVariableValue function, which takes the sys_id of the question and the sys_id of the requested item as arguments. To locate the value selected for this question for this order, we use the sc_item_option_mtom table, which maps requested items to individual question responses.
getVariableValue: function(questionId, itemId) {
var response = '';
var mtomGR = new GlideRecord('sc_item_option_mtom');
mtomGR.addQuery('request_item', itemId);
mtomGR.addQuery('sc_item_option.item_option_new', questionId);
mtomGR.query();
if (mtomGR.next()) {
response = mtomGR.getDisplayValue('sc_item_option.value');
}
return response;
}
Now let’s save that and give the page another look.
Well, that’s better, but it is still not exactly what we want. The values that appear in the columns are the raw values for the variables, but what we would really like to see is the display value. To get that, we have to go all the way back to the original question choices and find the choice record that matches both the question and the answer. For that, you need the sys_id of the question and the value of the answer.
getDisplayValue: function(questionId, value) {
var response = '';
var choiceGR = new GlideRecord('question_choice');
choiceGR.addQuery('question', questionId);
choiceGR.addQuery('value', value);
choiceGR.query();
if (choiceGR.next()) {
response = choiceGR.getDisplayValue('text');
}
return response;
}
To utilize this new function, we have to tweak our getVariableValue function just a little bit to make the call.
getVariableValue: function(questionId, itemId) {
var response = '';
var mtomGR = new GlideRecord('sc_item_option_mtom');
mtomGR.addQuery('request_item', itemId);
mtomGR.addQuery('sc_item_option.item_option_new', questionId);
mtomGR.query();
if (mtomGR.next()) {
var value = mtomGR.getDisplayValue('sc_item_option.value');
if (value) {
response = this.getDisplayValue(questionId, value);
}
}
return response;
}
Now let’s take one more look at that page now that we have made these modifications.
That’s better! Now we have the same text in our columns that the user saw when placing the order, and that’s what we really want to see here. Or at least, that’s what I wanted to see. You may be interested in something completely different, but that’s the whole point of this approach. You can basically script whatever you want to put whatever you want to put in whatever column or columns you would like to define. These are just a couple of examples, but there really is no limit to what you might be able to do with a little imagination and some Javascript.
So here is the final version of our second close-to-real-world example value provider script:
var ScriptedCatalogValueProvider = Class.create();
ScriptedCatalogValueProvider.prototype = {
initialize: function() {
},
questionMap: {
cpu: 'e46305fbc0a8010a01f7d51642fd6737',
memory: 'e463064ac0a8010a01f7d516207cd5ab',
drive: 'e4630669c0a8010a01f7d51690673603',
os: 'e4630688c0a8010a01f7d516f68c1504'
},
getScriptedValue: function(item, config) {
var response = '';
var column = config.name;
if (this.questionMap[column]) {
response = this.getVariableValue(this.questionMap[column], item.sys_id);
}
return response;
},
getVariableValue: function(questionId, itemId) {
var response = '';
var mtomGR = new GlideRecord('sc_item_option_mtom');
mtomGR.addQuery('request_item', itemId);
mtomGR.addQuery('sc_item_option.item_option_new', questionId);
mtomGR.query();
if (mtomGR.next()) {
var value = mtomGR.getDisplayValue('sc_item_option.value');
if (value) {
response = this.getDisplayValue(questionId, value);
}
}
return response;
},
getDisplayValue: function(questionId, value) {
var response = '';
var choiceGR = new GlideRecord('question_choice');
choiceGR.addQuery('question', questionId);
choiceGR.addQuery('value', value);
choiceGR.query();
if (choiceGR.next()) {
response = choiceGR.getDisplayValue('text');
}
return response;
},
type: 'ScriptedCatalogValueProvider'
};
The added columns still do not align with the original columns, which I would like to fix before I build a new Update Set, and we still have a couple more wrapper widgets to address, but I think we are getting close. Maybe we can wrap this whole thing up in our next installment.
“We all need people who will give us feedback. That’s how we improve.” — Bill Gates
Last time, we released a new batch of Update Sets for the latest iteration of this effort and put out a plea for folks to take it all out for a spin. We got quite a lot of good, detailed feedback this time (Thanks, Joe!), so let’s make a quick list of everything that has been reported so far.
Preview errors during install
Application publishing failed during logo image copy
Application publishing failed after logo image removal
Application publishing failed due to Host instance being off line
Application publishing succeeded with new logo image, but on Host instance, logo image was attached to the version record instead of the Update Set XML file
None of these are good, but let’s take a look at them one at a time.
Preview errors during install
This one, I am able to duplicate. I also received 20 Preview errors when installing the Update Set on a new instance. Every one of the errors is basically the same.
Every one of the 20 errors contains the same message text.
Could not find a record in sys_hub_flow_base for column model referenced in this update
The accepted answer seems to be that this error message comes out because the Flow that you are trying to install is not present on the target instance. Well, that’s understandable, since you haven’t committed the Update Set just yet, but it doesn’t seem to me that that should be considered an error. Everyone’s answer is just to accept the remote update, but if you are shooting for a clean install, it doesn’t really look good to have these errors pop up for no reason. I looked for a way to suppress them or eliminate them, but so far I have not found anything of value. So it looks like you just accept them and continue, which is what I suggested when I first put this out there to install, but I don’t really like it. Maybe one day I will find a way to keep these messages from coming out, but for now, this is just the way that it is.
Application publishing failed during logo image copy
This one I have not been able to duplicate, which is unfortunate, because I would like to resolve it, and resolve it in a way that I can prove by running tests before and after the fix. In all of my testing, I have never had an image copy fail, so I am not sure how to proceed. However, it does occur to me that a failed logo image copy should not kill the entire process. Yes, it would be good to have the image along with the rest of the artifacts, but if that is the only issue, it seems to me that the rest of the publishing process should proceed. Here is the copy image function as it stands in version 0.7:
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: ' + JSON.stringify(values));
}
} else {
answer = this.processError(answer, 'Unrecognizable response from logo attachment copy: ' + JSON.stringify(values));
}
return logoId;
}
The processError function that is called when things go South logs the details of the error, displays a message, and then adds an error property to the answer object. I think if I remove the error property from the answer object, then the publication process will not stop at this point and everything will continue as if there was no image associated with the application. This seems like the preferable approach, at least to me. Maybe something like this:
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: ' + JSON.stringify(values));
delete answer.error;
}
} else {
answer = this.processError(answer, 'Unrecognizable response from logo attachment copy: ' + JSON.stringify(values));
delete answer.error;
}
return logoId;
}
That still doesn’t explain why this particular image could not be copied, but at least it would allow the publishing of the application to continue.
Application publishing failed after logo image removal
This is another one that I cannot seem to duplicate. The code related to an application image is fairly straightforward: if the app has an image and the store record does not, then it copies it over; otherwise, it does not do anything at all. If the app had no image, then if the publishing failed, it must have failed somewhere else, as the image copy function should not have even been invoked. Here is the relevant section of code:
if (sysAppGR.getValue('logo') && !mbrAppGR.getValue('logo')) {
mbrAppGR.setValue('logo', this.copyLogoImage(answer));
}
If the app had no logo image, then nothing should have happened. I will have to look into this one a little deeper any maybe ask for a little more information before I understand what happened on this one.
Application publishing failed due to Host instance being off line
This is not actually a problem with the app, as there is no way to publish an application to a Host that is not up and running. but it does bring up an interesting question: should we check to see if the Host is available before we launch the process? That would at least prevent someone from going through half of the process only to have it die when it tries to move the artifacts over to the Host. We already have a getStoreInfo function that would tell us if the Host was available or not, so it wouldn’t take much to add a quick check before we launched the publishing process, and then inform the operator if things were not going to work out.
Application publishing succeeded with new logo image, but on Host instance, logo image was attached to the version record instead of the Update Set XML file
I have not found the source of this one just yet, but it appears to me that one or more sys_id values got passed to the wrong function or written to the wrong variable. Since everything turned out OK on the original Client, but ended up in the wrong place on the Host, the problem has to be in the REST API calls made from the Client to the Host. There are three calls that move attachments, one for the instance logo image, one for the application logo image, and one for the Update Set XML file attached to the version record. Either the logo image API call attached the logo to the wrong base record or the Update Set XML file call sent over the wrong attachment. A review of the relevant REST API call log records might reveal which one caused the problem, but I will dig through the code for both and see if I can understand how this might have happened. Obviously, you cannot install the app if you don’t have the Update Set XML file attached to the version record. This one definitely has to be fixed.
This was all great feedback, and very detailed, including copies of log file entries. That is very helpful in diagnosing these issue. If anyone else is having similar issues, please report them as well, and include as much information as you feel would be appropriate. And if someone has pulled this down and was able to run things without running into these issues, I would love to hear about that as well. As always, all feedback is welcome, positive, negative, or otherwise.
And Joe, if you are still willing to do a little more testing, try to publish a different app from your otherClient, and see if you run into any similar issues with that. If you can find a fourth instance to join your trio, you might have the owner of that instance give this a shot as well. And thanks again for your assistance. It is very much appreciated. Thanks to all of you for helping to make this work the way that it should. I look forward to hearing more from anyone willing to give this all a try. Next time, we will take a look at any additional feedback, as well as any modifications that have been implemented as a result of the feedback that we have received thus far.
“There is no such thing as completion. These are only stages in an endless progression. There are no final outcomes or decisions, since nothing ever stays the same.” — Frederick Lenz
Last time, we finished adding the logging process to the remaining REST API calls in the CollaborationStoreUtilsScript Include. Now we need to do the same thing for all of the remaining REST API calls in the InstanceSyncUtilsScript Include. Here is the first one as it stands right now.
syncInstances: function(targetGR, instanceList) {
var request = new sn_ws.RESTMessageV2();
request.setHttpMethod('get');
request.setBasicAuth(this.CSU.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
request.setRequestHeader("Accept", "application/json");
request.setEndpoint('https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization?sysparm_fields=instance%2Csys_id');
var response = request.execute();
if (response.haveError()) {
gs.error('InstanceSyncUtils.syncInstance - Error returned from attempt to fetch instance list from instance ' + targetGR.getDisplayValue('instance') + ': ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
} else if (response.getStatusCode() == '200') {
var jsonString = response.getBody();
var jsonObject = {};
try {
jsonObject = JSON.parse(jsonString);
} catch (e) {
gs.error('InstanceSyncUtils.syncInstance - Unparsable JSON string returned from attempt to fetch instance list: ' + jsonString);
}
if (Array.isArray(jsonObject.result)) {
for (var i=0; i<instanceList.length; i++) {
var thisInstance = instanceList[i];
var remoteSysId = '';
for (var j=0; j<jsonObject.result.length && remoteSysId == ''; j++) {
if (jsonObject.result[j].instance == thisInstance) {
remoteSysId = jsonObject.result[j].sys_id;
}
}
if (remoteSysId == '') {
remoteSysId = this.sendInstance(targetGR, thisInstance);
}
this.syncApplications(targetGR, thisInstance, remoteSysId);
}
} else {
gs.error('InstanceSyncUtils.syncInstance - Invalid response body returned from attempt to fetch instance list: ' + response.getBody());
}
} else {
gs.error('InstanceSyncUtils.syncInstance - Invalid HTTP response code returned from attempt to fetch instance list: ' + response.getStatusCode());
}
}
Up to this point, we have always called the logging routine just before we returned the result object. In the above function, however, we call other functions that also make their own REST API calls, so it would be preferable to log this call before calling any other function that might make a call of its own. Because of this, not only will we need to restructure the code to build the result object that the logging function is expecting, we will also need to make the call to the logging function prior to making the call to the other functions in the instance sync process. To begin, we will build the result object in the normal manner by populating the url and method properties, and then using those values to populate the sn_ws.RESTMessageV2 object.
var result = {};
result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization?sysparm_fields=instance%2Csys_id';
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('Accept', 'application/json');
Once the sn_ws.RESTMessageV2 is fully populated, we can then obtain the response object by executing the call and then continue populating the result object with the values returned in the response.
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();
gs.error('InstanceSyncUtils.syncInstance - Unparsable JSON string returned from attempt to fetch instance list: ' + result.body);
}
}
result.error = response.haveError();
if (result.error) {
result.error_code = response.getErrorCode();
result.error_message = response.getErrorMessage();
gs.error('InstanceSyncUtils.syncInstance - Error returned from attempt to fetch instance list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.error_code + ' - ' + result.error_message);
} else if (result.status != '200') {
gs.error('InstanceSyncUtils.syncInstance - Invalid HTTP response code returned from attempt to fetch instance list: ' + result.status);
}
Now that the result object is fully populated, we can go ahead and make the call to the logging function before calling the other functions involved in the instance sync process.
this.logRESTCall(targetGR, result);
if (!result.error && result.status == '200' && result.obj) {
if (Array.isArray(result.obj.result)) {
for (var i=0; i<instanceList.length; i++) {
var thisInstance = instanceList[i];
var remoteSysId = '';
for (var j=0; j<result.obj.result.length && remoteSysId == ''; j++) {
if (result.obj.result[j].instance == thisInstance) {
remoteSysId = result.obj.result[j].sys_id;
}
}
if (remoteSysId == '') {
remoteSysId = this.sendInstance(targetGR, thisInstance);
}
this.syncApplications(targetGR, thisInstance, remoteSysId);
}
} else {
gs.error('InstanceSyncUtils.syncInstance - Invalid response body returned from attempt to fetch instance list: ' + result.body);
}
}
Putting it all together, the entire new function now looks like this.
syncInstances: function(targetGR, instanceList) {
var result = {};
result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization?sysparm_fields=instance%2Csys_id';
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('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();
gs.error('InstanceSyncUtils.syncInstance - Unparsable JSON string returned from attempt to fetch instance list: ' + result.body);
}
}
result.error = response.haveError();
if (result.error) {
result.error_code = response.getErrorCode();
result.error_message = response.getErrorMessage();
gs.error('InstanceSyncUtils.syncInstance - Error returned from attempt to fetch instance list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.error_code + ' - ' + result.error_message);
} else if (result.status != '200') {
gs.error('InstanceSyncUtils.syncInstance - Invalid HTTP response code returned from attempt to fetch instance list: ' + result.status);
}
this.logRESTCall(targetGR, result);
if (!result.error && result.status == '200' && result.obj) {
if (Array.isArray(result.obj.result)) {
for (var i=0; i<instanceList.length; i++) {
var thisInstance = instanceList[i];
var remoteSysId = '';
for (var j=0; j<result.obj.result.length && remoteSysId == ''; j++) {
if (result.obj.result[j].instance == thisInstance) {
remoteSysId = result.obj.result[j].sys_id;
}
}
if (remoteSysId == '') {
remoteSysId = this.sendInstance(targetGR, thisInstance);
}
this.syncApplications(targetGR, thisInstance, remoteSysId);
}
} else {
gs.error('InstanceSyncUtils.syncInstance - Invalid response body returned from attempt to fetch instance list: ' + result.body);
}
}
}
That takes care of the syncInstances function. Now we need to do the same with the syncApplications function, which currently look like this.
syncApplications: function(targetGR, thisInstance, remoteSysId) {
var applicationList = [];
var applicationGR = new GlideRecord('x_11556_col_store_member_application');
applicationGR.addQuery('provider.instance', thisInstance);
applicationGR.query();
while (applicationGR.next()) {
applicationList.push(applicationGR.getDisplayValue('name'));
}
if (applicationList.length > 0) {
var request = new sn_ws.RESTMessageV2();
request.setHttpMethod('get');
request.setBasicAuth(this.CSU.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
request.setRequestHeader("Accept", "application/json");
request.setEndpoint('https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=name%2Csys_id&sysparm_query=provider%3D' + remoteSysId);
var response = request.execute();
if (response.haveError()) {
gs.error('InstanceSyncUtils.syncApplications - Error returned from attempt to fetch application list: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
} else if (response.getStatusCode() == '200') {
var jsonString = response.getBody();
var jsonObject = {};
try {
jsonObject = JSON.parse(jsonString);
} catch (e) {
gs.error('InstanceSyncUtils.syncApplications - Unparsable JSON string returned from attempt to fetch application list: ' + jsonString);
}
if (Array.isArray(jsonObject.result)) {
for (var i=0; i<applicationList.length; i++) {
var thisApplication = applicationList[i];
var remoteAppId = '';
for (var j=0; j<jsonObject.result.length && remoteAppId == ''; j++) {
if (jsonObject.result[j].name == thisApplication) {
remoteAppId = jsonObject.result[j].sys_id;
}
}
if (remoteAppId == '') {
remoteAppId = this.sendApplication(targetGR, thisApplication, thisInstance, remoteSysId);
}
this.syncVersions(targetGR, thisApplication, thisInstance, remoteAppId);
}
} else {
gs.error('InstanceSyncUtils.syncApplications - Invalid response body returned from attempt to fetch application list: ' + response.getBody());
}
} else {
gs.error('InstanceSyncUtils.syncApplications - Invalid HTTP response code returned from attempt to fetch application list: ' + response.getStatusCode());
}
} else {
gs.info('InstanceSyncUtils.syncApplications - No applications to sync for instance ' + thisInstance);
}
}
Using the same restructuring approach, we can convert the function to this.
syncApplications: function(targetGR, thisInstance, remoteSysId) {
var applicationList = [];
var applicationGR = new GlideRecord('x_11556_col_store_member_application');
applicationGR.addQuery('provider.instance', thisInstance);
applicationGR.query();
while (applicationGR.next()) {
applicationList.push(applicationGR.getDisplayValue('name'));
}
if (applicationList.length > 0) {
var result = {};
result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=name%2Csys_id&sysparm_query=provider%3D' + remoteSysId;
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('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();
gs.error('InstanceSyncUtils.syncApplications - Unparsable JSON string returned from attempt to fetch application list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.body);
}
}
result.error = response.haveError();
if (result.error) {
result.error_code = response.getErrorCode();
result.error_message = response.getErrorMessage();
gs.error('InstanceSyncUtils.syncApplications - Error returned from attempt to fetch application list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.error_code + ' - ' + result.error_message);
} else if (result.status != '200') {
gs.error('InstanceSyncUtils.syncApplications - Invalid HTTP response code returned from attempt to fetch application list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.status);
}
this.logRESTCall(targetGR, result);
if (!result.error && result.status == '200' && result.obj) {
if (Array.isArray(result.obj.result)) {
for (var i=0; i<applicationList.length; i++) {
var thisApplication = applicationList[i];
var remoteAppId = '';
for (var j=0; j<result.obj.result.length && remoteAppId == ''; j++) {
if (result.obj.result[j].name == thisApplication) {
remoteAppId = result.obj.result[j].sys_id;
}
}
if (remoteAppId == '') {
remoteAppId = this.sendApplication(targetGR, thisApplication, thisInstance, remoteSysId);
}
this.syncVersions(targetGR, thisApplication, thisInstance, remoteAppId);
}
} else {
gs.error('InstanceSyncUtils.syncApplications - Invalid response body returned from attempt to fetch application list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.body);
}
}
} else {
gs.info('InstanceSyncUtils.syncApplications - No applications to sync for instance ' + thisInstance);
}
}
We can repeat this same refactoring exercise for the two other similar functions, syncVersions and syncAttachments, which now look like this.
syncVersions: function(targetGR, thisApplication, thisInstance, remoteAppId) {
var versionList = [];
var versionIdList = [];
var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
versionGR.addQuery('member_application.name', thisApplication);
versionGR.addQuery('member_application.provider.instance', thisInstance);
versionGR.query();
while (versionGR.next()) {
versionList.push(versionGR.getDisplayValue('version'));
versionIdList.push(versionGR.getUniqueValue());
}
if (versionList.length > 0) {
result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application_version?sysparm_fields=version%2Csys_id&sysparm_query=member_application%3D' + remoteAppId;
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('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();
gs.error('InstanceSyncUtils.syncVersions - Unparsable JSON string returned from attempt to fetch version list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.body);
}
}
result.error = response.haveError();
if (result.error) {
result.error_code = response.getErrorCode();
result.error_message = response.getErrorMessage();
gs.error('InstanceSyncUtils.syncVersions - Error returned from attempt to fetch version list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.error_code + ' - ' + result.error_message);
} else if (result.status != '200') {
gs.error('InstanceSyncUtils.syncVersions - Invalid HTTP response code returned from attempt to fetch version list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.status);
}
this.logRESTCall(targetGR, result);
if (!result.error && result.status == '200' && result.obj) {
if (Array.isArray(result.obj.result)) {
for (var i=0; i<versionList.length; i++) {
var thisVersion = versionList[i];
var thisVersionId = versionIdList[i];
var remoteVerId = '';
for (var j=0; j<result.obj.result.length && remoteVerId == ''; j++) {
if (result.obj.result[j].version == thisVersion) {
remoteVerId = result.obj.result[j].sys_id;
}
}
if (remoteVerId == '') {
remoteVerId = this.sendVersion(targetGR, thisVersion, thisApplication, thisInstance, remoteAppId);
}
this.syncAttachments(targetGR, thisVersionId, thisVersion, thisApplication, thisInstance, remoteVerId);
}
} else {
gs.error('InstanceSyncUtils.syncVersions - Invalid response body returned from attempt to fetch version list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.body);
}
}
} else {
gs.info('InstanceSyncUtils.syncVersions - No versions to sync for application ' + thisApplication + ' on instance ' + thisInstance);
}
}
syncAttachments: function(targetGR, thisVersionId, thisVersion, thisApplication, thisInstance, remoteVerId) {
var attachmentList = [];
var attachmentGR = new GlideRecord('sys_attachment');
attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
attachmentGR.addQuery('table_sys_id', thisVersionId);
attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
attachmentGR.query();
while (attachmentGR.next()) {
attachmentList.push(attachmentGR.getUniqueValue());
}
if (attachmentList.length > 0) {
var result = {};
result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/sys_attachment?sysparm_fields=sys_id&sysparm_query=table_name%3Dx_11556_col_store_member_application_version%5Etable_sys_id%3D' + remoteVerId + '%5Econtent_typeCONTAINSxml';
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('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();
gs.error('InstanceSyncUtils.syncAttachments - Unparsable JSON string returned from attempt to fetch attachment list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.body);
}
}
result.error = response.haveError();
if (result.error) {
result.error_code = response.getErrorCode();
result.error_message = response.getErrorMessage();
gs.error('InstanceSyncUtils.syncAttachments - Error returned from attempt to fetch attachment list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.error_code + ' - ' + result.error_message);
} else if (result.status != '200') {
gs.error('InstanceSyncUtils.syncAttachments - Invalid HTTP response code returned from attempt to fetch attachment list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.status);
}
this.logRESTCall(targetGR, result);
if (!result.error && result.status == '200' && result.obj) {
if (Array.isArray(result.obj.result)) {
if (result.obj.result.length == 0) {
this.sendAttachment(targetGR, attachmentList[0], remoteVerId, thisVersion, thisApplication);
}
} else {
gs.error('InstanceSyncUtils.syncAttachments - Invalid response body returned from attempt to fetch attachment list: ' + result.body);
}
}
} else {
gs.info('InstanceSyncUtils.syncAttachments - No attachments to sync for version ' + thisVersionId + ' of application ' + thisApplication + ' on instance ' + thisInstance);
}
}
That should take care of all of the REST API calls in all of the Script Includes in the application. Now every call will be recorded in the new table and linked to the instance to which the call was made. With the completion of the work on the images and the logging, it is about time to create yet another Update Set and turn it over to the testers for some serious regression testing. Before we do that, though, it would probably be a good idea to try all of this out ourselves and make sure that it all works. Let’s jump right into that next time out.
“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 CollaborationStoreUtilsScript 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.
That should take care of all of the REST API calls in the CollaborationStoreUtilsScript Include. There were never any REST API calls in the ApplicationInstallerScript Include, and we just removed all of the REST API calls in the ApplicationPublishersScript 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.