node-red-contrib-i3x 0.0.1
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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/examples/i3x-complete-demo.json +1272 -0
- package/lib/i3x-client.js +425 -0
- package/lib/node-utils.js +61 -0
- package/nodes/i3x-browse.html +106 -0
- package/nodes/i3x-browse.js +93 -0
- package/nodes/i3x-history.html +75 -0
- package/nodes/i3x-history.js +67 -0
- package/nodes/i3x-read.html +60 -0
- package/nodes/i3x-read.js +48 -0
- package/nodes/i3x-server.html +98 -0
- package/nodes/i3x-server.js +75 -0
- package/nodes/i3x-subscribe.html +79 -0
- package/nodes/i3x-subscribe.js +152 -0
- package/nodes/i3x-write.html +72 -0
- package/nodes/i3x-write.js +56 -0
- package/nodes/icons/i3x-icon.svg +11 -0
- package/package.json +59 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* I3XClient – Shared HTTP client for the i3X (CESMII) API.
|
|
3
|
+
*
|
|
4
|
+
* Wraps all REST endpoints described in the i3X OpenAPI v0.0.1 spec.
|
|
5
|
+
* Used by every node-red-contrib-i3x node via the i3x-server config node.
|
|
6
|
+
*
|
|
7
|
+
* @see https://i3x.cesmii.net/docs
|
|
8
|
+
*/
|
|
9
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
const axios = require("axios");
|
|
12
|
+
const { EventEmitter } = require("events");
|
|
13
|
+
|
|
14
|
+
const RETRY_STATUS_CODES = new Set([429, 502, 503, 504]);
|
|
15
|
+
const MAX_RETRIES = 3;
|
|
16
|
+
const RETRY_DELAY_MS = 1000;
|
|
17
|
+
|
|
18
|
+
class I3XClient extends EventEmitter {
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} config
|
|
21
|
+
* @param {string} config.baseUrl – e.g. "https://i3x.cesmii.net"
|
|
22
|
+
* @param {string} [config.apiVersion] – path prefix, default ""
|
|
23
|
+
* @param {string} [config.authType] – "none"|"basic"|"bearer"|"apikey"
|
|
24
|
+
* @param {string} [config.username]
|
|
25
|
+
* @param {string} [config.password]
|
|
26
|
+
* @param {string} [config.token]
|
|
27
|
+
* @param {string} [config.apiKey]
|
|
28
|
+
* @param {object} [config.tlsOptions] – { rejectUnauthorized, ca, cert, key }
|
|
29
|
+
* @param {number} [config.timeout] – ms, default 10000
|
|
30
|
+
*/
|
|
31
|
+
constructor(config) {
|
|
32
|
+
super();
|
|
33
|
+
this.baseUrl = (config.baseUrl || "").replace(/\/+$/, "");
|
|
34
|
+
this.apiVersion = config.apiVersion || "";
|
|
35
|
+
this.authType = config.authType || "none";
|
|
36
|
+
this.timeout = config.timeout || 10000;
|
|
37
|
+
|
|
38
|
+
const axiosConfig = {
|
|
39
|
+
baseURL: this._prefix(),
|
|
40
|
+
timeout: this.timeout,
|
|
41
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (this.authType === "basic" && config.username) {
|
|
45
|
+
axiosConfig.auth = { username: config.username, password: config.password || "" };
|
|
46
|
+
} else if (this.authType === "bearer" && config.token) {
|
|
47
|
+
axiosConfig.headers["Authorization"] = `Bearer ${config.token}`;
|
|
48
|
+
} else if (this.authType === "apikey" && config.apiKey) {
|
|
49
|
+
axiosConfig.headers["X-API-Key"] = config.apiKey;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (config.tlsOptions) {
|
|
53
|
+
const https = require("https");
|
|
54
|
+
axiosConfig.httpsAgent = new https.Agent(config.tlsOptions);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this.http = axios.create(axiosConfig);
|
|
58
|
+
this._activeSubscriptions = new Map();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Explore ────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** @returns {Promise<Array<{uri:string, displayName:string}>>} */
|
|
64
|
+
async getNamespaces() {
|
|
65
|
+
return this._get("/namespaces");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {object} [options]
|
|
70
|
+
* @param {string} [options.namespaceUri]
|
|
71
|
+
*/
|
|
72
|
+
async getObjectTypes(options = {}) {
|
|
73
|
+
const params = {};
|
|
74
|
+
if (options.namespaceUri) params.namespaceUri = options.namespaceUri;
|
|
75
|
+
return this._get("/objecttypes", params);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @param {string[]} elementIds */
|
|
79
|
+
async queryObjectTypes(elementIds) {
|
|
80
|
+
return this._post("/objecttypes/query", { elementIds });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {object} [options]
|
|
85
|
+
* @param {string} [options.namespaceUri]
|
|
86
|
+
*/
|
|
87
|
+
async getRelationshipTypes(options = {}) {
|
|
88
|
+
const params = {};
|
|
89
|
+
if (options.namespaceUri) params.namespaceUri = options.namespaceUri;
|
|
90
|
+
return this._get("/relationshiptypes", params);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @param {string[]} elementIds */
|
|
94
|
+
async queryRelationshipTypes(elementIds) {
|
|
95
|
+
return this._post("/relationshiptypes/query", { elementIds });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {object} [options]
|
|
100
|
+
* @param {string} [options.typeId]
|
|
101
|
+
* @param {boolean} [options.includeMetadata]
|
|
102
|
+
*/
|
|
103
|
+
async getObjects(options = {}) {
|
|
104
|
+
const params = {};
|
|
105
|
+
if (options.typeId) params.typeId = options.typeId;
|
|
106
|
+
if (options.includeMetadata) params.includeMetadata = true;
|
|
107
|
+
return this._get("/objects", params);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {string[]} elementIds
|
|
112
|
+
* @param {object} [options]
|
|
113
|
+
* @param {boolean} [options.includeMetadata]
|
|
114
|
+
*/
|
|
115
|
+
async listObjects(elementIds, options = {}) {
|
|
116
|
+
const body = { elementIds };
|
|
117
|
+
if (options.includeMetadata) body.includeMetadata = true;
|
|
118
|
+
return this._post("/objects/list", body);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {string[]} elementIds
|
|
123
|
+
* @param {object} [options]
|
|
124
|
+
* @param {string} [options.relationshipType]
|
|
125
|
+
* @param {boolean} [options.includeMetadata]
|
|
126
|
+
*/
|
|
127
|
+
async getRelatedObjects(elementIds, options = {}) {
|
|
128
|
+
const body = { elementIds };
|
|
129
|
+
if (options.relationshipType) body.relationshiptype = options.relationshipType;
|
|
130
|
+
if (options.includeMetadata) body.includeMetadata = true;
|
|
131
|
+
return this._post("/objects/related", body);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Query ──────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {string[]} elementIds
|
|
138
|
+
* @param {object} [options]
|
|
139
|
+
* @param {number} [options.maxDepth] – 0 = infinite, 1 = no recursion
|
|
140
|
+
*/
|
|
141
|
+
async readValues(elementIds, options = {}) {
|
|
142
|
+
const body = { elementIds };
|
|
143
|
+
if (options.maxDepth !== undefined) body.maxDepth = options.maxDepth;
|
|
144
|
+
return this._post("/objects/value", body);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {string[]} elementIds
|
|
149
|
+
* @param {object} [options]
|
|
150
|
+
* @param {string} [options.startTime] – ISO 8601
|
|
151
|
+
* @param {string} [options.endTime] – ISO 8601
|
|
152
|
+
* @param {number} [options.maxDepth]
|
|
153
|
+
*/
|
|
154
|
+
async readHistory(elementIds, options = {}) {
|
|
155
|
+
const body = { elementIds };
|
|
156
|
+
if (options.startTime) body.startTime = options.startTime;
|
|
157
|
+
if (options.endTime) body.endTime = options.endTime;
|
|
158
|
+
if (options.maxDepth !== undefined) body.maxDepth = options.maxDepth;
|
|
159
|
+
return this._post("/objects/history", body);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Update ─────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param {string} elementId
|
|
166
|
+
* @param {*} value
|
|
167
|
+
*/
|
|
168
|
+
async writeValue(elementId, value) {
|
|
169
|
+
return this._put(`/objects/${encodeURIComponent(elementId)}/value`, value);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @param {string} elementId
|
|
174
|
+
* @param {*} data – historical data payload
|
|
175
|
+
*/
|
|
176
|
+
async writeHistory(elementId, data) {
|
|
177
|
+
return this._put(`/objects/${encodeURIComponent(elementId)}/history`, data);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Subscribe ──────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
async listSubscriptions() {
|
|
183
|
+
return this._get("/subscriptions");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** @returns {Promise<{subscriptionId:string, message:string}>} */
|
|
187
|
+
async createSubscription() {
|
|
188
|
+
return this._post("/subscriptions", {});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** @param {string} subscriptionId */
|
|
192
|
+
async getSubscription(subscriptionId) {
|
|
193
|
+
return this._get(`/subscriptions/${encodeURIComponent(subscriptionId)}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** @param {string} subscriptionId */
|
|
197
|
+
async deleteSubscription(subscriptionId) {
|
|
198
|
+
return this._delete(`/subscriptions/${encodeURIComponent(subscriptionId)}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {string} subscriptionId
|
|
203
|
+
* @param {string[]} elementIds
|
|
204
|
+
* @param {number} [maxDepth]
|
|
205
|
+
*/
|
|
206
|
+
async registerMonitoredItems(subscriptionId, elementIds, maxDepth = 1) {
|
|
207
|
+
const body = { elementIds, maxDepth };
|
|
208
|
+
return this._post(
|
|
209
|
+
`/subscriptions/${encodeURIComponent(subscriptionId)}/register`,
|
|
210
|
+
body
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @param {string} subscriptionId
|
|
216
|
+
* @param {string[]} elementIds
|
|
217
|
+
*/
|
|
218
|
+
async unregisterMonitoredItems(subscriptionId, elementIds) {
|
|
219
|
+
const body = { elementIds };
|
|
220
|
+
return this._post(
|
|
221
|
+
`/subscriptions/${encodeURIComponent(subscriptionId)}/unregister`,
|
|
222
|
+
body
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Open an SSE stream for the given subscription.
|
|
228
|
+
* Supports automatic reconnection on stream errors.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} subscriptionId
|
|
231
|
+
* @param {object} callbacks
|
|
232
|
+
* @param {function} callbacks.onData – called with each parsed SSE event
|
|
233
|
+
* @param {function} [callbacks.onError] – called on stream errors (instead of EventEmitter)
|
|
234
|
+
* @param {function} [callbacks.onReconnect] – called when a reconnection attempt starts
|
|
235
|
+
* @param {number} [maxReconnects=5] – max consecutive reconnection attempts
|
|
236
|
+
* @returns {{ close: function }} handle to close the stream
|
|
237
|
+
*/
|
|
238
|
+
streamSubscription(subscriptionId, callbacks, maxReconnects = 5) {
|
|
239
|
+
if (typeof callbacks === "function") {
|
|
240
|
+
callbacks = { onData: callbacks };
|
|
241
|
+
}
|
|
242
|
+
const { onData, onError, onReconnect } = callbacks;
|
|
243
|
+
|
|
244
|
+
const url = `${this._prefix()}/subscriptions/${encodeURIComponent(subscriptionId)}/stream`;
|
|
245
|
+
let controller = new AbortController();
|
|
246
|
+
let closed = false;
|
|
247
|
+
let reconnectCount = 0;
|
|
248
|
+
|
|
249
|
+
const headers = { ...this.http.defaults.headers.common, Accept: "text/event-stream" };
|
|
250
|
+
if (this.http.defaults.auth) {
|
|
251
|
+
const b64 = Buffer.from(
|
|
252
|
+
`${this.http.defaults.auth.username}:${this.http.defaults.auth.password}`
|
|
253
|
+
).toString("base64");
|
|
254
|
+
headers["Authorization"] = `Basic ${b64}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const connect = async () => {
|
|
258
|
+
if (closed) return;
|
|
259
|
+
try {
|
|
260
|
+
controller = new AbortController();
|
|
261
|
+
const response = await axios({
|
|
262
|
+
method: "get",
|
|
263
|
+
url,
|
|
264
|
+
headers,
|
|
265
|
+
responseType: "stream",
|
|
266
|
+
signal: controller.signal,
|
|
267
|
+
timeout: 0,
|
|
268
|
+
httpsAgent: this.http.defaults.httpsAgent,
|
|
269
|
+
});
|
|
270
|
+
reconnectCount = 0;
|
|
271
|
+
let buffer = "";
|
|
272
|
+
response.data.on("data", (chunk) => {
|
|
273
|
+
buffer += chunk.toString();
|
|
274
|
+
const parts = buffer.split("\n\n");
|
|
275
|
+
buffer = parts.pop();
|
|
276
|
+
for (const part of parts) {
|
|
277
|
+
const dataLine = part
|
|
278
|
+
.split("\n")
|
|
279
|
+
.find((l) => l.startsWith("data:"));
|
|
280
|
+
if (dataLine) {
|
|
281
|
+
try {
|
|
282
|
+
onData(JSON.parse(dataLine.slice(5).trim()));
|
|
283
|
+
} catch (_) {
|
|
284
|
+
onData(dataLine.slice(5).trim());
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
response.data.on("end", () => {
|
|
290
|
+
if (!closed) reconnect();
|
|
291
|
+
});
|
|
292
|
+
response.data.on("error", (err) => {
|
|
293
|
+
if (!closed) reconnect(err);
|
|
294
|
+
});
|
|
295
|
+
} catch (err) {
|
|
296
|
+
if (!closed) reconnect(err);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const reconnect = (err) => {
|
|
301
|
+
if (closed) return;
|
|
302
|
+
reconnectCount++;
|
|
303
|
+
if (reconnectCount > maxReconnects) {
|
|
304
|
+
if (onError) onError(err || new Error("Max reconnection attempts reached"));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (onReconnect) onReconnect(reconnectCount);
|
|
308
|
+
const delay = Math.min(1000 * Math.pow(2, reconnectCount - 1), 30000);
|
|
309
|
+
setTimeout(connect, delay);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
connect();
|
|
313
|
+
|
|
314
|
+
const handle = {
|
|
315
|
+
close: () => {
|
|
316
|
+
closed = true;
|
|
317
|
+
controller.abort();
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
this._activeSubscriptions.set(subscriptionId, handle);
|
|
321
|
+
return handle;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Poll-based sync: returns and clears queued updates.
|
|
326
|
+
* @param {string} subscriptionId
|
|
327
|
+
*/
|
|
328
|
+
async syncSubscription(subscriptionId) {
|
|
329
|
+
return this._post(
|
|
330
|
+
`/subscriptions/${encodeURIComponent(subscriptionId)}/sync`,
|
|
331
|
+
{}
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── Utility ────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Lightweight connectivity test.
|
|
339
|
+
* Uses GET /namespaces as health indicator.
|
|
340
|
+
*/
|
|
341
|
+
async testConnection() {
|
|
342
|
+
await this._get("/namespaces");
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Close all active SSE streams and clean up. */
|
|
347
|
+
destroy() {
|
|
348
|
+
for (const [, handle] of this._activeSubscriptions) {
|
|
349
|
+
handle.close();
|
|
350
|
+
}
|
|
351
|
+
this._activeSubscriptions.clear();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── Internal helpers ───────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
/** @private */
|
|
357
|
+
_prefix() {
|
|
358
|
+
const ver = this.apiVersion ? `/${this.apiVersion.replace(/^\//, "")}` : "";
|
|
359
|
+
return `${this.baseUrl}${ver}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** @private */
|
|
363
|
+
async _get(path, params) {
|
|
364
|
+
return this._request("get", path, { params });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** @private */
|
|
368
|
+
async _post(path, data) {
|
|
369
|
+
return this._request("post", path, { data });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** @private */
|
|
373
|
+
async _put(path, data) {
|
|
374
|
+
return this._request("put", path, { data });
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** @private */
|
|
378
|
+
async _delete(path) {
|
|
379
|
+
return this._request("delete", path);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Central request dispatcher with retry logic.
|
|
384
|
+
* @private
|
|
385
|
+
*/
|
|
386
|
+
async _request(method, path, opts = {}) {
|
|
387
|
+
let lastErr;
|
|
388
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
389
|
+
try {
|
|
390
|
+
const res = await this.http.request({ method, url: path, ...opts });
|
|
391
|
+
return res.data;
|
|
392
|
+
} catch (err) {
|
|
393
|
+
lastErr = err;
|
|
394
|
+
const status = err.response && err.response.status;
|
|
395
|
+
if (status && RETRY_STATUS_CODES.has(status) && attempt < MAX_RETRIES) {
|
|
396
|
+
const delay = RETRY_DELAY_MS * Math.pow(2, attempt);
|
|
397
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
throw this._wrapError(err);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
throw this._wrapError(lastErr);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Produce a normalised error object.
|
|
408
|
+
* @private
|
|
409
|
+
*/
|
|
410
|
+
_wrapError(err) {
|
|
411
|
+
if (err._i3x) return err;
|
|
412
|
+
const wrapped = new Error(err.message);
|
|
413
|
+
wrapped._i3x = true;
|
|
414
|
+
if (err.response) {
|
|
415
|
+
wrapped.statusCode = err.response.status;
|
|
416
|
+
wrapped.statusText = err.response.statusText;
|
|
417
|
+
wrapped.body = err.response.data;
|
|
418
|
+
} else if (err.code) {
|
|
419
|
+
wrapped.code = err.code;
|
|
420
|
+
}
|
|
421
|
+
return wrapped;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
module.exports = I3XClient;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for i3x Node-RED nodes.
|
|
3
|
+
* Eliminates boilerplate for server binding and status management.
|
|
4
|
+
*/
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Bind an operation node to its i3x-server config node.
|
|
9
|
+
* Sets up connection status indicators and returns false if no server is configured.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} node – the Node-RED node instance
|
|
12
|
+
* @param {object} RED – the Node-RED runtime
|
|
13
|
+
* @param {string} serverId – config.server
|
|
14
|
+
* @returns {boolean} true if server is available, false otherwise
|
|
15
|
+
*/
|
|
16
|
+
function bindServer(node, RED, serverId) {
|
|
17
|
+
node.server = RED.nodes.getNode(serverId);
|
|
18
|
+
|
|
19
|
+
if (!node.server) {
|
|
20
|
+
node.status({ fill: "red", shape: "ring", text: "no server configured" });
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
node.server.on("connected", () => {
|
|
25
|
+
node.status({ fill: "green", shape: "dot", text: "connected" });
|
|
26
|
+
});
|
|
27
|
+
node.server.on("disconnected", () => {
|
|
28
|
+
node.status({ fill: "red", shape: "ring", text: "disconnected" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (node.server.connected) {
|
|
32
|
+
node.status({ fill: "green", shape: "dot", text: "connected" });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse a value that may be a comma-separated string or an array into an array of strings.
|
|
40
|
+
* @param {string|string[]} input
|
|
41
|
+
* @returns {string[]}
|
|
42
|
+
*/
|
|
43
|
+
function parseIds(input) {
|
|
44
|
+
if (Array.isArray(input)) return input;
|
|
45
|
+
if (typeof input === "string") {
|
|
46
|
+
return input.split(",").map((s) => s.trim()).filter(Boolean);
|
|
47
|
+
}
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Backwards-compatible send helper for Node-RED < 1.0.
|
|
53
|
+
* @param {object} node
|
|
54
|
+
* @param {function|undefined} send
|
|
55
|
+
* @returns {function}
|
|
56
|
+
*/
|
|
57
|
+
function safeSend(node, send) {
|
|
58
|
+
return send || function () { node.send.apply(node, arguments); };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { bindServer, parseIds, safeSend };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script type="text/html" data-template-name="i3x-browse">
|
|
2
|
+
<div class="form-row">
|
|
3
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
4
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
5
|
+
</div>
|
|
6
|
+
<div class="form-row">
|
|
7
|
+
<label for="node-input-server"><i class="fa fa-server"></i> Server</label>
|
|
8
|
+
<input type="text" id="node-input-server">
|
|
9
|
+
</div>
|
|
10
|
+
<div class="form-row">
|
|
11
|
+
<label for="node-input-browseTarget"><i class="fa fa-sitemap"></i> Target</label>
|
|
12
|
+
<select id="node-input-browseTarget">
|
|
13
|
+
<option value="namespaces">Namespaces</option>
|
|
14
|
+
<option value="objecttypes">Object Types</option>
|
|
15
|
+
<option value="relationshiptypes">Relationship Types</option>
|
|
16
|
+
<option value="objects">Objects</option>
|
|
17
|
+
<option value="related">Related Objects</option>
|
|
18
|
+
</select>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="form-row i3x-browse-elementId">
|
|
21
|
+
<label for="node-input-elementId"><i class="fa fa-crosshairs"></i> Element ID</label>
|
|
22
|
+
<input type="text" id="node-input-elementId" placeholder="optional – filter by element ID">
|
|
23
|
+
</div>
|
|
24
|
+
<div class="form-row i3x-browse-typeId">
|
|
25
|
+
<label for="node-input-typeId"><i class="fa fa-filter"></i> Type ID</label>
|
|
26
|
+
<input type="text" id="node-input-typeId" placeholder="optional – filter objects by type">
|
|
27
|
+
</div>
|
|
28
|
+
<div class="form-row i3x-browse-ns">
|
|
29
|
+
<label for="node-input-namespaceUri"><i class="fa fa-folder-o"></i> Namespace</label>
|
|
30
|
+
<input type="text" id="node-input-namespaceUri" placeholder="optional – filter by namespace URI">
|
|
31
|
+
</div>
|
|
32
|
+
<div class="form-row i3x-browse-rel">
|
|
33
|
+
<label for="node-input-relationshipType"><i class="fa fa-link"></i> Rel. Type</label>
|
|
34
|
+
<input type="text" id="node-input-relationshipType" placeholder="optional – filter by relationship type">
|
|
35
|
+
</div>
|
|
36
|
+
<div class="form-row i3x-browse-meta">
|
|
37
|
+
<label for="node-input-includeMetadata"><i class="fa fa-info-circle"></i> Metadata</label>
|
|
38
|
+
<input type="checkbox" id="node-input-includeMetadata" style="width:auto;">
|
|
39
|
+
<span> Include full metadata</span>
|
|
40
|
+
</div>
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<script type="text/html" data-help-name="i3x-browse">
|
|
44
|
+
<p>Explores the i3X information model – namespaces, object types, relationship types, objects, and related objects.</p>
|
|
45
|
+
|
|
46
|
+
<h3>Inputs</h3>
|
|
47
|
+
<dl class="message-properties">
|
|
48
|
+
<dt class="optional">browseTarget <span class="property-type">string</span></dt>
|
|
49
|
+
<dd>What to browse: <code>namespaces</code>, <code>objecttypes</code>, <code>relationshiptypes</code>, <code>objects</code>, or <code>related</code>.</dd>
|
|
50
|
+
<dt class="optional">elementId <span class="property-type">string | string[]</span></dt>
|
|
51
|
+
<dd>One or more element IDs to query directly.</dd>
|
|
52
|
+
<dt class="optional">typeId <span class="property-type">string</span></dt>
|
|
53
|
+
<dd>Filter objects by type ID (only for <code>objects</code> target).</dd>
|
|
54
|
+
<dt class="optional">namespaceUri <span class="property-type">string</span></dt>
|
|
55
|
+
<dd>Filter by namespace URI (for <code>objecttypes</code> / <code>relationshiptypes</code>).</dd>
|
|
56
|
+
<dt class="optional">relationshipType <span class="property-type">string</span></dt>
|
|
57
|
+
<dd>Filter related objects by relationship type.</dd>
|
|
58
|
+
</dl>
|
|
59
|
+
|
|
60
|
+
<h3>Outputs</h3>
|
|
61
|
+
<dl class="message-properties">
|
|
62
|
+
<dt>payload <span class="property-type">array</span></dt>
|
|
63
|
+
<dd>Array of discovered objects matching the browse criteria.</dd>
|
|
64
|
+
</dl>
|
|
65
|
+
|
|
66
|
+
<h3>Details</h3>
|
|
67
|
+
<p>This node talks to the <b>Explore</b> endpoints of the i3X API.
|
|
68
|
+
<code>msg</code> properties override the values configured in the node editor.</p>
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<script type="text/javascript">
|
|
72
|
+
RED.nodes.registerType("i3x-browse", {
|
|
73
|
+
category: "i3x",
|
|
74
|
+
color: "#5DB87C",
|
|
75
|
+
defaults: {
|
|
76
|
+
name: { value: "" },
|
|
77
|
+
server: { value: "", type: "i3x-server", required: true },
|
|
78
|
+
browseTarget: { value: "objects" },
|
|
79
|
+
elementId: { value: "" },
|
|
80
|
+
typeId: { value: "" },
|
|
81
|
+
namespaceUri: { value: "" },
|
|
82
|
+
includeMetadata: { value: false },
|
|
83
|
+
relationshipType: { value: "" },
|
|
84
|
+
},
|
|
85
|
+
inputs: 1,
|
|
86
|
+
outputs: 1,
|
|
87
|
+
icon: "i3x-icon.svg",
|
|
88
|
+
paletteLabel: "i3x browse",
|
|
89
|
+
label: function () {
|
|
90
|
+
return this.name || this.browseTarget || "i3x browse";
|
|
91
|
+
},
|
|
92
|
+
oneditprepare: function () {
|
|
93
|
+
var target = $("#node-input-browseTarget");
|
|
94
|
+
function toggle() {
|
|
95
|
+
var v = target.val();
|
|
96
|
+
$(".i3x-browse-elementId").toggle(v !== "namespaces");
|
|
97
|
+
$(".i3x-browse-typeId").toggle(v === "objects");
|
|
98
|
+
$(".i3x-browse-ns").toggle(v === "objecttypes" || v === "relationshiptypes");
|
|
99
|
+
$(".i3x-browse-rel").toggle(v === "related");
|
|
100
|
+
$(".i3x-browse-meta").toggle(v === "objects" || v === "related");
|
|
101
|
+
}
|
|
102
|
+
target.on("change", toggle);
|
|
103
|
+
toggle();
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
</script>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i3x-browse – Explore namespaces, object types, objects, and relationships.
|
|
3
|
+
*/
|
|
4
|
+
"use strict";
|
|
5
|
+
|
|
6
|
+
const { bindServer, safeSend } = require("../lib/node-utils");
|
|
7
|
+
|
|
8
|
+
module.exports = function (RED) {
|
|
9
|
+
function I3XBrowseNode(config) {
|
|
10
|
+
RED.nodes.createNode(this, config);
|
|
11
|
+
const node = this;
|
|
12
|
+
|
|
13
|
+
node.browseTarget = config.browseTarget || "objects";
|
|
14
|
+
node.elementId = config.elementId || "";
|
|
15
|
+
node.typeId = config.typeId || "";
|
|
16
|
+
node.namespaceUri = config.namespaceUri || "";
|
|
17
|
+
node.includeMetadata = config.includeMetadata || false;
|
|
18
|
+
node.relationshipType = config.relationshipType || "";
|
|
19
|
+
|
|
20
|
+
if (!bindServer(node, RED, config.server)) return;
|
|
21
|
+
|
|
22
|
+
node.on("input", async function (msg, send, done) {
|
|
23
|
+
send = safeSend(node, send);
|
|
24
|
+
const client = node.server.client;
|
|
25
|
+
|
|
26
|
+
const target = msg.browseTarget || node.browseTarget;
|
|
27
|
+
const elementId = msg.elementId || node.elementId;
|
|
28
|
+
const typeId = msg.typeId || node.typeId;
|
|
29
|
+
const nsUri = msg.namespaceUri || node.namespaceUri;
|
|
30
|
+
const inclMeta = msg.includeMetadata !== undefined ? msg.includeMetadata : node.includeMetadata;
|
|
31
|
+
const relType = msg.relationshipType || node.relationshipType;
|
|
32
|
+
|
|
33
|
+
node.status({ fill: "blue", shape: "dot", text: "requesting..." });
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
let result;
|
|
37
|
+
switch (target) {
|
|
38
|
+
case "namespaces":
|
|
39
|
+
result = await client.getNamespaces();
|
|
40
|
+
break;
|
|
41
|
+
case "objecttypes":
|
|
42
|
+
if (elementId) {
|
|
43
|
+
const ids = Array.isArray(elementId) ? elementId : [elementId];
|
|
44
|
+
result = await client.queryObjectTypes(ids);
|
|
45
|
+
} else {
|
|
46
|
+
result = await client.getObjectTypes({ namespaceUri: nsUri || undefined });
|
|
47
|
+
}
|
|
48
|
+
break;
|
|
49
|
+
case "relationshiptypes":
|
|
50
|
+
if (elementId) {
|
|
51
|
+
const ids = Array.isArray(elementId) ? elementId : [elementId];
|
|
52
|
+
result = await client.queryRelationshipTypes(ids);
|
|
53
|
+
} else {
|
|
54
|
+
result = await client.getRelationshipTypes({ namespaceUri: nsUri || undefined });
|
|
55
|
+
}
|
|
56
|
+
break;
|
|
57
|
+
case "objects":
|
|
58
|
+
if (elementId) {
|
|
59
|
+
const ids = Array.isArray(elementId) ? elementId : [elementId];
|
|
60
|
+
result = await client.listObjects(ids, { includeMetadata: inclMeta });
|
|
61
|
+
} else {
|
|
62
|
+
result = await client.getObjects({ typeId: typeId || undefined, includeMetadata: inclMeta });
|
|
63
|
+
}
|
|
64
|
+
break;
|
|
65
|
+
case "related":
|
|
66
|
+
if (!elementId) {
|
|
67
|
+
throw new Error("elementId is required for related objects query");
|
|
68
|
+
}
|
|
69
|
+
{
|
|
70
|
+
const ids = Array.isArray(elementId) ? elementId : [elementId];
|
|
71
|
+
result = await client.getRelatedObjects(ids, {
|
|
72
|
+
relationshipType: relType || undefined,
|
|
73
|
+
includeMetadata: inclMeta,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
default:
|
|
78
|
+
throw new Error("Unknown browse target: " + target);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
msg.payload = result;
|
|
82
|
+
node.status({ fill: "green", shape: "dot", text: "ok" });
|
|
83
|
+
send(msg);
|
|
84
|
+
if (done) done();
|
|
85
|
+
} catch (err) {
|
|
86
|
+
node.status({ fill: "red", shape: "ring", text: err.message.substring(0, 32) });
|
|
87
|
+
if (done) done(err); else node.error(err, msg);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
RED.nodes.registerType("i3x-browse", I3XBrowseNode);
|
|
93
|
+
};
|