diff --git a/box/box.html b/box/box.html
index 43622bfa..2f37ea09 100644
--- a/box/box.html
+++ b/box/box.html
@@ -151,6 +151,14 @@
+
+
+
+
+
+
+
+
@@ -176,13 +184,20 @@
defaults: {
box: {type:"box-credentials",required:true},
filepattern: {value:""},
- name: {value:""}
+ name: {value:""},
+ longpolling: {value: false},
+ interval: {value: 600}
},
inputs:0,
outputs:1,
icon: "box.png",
label: function() {
return this.name||this.filepattern||'Box';
+ },
+ oneditprepare: function() {
+ $('#node-input-longpolling').change(function () {
+ $('#node-input-interval').prop('disabled', $(this).prop('checked'));
+ });
}
});
diff --git a/box/box.js b/box/box.js
index 74280442..69b443fb 100644
--- a/box/box.js
+++ b/box/box.js
@@ -62,7 +62,7 @@ module.exports = function(RED) {
return;
}
if (data.error) {
- node.error(RED._("box.error.refresh-token-error",{message:data.error.message}));
+ node.error(RED._("box.error.refresh-token-error",{message:data.error}));
return;
}
// console.log("refreshed: " + require('util').inspect(data));
@@ -210,11 +210,14 @@ module.exports = function(RED) {
};
function constructFullPath(entry) {
- var parentPath = entry.path_collection.entries
- .filter(function (e) { return e.id !== "0"; })
- .map(function (e) { return e.name; })
- .join('/');
- return (parentPath !== "" ? parentPath+'/' : "") + entry.name;
+ if (entry.path_collection) {
+ var parentPath = entry.path_collection.entries
+ .filter(function (e) { return e.id !== "0"; })
+ .map(function (e) { return e.name; })
+ .join('/');
+ return (parentPath !== "" ? parentPath+'/' : "") + entry.name;
+ }
+ return entry.name;
}
RED.httpAdmin.get('/box-credentials/auth', function(req, res) {
@@ -315,80 +318,85 @@ module.exports = function(RED) {
res.send(RED._("box.error.authorized"));
});
});
- });
+1 });
function BoxInNode(n) {
RED.nodes.createNode(this,n);
this.filepattern = n.filepattern || "";
+ this.interval = n.interval || 60;
+ this.longpolling = Boolean(n.longpolling);
this.box = RED.nodes.getNode(n.box);
+ this.seenEvents = {};
var node = this;
if (!this.box || !this.box.credentials.accessToken) {
this.warn(RED._("box.warn.missing-credentials"));
return;
}
node.status({fill:"blue",shape:"dot",text:"box.status.initializing"});
- this.box.request({
- url: 'https://api.box.com/2.0/events?stream_position=now&stream_type=changes',
- }, function (err, data) {
- if (err) {
- node.error(RED._("box.error.event-stream-initialize-failed",{err:err.toString()}));
- node.status({fill:"red",shape:"ring",text:"box.status.failed"});
- return;
+
+ // this will fire once the initial stream position is determined
+ node.on('ready', function () {
+ if (node.longpolling) {
+ node.startLongPolling();
+ } else {
+ node.startIntervalPolling();
}
- node.state = data.next_stream_position;
- node.status({});
- node.on("input", function(msg) {
- node.status({fill:"blue",shape:"dot",text:"box.status.checking-for-events"});
- node.box.request({
- url: 'https://api.box.com/2.0/events?stream_position='+node.state+'&stream_type=changes',
- }, function(err, data) {
- if (err) {
- node.error(RED._("box.error.events-fetch-failed",{err:err.toString()}),msg);
- node.status({});
- return;
- }
+ });
+
+ // this fires if either the long-poller says we should have data, or on an interval.
+ node.on('check-events', function() {
+ node.status({fill:"blue",shape:"dot",text:"box.status.checking-for-events"});
+ node.box.request({
+ url: 'https://api.box.com/2.0/events?stream_position='+node.state+'&stream_type=changes',
+ }, function(err, data) {
+ if (err) {
+ node.error(RED._("box.error.events-fetch-failed",{err:err.toString()}),{});
node.status({});
- node.state = data.next_stream_position;
- for (var i = 0; i < data.entries.length; i++) {
- // TODO: support other event types
- // TODO: suppress duplicate events
- // for both of the above see:
- // https://developers.box.com/docs/#events
- var event;
- if (data.entries[i].event_type === 'ITEM_CREATE') {
- event = 'add';
- } else if (data.entries[i].event_type === 'ITEM_UPLOAD') {
- event = 'add';
- } else if (data.entries[i].event_type === 'ITEM_RENAME') {
- event = 'add';
- // TODO: emit delete event?
- } else if (data.entries[i].event_type === 'ITEM_TRASH') {
- // need to find old path
- node.lookupOldPath({}, data.entries[i], 'delete');
- /* strictly speaking the {} argument above should
- * be clone(msg) but:
- * - it must be {}
- * - if there was any possibility of a different
- * msg then it should be cloned using the
- * node-red/red/nodes/Node.js cloning function
- */
- continue;
- } else {
- event = 'unknown';
- }
- //console.log(JSON.stringify(data.entries[i], null, 2));
- node.sendEvent(msg, data.entries[i], event);
+ return;
+ }
+ node.status({});
+ node.state = data.next_stream_position;
+ node.emit('ready');
+ for (var i = 0; i < data.entries.length; i++) {
+ // TODO: support other event types
+ // TODO: suppress duplicate events
+ // for both of the above see:
+ // https://developers.box.com/docs/#events
+ var event;
+ if (node.seenEvents[data.entries[i].event_id]) {
+ continue;
}
- });
- });
- var interval = setInterval(function() {
- node.emit("input", {});
- }, 600000); // 10 minutes
- node.on("close", function() {
- if (interval !== null) { clearInterval(interval); }
+ node.seenEvents[data.entries[i].event_id] = true;
+ if (data.entries[i].event_type === 'ITEM_CREATE') {
+ event = 'add';
+ } else if (data.entries[i].event_type === 'ITEM_UPLOAD') {
+ event = 'add';
+ } else if (data.entries[i].event_type === 'ITEM_RENAME') {
+ event = 'add';
+ // TODO: emit delete event?
+ } else if (data.entries[i].event_type === 'ITEM_TRASH') {
+ // need to find old path
+ node.lookupOldPath({}, data.entries[i], 'delete');
+ /* strictly speaking the {} argument above should
+ * be clone(msg) but:
+ * - it must be {}
+ * - if there was any possibility of a different
+ * msg then it should be cloned using the
+ * node-red/red/nodes/Node.js cloning function
+ */
+ continue;
+ } else {
+ event = 'unknown';
+ }
+ //console.log(JSON.stringify(data.entries[i], null, 2));
+ node.sendEvent({}, data.entries[i], event);
+ }
});
});
+
+ node.getInitialStreamPosition();
}
+
RED.nodes.registerType("box in", BoxInNode);
BoxInNode.prototype.sendEvent = function(msg, entry, event, path) {
@@ -424,6 +432,126 @@ module.exports = function(RED) {
});
};
+ /**
+ * Init long-polling to retrieve events from Box in real-time
+ *
+ * If long polling is enabled, we make an OPTIONS request to the "events"
+ * endpoint. this endpoint will return another URL to poll, in addition
+ * to a "timeout" value. we will then make a GET request to the supplied URL
+ * and wait for a response. if the response is "new_change", we hit the
+ * "events" endpoint with the usual GET request (and stream position). if the
+ * response is "retry_timeout", we retry the operation again from the OPTIONS
+ * request. if the "timeout" value is exceeded, we retry the GET request.
+ * if "max_retries" is exceeded, we start again from OPTIONS.
+ * @see https://developer.box.com/v2.0/reference#long-polling
+ * @private
+ */
+ BoxInNode.prototype.startLongPolling = function() {
+ var node = this;
+ node.box.request({
+ url: 'https://api.box.com/2.0/events',
+ method: 'OPTIONS'
+ }, function (err, data) {
+ if (err) {
+ node.error(RED._('box.error.long-polling-failed'), {
+ err: err.toString()
+ });
+ return;
+ }
+ if (!(data.entries && data.entries.length)) {
+ node.error(RED._('box.error.invalid-response'), {
+ err: err.toString()
+ });
+ return;
+ }
+ node.longpoll(data.entries.shift());
+ });
+ }
+
+ /**
+ * Initializes default (interval-based) polling
+ */
+ BoxInNode.prototype.startIntervalPolling = function () {
+ var node = this;
+
+ if (!node.pollingInterval) {
+ node.pollingInterval = setInterval(function() {
+ node.emit('check-events');
+ }, node.interval * 1000); // interval in ms
+
+ node.on("close", function() {
+ clearInterval(node.pollingInterval);
+ });
+ }
+ };
+
+ /**
+ * Gets initial stream position from events endpoint
+ * Saves as "state" property. When ready, emits "ready" event, which will
+ * initialize long-polling or interval-based polling, depending on Node config.
+ */
+ BoxInNode.prototype.getInitialStreamPosition = function () {
+ var node = this;
+ node.box.request({
+ url: 'https://api.box.com/2.0/events?stream_position=now&stream_type=changes',
+ }, function (err, data) {
+ if (err) {
+ node.error(RED._('box.error.event-stream-initialize-failed', {err: err.toString()}));
+ node.status({
+ fill: 'red',
+ shape: 'ring',
+ text: 'box.status.failed'
+ });
+ return;
+ }
+ node.state = data.next_stream_position;
+ node.status({});
+ node.emit('ready');
+ });
+ };
+
+ /**
+ * Long-poll Box for new events
+ * This function calls itself recursively until config.max_retries is hit. At that point
+ * it will call BoxInNode#startPolling again.
+ * @private
+ * @param {Object} config Object returned by Box's "events" endpoint when hit with OPTIONS method
+ * @param {number} config.max_retries Number of retries allowed
+ * @param {number} config.retry_timeout Retry the GET request after this many seconds
+ * @param {string} config.url Endpoint of GET request
+ * @param {number} [count=0] Current retry count
+ */
+ BoxInNode.prototype.longpoll = function (config, count) {
+ var node = this;
+ count = count || 0;
+ node.box.request({
+ url: config.url,
+ timeout: parseInt(config.retry_timeout, 10) * 1000
+ }, function (err, data) {
+ if (err) {
+ if (err.code === 'ESOCKETTIMEDOUT') {
+ if (count === parseInt(config.max_retries, 10) - 1) {
+ node.startLongPolling();
+ return;
+ }
+ process.nextTick(function() {
+ node.longpoll(config, ++count);
+ });
+ return;
+ }
+ node.error(RED._('box.error.long-polling-failed'), {err: err.toString()});
+ return;
+ }
+ if (data.message === 'new_change') {
+ node.emit('check-events');
+ } else if (data.message === 'reconnect') {
+ node.startLongPolling();
+ } else {
+ // ???
+ }
+ });
+ };
+
function BoxQueryNode(n) {
RED.nodes.createNode(this,n);
this.filename = n.filename || "";
diff --git a/box/locales/en-US/box.json b/box/locales/en-US/box.json
index d65ab4a5..d56b6710 100644
--- a/box/locales/en-US/box.json
+++ b/box/locales/en-US/box.json
@@ -11,7 +11,9 @@
"clientid": "Client Id",
"secret": "Secret",
"authenticate": "Authenticate with Box",
- "boxuser": "Box User"
+ "boxuser": "Box User",
+ "interval": "Polling Interval (seconds)",
+ "longpolling": "Real-Time Updates?"
},
"placeholder": {
"pattern": "Filepattern",
@@ -30,7 +32,8 @@
"resolving-path": "resolving path",
"downloading": "downloading",
"uploading": "uploading",
- "overwriting": "overwriting"
+ "overwriting": "overwriting",
+ "waiting": "waiting"
},
"warn": {
"refresh-token": "trying to refresh token due to expiry",
@@ -55,7 +58,9 @@
"no-filename-specified": "No filename specified",
"path-resolve-failed": "failed to resolve path: __err__",
"download-failed": "download failed: __err__",
- "upload-failed": "failed upload: __err__"
+ "upload-failed": "failed upload: __err__",
+ "long-polling-failed": "long-polling request failed: __err__",
+ "invalid-response": "invalid response from Box when polling: __err__"
}
}
}
diff --git a/test/box/box_spec.js b/test/box/box_spec.js
index 0fd1ff6a..b7f54c3c 100644
--- a/test/box/box_spec.js
+++ b/test/box/box_spec.js
@@ -148,12 +148,75 @@ describe('box nodes', function() {
var onStub = sinon.stub(box,'on').callsFake(function(event, cb) {
var res = onFunction.apply(box, arguments);
onStub.restore();
- box.emit('input', {}); // trigger poll
+ box.emit('check-events', {}); // trigger poll
return res;
});
});
});
+ it('should support long-polling', function(done) {
+ nock('https://api.box.com:443')
+ .get('/2.0/events?stream_position=now&stream_type=changes')
+ .reply(200, {
+ "chunk_size":0,"next_stream_position":1000,
+ "entries":[]
+ }, { 'content-type': 'application/json' })
+ .options('/2.0/events')
+ .reply(200, {
+ chunk_size: 1,
+ entries: [{
+ type: 'realtime_server',
+ // actually some other domain entirely
+ url: "https://api.box.com:443/2.0/some-long-polling-endpoint",
+ retry_timeout: 610,
+ max_retries: '10' // this is a string for some reason
+ }]
+ })
+ .get('/2.0/some-long-polling-endpoint')
+ .reply(200, {
+ message: 'new_change'
+ })
+ .get('/2.0/events?stream_position=1000&stream_type=changes')
+ .reply(200, {"entries":[{
+ "type":"event","event_id":"1234",
+ "event_type":"ITEM_UPLOAD",
+ "session_id":"1234567",
+ "source":{
+ "type":"file","id":"7","name":"foobar.txt",
+ "path_collection":{"total_count":2,"entries":[
+ {"type":"folder","id":"0","name":"All Files"},
+ {"type":"folder","id":"2","name":"node-red"}
+ ]},
+ "parent":{"type":"folder","id":"2","name":"node-red"},
+ }}],"chunk_size":1,"next_stream_position":2000}, {
+ 'content-type': 'application/json',
+ });
+ helper.load(boxNode,
+ [{id:"box-config", type:"box-credentials"},
+ {id:"box", type:"box in", box:"box-config", longpolling: true, wires:[["output"]]},
+ {id:"output", type:"helper"},
+ ], {
+ "box-config": {
+ clientId: "ID",
+ clientSecret: "SECRET",
+ accessToken: "ACCESS",
+ refreshToken: "REFRESH",
+ expireTime: 1000+(new Date().getTime()/1000)
+ },
+ }, function() {
+ var box = helper.getNode("box");
+ box.should.have.property('id', 'box');
+ var output = helper.getNode("output");
+ output.should.have.property('id', 'output');
+ output.on("input", function(msg) {
+ msg.should.have.property('payload', "node-red/foobar.txt");
+ msg.should.have.property('file', "foobar.txt");
+ msg.should.have.property('event', 'add');
+ done();
+ });
+ });
+ });
+
it('should report file delete event', function(done) {
nock('https://api.box.com:443')
.get('/2.0/events?stream_position=now&stream_type=changes')
@@ -224,7 +287,7 @@ describe('box nodes', function() {
var onStub = sinon.stub(box, 'on').callsFake(function(event, cb) {
var res = onFunction.apply(box, arguments);
onStub.restore();
- box.emit('input', {}); // trigger poll
+ box.emit('check-events', {}); // trigger poll
return res;
});
});