serverless-spy
Version:
CDK-based library for writing elegant integration tests on AWS serverless architecture and an additional web console to monitor events in real time.
772 lines (720 loc) • 28 kB
JavaScript
/*
* Copyright 2010-2015 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
//node.js deps
var events = require('events');
var inherits = require('util').inherits;
//npm deps
//app deps
var deviceModule = require('../device');
var isUndefined = require('../common/lib/is-undefined');
//
// private functions
//
function buildThingShadowTopic(thingName, operation, type) {
if (!isUndefined(type)) {
return '$aws/things/' + thingName + '/shadow/' + operation + '/' + type;
}
return '$aws/things/' + thingName + '/shadow/' + operation;
}
function isThingShadowTopic(topicTokens, direction) {
var rc = false;
if (topicTokens[0] === '$aws') {
//
// Thing shadow topics have the form:
//
// $aws/things/{thingName}/shadow/{Operation}/{Status}
//
// Where {Operation} === update|get|delete
// And {Status} === accepted|rejected|delta
//
if ((topicTokens[1] === 'things') &&
(topicTokens[3] === 'shadow') &&
((topicTokens[4] === 'update') ||
(topicTokens[4] === 'get') ||
(topicTokens[4] === 'delete'))) {
//
// Looks good so far; now check the direction and see if
// still makes sense.
//
if (direction === 'subscribe') {
if (((topicTokens[5] === 'accepted') ||
(topicTokens[5] === 'rejected') ||
(topicTokens[5] === 'delta')) &&
(topicTokens.length === 6)) {
rc = true;
}
} else // direction === 'publish'
{
if (topicTokens.length === 5) {
rc = true;
}
}
}
}
return rc;
}
//begin module
function ThingShadowsClient(deviceOptions, thingShadowOptions) {
//
// Force instantiation using the 'new' operator; this will cause inherited
// constructors (e.g. the 'events' class) to be called.
//
if (!(this instanceof ThingShadowsClient)) {
return new ThingShadowsClient(deviceOptions, thingShadowOptions);
}
//
// A copy of 'this' for use inside of closures
//
var that = this;
//
// Track Thing Shadow registrations in here.
//
var thingShadows = [{}];
//
// Implements for every operation, used to construct clientToken.
//
var operationCount = 0;
//
// Operation timeout (milliseconds). If no accepted or rejected response
// to a thing operation is received within this time, subscriptions
// to the accepted and rejected sub-topics for a thing are cancelled.
//
var operationTimeout = 10000; /* milliseconds */
//
// Variable used by the testing API setConnectionStatus() to simulate
// network connectivity failures.
//
var connected = true;
//
// Instantiate the device.
//
var device = deviceModule.DeviceClient(deviceOptions);
if (!isUndefined(thingShadowOptions)) {
if (!isUndefined(thingShadowOptions.operationTimeout)) {
operationTimeout = thingShadowOptions.operationTimeout;
}
}
//
// Private function to subscribe and unsubscribe from topics.
//
this._handleSubscriptions = function(thingName, topicSpecs, devFunction, callback) {
var topics = [];
//
// Build an array of topic names.
//
for (var i = 0, topicsLen = topicSpecs.length; i < topicsLen; i++) {
for (var j = 0, opsLen = topicSpecs[i].operations.length; j < opsLen; j++) {
for (var k = 0, statLen = topicSpecs[i].statii.length; k < statLen; k++) {
topics.push(buildThingShadowTopic(thingName,
topicSpecs[i].operations[j],
topicSpecs[i].statii[k]));
}
}
}
if (thingShadows[thingName].debug === true) {
console.log(devFunction + ' on ' + topics);
}
//
// Subscribe/unsubscribe from the topics and perform callback when complete.
//
var args = [];
args.push(topics);
if (devFunction === 'subscribe') {
// QoS only applicable for subscribe
args.push({
qos: thingShadows[thingName].qos
});
// add our callback to check the SUBACK response for granted subscriptions
args.push(function(err, granted) {
if (!isUndefined(callback)) {
if (err) {
callback(err);
return;
}
//
// Check to see if we got all topic subscriptions granted.
//
var failedTopics = [];
for (var k = 0, grantedLen = granted.length; k < grantedLen; k++) {
//
// 128 is 0x80 - Failure from the MQTT lib.
//
if (granted[k].qos === 128) {
failedTopics.push(granted[k]);
}
}
if (failedTopics.length > 0) {
callback('Not all subscriptions were granted', failedTopics);
return;
}
// all subscriptions were granted
callback();
}
});
} else {
if (!isUndefined(callback)) {
args.push(callback);
}
}
device[devFunction].apply(device, args);
};
//
// Private function to handle messages and dispatch them accordingly.
//
this._handleMessages = function(thingName, operation, operationStatus, payload) {
var stateObject = {};
try {
stateObject = JSON.parse(payload.toString());
} catch (err) {
if (deviceOptions.debug === true) {
console.error('failed parsing JSON \'' + payload.toString() + '\', ' + err);
}
return;
}
var clientToken = stateObject.clientToken;
var version = stateObject.version;
//
// Remove the properties 'clientToken' and 'version' from the stateObject;
// these properties are internal to this class.
//
delete stateObject.clientToken;
//Expose shadow version from raw object
//delete stateObject.version;
//
// Update the thing version on every accepted or delta message which
// contains it.
//
if ((!isUndefined(version)) && (operationStatus !== 'rejected')) {
//
// The thing shadow version is incremented by AWS IoT and should always
// increase. Do not update our local version if the received version is
// less than our version.
//
if ((isUndefined(thingShadows[thingName].version)) ||
(version >= thingShadows[thingName].version)) {
thingShadows[thingName].version = version;
} else {
//
// We've received a message from AWS IoT with a version number lower than
// we would expect. There are two things that can cause this:
//
// 1) The shadow has been deleted (version # reverts to 1 in this case.)
// 2) The message has arrived out-of-order.
//
// For case 1) we can look at the operation to determine that this
// is the case and notify the client if appropriate. For case 2,
// we will not process it unless the client has specifically expressed
// an interested in these messages by setting 'discardStale' to false.
//
if (operation !== 'delete' && thingShadows[thingName].discardStale === true) {
if (deviceOptions.debug === true) {
console.warn('out-of-date version \'' + version + '\' on \'' +
thingName + '\' (local version \'' +
thingShadows[thingName].version + '\')');
}
return;
}
}
}
//
// If this is a 'delta' message, emit an event for it and return.
//
if (operationStatus === 'delta') {
this.emit('delta', thingName, stateObject);
return;
}
//
// only accepted/rejected messages past this point
// ===============================================
// If this is an unkown clientToken (e.g., it doesn't have a corresponding
// client token property, the shadow has been modified by another client.
// If it's an update/accepted or delete/accepted, update the shadow and
// notify the client.
//
if (isUndefined(thingShadows[thingName].clientToken) ||
thingShadows[thingName].clientToken !== clientToken) {
if ((operationStatus === 'accepted') && (operation !== 'get')) {
//
// This is a foreign update or delete accepted, update our
// shadow with the latest state and send a notification.
//
this.emit('foreignStateChange', thingName, operation, stateObject);
}
return;
}
//
// A response has been received, so cancel any outstanding timeout on this
// thingName/clientToken, delete the timeout handle, and unsubscribe from
// all sub-topics.
//
clearTimeout(
thingShadows[thingName].timeout);
delete thingShadows[thingName].timeout;
//
// Delete the operation's client token.
//
delete thingShadows[thingName].clientToken;
//
// Mark this operation as complete.
//
thingShadows[thingName].pending = false;
//
// Unsubscribe from the 'accepted' and 'rejected' sub-topics unless we are
// persistently subscribed to this thing shadow.
//
if (thingShadows[thingName].persistentSubscribe === false) {
this._handleSubscriptions(thingName, [{
operations: [operation],
statii: ['accepted', 'rejected']
}], 'unsubscribe');
}
//
// Emit an event detailing the operation status; the clientToken is included
// as an argument so that the application can correlate status events to
// the operations they are associated with.
//
this.emit('status', thingName, operationStatus, clientToken, stateObject);
};
device.on('connect', function() {
that.emit('connect');
});
device.on('close', function() {
that.emit('close');
});
device.on('reconnect', function() {
that.emit('reconnect');
});
device.on('offline', function() {
that.emit('offline');
});
device.on('error', function(error) {
that.emit('error', error);
});
device.on('packetsend', function(packet) {
that.emit('packetsend', packet);
});
device.on('packetreceive', function(packet) {
that.emit('packetreceive', packet);
});
device.on('message', function(topic, payload) {
if (connected === true) {
//
// Parse the topic to determine what to do with it.
//
var topicTokens = topic.split('/');
//
// First, do a rough check to see if we should continue or not.
//
if (isThingShadowTopic(topicTokens, 'subscribe')) {
//
// This looks like a valid Thing topic, so see if the Thing is in the
// registered Thing table.
//
if (thingShadows.hasOwnProperty(topicTokens[2])) {
//
// This is a registered Thing, so perform message handling on it.
//
that._handleMessages(topicTokens[2], // thingName
topicTokens[4], // operation
topicTokens[5], // status
payload);
}
//
// Any messages received for unregistered Things fall here and are ignored.
//
} else {
//
// This isn't a Thing topic, so pass it along to the instance if they have
// indicated they want to handle it.
//
that.emit('message', topic, payload);
}
}
});
this._thingOperation = function(thingName, operation, stateObject) {
var rc = null;
if (thingShadows.hasOwnProperty(thingName)) {
//
// Don't allow a new operation if an existing one is still in process.
//
if (thingShadows[thingName].pending === false) {
//
// Starting a new operation
//
thingShadows[thingName].pending = true;
//
// If not provided, construct a clientToken from the clientId and a rolling
// operation count. The clientToken is transmitted in any published stateObject
// and is returned to the caller for each operation. Applications can use
// clientToken values to correlate received responses or timeouts with
// the original operations.
//
var clientToken;
if (isUndefined(stateObject.clientToken)) {
//
// AWS IoT restricts client tokens to 64 bytes, so use only the last 48
// characters of the client ID when constructing a client token.
//
var clientIdLength = deviceOptions.clientId.length;
if (clientIdLength > 48) {
clientToken = deviceOptions.clientId.substr(clientIdLength - 48) + '-' + operationCount++;
} else {
clientToken = deviceOptions.clientId + '-' + operationCount++;
}
} else {
clientToken = stateObject.clientToken;
}
//
// Remember the client token for this operation; it will be
// deleted when the operation completes or times out.
//
thingShadows[thingName].clientToken = clientToken;
var publishTopic = buildThingShadowTopic(thingName,
operation);
//
// Subscribe to the 'accepted' and 'rejected' sub-topics for this get
// operation and set a timeout beyond which they will be unsubscribed if
// no messages have been received for either of them.
//
thingShadows[thingName].timeout = setTimeout(
function(thingName, clientToken) {
//
// Timed-out. Unsubscribe from the 'accepted' and 'rejected' sub-topics unless
// we are persistently subscribing to this thing shadow.
//
if (thingShadows[thingName].persistentSubscribe === false) {
that._handleSubscriptions(thingName, [{
operations: [operation],
statii: ['accepted', 'rejected']
}], 'unsubscribe');
}
//
// Mark this operation as complete.
//
thingShadows[thingName].pending = false;
//
// Delete the timeout handle and client token for this thingName.
//
delete thingShadows[thingName].timeout;
delete thingShadows[thingName].clientToken;
//
// Emit an event for the timeout; the clientToken is included as an argument
// so that the application can correlate timeout events to the operations
// they are associated with.
//
that.emit('timeout', thingName, clientToken);
}, operationTimeout,
thingName, clientToken);
//
// Subscribe to the 'accepted' and 'rejected' sub-topics unless we are
// persistently subscribing, in which case we can publish to the topic immediately
// since we are already subscribed to all applicable sub-topics.
//
if (thingShadows[thingName].persistentSubscribe === false) {
this._handleSubscriptions(thingName, [{
operations: [operation],
statii: ['accepted', 'rejected'],
}], 'subscribe',
function(err, failedTopics) {
if (!isUndefined(err) || !isUndefined(failedTopics)) {
console.warn('failed subscription to accepted/rejected topics');
return;
}
//
// If 'stateObject' is defined, publish it to the publish topic for this
// thingName+operation.
//
if (!isUndefined(stateObject)) {
//
// Add the version # (if known and versioning is enabled) and
// 'clientToken' properties to the stateObject.
//
if (!isUndefined(thingShadows[thingName].version) &&
thingShadows[thingName].enableVersioning) {
stateObject.version = thingShadows[thingName].version;
}
stateObject.clientToken = clientToken;
device.publish(publishTopic,
JSON.stringify(stateObject), {
qos: thingShadows[thingName].qos
});
if (!(isUndefined(thingShadows[thingName])) &&
thingShadows[thingName].debug === true) {
console.log('publishing \'' + JSON.stringify(stateObject) +
' on \'' + publishTopic + '\'');
}
}
});
} else {
//
// Add the version # (if known and versioning is enabled) and
// 'clientToken' properties to the stateObject.
//
if (!isUndefined(thingShadows[thingName].version) &&
thingShadows[thingName].enableVersioning) {
stateObject.version = thingShadows[thingName].version;
}
stateObject.clientToken = clientToken;
device.publish(publishTopic,
JSON.stringify(stateObject), {
qos: thingShadows[thingName].qos
});
if (thingShadows[thingName].debug === true) {
console.log('publishing \'' + JSON.stringify(stateObject) +
' on \'' + publishTopic + '\'');
}
}
rc = clientToken; // return the clientToken to the caller
} else {
if (deviceOptions.debug === true) {
console.error(operation + ' still in progress on thing: ', thingName);
}
}
} else {
if (deviceOptions.debug === true) {
console.error('attempting to ' + operation + ' unknown thing: ', thingName);
}
}
return rc;
};
this.register = function(thingName, options, callback) {
if (!thingShadows.hasOwnProperty(thingName)) {
//
// Initialize the registration entry for this thing; because the version # is
// not yet known, do not add the property for it yet. The version number
// property will be added after the first accepted update from AWS IoT.
//
var ignoreDeltas = false;
var topicSpecs = [];
thingShadows[thingName] = {
persistentSubscribe: true,
debug: false,
discardStale: true,
enableVersioning: true,
qos: 0,
pending: true
};
if (typeof options === 'function') {
callback = options;
options = null;
}
if (!isUndefined(options)) {
if (!isUndefined(options.ignoreDeltas)) {
ignoreDeltas = options.ignoreDeltas;
}
if (!isUndefined(options.persistentSubscribe)) {
thingShadows[thingName].persistentSubscribe = options.persistentSubscribe;
}
if (!isUndefined(options.debug)) {
thingShadows[thingName].debug = options.debug;
}
if (!isUndefined(options.discardStale)) {
thingShadows[thingName].discardStale = options.discardStale;
}
if (!isUndefined(options.enableVersioning)) {
thingShadows[thingName].enableVersioning = options.enableVersioning;
}
if (!isUndefined(options.qos)) {
thingShadows[thingName].qos = options.qos;
}
}
//
// Always listen for deltas unless requested otherwise.
//
if (ignoreDeltas === false) {
topicSpecs.push({
operations: ['update'],
statii: ['delta']
});
}
//
// If we are persistently subscribing, we subscribe to everything we could ever
// possibly be interested in. This will provide us the ability to publish
// without waiting at the cost of potentially increased irrelevant traffic
// which the application will need to filter out.
//
if (thingShadows[thingName].persistentSubscribe === true) {
topicSpecs.push({
operations: ['update', 'get', 'delete'],
statii: ['accepted', 'rejected']
});
}
if (topicSpecs.length > 0) {
this._handleSubscriptions(thingName, topicSpecs, 'subscribe', function(err, failedTopics) {
if (isUndefined(err) && isUndefined(failedTopics)) {
thingShadows[thingName].pending = false;
}
if (!isUndefined(callback)) {
callback(err, failedTopics);
}
});
} else {
thingShadows[thingName].pending = false;
if (!isUndefined(callback)) {
callback();
}
}
} else {
if (deviceOptions.debug === true) {
console.error('thing already registered: ', thingName);
}
}
};
this.unregister = function(thingName) {
if (thingShadows.hasOwnProperty(thingName)) {
var topicSpecs = [];
//
// If an operation is outstanding, it will have a timeout set; when it
// expires any accept/reject sub-topic subscriptions for the thing will be
// deleted. If any messages arrive after the thing has been deleted, they
// will simply be ignored as it no longer exists in the thing registrations.
// The only sub-topic we need to unsubscribe from is the delta sub-topic,
// which is always active.
//
topicSpecs.push({
operations: ['update'],
statii: ['delta']
});
//
// If we are persistently subscribing, we subscribe to everything we could ever
// possibly be interested in; this means that when it's time to unregister
// interest in a thing, we need to unsubscribe from all of these topics.
//
if (thingShadows[thingName].persistentSubscribe === true) {
topicSpecs.push({
operations: ['update', 'get', 'delete'],
statii: ['accepted', 'rejected']
});
}
this._handleSubscriptions(thingName, topicSpecs, 'unsubscribe');
//
// Delete any pending timeout
//
if (!isUndefined(thingShadows[thingName].timeout)) {
clearTimeout(thingShadows[thingName].timeout);
}
//
// Delete the thing from the Thing registrations.
//
delete thingShadows[thingName];
} else {
if (deviceOptions.debug === true) {
console.error('attempting to unregister unknown thing: ', thingName);
}
}
};
//
// Perform an update operation on the given thing shadow.
//
this.update = function(thingName, stateObject) {
var rc = null;
//
// Verify that the message does not contain a property named 'version',
// as these property is reserved for use within this class.
//
if (isUndefined(stateObject.version)) {
rc = that._thingOperation(thingName, 'update', stateObject);
} else {
console.error('message can\'t contain \'version\' property');
}
return rc;
};
//
// Perform a get operation on the given thing shadow; allow the user
// to specify their own client token if they don't want to use the
// default.
//
this.get = function(thingName, clientToken) {
var stateObject = {};
if (!isUndefined(clientToken)) {
stateObject.clientToken = clientToken;
}
return that._thingOperation(thingName, 'get', stateObject);
};
//
// Perform a delete operation on the given thing shadow.
//
this.delete = function(thingName, clientToken) {
var stateObject = {};
if (!isUndefined(clientToken)) {
stateObject.clientToken = clientToken;
}
return that._thingOperation(thingName, 'delete', stateObject);
};
//
// Publish on non-thing topics.
//
this.publish = function(topic, message, options, callback) {
device.publish(topic, message, options, callback);
};
//
// Subscribe to non-thing topics.
//
this.subscribe = function(topics, options, callback) {
var topicsArray = [];
if (typeof topics === 'string') {
topicsArray.push(topics);
} else if (typeof topics === 'object' && topics.length) {
topicsArray = topics;
}
device.subscribe(topicsArray, options, callback);
};
//
// Unsubscribe from non-thing topics.
//
this.unsubscribe = function(topics, callback) {
var topicsArray = [];
if (typeof topics === 'string') {
topicsArray.push(topics);
} else if (typeof topics === 'object' && topics.length) {
topicsArray = topics;
}
device.unsubscribe(topicsArray, callback);
};
//
// Close the device connection; this will be passed through to
// the device class.
//
this.end = function(force, callback) {
device.end(force, callback);
};
//
// Call this function to update the credentials used when
// connecting via WebSocket/SigV4; this will be passed through
// to the device class.
//
this.updateWebSocketCredentials = function(accessKeyId, secretKey, sessionToken, expiration) {
device.updateWebSocketCredentials(accessKeyId, secretKey, sessionToken, expiration);
};
//
// Call this function to update the custom auth headers
// This will be passed through to the device class
//
this.updateCustomAuthHeaders = function(newHeaders) {
device.updateCustomAuthHeaders(newHeaders);
};
//
// This is an unpublished API used for testing.
//
this.setConnectionStatus = function(connectionStatus) {
connected = connectionStatus;
};
events.EventEmitter.call(this);
}
//
// Allow instances to listen in on events that we produce for them
//
inherits(ThingShadowsClient, events.EventEmitter);
module.exports = ThingShadowsClient;