node-red-contrib-tdn-eventsub 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/examples/rest-basic.json +290 -0
- package/lib/client.js +322 -0
- package/lib/outputs.js +76 -0
- package/nodes/rest-item-status.html +101 -0
- package/nodes/rest-item-status.js +200 -0
- package/nodes/rest-lists.html +268 -0
- package/nodes/rest-lists.js +115 -0
- package/nodes/rest-server.html +120 -0
- package/nodes/rest-server.js +57 -0
- package/package.json +36 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { OUT_RESPONSE, send, setStatus, reportError, storeName, nowIso } = require('../lib/outputs');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Item status subscription (REST Server v8.30+, RESTStatus licence).
|
|
6
|
+
*
|
|
7
|
+
* POST features.items.updates.href {itemIds:[...]} -> current status + next.href
|
|
8
|
+
* loop: GET next.href (long poll, ~50 s server timeout)
|
|
9
|
+
* -> updates (emit) or empty (timeout: just wait again)
|
|
10
|
+
* -> always follow the new next.href
|
|
11
|
+
* 404 -> subscription dropped (>30 s gap or server restart): POST again
|
|
12
|
+
* other errors -> back off and re-subscribe, forever, until stopped
|
|
13
|
+
*
|
|
14
|
+
* One trigger starts it; it then runs indefinitely. A new trigger with ids replaces
|
|
15
|
+
* the subscription; msg.payload "stop" (or msg.stop = true) ends it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MAX_GAP_MS = 20000; // the server drops a subscription if next.href isn't fetched within 30 s
|
|
19
|
+
const LONG_POLL_MS = 120000; // client-side timeout for the GET (server returns at ~50 s)
|
|
20
|
+
|
|
21
|
+
function parseIds(msg) {
|
|
22
|
+
let v = msg.itemIds !== undefined ? msg.itemIds : msg.payload;
|
|
23
|
+
if (v && typeof v === 'object' && !Array.isArray(v) && v.itemIds !== undefined) v = v.itemIds;
|
|
24
|
+
if (typeof v === 'number') v = [v];
|
|
25
|
+
if (typeof v === 'string') v = v.split(/[\s,;]+/);
|
|
26
|
+
if (!Array.isArray(v)) return [];
|
|
27
|
+
const ids = [];
|
|
28
|
+
for (const x of v) {
|
|
29
|
+
const id = x && typeof x === 'object' ? (x.id !== undefined ? x.id : null) : x; // accept item objects too
|
|
30
|
+
if (id === null || id === undefined) continue;
|
|
31
|
+
const s = String(id).trim();
|
|
32
|
+
if (s && !ids.includes(s)) ids.push(s);
|
|
33
|
+
}
|
|
34
|
+
return ids;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isStop(msg) {
|
|
38
|
+
return msg.stop === true || (typeof msg.payload === 'string' && msg.payload.trim().toLowerCase() === 'stop');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = function (RED) {
|
|
42
|
+
function RestItemStatusNode(config) {
|
|
43
|
+
RED.nodes.createNode(this, config);
|
|
44
|
+
const node = this;
|
|
45
|
+
node.name = config.name;
|
|
46
|
+
node.server = RED.nodes.getNode(config.server);
|
|
47
|
+
node.pollDelay = Math.min(Math.max(parseInt(config.pollDelay, 10) || 0, 0), MAX_GAP_MS);
|
|
48
|
+
node.retryDelay = Math.max(parseInt(config.retryDelay, 10) || 5000, 500);
|
|
49
|
+
node.maxRetryDelay = Math.max(parseInt(config.maxRetryDelay, 10) || 60000, node.retryDelay);
|
|
50
|
+
node.emitTimeouts = config.emitTimeouts === true;
|
|
51
|
+
node.contextKey = (config.contextKey || '').trim();
|
|
52
|
+
node.contextStore = config.contextStore || '';
|
|
53
|
+
|
|
54
|
+
let run = null; // { ctrl, ids }
|
|
55
|
+
|
|
56
|
+
function sleep(ms, signal) {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
if (signal.aborted) return resolve();
|
|
59
|
+
const t = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, ms);
|
|
60
|
+
const onAbort = () => { clearTimeout(t); resolve(); };
|
|
61
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function storeUpdates(updates) {
|
|
66
|
+
if (!node.contextKey || !Array.isArray(updates) || !updates.length) return;
|
|
67
|
+
const g = node.context().global;
|
|
68
|
+
const store = storeName(node.contextStore);
|
|
69
|
+
const map = Object.assign({}, g.get(node.contextKey, store) || {});
|
|
70
|
+
const t = nowIso();
|
|
71
|
+
for (const u of updates) if (u && u.id !== undefined) map[u.id] = Object.assign({}, u, { updated: t });
|
|
72
|
+
g.set(node.contextKey, map, store);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function emit(res, phase, ids, seq) {
|
|
76
|
+
const body = res.body || {};
|
|
77
|
+
storeUpdates(body.updates);
|
|
78
|
+
send(node, OUT_RESPONSE, {
|
|
79
|
+
topic: 'rest/items/updates',
|
|
80
|
+
payload: body, // full server response
|
|
81
|
+
updates: Array.isArray(body.updates) ? body.updates : [],
|
|
82
|
+
phase, // 'subscribe' | 'update' | 'timeout'
|
|
83
|
+
seq,
|
|
84
|
+
itemIds: ids,
|
|
85
|
+
statusCode: res.statusCode,
|
|
86
|
+
url: res.url
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function loop(ids, ctrl) {
|
|
91
|
+
const signal = ctrl.signal;
|
|
92
|
+
let client;
|
|
93
|
+
try { client = node.server.getClient(); } catch (e) { reportError(node, e); return; }
|
|
94
|
+
|
|
95
|
+
let next = null;
|
|
96
|
+
let failures = 0;
|
|
97
|
+
let seq = 0;
|
|
98
|
+
while (!signal.aborted) {
|
|
99
|
+
try {
|
|
100
|
+
if (!next) {
|
|
101
|
+
setStatus(node, 'subscribing', `subscribing ${ids.length} item(s)`, { itemIds: ids });
|
|
102
|
+
const href = await client.feature('items.updates', { signal });
|
|
103
|
+
const res = await client.request('POST', href, { itemIds: ids }, { signal });
|
|
104
|
+
if (signal.aborted) break;
|
|
105
|
+
emit(res, 'subscribe', ids, ++seq);
|
|
106
|
+
next = res.body && res.body.next && res.body.next.href;
|
|
107
|
+
if (!next) throw new Error('Subscription response had no next.href');
|
|
108
|
+
setStatus(node, 'subscribed', `subscribed ${ids.length} item(s)`, { itemIds: ids });
|
|
109
|
+
} else {
|
|
110
|
+
if (node.pollDelay) await sleep(node.pollDelay, signal);
|
|
111
|
+
if (signal.aborted) break;
|
|
112
|
+
// badge only - every long poll would otherwise flood the status output
|
|
113
|
+
node.status({ fill: 'green', shape: 'ring', text: `waiting (${ids.length} item(s))` });
|
|
114
|
+
const res = await client.request('GET', next, null, { signal, timeoutMs: LONG_POLL_MS });
|
|
115
|
+
if (signal.aborted) break;
|
|
116
|
+
const updates = res.body && Array.isArray(res.body.updates) ? res.body.updates : [];
|
|
117
|
+
if (updates.length) {
|
|
118
|
+
emit(res, 'update', ids, ++seq);
|
|
119
|
+
setStatus(node, 'update', `${updates.length} update(s) @ ${new Date().toLocaleTimeString()}`, {
|
|
120
|
+
count: updates.length,
|
|
121
|
+
ids: updates.map((u) => u.id)
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
// update-wait timed out with nothing new: send another update-wait.
|
|
125
|
+
// Badge only - this happens every ~50 s and would flood the status output.
|
|
126
|
+
if (node.emitTimeouts) emit(res, 'timeout', ids, ++seq);
|
|
127
|
+
node.status({ fill: 'green', shape: 'ring', text: `wait timed out, re-waiting (${ids.length} item(s))` });
|
|
128
|
+
}
|
|
129
|
+
next = res.body && res.body.next && res.body.next.href;
|
|
130
|
+
if (!next) {
|
|
131
|
+
setStatus(node, 'resubscribing', 'no next link, re-subscribing');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
failures = 0;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (signal.aborted || err.aborted) break;
|
|
137
|
+
next = null;
|
|
138
|
+
if (err.statusCode === 404 && failures === 0) {
|
|
139
|
+
// subscription expired (gap > 30 s or server restart) - re-POST straight away once
|
|
140
|
+
setStatus(node, 'resubscribing', 'subscription expired (404), re-subscribing');
|
|
141
|
+
failures++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
failures++;
|
|
145
|
+
const delay = Math.min(node.retryDelay * Math.pow(2, Math.min(failures - 1, 10)), node.maxRetryDelay);
|
|
146
|
+
reportError(node, err, { itemIds: ids, attempt: failures, retryInMs: delay });
|
|
147
|
+
setStatus(node, 'retrying', `retry ${failures} in ${Math.round(delay / 1000)} s`, { attempt: failures, retryInMs: delay, error: err.message });
|
|
148
|
+
await sleep(delay, signal);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function stop(reason) {
|
|
154
|
+
if (run) {
|
|
155
|
+
run.ctrl.abort();
|
|
156
|
+
run = null;
|
|
157
|
+
setStatus(node, 'stopped', reason || 'stopped');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!node.server) setStatus(node, 'error', 'no server config');
|
|
162
|
+
else node.status({ fill: 'grey', shape: 'ring', text: 'idle' });
|
|
163
|
+
|
|
164
|
+
node.on('input', function (msg, sendFn, done) {
|
|
165
|
+
done = done || function () {};
|
|
166
|
+
if (isStop(msg)) {
|
|
167
|
+
if (run) stop('stopped by input');
|
|
168
|
+
else setStatus(node, 'stopped', 'not running');
|
|
169
|
+
return done();
|
|
170
|
+
}
|
|
171
|
+
if (!node.server) {
|
|
172
|
+
reportError(node, new Error('No REST server configured'));
|
|
173
|
+
return done();
|
|
174
|
+
}
|
|
175
|
+
const ids = parseIds(msg);
|
|
176
|
+
if (!ids.length) {
|
|
177
|
+
reportError(node, new Error('No item ids in msg.payload / msg.itemIds (string "508,526", array, or {itemIds:[...]})'));
|
|
178
|
+
return done();
|
|
179
|
+
}
|
|
180
|
+
if (run) stop('replaced by new subscription');
|
|
181
|
+
const ctrl = new AbortController();
|
|
182
|
+
run = { ctrl, ids };
|
|
183
|
+
loop(ids, ctrl).catch((e) => reportError(node, e)).finally(() => {
|
|
184
|
+
if (run && run.ctrl === ctrl) run = null;
|
|
185
|
+
});
|
|
186
|
+
done(); // the trigger message is handled; the subscription carries on by itself
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
node.on('close', function (removed, done) {
|
|
190
|
+
if (typeof removed === 'function') { done = removed; }
|
|
191
|
+
if (run) { run.ctrl.abort(); run = null; }
|
|
192
|
+
node.status({});
|
|
193
|
+
done();
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
RED.nodes.registerType('tdn-rest-item-status', RestItemStatusNode);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
module.exports.parseIds = parseIds;
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
(function () {
|
|
3
|
+
var defs = [{"type":"tdn-rest-access-groups","palette":"access groups","key":"rest_accessGroups","icon":"font-awesome/fa-users"},{"type":"tdn-rest-cardholders","palette":"cardholders","key":"rest_cardholders","icon":"font-awesome/fa-id-card"},{"type":"tdn-rest-items","palette":"items","key":"rest_items","icon":"font-awesome/fa-sitemap"}];
|
|
4
|
+
defs.forEach(function (d) {
|
|
5
|
+
RED.nodes.registerType(d.type, {
|
|
6
|
+
category: 'TDN REST',
|
|
7
|
+
color: '#E2D96E',
|
|
8
|
+
defaults: {
|
|
9
|
+
name: { value: '' },
|
|
10
|
+
server: { value: '', type: 'tdn-rest-server', required: true },
|
|
11
|
+
contextKey: { value: d.key },
|
|
12
|
+
contextStore: { value: '' },
|
|
13
|
+
top: { value: 1000, validate: RED.validators.number() },
|
|
14
|
+
sort: { value: 'id' },
|
|
15
|
+
fields: { value: '' },
|
|
16
|
+
query: { value: '' },
|
|
17
|
+
maxPages: { value: 0, validate: RED.validators.number() },
|
|
18
|
+
includePages: { value: true }
|
|
19
|
+
},
|
|
20
|
+
inputs: 1,
|
|
21
|
+
outputs: 3,
|
|
22
|
+
outputLabels: ['response', 'status', 'error'],
|
|
23
|
+
icon: d.icon,
|
|
24
|
+
label: function () { return this.name || ('get ' + d.palette); },
|
|
25
|
+
paletteLabel: 'get ' + d.palette,
|
|
26
|
+
oneditprepare: function () {
|
|
27
|
+
var sel = $('#node-input-contextStore');
|
|
28
|
+
var stores = (RED.settings.context && RED.settings.context.stores) || ['default'];
|
|
29
|
+
sel.empty();
|
|
30
|
+
sel.append($('<option>').val('').text('default'));
|
|
31
|
+
stores.forEach(function (s) { if (s !== 'default') sel.append($('<option>').val(s).text(s)); });
|
|
32
|
+
sel.val(this.contextStore || '');
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
})();
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<script type="text/html" data-template-name="tdn-rest-access-groups">
|
|
40
|
+
<div class="form-row">
|
|
41
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
42
|
+
<input type="text" id="node-input-name" placeholder="get access groups">
|
|
43
|
+
</div>
|
|
44
|
+
<div class="form-row">
|
|
45
|
+
<label for="node-input-server"><i class="fa fa-server"></i> Server</label>
|
|
46
|
+
<input type="text" id="node-input-server">
|
|
47
|
+
</div>
|
|
48
|
+
<div class="form-row">
|
|
49
|
+
<label for="node-input-contextKey"><i class="fa fa-globe"></i> Global key</label>
|
|
50
|
+
<input type="text" id="node-input-contextKey" placeholder="rest_accessGroups">
|
|
51
|
+
</div>
|
|
52
|
+
<div class="form-row">
|
|
53
|
+
<label for="node-input-contextStore"><i class="fa fa-database"></i> Context store</label>
|
|
54
|
+
<select id="node-input-contextStore"></select>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="form-row">
|
|
57
|
+
<label for="node-input-top">Page size (top)</label>
|
|
58
|
+
<input type="text" id="node-input-top" style="width:100px">
|
|
59
|
+
</div>
|
|
60
|
+
<div class="form-row">
|
|
61
|
+
<label for="node-input-sort">Sort</label>
|
|
62
|
+
<select id="node-input-sort" style="width:120px">
|
|
63
|
+
<option value="id">id</option><option value="-id">-id</option>
|
|
64
|
+
<option value="name">name</option><option value="-name">-name</option>
|
|
65
|
+
</select>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="form-row">
|
|
68
|
+
<label for="node-input-fields">Fields</label>
|
|
69
|
+
<input type="text" id="node-input-fields" placeholder="server defaults, e.g. id,href,name,description,parent,division">
|
|
70
|
+
</div>
|
|
71
|
+
<div class="form-row">
|
|
72
|
+
<label for="node-input-query">Extra query</label>
|
|
73
|
+
<input type="text" id="node-input-query" placeholder="name=R%26D&division=2">
|
|
74
|
+
</div>
|
|
75
|
+
<div class="form-row">
|
|
76
|
+
<label for="node-input-maxPages">Max pages</label>
|
|
77
|
+
<input type="text" id="node-input-maxPages" style="width:100px"> <span>0 = all</span>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="form-row">
|
|
80
|
+
<label> </label>
|
|
81
|
+
<input type="checkbox" id="node-input-includePages" style="display:inline-block;width:auto;vertical-align:top">
|
|
82
|
+
<label for="node-input-includePages" style="width:auto">Include raw server pages in <code>msg.pages</code></label>
|
|
83
|
+
</div>
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<script type="text/html" data-help-name="tdn-rest-access-groups">
|
|
87
|
+
<p>Downloads all access groups from the REST Server, following <code>next</code> links until done,
|
|
88
|
+
and stores the merged list in global context. Request/reply: one trigger, one response.</p>
|
|
89
|
+
<p>The URL comes from <code>features.accessGroups.accessGroups.href</code> in <code>GET /api</code> (needs RESTCardholders).</p>
|
|
90
|
+
<h3>Input</h3>
|
|
91
|
+
<dl class="message-properties">
|
|
92
|
+
<dt>any <span class="property-type">msg</span></dt><dd>Any message triggers a full download. A trigger while a download is running is ignored (reported on the status output).</dd>
|
|
93
|
+
<dt class="optional">query <span class="property-type">object | string</span></dt><dd>Extra query parameters, merged over the node's settings, e.g. <code>{"name":"smith"}</code>.</dd>
|
|
94
|
+
<dt class="optional">fields <span class="property-type">string</span></dt><dd>Overrides the <i>Fields</i> setting.</dd>
|
|
95
|
+
<dt class="optional">top / maxPages <span class="property-type">number</span></dt><dd>Override page size / page limit.</dd>
|
|
96
|
+
<dt class="optional">contextKey <span class="property-type">string</span></dt><dd>Overrides the global key.</dd>
|
|
97
|
+
</dl>
|
|
98
|
+
<h3>Outputs</h3>
|
|
99
|
+
<ol class="node-ports">
|
|
100
|
+
<li>Response
|
|
101
|
+
<dl class="message-properties">
|
|
102
|
+
<dt>payload.results <span class="property-type">array</span></dt><dd>All access groups, every page merged.</dd>
|
|
103
|
+
<dt>payload.count / payload.pageCount <span class="property-type">number</span></dt><dd></dd>
|
|
104
|
+
<dt>pages <span class="property-type">array</span></dt><dd>Each raw server response body, unmodified (if enabled).</dd>
|
|
105
|
+
<dt>request <span class="property-type">object</span></dt><dd>First-page href and parameters used.</dd>
|
|
106
|
+
<dt>contextKey <span class="property-type">string</span></dt><dd>Global key written.</dd>
|
|
107
|
+
</dl>
|
|
108
|
+
</li>
|
|
109
|
+
<li>Status – <code>payload.state</code>: <code>requesting</code>, <code>done</code>, <code>busy</code> with <code>text</code>, <code>time</code>, node id/name.</li>
|
|
110
|
+
<li>Error – <code>payload.message</code>, <code>statusCode</code>, <code>body</code>, <code>url</code>, <code>code</code>.</li>
|
|
111
|
+
</ol>
|
|
112
|
+
<h3>Global context</h3>
|
|
113
|
+
<p><code>global.<key></code> holds the results array; <code>global.<key>_updated</code> holds the ISO time of the last successful download.</p>
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
<script type="text/html" data-template-name="tdn-rest-cardholders">
|
|
117
|
+
<div class="form-row">
|
|
118
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
119
|
+
<input type="text" id="node-input-name" placeholder="get cardholders">
|
|
120
|
+
</div>
|
|
121
|
+
<div class="form-row">
|
|
122
|
+
<label for="node-input-server"><i class="fa fa-server"></i> Server</label>
|
|
123
|
+
<input type="text" id="node-input-server">
|
|
124
|
+
</div>
|
|
125
|
+
<div class="form-row">
|
|
126
|
+
<label for="node-input-contextKey"><i class="fa fa-globe"></i> Global key</label>
|
|
127
|
+
<input type="text" id="node-input-contextKey" placeholder="rest_cardholders">
|
|
128
|
+
</div>
|
|
129
|
+
<div class="form-row">
|
|
130
|
+
<label for="node-input-contextStore"><i class="fa fa-database"></i> Context store</label>
|
|
131
|
+
<select id="node-input-contextStore"></select>
|
|
132
|
+
</div>
|
|
133
|
+
<div class="form-row">
|
|
134
|
+
<label for="node-input-top">Page size (top)</label>
|
|
135
|
+
<input type="text" id="node-input-top" style="width:100px">
|
|
136
|
+
</div>
|
|
137
|
+
<div class="form-row">
|
|
138
|
+
<label for="node-input-sort">Sort</label>
|
|
139
|
+
<select id="node-input-sort" style="width:120px">
|
|
140
|
+
<option value="id">id</option><option value="-id">-id</option>
|
|
141
|
+
<option value="name">name</option><option value="-name">-name</option>
|
|
142
|
+
</select>
|
|
143
|
+
</div>
|
|
144
|
+
<div class="form-row">
|
|
145
|
+
<label for="node-input-fields">Fields</label>
|
|
146
|
+
<input type="text" id="node-input-fields" placeholder="server defaults, e.g. defaults,cards,accessGroups">
|
|
147
|
+
</div>
|
|
148
|
+
<div class="form-row">
|
|
149
|
+
<label for="node-input-query">Extra query</label>
|
|
150
|
+
<input type="text" id="node-input-query" placeholder="name=smith&division=2">
|
|
151
|
+
</div>
|
|
152
|
+
<div class="form-row">
|
|
153
|
+
<label for="node-input-maxPages">Max pages</label>
|
|
154
|
+
<input type="text" id="node-input-maxPages" style="width:100px"> <span>0 = all</span>
|
|
155
|
+
</div>
|
|
156
|
+
<div class="form-row">
|
|
157
|
+
<label> </label>
|
|
158
|
+
<input type="checkbox" id="node-input-includePages" style="display:inline-block;width:auto;vertical-align:top">
|
|
159
|
+
<label for="node-input-includePages" style="width:auto">Include raw server pages in <code>msg.pages</code></label>
|
|
160
|
+
</div>
|
|
161
|
+
</script>
|
|
162
|
+
|
|
163
|
+
<script type="text/html" data-help-name="tdn-rest-cardholders">
|
|
164
|
+
<p>Downloads all cardholders from the REST Server, following <code>next</code> links until done,
|
|
165
|
+
and stores the merged list in global context. Request/reply: one trigger, one response.</p>
|
|
166
|
+
<p>The URL comes from <code>features.cardholders.cardholders.href</code> in <code>GET /api</code> (needs RESTCardholders).</p>
|
|
167
|
+
<h3>Input</h3>
|
|
168
|
+
<dl class="message-properties">
|
|
169
|
+
<dt>any <span class="property-type">msg</span></dt><dd>Any message triggers a full download. A trigger while a download is running is ignored (reported on the status output).</dd>
|
|
170
|
+
<dt class="optional">query <span class="property-type">object | string</span></dt><dd>Extra query parameters, merged over the node's settings, e.g. <code>{"name":"smith"}</code>.</dd>
|
|
171
|
+
<dt class="optional">fields <span class="property-type">string</span></dt><dd>Overrides the <i>Fields</i> setting.</dd>
|
|
172
|
+
<dt class="optional">top / maxPages <span class="property-type">number</span></dt><dd>Override page size / page limit.</dd>
|
|
173
|
+
<dt class="optional">contextKey <span class="property-type">string</span></dt><dd>Overrides the global key.</dd>
|
|
174
|
+
</dl>
|
|
175
|
+
<h3>Outputs</h3>
|
|
176
|
+
<ol class="node-ports">
|
|
177
|
+
<li>Response
|
|
178
|
+
<dl class="message-properties">
|
|
179
|
+
<dt>payload.results <span class="property-type">array</span></dt><dd>All cardholders, every page merged.</dd>
|
|
180
|
+
<dt>payload.count / payload.pageCount <span class="property-type">number</span></dt><dd></dd>
|
|
181
|
+
<dt>pages <span class="property-type">array</span></dt><dd>Each raw server response body, unmodified (if enabled).</dd>
|
|
182
|
+
<dt>request <span class="property-type">object</span></dt><dd>First-page href and parameters used.</dd>
|
|
183
|
+
<dt>contextKey <span class="property-type">string</span></dt><dd>Global key written.</dd>
|
|
184
|
+
</dl>
|
|
185
|
+
</li>
|
|
186
|
+
<li>Status – <code>payload.state</code>: <code>requesting</code>, <code>done</code>, <code>busy</code> with <code>text</code>, <code>time</code>, node id/name.</li>
|
|
187
|
+
<li>Error – <code>payload.message</code>, <code>statusCode</code>, <code>body</code>, <code>url</code>, <code>code</code>.</li>
|
|
188
|
+
</ol>
|
|
189
|
+
<h3>Global context</h3>
|
|
190
|
+
<p><code>global.<key></code> holds the results array; <code>global.<key>_updated</code> holds the ISO time of the last successful download.</p>
|
|
191
|
+
</script>
|
|
192
|
+
|
|
193
|
+
<script type="text/html" data-template-name="tdn-rest-items">
|
|
194
|
+
<div class="form-row">
|
|
195
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
196
|
+
<input type="text" id="node-input-name" placeholder="get items">
|
|
197
|
+
</div>
|
|
198
|
+
<div class="form-row">
|
|
199
|
+
<label for="node-input-server"><i class="fa fa-server"></i> Server</label>
|
|
200
|
+
<input type="text" id="node-input-server">
|
|
201
|
+
</div>
|
|
202
|
+
<div class="form-row">
|
|
203
|
+
<label for="node-input-contextKey"><i class="fa fa-globe"></i> Global key</label>
|
|
204
|
+
<input type="text" id="node-input-contextKey" placeholder="rest_items">
|
|
205
|
+
</div>
|
|
206
|
+
<div class="form-row">
|
|
207
|
+
<label for="node-input-contextStore"><i class="fa fa-database"></i> Context store</label>
|
|
208
|
+
<select id="node-input-contextStore"></select>
|
|
209
|
+
</div>
|
|
210
|
+
<div class="form-row">
|
|
211
|
+
<label for="node-input-top">Page size (top)</label>
|
|
212
|
+
<input type="text" id="node-input-top" style="width:100px">
|
|
213
|
+
</div>
|
|
214
|
+
<div class="form-row">
|
|
215
|
+
<label for="node-input-sort">Sort</label>
|
|
216
|
+
<select id="node-input-sort" style="width:120px">
|
|
217
|
+
<option value="id">id</option><option value="-id">-id</option>
|
|
218
|
+
<option value="name">name</option><option value="-name">-name</option>
|
|
219
|
+
</select>
|
|
220
|
+
</div>
|
|
221
|
+
<div class="form-row">
|
|
222
|
+
<label for="node-input-fields">Fields</label>
|
|
223
|
+
<input type="text" id="node-input-fields" placeholder="server defaults, e.g. defaults,division">
|
|
224
|
+
</div>
|
|
225
|
+
<div class="form-row">
|
|
226
|
+
<label for="node-input-query">Extra query</label>
|
|
227
|
+
<input type="text" id="node-input-query" placeholder="type=11,14&division=2">
|
|
228
|
+
</div>
|
|
229
|
+
<div class="form-row">
|
|
230
|
+
<label for="node-input-maxPages">Max pages</label>
|
|
231
|
+
<input type="text" id="node-input-maxPages" style="width:100px"> <span>0 = all</span>
|
|
232
|
+
</div>
|
|
233
|
+
<div class="form-row">
|
|
234
|
+
<label> </label>
|
|
235
|
+
<input type="checkbox" id="node-input-includePages" style="display:inline-block;width:auto;vertical-align:top">
|
|
236
|
+
<label for="node-input-includePages" style="width:auto">Include raw server pages in <code>msg.pages</code></label>
|
|
237
|
+
</div>
|
|
238
|
+
</script>
|
|
239
|
+
|
|
240
|
+
<script type="text/html" data-help-name="tdn-rest-items">
|
|
241
|
+
<p>Downloads all items from the REST Server, following <code>next</code> links until done,
|
|
242
|
+
and stores the merged list in global context. Request/reply: one trigger, one response.</p>
|
|
243
|
+
<p>The URL comes from <code>features.items.items.href</code> in <code>GET /api</code> (needs any REST licence).</p>
|
|
244
|
+
<h3>Input</h3>
|
|
245
|
+
<dl class="message-properties">
|
|
246
|
+
<dt>any <span class="property-type">msg</span></dt><dd>Any message triggers a full download. A trigger while a download is running is ignored (reported on the status output).</dd>
|
|
247
|
+
<dt class="optional">query <span class="property-type">object | string</span></dt><dd>Extra query parameters, merged over the node's settings, e.g. <code>{"name":"smith"}</code>.</dd>
|
|
248
|
+
<dt class="optional">fields <span class="property-type">string</span></dt><dd>Overrides the <i>Fields</i> setting.</dd>
|
|
249
|
+
<dt class="optional">top / maxPages <span class="property-type">number</span></dt><dd>Override page size / page limit.</dd>
|
|
250
|
+
<dt class="optional">contextKey <span class="property-type">string</span></dt><dd>Overrides the global key.</dd>
|
|
251
|
+
</dl>
|
|
252
|
+
<h3>Outputs</h3>
|
|
253
|
+
<ol class="node-ports">
|
|
254
|
+
<li>Response
|
|
255
|
+
<dl class="message-properties">
|
|
256
|
+
<dt>payload.results <span class="property-type">array</span></dt><dd>All items, every page merged.</dd>
|
|
257
|
+
<dt>payload.count / payload.pageCount <span class="property-type">number</span></dt><dd></dd>
|
|
258
|
+
<dt>pages <span class="property-type">array</span></dt><dd>Each raw server response body, unmodified (if enabled).</dd>
|
|
259
|
+
<dt>request <span class="property-type">object</span></dt><dd>First-page href and parameters used.</dd>
|
|
260
|
+
<dt>contextKey <span class="property-type">string</span></dt><dd>Global key written.</dd>
|
|
261
|
+
</dl>
|
|
262
|
+
</li>
|
|
263
|
+
<li>Status – <code>payload.state</code>: <code>requesting</code>, <code>done</code>, <code>busy</code> with <code>text</code>, <code>time</code>, node id/name.</li>
|
|
264
|
+
<li>Error – <code>payload.message</code>, <code>statusCode</code>, <code>body</code>, <code>url</code>, <code>code</code>.</li>
|
|
265
|
+
</ol>
|
|
266
|
+
<h3>Global context</h3>
|
|
267
|
+
<p><code>global.<key></code> holds the results array; <code>global.<key>_updated</code> holds the ISO time of the last successful download.</p>
|
|
268
|
+
</script>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { RestClient } = require('../lib/client');
|
|
3
|
+
const { OUT_RESPONSE, send, setStatus, reportError, storeName, nowIso } = require('../lib/outputs');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Request/reply "list" nodes. Each trigger:
|
|
7
|
+
* GET /api -> features.<x>.<y>.href -> GET first page -> follow next.href
|
|
8
|
+
* -> store merged results in global context -> output full response
|
|
9
|
+
*/
|
|
10
|
+
const LISTS = [
|
|
11
|
+
{ type: 'tdn-rest-access-groups', feature: 'accessGroups.accessGroups', label: 'access groups', defaultKey: 'rest_accessGroups' },
|
|
12
|
+
{ type: 'tdn-rest-cardholders', feature: 'cardholders.cardholders', label: 'cardholders', defaultKey: 'rest_cardholders' },
|
|
13
|
+
{ type: 'tdn-rest-items', feature: 'items.items', label: 'items', defaultKey: 'rest_items' }
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
module.exports = function (RED) {
|
|
17
|
+
function makeListNode(def) {
|
|
18
|
+
return function RestListNode(config) {
|
|
19
|
+
RED.nodes.createNode(this, config);
|
|
20
|
+
const node = this;
|
|
21
|
+
node.name = config.name;
|
|
22
|
+
node.server = RED.nodes.getNode(config.server);
|
|
23
|
+
node.contextKey = (config.contextKey || '').trim();
|
|
24
|
+
node.contextStore = config.contextStore || '';
|
|
25
|
+
node.top = parseInt(config.top, 10) || 1000;
|
|
26
|
+
node.sort = config.sort || 'id';
|
|
27
|
+
node.fields = (config.fields || '').trim();
|
|
28
|
+
node.query = (config.query || '').trim();
|
|
29
|
+
node.maxPages = parseInt(config.maxPages, 10) || 0;
|
|
30
|
+
node.includePages = config.includePages !== false;
|
|
31
|
+
|
|
32
|
+
let running = null; // AbortController of in-flight run
|
|
33
|
+
|
|
34
|
+
if (!node.server) setStatus(node, 'error', 'no server config');
|
|
35
|
+
else node.status({ fill: 'grey', shape: 'ring', text: 'idle' });
|
|
36
|
+
|
|
37
|
+
node.on('input', async function (msg, sendFn, done) {
|
|
38
|
+
done = done || function (e) { if (e) node.error(e, msg); };
|
|
39
|
+
if (!node.server) {
|
|
40
|
+
reportError(node, new Error('No REST server configured'));
|
|
41
|
+
return done();
|
|
42
|
+
}
|
|
43
|
+
if (running) {
|
|
44
|
+
setStatus(node, 'busy', 'busy - trigger ignored', { reason: 'request already in progress' });
|
|
45
|
+
return done();
|
|
46
|
+
}
|
|
47
|
+
const ctrl = new AbortController();
|
|
48
|
+
running = ctrl;
|
|
49
|
+
const started = Date.now();
|
|
50
|
+
try {
|
|
51
|
+
const client = node.server.getClient();
|
|
52
|
+
setStatus(node, 'requesting', `requesting ${def.label}`);
|
|
53
|
+
const firstHref = await client.feature(def.feature, { signal: ctrl.signal });
|
|
54
|
+
|
|
55
|
+
// node config, then optional per-message overrides
|
|
56
|
+
const params = Object.assign(
|
|
57
|
+
{ top: node.top, sort: node.sort, fields: node.fields },
|
|
58
|
+
RestClient.parseQueryString(node.query)
|
|
59
|
+
);
|
|
60
|
+
if (msg.query) {
|
|
61
|
+
Object.assign(params, typeof msg.query === 'string' ? RestClient.parseQueryString(msg.query) : msg.query);
|
|
62
|
+
}
|
|
63
|
+
if (msg.fields !== undefined) params.fields = msg.fields;
|
|
64
|
+
if (msg.top !== undefined) params.top = msg.top;
|
|
65
|
+
|
|
66
|
+
const href = RestClient.addQuery(firstHref, params);
|
|
67
|
+
const out = await client.getAll(href, {
|
|
68
|
+
signal: ctrl.signal,
|
|
69
|
+
maxPages: msg.maxPages !== undefined ? msg.maxPages : node.maxPages,
|
|
70
|
+
onPage: (body, i) => {
|
|
71
|
+
const n = Array.isArray(body.results) ? body.results.length : 0;
|
|
72
|
+
node.status({ fill: 'blue', shape: 'dot', text: `page ${i + 1} (+${n})` });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const key = (msg.contextKey || node.contextKey || def.defaultKey);
|
|
77
|
+
const store = storeName(node.contextStore);
|
|
78
|
+
node.context().global.set(key, out.results, store);
|
|
79
|
+
node.context().global.set(key + '_updated', nowIso(), store);
|
|
80
|
+
|
|
81
|
+
msg.payload = {
|
|
82
|
+
results: out.results,
|
|
83
|
+
count: out.results.length,
|
|
84
|
+
pageCount: out.pageCount
|
|
85
|
+
};
|
|
86
|
+
if (node.includePages) msg.pages = out.pages;
|
|
87
|
+
msg.request = { href, params };
|
|
88
|
+
msg.contextKey = key;
|
|
89
|
+
msg.statusCode = 200;
|
|
90
|
+
msg.topic = msg.topic || def.type.replace(/^tdn-/, '');
|
|
91
|
+
send(node, OUT_RESPONSE, msg);
|
|
92
|
+
setStatus(node, 'done', `${out.results.length} ${def.label} (${out.pageCount} pg)`, {
|
|
93
|
+
count: out.results.length,
|
|
94
|
+
pageCount: out.pageCount,
|
|
95
|
+
contextKey: key,
|
|
96
|
+
durationMs: Date.now() - started
|
|
97
|
+
});
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (!err.aborted) reportError(node, err, { feature: def.feature });
|
|
100
|
+
} finally {
|
|
101
|
+
running = null;
|
|
102
|
+
done();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
node.on('close', function (done) {
|
|
107
|
+
if (running) running.abort();
|
|
108
|
+
running = null;
|
|
109
|
+
done();
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const def of LISTS) RED.nodes.registerType(def.type, makeListNode(def));
|
|
115
|
+
};
|