Skip to content

Commit

Permalink
Fixed issue where it was possible for State API calls fired in quick …
Browse files Browse the repository at this point in the history
…succession to the same object, to persist the older data, even if the calls where fired in the correct order. State calls are now queued, so it is no longer possible for the LRS to save the states in the wrong order due to the time it takes to execute the asynchronous requests. This also required a move from the XHR requests used in the ADLWrapper as these do not work with the callback functionality used with Async.queue. Moved to using the Fetch API with a modified polyfill for browsers which don't support the API or the keepalive attribute, to resolve adlnet/xAPIWrapper#166.
  • Loading branch information
danielghost committed Jul 21, 2020
1 parent da35171 commit 4164ffe
Show file tree
Hide file tree
Showing 4 changed files with 222 additions and 147 deletions.
7 changes: 4 additions & 3 deletions js/adapt-xapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ define([
'./launchModel',
'./statementModel',
'./stateModel',
'libraries/url',
'libraries/xapiwrapper.min'
'libraries/xapiwrapper.min',
'libraries/url-polyfill',
'libraries/fetch-polyfill'
], function(Adapt, OfflineStorage, ErrorNotificationModel, LaunchModel, StatementModel, StateModel) {

var xAPI = Backbone.Controller.extend({
Expand Down Expand Up @@ -57,7 +58,7 @@ define([
initializeStatement: function() {
var config = {
activityId: this.getActivityId(),
//registration: this.launchModel.get('registration'),
registration: this.launchModel.get('registration'),
actor: this.launchModel.get('actor'),
contextActivities: this.launchModel.get('contextActivities')
};
Expand Down
305 changes: 189 additions & 116 deletions js/stateModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ define([

var COMPONENTS_KEY = 'components';
var DURATIONS_KEY = 'durations';
var LOCATION_KEY = 'location';

var StateModel = Backbone.Model.extend({

Expand All @@ -26,6 +25,7 @@ define([
_isInitialized: false,
_isLoaded: false,
_isRestored: false,
_queues: {},

initialize: function(attributes, options) {
this.listenTo(Adapt, {
Expand Down Expand Up @@ -77,76 +77,64 @@ define([
},

load: function() {
var activityId = this.get('activityId');
var actor = this.get('actor');
var registration = this.get('registration');
var states = this.xAPIWrapper.getState(activityId, actor, null, registration);

if (states === null) {
this.showErrorNotification();
} else {
var scope = this;

Async.each(states, function(id, callback) {
scope.xAPIWrapper.getState(activityId, actor, id, registration, null, function(request) {
Adapt.log.debug(request.response);

switch (request.status) {
case 200:
var state;
var response = request.response;

// account for invalid JSON string?
try {
state = JSON.parse(response);
} catch(e) {
state = response;
}

scope.set(id, state);
break;
case 404:
Adapt.log.error("Could not find " + id + " in State API.");
break;
}
var scope = this;

callback();
this._getStates(function(err, data) {
if (err) {
scope.showErrorNotification();
} else {
var states = data;

Async.each(states, function(id, callback) {
scope._fetchState(id, function(err, data) {
if (err) {
scope.showErrorNotification();
} else {
// all data is now saved and retrieved as JSON, so no need for try/catch anymore
scope.set(id, data);
}

callback();
});
}, function(err) {
if (err) {
scope.showErrorNotification();
} else {
scope._isLoaded = true;

Adapt.trigger('xapi:stateLoaded');

scope.listenToOnce(Adapt, 'app:dataReady', scope.onDataReady);
}
});
}, function(err) {
if (err) {
scope.showErrorNotification();
} else {
scope._isLoaded = true;

Adapt.trigger('xapi:stateLoaded');

scope.listenTo(Adapt, 'app:dataReady', scope.onDataReady);
}
});
}
}
});
},

reset: function() {
var states = this._getStates();
var scope = this;

this._isRestored = false;

Adapt.wait.begin();

Async.each(states, function(id, callback) {
scope.delete(id, callback);
}, function(err) {

this._getStates(function(err, data) {
if (err) {
scope.showErrorNotification();
}
} else {
Adapt.wait.begin();

var data = {};
data[COMPONENTS_KEY] = [];
data[DURATIONS_KEY] = [];
scope.set(data, { silent: true });
var states = data;

Adapt.wait.end();
Async.each(states, function(id, callback) {
scope.delete(id, callback);
}, function(err) {
if (err) scope.showErrorNotification();

var data = {};
data[COMPONENTS_KEY] = [];
data[DURATIONS_KEY] = [];
scope.set(data, { silent: true });

Adapt.wait.end();
});
}
});
},

Expand All @@ -162,63 +150,142 @@ define([
set: function(id, value) {
Backbone.Model.prototype.set.apply(this, arguments);

// delete location if empty - xAPIWrapper returns early from empty values, meaning once a location has been set, it doesn't reset on returning to the menu
if (id === LOCATION_KEY && this.has(LOCATION_KEY) && value === "") {
this.unset(id, { silent: true });
this.delete(id);

return;
}

// @todo: save every time the value changes, or only on specific events?
if (this._isLoaded) this.save(id);
if (this._isLoaded) {
if (Adapt.terminate) {
this.save(id);
} else {
var queue = this._getQueueById(id);
queue.push(id);
}
}
},

save: function(id) {
this.xAPIWrapper.sendState(this.get('activityId'), this.get('actor'), id, this.get('registration'), this.get(id), null, null, function(request) {
Adapt.log.debug(request.response);

switch (request.status) {
case 204:
// no content
break;
case 401:
// @todo: add a session expired notification?
case 404:
this.showErrorNotification();
break;
}
save: function(id, callback) {
var scope = this;
var state = this.get(id);
var data = JSON.stringify(state);

fetch(this._getStateURL(id), {
keepalive: Adapt.terminate || false,
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": this.xAPIWrapper.lrs.auth,
"X-Experience-API-Version": this.xAPIWrapper.xapiVersion
},
body: data
}).then(function(response) {
//if (response) Adapt.log.debug(response);

if (!response.ok) throw Error(response.statusText);

if (callback) callback();

return response;
}).catch(function(error) {
scope.showErrorNotification();

if (callback) callback();
});
},

delete: function(id, callback) {
this.unset(id, { silent: true });

this.xAPIWrapper.deleteState(this.get('activityId'), this.get('actor'), id, this.get('registration'), null, null, function(request) {
Adapt.log.debug(request.response);

switch (request.status) {
case 204:
// no content
break;
case 401:
// @todo: add a session expired notification?
case 404:
this.showErrorNotification();
break;
var scope = this;

fetch(this._getStateURL(id), {
method: "DELETE",
headers: {
"Authorization": this.xAPIWrapper.lrs.auth,
"X-Experience-API-Version": this.xAPIWrapper.xapiVersion
}
}).then(function(response) {
if (!response.ok) throw Error(response.statusText);

if (callback) callback();

return response;
}).catch(function(error) {
scope.showErrorNotification();

if (callback) callback();
});
},

_getStates: function() {
_getStateURL: function(stateId) {
var activityId = this.get('activityId');
var actor = this.get('actor');
var agent = this.get('actor');
var registration = this.get('registration');
var states = this.xAPIWrapper.getState(activityId, actor, null, registration);
var url = this.xAPIWrapper.lrs.endpoint + "activities/state?activityId=" + encodeURIComponent(activityId) + "&agent="+ encodeURIComponent(JSON.stringify(agent));

if (registration) url += "&registration=" + encodeURIComponent(registration);
if (stateId) url += "&stateId=" + encodeURIComponent(stateId);

return url;
},

_fetchState: function(stateId, callback) {
var scope = this;

fetch(this._getStateURL(stateId), {
//cache: "no-cache",
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": this.xAPIWrapper.lrs.auth,
"X-Experience-API-Version": this.xAPIWrapper.xapiVersion,
"Cache-Control": "no-cache",
"Pragma": "no-cache"
}
}).then(function(response) {
if (!response.ok) throw Error(response.statusText);

return response.json();
}).then(function(data) {
//if (data) Adapt.log.debug(data);

if (callback) callback(null, data);
}).catch(function(error) {
scope.showErrorNotification();

if (callback) callback();
});
},

_getStates: function(callback) {
var scope = this;

Adapt.wait.begin();

this._fetchState(null, function(err, data) {
if (err) {
scope.showErrorNotification();

return states;
if (callback) callback(err, null);
} else {
if (callback) callback(null, data);
}

Adapt.wait.end();
});
},

_getQueueById: function(id) {
var queue = this._queues[id];

if (!queue) {
queue = this._queues[id] = Async.queue(_.bind(function(id, callback) {
this.save(id, callback);
}, this), 1);

queue.drain = function() {
Adapt.log.debug("State API queue cleared for " + id);
};
}

return queue;
},

_restoreComponentsData: function() {
Expand Down Expand Up @@ -329,29 +396,35 @@ define([

this._isRestored = false;

var states = this._getStates();
var scope = this;

var statesToReset = states.filter(function(id) {
return id !== 'lang';
});
this._getStates(function(err, data) {
if (err) {
scope.showErrorNotification();
} else {
Adapt.wait.begin();

var scope = this;
var states = data;

Adapt.wait.begin();
var statesToReset = states.filter(function(id) {
return id !== 'lang';
});

Async.each(statesToReset, function(id, callback) {
scope.delete(id, callback);
}, function(err) {
if (err) scope.showErrorNotification();
Async.each(statesToReset, function(id, callback) {
scope.delete(id, callback);
}, function(err) {
if (err) scope.showErrorNotification();

var data = {};
data[COMPONENTS_KEY] = [];
data[DURATIONS_KEY] = [];
scope.set(data, { silent: true });
var data = {};
data[COMPONENTS_KEY] = [];
data[DURATIONS_KEY] = [];
scope.set(data, { silent: true });

Adapt.wait.end();
Adapt.wait.end();
});
}
});
},
}

});

Expand Down
Loading

1 comment on commit 4164ffe

@danielghost
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See 9bf8ad9 for missing library files.

This commit also fixes a caching issue with retrieving the State when using the ADL xAPIWrapper in IE11.

Please sign in to comment.