diff --git a/README.md b/README.md index 342e1ee..91c57e2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ + # Node generator for Node-RED Node generator is a command line tool to generate Node-RED nodes based on various sources such as an Open API document or a Function node. @@ -22,6 +23,8 @@ You may need to run this with `sudo`, or from within an Administrator command sh Supported source: - Open API document - Function node (js file in library, "~/.node-red/lib/function/") + - Swagger definition + - (Beta) Thing Description of W3C Web of Things (jsonld file or URL that points jsonld file) Options: -o : Destination path to save generated node (default: current directory) @@ -35,6 +38,8 @@ You may need to run this with `sudo`, or from within an Administrator command sh --color : Color for node appearance (format: color hexadecimal numbers like "A6BBCF") --tgz : Save node as tgz file --help : Show help + --wottd : explicitly instruct that source file/URL points a Thing Description + --lang : Language negotiation information when retrieve a Thing Description -v : Show node generator version ### Example 1. Create an original node from Open API document @@ -60,6 +65,28 @@ You may need to run this with `sudo`, or from within an Administrator command sh -> You can use lower-case node on Node-RED flow editor. +### Example 3. Create original node from Thing Description + +- node-red-nodegen example.jsonld +- cd node-red-contrib-example-thing +- sudo npm link +- cd ~/.node-red +- npm link node-red-contrib-example-thing +- node-red + +-> You can use Example Thing node on Node-RED flow editor. + +### Example 4. Create original node from Thing Description via HTTP + +- node-red-nodegen http://example.com/td.jsonld --wottd --lang "en-US,en;q=0.5" +- cd node-red-contrib-example-thing +- sudo npm link +- cd ~/.node-red +- npm link node-red-contrib-example-thing +- node-red + +-> You can use Example Thing node on Node-RED flow editor. + ## Documentation - [Use cases](https://github.com/node-red/node-red-nodegen/blob/0.0.4/docs/index.md#use-cases) ([Japanese](https://github.com/node-red/node-red-nodegen/blob/0.0.4/docs/index_ja.md#use-cases)) - [How to use Node generator](https://github.com/node-red/node-red-nodegen/blob/0.0.4/docs/index.md#how-to-use-node-generator) ([Japanese](https://github.com/node-red/node-red-nodegen/blob/0.0.4/docs/index_ja.md#how-to-use-node-generator)) diff --git a/bin/node-red-nodegen.js b/bin/node-red-nodegen.js old mode 100644 new mode 100755 index 650a31d..cb978c2 --- a/bin/node-red-nodegen.js +++ b/bin/node-red-nodegen.js @@ -56,6 +56,8 @@ function help() { ' [--color ]' + ' [--tgz]' + ' [--help]' + + ' [--wottd]' + + ' [--lang ]' + ' [-v]\n' + '\n' + 'Description:'.bold + '\n' + @@ -64,6 +66,8 @@ function help() { 'Supported source:'.bold + '\n' + ' - Open API document\n' + // ' - Subflow node (json file of subflow)\n' + + ' - Swagger definition\n' + + ' - Thing Description (jsonld file or URL that points jsonld file)\n' + ' - Function node (js file in library, "~/.node-red/lib/function/")\n' + '\n' + 'Options:\n'.bold + @@ -78,6 +82,8 @@ function help() { ' --color : Color for node appearance (format: color hexadecimal numbers like "A6BBCF")\n' + ' --tgz : Save node as tgz file\n' + ' --help : Show help\n' + + ' --wottd : explicitly instruct source file/URL points a Thing Description\n' + + ' --lang : Language negotiation information when retrieve a Thing Description\n' + ' -v : Show node generator version\n'; console.log(helpText); } @@ -87,6 +93,16 @@ function version() { console.log(packageJson.version); } +function skipBom(body) { + if (body[0]===0xEF && + body[1]===0xBB && + body[2]===0xBF) { + return body.slice(3); + } else { + return body; + } +} + if (argv.help || argv.h) { help(); } else if (argv.v) { @@ -94,7 +110,7 @@ if (argv.help || argv.h) { } else { var sourcePath = argv._[0]; if (sourcePath) { - if (sourcePath.startsWith('http://') || sourcePath.startsWith('https://')) { + if (!argv.wottd && (sourcePath.startsWith('http://') || sourcePath.startsWith('https://'))) { request(sourcePath, function (error, response, body) { if (!error) { data.src = JSON.parse(body); @@ -107,7 +123,28 @@ if (argv.help || argv.h) { console.error(error); } }); - } else if (sourcePath.endsWith('.json')) { + } else if (argv.wottd && (sourcePath.startsWith('http://') || sourcePath.startsWith('https://'))) { + const req = { + url: sourcePath, + } + if (argv.lang) { + req.headers = { + 'Accept-Language': argv.lang + } + } + request(req, function (error, response, body) { + if (!error) { + data.src = JSON.parse(skipBom(body)); + nodegen.wottd2node(data, options).then(function (result) { + console.log('Success: ' + result); + }).catch(function (error) { + console.log('Error: ' + error); + }); + } else { + console.error(error); + } + }); + } else if (sourcePath.endsWith('.json') && !argv.wottd) { data.src = JSON.parse(fs.readFileSync(sourcePath)); nodegen.swagger2node(data, options).then(function (result) { console.log('Success: ' + result); @@ -128,6 +165,13 @@ if (argv.help || argv.h) { }).catch(function (error) { console.log('Error: ' + error); }); + } else if (sourcePath.endsWith('.jsonld') || argv.wottd) { + data.src = JSON.parse(skipBom(fs.readFileSync(sourcePath))); + nodegen.wottd2node(data, options).then(function (result) { + console.log('Success: ' + result); + }).catch(function (error) { + console.log('Error: ' + error); + }); } else { console.error('error: Unsupported file type'); } diff --git a/lib/nodegen.js b/lib/nodegen.js index 2054a86..ef538a9 100644 --- a/lib/nodegen.js +++ b/lib/nodegen.js @@ -26,6 +26,8 @@ var csv = require('csv-string'); var CodeGen = require('swagger-js-codegen-formdata').CodeGen; var jimp = require("jimp"); +var wotutils = require('./wotutils'); + function createCommonFiles(templateDirectory, data) { "use strict"; // Make directories @@ -566,7 +568,194 @@ function swagger2node(data, options) { }); } + +function wottd2node(data, options) { + return when.promise(function (resolve, reject) { + let td = data.src; + + // validate TD + const validateResult = wotutils.validateTd(td); + if (validateResult.result === false) { + console.warn(`Invalid Thing Description:\n${validateResult.errorText}`); + } else { + console.info(`Schema validation succeeded.`); + } + + // if name is not specified, use td.title for module name. + if (!data.name || data.name === '') { + // filtering out special characters + data.name = 'wot' + td.title.replace(/[^A-Za-z0-9]/g, '').toLowerCase(); + } + + if (data.module) { + if (data.prefix) { + reject('error: module name and prefix are conflicted'); + return; + } + } else { + if (data.prefix) { + data.module = data.prefix + data.name; + } else { + data.module = 'node-red-contrib-' + data.name; + } + } + + if (!data.version || data.version === '') { + if (td.version && td.version.instance) { + data.version = td.version.instance; + } else { + data.version = '0.0.1'; + } + } + + data.tdstr = JSON.stringify(td); + td = wotutils.normalizeTd(td); + td = wotutils.filterFormTd(td); + data.normtd = JSON.stringify(td); + data.properties = []; + const rwo = {}; + for (const p in td.properties) { // convert to array + if (td.properties.hasOwnProperty(p)) { + const q = td.properties[p]; + q.name = p; + if (!q.title || q.title === '') { + q.title = q.name; + } + if (q.forms) { + for (var i = 0; i < q.forms.length; i++) { + q.forms[i].index = i; + } + } + data.properties.push(q); + rwo[p] = { + readable: !q.writeOnly, + writable: !q.readOnly, + observable: q.observable + }; + + } + } + data.actions = []; + for (const a in td.actions) { + if (td.actions.hasOwnProperty(a)) { + const q = td.actions[a]; + q.name = a; + if (!q.title || q.title === '') { + q.title = q.name; + } + if (q.forms) { + for (var i = 0; i < q.forms.length; i++) { + q.forms[i].index = i; + } + } + data.actions.push(q); + } + } + data.events = []; + for (const e in td.events) { + if (td.events.hasOwnProperty(e)) { + const q = td.events[e]; + q.name = e; + if (!q.title || q.title === '') { + q.title = q.name; + } + if (q.forms) { + for (var i = 0; i < q.forms.length; i++) { + q.forms[i].index = i; + } + } + data.events.push(q); + } + } + + const wotmeta = []; + if (td.hasOwnProperty('lastModified')) { + wotmeta.push({name: "lastModified", value: td.lastModified}); + } + if (td.hasOwnProperty('created')) { + wotmeta.push({name: "created", value: td.created}); + } + if (td.hasOwnProperty('support')) { + wotmeta.push({name: "support", value: JSON.stringify(td.support)}); + } + if (td.hasOwnProperty("id")) { + wotmeta.push({name: "id", value: td.id, last: true}); + } + + const formsel = wotutils.makeformsel(td); + + data.genformsel = JSON.stringify(formsel); + data.genproprwo = JSON.stringify(rwo); + + data.ufName = td.title.replace(/[^A-Za-z0-9]/g, ' ').trim(); + data.nodeName = data.name; + data.projectName = data.module; + data.projectVersion = data.version; + data.keywords = extractKeywords(data.keywords); + data.category = data.category || 'Web of Things'; + data.description = td.description; + data.licenseName = 'Apache-2.0'; + data.licenseUrl = ''; + data.links = td.links; + data.support = td.support; + data.iconpath = wotutils.woticon(td); + data.wotmeta = wotmeta; + + let lang = null; + if (td.hasOwnProperty('@context') && Array.isArray(td['@context'])) { + td['@context'].forEach(e => { + if (e.hasOwnProperty("@language")) { + lang = e['@language']; + } + }); + } + if (lang === null) { + data.textdir = "auto"; + } else { + data.textdir = wotutils.textDirection(lang); + } + + createCommonFiles(path.join(__dirname, '../templates/webofthings'), data); + + // Create package.json + const packageTemplate = fs.readFileSync(path.join(__dirname, '../templates/webofthings/package.json.mustache'), 'utf-8'); + const packageSourceCode = mustache.render(packageTemplate, data); + fs.writeFileSync(path.join(data.dst, data.module, 'package.json'), packageSourceCode); + + // Create node.js + const nodeTemplate = fs.readFileSync(path.join(__dirname, '../templates/webofthings/node.js.mustache'), 'utf-8'); + let nodeSourceCode = mustache.render(nodeTemplate, data); + if (options.obfuscate) { + nodeSourceCode = obfuscator.obfuscate(nodeSourceCode, { stringArrayEncoding: 'rc4' }); + } + fs.writeFileSync(path.join(data.dst, data.module, 'node.js'), nodeSourceCode); + + // Create node.html + const htmlTemplate = fs.readFileSync(path.join(__dirname, '../templates/webofthings/node.html.mustache'), 'utf-8'); + const htmlSourceCode = mustache.render(htmlTemplate, data); + fs.writeFileSync(path.join(data.dst, data.module, 'node.html'), htmlSourceCode); + + // Create README.html + const readmeTemplate = fs.readFileSync(path.join(__dirname, '../templates/webofthings/README.md.mustache'), 'utf-8'); + const readmeSourceCode = mustache.render(readmeTemplate, data); + fs.writeFileSync(path.join(data.dst, data.module, 'README.md'), readmeSourceCode); + + // Create LICENSE + const licenseTemplate = fs.readFileSync(path.join(__dirname, '../templates/webofthings/LICENSE.mustache'), 'utf-8'); + const licenseSourceCode = mustache.render(licenseTemplate, data); + fs.writeFileSync(path.join(data.dst, data.module, 'LICENSE'), licenseSourceCode); + + if (options.tgz) { + runNpmPack(data); + resolve(path.join(data.dst, data.module + '-' + data.version + '.tgz')); + } else { + resolve(path.join(data.dst, data.module)); + } + }); +} + module.exports = { function2node: function2node, - swagger2node: swagger2node + swagger2node: swagger2node, + wottd2node: wottd2node }; diff --git a/lib/td-json-schema-validation.json b/lib/td-json-schema-validation.json new file mode 100644 index 0000000..5751ca5 --- /dev/null +++ b/lib/td-json-schema-validation.json @@ -0,0 +1,1153 @@ +{ + "title": "WoT TD Schema - 16 October 2019", + "description": "JSON Schema for validating TD instances against the TD model. TD instances can be with or without terms that have default values", + "$schema ": "http://json-schema.org/draft-07/schema#", + "definitions": { + "thing-context-w3c-uri": { + "type": "string", + "enum": [ + "https://www.w3.org/2019/wot/td/v1" + ] + }, + "thing-context": { + "oneOf": [{ + "type": "array", + "items": { + "anyOf": [{ + "$ref": "#/definitions/anyUri" + }, + { + "type": "object" + } + ] + }, + "contains": { + "$ref": "#/definitions/thing-context-w3c-uri" + } + }, + { + "$ref": "#/definitions/thing-context-w3c-uri" + } + ] + }, + "type_declaration": { + "oneOf": [{ + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "property_element": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_property" + } + }, + "observable": { + "type": "boolean" + }, + "writeOnly": { + "type": "boolean" + }, + "readOnly": { + "type": "boolean" + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + }, + "unit": { + "type": "string" + }, + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + "format": { + "type": "string" + }, + "const": {}, + "type": { + "type": "string", + "enum": [ + "boolean", + "integer", + "number", + "string", + "object", + "array", + "null" + ] + }, + "items": { + "oneOf": [{ + "$ref": "#/definitions/dataSchema" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + } + ] + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "minItems": { + "type": "integer", + "minimum": 0 + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "forms" + ], + "additionalProperties": true + }, + "action_element": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_action" + } + }, + "input": { + "$ref": "#/definitions/dataSchema" + }, + "output": { + "$ref": "#/definitions/dataSchema" + }, + "safe": { + "type": "boolean" + }, + "idempotent": { + "type": "boolean" + } + }, + "required": [ + "forms" + ], + "additionalProperties": true + }, + "event_element": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_event" + } + }, + "subscription": { + "$ref": "#/definitions/dataSchema" + }, + "data": { + "$ref": "#/definitions/dataSchema" + }, + "cancellation": { + "$ref": "#/definitions/dataSchema" + }, + "type": { + "not": {} + }, + "enum": { + "not": {} + }, + "const": { + "not": {} + } + }, + "required": [ + "forms" + ], + "additionalProperties": true + }, + "form_element_property": { + "type": "object", + "properties": { + "href": { + "$ref": "#/definitions/anyUri" + }, + "op": { + "oneOf": [{ + "type": "string", + "enum": [ + "readproperty", + "writeproperty", + "observeproperty", + "unobserveproperty" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "readproperty", + "writeproperty", + "observeproperty", + "unobserveproperty" + ] + } + } + ] + }, + "contentType": { + "type": "string" + }, + "security": { + "type": "array", + "items": { + "type": "string" + } + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "subProtocol": { + "type": "string", + "enum": [ + "longpoll", + "websub", + "sse" + ] + }, + "response": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + } + } + } + }, + "required": [ + "href" + ], + "additionalProperties": true + }, + "form_element_action": { + "type": "object", + "properties": { + "href": { + "$ref": "#/definitions/anyUri" + }, + "op": { + "oneOf": [{ + "type": "string", + "enum": [ + "invokeaction" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "invokeaction" + ] + } + } + ] + }, + "contentType": { + "type": "string" + }, + "security": { + "type": "array", + "items": { + "type": "string" + } + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "subProtocol": { + "type": "string", + "enum": [ + "longpoll", + "websub", + "sse" + ] + }, + "response": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + } + } + } + }, + "required": [ + "href" + ], + "additionalProperties": true + }, + "form_element_event": { + "type": "object", + "properties": { + "href": { + "$ref": "#/definitions/anyUri" + }, + "op": { + "oneOf": [{ + "type": "string", + "enum": [ + "subscribeevent", + "unsubscribeevent" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "subscribeevent", + "unsubscribeevent" + ] + } + } + ] + }, + "contentType": { + "type": "string" + }, + "security": { + "type": "array", + "items": { + "type": "string" + } + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "subProtocol": { + "type": "string", + "enum": [ + "longpoll", + "websub", + "sse" + ] + }, + "response": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + } + } + } + }, + "required": [ + "href" + ], + "additionalProperties": true + }, + "form_element_root": { + "type": "object", + "properties": { + "href": { + "$ref": "#/definitions/anyUri" + }, + "op": { + "oneOf": [{ + "type": "string", + "enum": [ + "readallproperties", + "writeallproperties", + "readmultipleproperties", + "writemultipleproperties" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "readallproperties", + "writeallproperties", + "readmultipleproperties", + "writemultipleproperties" + ] + } + } + ] + }, + "contentType": { + "type": "string" + }, + "security": { + "type": "array", + "items": { + "type": "string" + } + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "subProtocol": { + "type": "string", + "enum": [ + "longpoll", + "websub", + "sse" + ] + }, + "response": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + } + } + } + }, + "required": [ + "href" + ], + "additionalProperties": true + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "descriptions": { + "type": "object" + }, + "titles": { + "type": "object" + }, + "dataSchema": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "title": { + "$ref": "#/definitions/title" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "writeOnly": { + "type": "boolean" + }, + "readOnly": { + "type": "boolean" + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + }, + "unit": { + "type": "string" + }, + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + "format": { + "type": "string" + }, + "const": {}, + "type": { + "type": "string", + "enum": [ + "boolean", + "integer", + "number", + "string", + "object", + "array", + "null" + ] + }, + "items": { + "oneOf": [{ + "$ref": "#/definitions/dataSchema" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + } + ] + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "minItems": { + "type": "integer", + "minimum": 0 + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "link_element": { + "type": "object", + "properties": { + "anchor": { + "$ref": "#/definitions/anyUri" + }, + "href": { + "$ref": "#/definitions/anyUri" + }, + "rel": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "href" + ], + "additionalProperties": true + }, + "securityScheme": { + "oneOf": [{ + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "nosec" + ] + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "basic" + ] + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "cert" + ] + }, + "identity": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "digest" + ] + }, + "qop": { + "type": "string", + "enum": [ + "auth", + "auth-int" + ] + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "bearer" + ] + }, + "authorization": { + "$ref": "#/definitions/anyUri" + }, + "alg": { + "type": "string", + "enum": [ + "MD5", + "ES256", + "ES512-256" + ] + }, + "format": { + "type": "string", + "enum": [ + "jwt", + "jwe", + "jws" + ] + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "psk" + ] + }, + "identity": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "public" + ] + }, + "identity": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "authorization": { + "$ref": "#/definitions/anyUri" + }, + "token": { + "$ref": "#/definitions/anyUri" + }, + "refresh": { + "$ref": "#/definitions/anyUri" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "flow": { + "type": "string", + "enum": [ + "implicit", + "password", + "client", + "code" + ] + } + }, + "required": [ + "scheme", + "flow" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "apikey" + ] + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "pop" + ] + }, + "authorization": { + "$ref": "#/definitions/anyUri" + }, + "format": { + "type": "string", + "enum": [ + "jwt", + "jwe", + "jws" + ] + }, + "alg": { + "type": "string", + "enum": [ + "MD5", + "ES256", + "ES512-256" + ] + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + } + ] + }, + "anyUri": { + "type": "string", + "format": "uri-reference" + } + }, + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uri" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/property_element" + } + }, + "actions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/action_element" + } + }, + "events": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/event_element" + } + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "version": { + "type": "object", + "properties": { + "instance": { + "type": "string" + } + }, + "required": [ + "instance" + ] + }, + "links": { + "type": "array", + "items": { + "$ref": "#/definitions/link_element" + } + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_root" + } + }, + "base": { + "$ref": "#/definitions/anyUri" + }, + "securityDefinitions": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/definitions/securityScheme" + } + }, + "support": { + "$ref": "#/definitions/anyUri" + }, + "created": { + "type": "string" + }, + "modified": { + "type": "string" + }, + "security": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "@context": { + "$ref": "#/definitions/thing-context" + } + }, + "required": [ + "title", + "security", + "securityDefinitions", + "@context" + ], + "additionalProperties": true +} diff --git a/lib/wotutils.js b/lib/wotutils.js new file mode 100644 index 0000000..51c31cd --- /dev/null +++ b/lib/wotutils.js @@ -0,0 +1,350 @@ +/** + * Copyright JS Foundation and other contributors, http://js.foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + **/ + +const Ajv = require('ajv'); +const url = require('url'); +const fs = require('fs'); +const path = require('path'); +const cldr = require('cldr'); + +function validateTd(td) { + const TDSchema = + JSON.parse(fs.readFileSync(path.join(__dirname, './td-json-schema-validation.json'), 'utf-8')); + const ajv = new Ajv({allErrors: true}); + const valid = ajv.validate(TDSchema, td); + + return { result: valid, errorText: valid?"":ajv.errorsText()}; +} + +function setdefault(obj, propname, dflt) { + return obj.hasOwnProperty(propname) ? obj[propname] : dflt; +} + +function isOpInForms(op, forms) { + return forms && forms + .map(e=>(e.op && typeof e.op === 'string') ? [e.op] : e.op) + .reduce((a,v)=>a.concat(v),[]) + .some(e=>e === op); +} + +function textDirection(lang) { + let dir = 'auto'; + try { + const ori = cldr.extractLayout(lang); + if (ori.characterOrder === 'left-to-right') { + dir = 'ltr' + } else if (ori.characterOrder === 'right-to-left') { + dir = 'rtl' + } + } catch (e) { + if (lang === "az-Arab") { + dir = 'rtl' + } + } + return dir; +} + +function normalizeTd(td) { + + const baseUrl = td.base || ""; + + function formconv(intr, f, affordance) { + if (f.hasOwnProperty("href")) { + f.href = url.resolve(baseUrl, f.href); // decodeURIComponent(url.resolve(baseUrl, f.href)); + } + if (f.hasOwnProperty("security") && typeof f.security === 'string') { + f.security = [f.security]; + } + if (!f.hasOwnProperty("security")) { + if (intr.hasOwnProperty("security")) { + f.security = intr.security; + } else if (td.hasOwnProperty("security")) { + f.security = td.security; + } + } + f.contentType = setdefault(f, "contentType", "application/json"); + switch (affordance) { + case "PropertyAffordance": + f.op = setdefault(f, "op", ["readproperty", "writeproperty"]); + break; + case "ActionAffordance": + f.op = setdefault(f, "op", "invokeaction"); + break; + case "EventAffordance": + f.op = setdefault(f, "op", "subscribeevent"); + break; + } + + return f; + } + + // normalize 'security' as Array of String. + if (td.hasOwnProperty("security") && typeof td.security === "string") { + td.security = [td.security]; + } + + // Set default values in security definition + for (const sd in td.securityDefinitions) { + const sdef = td.securityDefinitions[sd]; + switch (sdef.scheme) { + case "basic": + sdef.in = setdefault(sdef, "in", "header"); + break; + case "digest": + sdef.in = setdefault(sdef, "in", "header"); + sdef.qop = setdefault(sdef, "qop", "auth"); + break; + case "bearer": + sdef.in = setdefault(sdef, "in", "header"); + sdef.alg = setdefault(sdef, "alg", "ES256"); + sdef.format = setdefault(sdef, "format", "jwt"); + break; + case "pop": + sdef.in = setdefault(sdef, "in", "header"); + sdef.alg = setdefault(sdef, "alg", "ES256"); + sdef.format = setdefault(sdef, "format", "jwt"); + break; + case "oauth2": + sdef.flow = setdefault(sdef, "flow", "implicit"); + break; + case "apikey": + sdef.in = setdefault(sdef, "in", "query"); + break; + default: + break; + } + } + // Set default values in properties + for (const p in td.properties) { + const pdef = td.properties[p]; + if (pdef.hasOwnProperty("security") && typeof pdef.security === 'string') { + pdef.security = [pdef.security]; + } + if (pdef.forms) { + pdef.forms = pdef.forms.map((f) => formconv(pdef, f, "PropertyAffordance")); + // no filtering based on protocol + } + + // if there is forms which have readproperty in op, writeOnly is false, otherwise true + pdef.writeOnly = setdefault(pdef, "writeOnly", !isOpInForms("readproperty", pdef.forms)); + // if there is forms which have writeproperty in op, readOnly is false, otherwise true + pdef.readOnly = setdefault(pdef, "readOnly", !isOpInForms("writeproperty", pdef.forms)); + // if there is forms which have observeproperty in op, observable is true, otherwise false + pdef.observable = setdefault(pdef, "observable", isOpInForms("observeproperty", pdef.forms)); + // in any cases, if it explicitly stated by writeOnly/readOnly/observable, use it. + + } + + // Set default values in actions + for (const a in td.actions) { + const adef = td.actions[a]; + adef.safe = setdefault(adef, "safe", false); + adef.idempotent = setdefault(adef, "idempotent", false); + if (adef.hasOwnProperty("security") && typeof adef.security === 'string') { + adef.security = [adef.security]; + } + if (adef.forms) { + adef.forms = adef.forms.map((f) => formconv(adef, f, "ActionAffordance")); + // no filtering based on protocol + } + } + + // Set default values in events + for (const e in td.events) { + const edef = td.events[e]; + if (edef.hasOwnProperty("security") && typeof edef.security === 'string') { + edef.security = [edef.security]; + } + if (edef.forms) { + edef.forms = edef.forms.map((f) => formconv(edef, f, "EventAffordance")); + // no filtering based on protocol + } + } + + // Set default values in toplevel forms --- TODO: make this work + if (td.forms) { + const pdef = td; + td.forms = td.forms.map((f) => formconv(pdef, f, "TopPropertyAffordance")); + } + + // Set default value in toplevel context + td["@context"] = setdefault(td, "@context", "https://www.w3.org/2019/wot/td/v1"); + + // Convert top level forms ({read/write}allproperties) to "ALLPROPERTIES" property. + if (td.forms) { + const convforms = td.forms + .map(f => { + if (f.op && typeof f.op === 'string') { + f.op = [f.op]; + } + f.op = f.op.map(o => { + let res = o; + switch (o) { + case 'readallproperties': + res = 'readproperty'; + break; + case 'writeallproperties': + res = 'writeproperty'; + } + return res; + }); + return f; + }); + td.properties['__ALLPROPERTIES'] = { + title: "All Properties", + description: "all properties of this Thing", + forms: convforms, + type: "object", + writeOnly: !isOpInForms("readproperty", convforms), + readOnly: !isOpInForms("writeproperty", convforms), + observable: false + }; + } + + return td; +} + +function filterFormTd(td) { + for (const p in td.properties) { + let forms = td.properties[p].forms; + if (forms) { + forms = forms.filter((f) => (f.hasOwnProperty("href") && + (f.href.match(/^https?:/) || f.href.match(/^wss?:/) || f.href.match(/^coaps?:/)))); + } + td.properties[p].forms = forms; + } + for (const a in td.actions) { + let forms = td.actions[a].forms; + if (forms) { + forms = forms.filter((f) => (f.hasOwnProperty("href") && + (f.href.match(/^https?:/) || f.href.match(/^wss?:/) || f.href.match(/^coaps?:/)))); + } + td.actions[a].forms = forms; + } + for (const e in td.events) { + let forms = td.events[e].forms; + if (forms) { + forms = forms.filter((f) => (f.hasOwnProperty("href") && + (f.href.match(/^https?:/) || f.href.match(/^wss?:/) || f.href.match(/^coaps?:/)))); + } + td.events[e].forms = forms; + } + return td; +} + +function makeformsel(td) { + const formsel = { + property: {}, + action: {}, + event: {} + }; + + for (const p in td.properties) { + const forms = td.properties[p].forms; + const readforms = []; + const writeforms = []; + const observeforms = []; + for (let i = 0; i < forms.length; i++) { + const secscheme = td.securityDefinitions[forms[i].security[0]].scheme; + if (!forms[i].hasOwnProperty('op') || forms[i].op.includes("readproperty")) { + readforms.push({index:i,secscheme:secscheme,title:forms[i].href}); + } + if (!forms[i].hasOwnProperty('op') || forms[i].op.includes("writeproperty")) { + writeforms.push({index:i,secscheme:secscheme,title:forms[i].href}); + } + if (forms[i].hasOwnProperty('op') && forms[i].op.includes("observeproperty")) { + observeforms.push({index:i,secscheme:secscheme,title:forms[i].href}); + } + } + formsel.property[p] = {read: readforms, write: writeforms, observe: observeforms}; + } + for (const a in td.actions) { + const forms = td.actions[a].forms; + formsel.action[a] = []; + for (let i = 0; i < forms.length; i++) { + const secscheme = td.securityDefinitions[forms[i].security[0]].scheme; + if (!forms[i].hasOwnProperty('op') || forms[i].op.includes("invokeaction")) { + formsel.action[a].push({index:i,secscheme:secscheme,title:forms[i].href}); + } + } + } + for (const e in td.events) { + const forms = td.events[e].forms; + formsel.event[e] = []; + for (let i = 0; i < forms.length; i++) { + const secscheme = td.securityDefinitions[forms[i].security[0]].scheme; + if (!forms[i].hasOwnProperty('op') || forms[i].op.includes("subscribeevent")) { + formsel.event[e].push({index:i,secscheme:secscheme,title:forms[i].href}); + } + } + } + + return formsel; +} + +function woticon(td) { + const iotschemaToIcon = { + "Sensor": "font-awesome/fa-microchip", + "BinarySwitch": "font-awesome/fa-toggle-on", + "SwitchStatus": "font-awesome/fa-toggle-on", + "Toggle": "font-awesome/fa-toggle-on", + "Light": "font-awesome/fa-lightbulb-o",//"light.png", + "Actuator": "font-awesome/fa-bolt", + "CurrentColour": "font-awesome/fa-paint-brush", + "ColourData": "font-awesome/fa-paint-brush", + "LightControl": "font-awesome/fa-cogs", + "Illuminance": "font-awesome/fa-sun-o", + "IlluminanceSensing": "font-awesome/fa-sun-o", + "MotionControl": "font-awesome/fa-arrows-alt", + "Temperature": "font-awesome/fa-thermometer-half", + "TemperatureSensing": "font-awesome/fa-thermometer-half", + "TemperatureData": "font-awesome/fa-thermometer-half", + "Thermostat": "font-awesome/fa-thermometer-half", + "Pump": "font-awesome/fa-tint", + "AirConditioner": "font-awesome/fa-snowflake-o", + "UltrasonicSensing": "font-awesome/fa-rss", + "HumiditySensing": "font-awesome/fa-umbrella", + "SoundPressure": "font-awesome/fa-volume-up", + "Valve": "font-awesome/fa-wrench", + "ProximitySensing": "font-awesome/fa-crosshairs" + }; + + const iotschemaPrefix = 'iot'; + + if (Array.isArray(td['@type'])) { + const candidates = td['@type'].map((e) => { + for (f in iotschemaToIcon) { + if (`${iotschemaPrefix}:${f}` === e) { + return iotschemaToIcon[f]; + }; + }; + }).filter((e) => e); + + if (candidates.length > 0) { + return candidates[0]; + } + } + return "white-globe.png"; +} + +module.exports = { + validateTd: validateTd, + normalizeTd: normalizeTd, + filterFormTd: filterFormTd, + makeformsel: makeformsel, + woticon: woticon, + textDirection: textDirection +} \ No newline at end of file diff --git a/package.json b/package.json index abe1622..d718972 100644 --- a/package.json +++ b/package.json @@ -73,9 +73,13 @@ "function", "swagger", "codegen", - "swagger-codegen" + "swagger-codegen", + "wot", + "web of things" ], "dependencies": { + "ajv": "^6.10.2", + "cldr": "^5.5.3", "colors": "1.3.3", "csv-string": "3.1.6", "javascript-obfuscator": "0.16.0", diff --git a/samples/MyLampThing.jsonld b/samples/MyLampThing.jsonld new file mode 100644 index 0000000..bb5def4 --- /dev/null +++ b/samples/MyLampThing.jsonld @@ -0,0 +1,39 @@ +{ + "@context": [ + "https://www.w3.org/2019/wot/td/v1", + { "saref": "https://w3id.org/saref#" } + ], + "id": "urn:dev:ops:32473-WoTLamp-1234", + "title": "MyLampThing", + "@type": "saref:LightSwitch", + "securityDefinitions": {"basic_sc": { + "scheme": "basic", + "in": "header" + }}, + "security": ["basic_sc"], + "properties": { + "status": { + "@type": "saref:OnOffState", + "type": "string", + "forms": [{ + "href": "https://mylamp.example.com/status" + }] + } + }, + "actions": { + "toggle": { + "@type": "saref:ToggleCommand", + "forms": [{ + "href": "https://mylamp.example.com/toggle" + }] + } + }, + "events": { + "overheating": { + "data": {"type": "string"}, + "forms": [{ + "href": "https://mylamp.example.com/oh" + }] + } + } +} \ No newline at end of file diff --git a/templates/webofthings/.travis.yml.mustache b/templates/webofthings/.travis.yml.mustache new file mode 100755 index 0000000..acc148c --- /dev/null +++ b/templates/webofthings/.travis.yml.mustache @@ -0,0 +1,4 @@ +language: node_js +node_js: + - "10" + - "8" diff --git a/templates/webofthings/LICENSE.mustache b/templates/webofthings/LICENSE.mustache new file mode 100644 index 0000000..02c35eb --- /dev/null +++ b/templates/webofthings/LICENSE.mustache @@ -0,0 +1,2 @@ +{{&licenseName}} +{{&licenseUrl}} diff --git a/templates/webofthings/README.md.mustache b/templates/webofthings/README.md.mustache new file mode 100644 index 0000000..7ee17fc --- /dev/null +++ b/templates/webofthings/README.md.mustache @@ -0,0 +1,36 @@ +{{projectName}} +===================== + +Node-RED node for {{nodeName}} + +{{description}} + +Install +------- + +Run the following command in your Node-RED user directory - typically `~/.node-red` + + npm install {{projectName}} + +Interactions +------------ + +### Properties + +{{#properties}} +- {{label}}: {{description}} +{{/properties}} + +### Actions + +{{#actions}} +- {{label}}: {{description}} +{{/actions}} + + +### Events + +{{#events}} +- {{label}}: {{description}} +{{/events}} + diff --git a/templates/webofthings/icons/.gitkeep b/templates/webofthings/icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/webofthings/locales/.gitkeep b/templates/webofthings/locales/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/webofthings/node.html.mustache b/templates/webofthings/node.html.mustache new file mode 100644 index 0000000..af7d1b4 --- /dev/null +++ b/templates/webofthings/node.html.mustache @@ -0,0 +1,343 @@ + + + + + diff --git a/templates/webofthings/node.js.mustache b/templates/webofthings/node.js.mustache new file mode 100644 index 0000000..b84137b --- /dev/null +++ b/templates/webofthings/node.js.mustache @@ -0,0 +1,533 @@ +module.exports = function (RED) { + "use strict"; + const request = require('request'); + const url = require('url'); + const HttpsProxyAgent = require('https-proxy-agent'); + const WebSocket = require('ws'); + const urltemplate = require('url-template'); + const Ajv = require('ajv'); + + function extractTemplate(href, context={}) { + return urltemplate.parse(href).expand(context); + } + + function getResType(form) { + if (form) { + if (form.response && form.response.contentType) { + return form.response.contentType; + } else if (form.contentType) { + return form.contentType; + } + } + return "application/json"; + } + + function isBinaryType(contentType) { + let result; + switch (contentType) { + case "image/jpeg": + case "application/octet-stream": + result = true; + break; + default: + result = false; + break; + } + return result; + } + + function coapMethodCodeToName(methodCode) { + switch (methodCode) { + case '1': + case '0.01': + case 'get': + case 'GET': + return 'get'; + break; + case '2': + case '0.02': + case 'post': + case 'POST': + return 'post'; + break; + case '3': + case '0.03': + case 'put': + case 'PUT': + return 'put'; + break; + case '4': + case '0.04': + case 'delete': + case 'DELETE': + return 'delete'; + break; + default: + return 'get'; + break; + } + } + + function bindingCoap(node, send, done, form, options={}) { // options.psk + const coap = require("node-coap-client").CoapClient; + node.trace("bindingCoap called"); + const msg = options.msg || {}; + const resource = extractTemplate(form.href, options.urivars); + let payload = null; + let method = null; + + if (options.interaction === "property-read") { + method = form.hasOwnProperty("cov:methodName") ? + coapMethodCodeToName(form['cov:methodName']) : 'get'; + } else if (options.interaction === "property-write") { + method = form.hasOwnProperty("cov:methodName") ? + coapMethodCodeToName(form['cov:methodName']) : 'put'; + payload = options.reqbody; + } else { // assume "action" + method = form.hasOwnProperty("cov:methodName") ? + coapMethodCodeToName(form['cov:methodName']) : 'post'; + payload = options.reqbody; + } + + node.trace(`CoAP request: resource=${resource}, method=${method}, payload=${payload}`); + coap.request(resource, method, payload) + .then(response => { // code, format, payload + node.trace(`CoAP response: code=${response.code.toString()}, format=${response.format}, payload=${response.payload}`); + if (response.format === 50) { // application/json; rfc7252 section 12.3, table 9 + try { + msg.payload = JSON.parse(response.payload); + } catch (e) { + msg.payload = response.payload; + } + } else { + msg.payload = response.payload; + } + if (options.outschema) { + const ajv = new Ajv({allErrors: true}); + if (!ajv.validate(options.outschema, msg.payload)) { + node.warn(`output schema validation error: ${ajv.errorsText()}`, msg); + } + } + send(msg); + if (done) { + done(); + } + }) + .catch(err => { + node.log(`Error: ${err.toString()}`); + msg.payload = `${err.toString()}: ${resource}`; + send(msg); + if (done) { + done(); + } + }); + } + + function bindingCoapObserve(node, form, options={}) { + const coap = require("node-coap-client").CoapClient; + node.status({fill:"yellow",shape:"dot",text:"CoAP try to observe ..."}); + const resource = extractTemplate(form.href,options.urivars); + const method = form.hasOwnProperty("cov:methodName") ? + coapMethodCodeToName(form['cov:methodName']) : 'get'; + const payload = options.reqbody; + const callback = response => { // code, format, payload + const msg = {}; + node.trace(`CoAP observe: code=${response.code.toString()}, format=${response.format}, payload=${response.payload}`); + if (response.format === 50) { // application/json; rfc7252 section 12.3, table 9 + try { + msg.payload = JSON.parse(response.payload); + } catch (e) { + msg.payload = response.payload; + } + } else { + msg.payload = response.payload; + } + if (options.outschema) { + const ajv = new Ajv({allErrors: true}); + if (!ajv.validate(options.outschema, msg.payload)) { + node.warn(`output schema validation error: ${ajv.errorsText()}`, msg); + } + } + node.send(msg); + }; + coap.observe(resource,method,callback,payload,options) + .then(() => { + node.status({fill:"green",shape:"dot",text:"CoAP Observing"}); + }) + .catch(err => { + node.status({fill:"red",shape:"dot",text:`CoAP Error: ${err}`}); + node.log(`Error: ${err.toString()}`); + }); + node.on('close', () => { + node.trace('Close node'); + coap.stopObserving(resource); + node.status({}); + }); + } + + function bindingWebSocket(node, form, options={}) { + let ws; + let reconnectTimeout; + let needReconnect = false; + const setupWsClient = () => { + let wsoptions = {}; + if (process.env.http_proxy) { + const agoptions = url.parse(process.env.http_proxy); + const agent = new HttpsProxyAgent(agoptions); + wsoptions = {agent: agent}; + } + if (options.hasOwnProperty("auth") && + (options.auth.hasOwnProperty("user") || options.auth.hasOwnProperty("bearer"))) { + wsoptions.auth = options.auth; + } + node.status({fill:"yellow",shape:"dot",text:"WS Connecting..."}); + needReconnect = true; + const href = extractTemplate(form.href,options.urivars); + ws = new WebSocket(href, wsoptions); + node.trace(`Connecting websocket: ${form.href}`); + + ws.on('open', () => { + node.status({fill:"green",shape:"dot",text:"WS Connected"}); + node.trace('websocket opened.'); + }); + ws.on('close', (code, reason) => { + node.status({}); + node.trace(`websocket closed (code=${code}, reason=${reason})`); + if (needReconnect) { + node.status({fill:"orange",shape:"dot",text:`WS Reconnecting...`}); + reconnectTimeout = setTimeout(setupWsClient, 5000); + } + }); + ws.on('error', (error) => { + node.status({fill:"red",shape:"dot",text:`WS Error: ${error}`}); + node.warn(`websocket error: ${error}`); + }); + ws.on('message', (data) => { + node.status({fill:"green",shape:"dot",text:"WS OK"}); + const msg = {}; + if (getResType(form) === "application/json") { + try { + msg.payload = JSON.parse(data); + } catch(e) { + msg.payload = data; + } + } else { + msg.payload = data; + } + if (options.outschema) { + const ajv = new Ajv({allErrors: true}); + if (!ajv.validate(options.outschema, msg.payload)) { + node.warn(`output schema validation error: ${ajv.errorsText()}`, msg); + } + } + node.send(msg) + }); + } + setupWsClient(); + node.on('close', () => { + node.trace('Close node'); + clearTimeout(reconnectTimeout); + needReconnect = false; + try { + ws.close(); + } catch (err) { + + } + node.status({}); + }); + } + + function bindingLongPoll(node, form, options={}) { + let reqObj; + let needReconnect = false; + let reconnectTimeout; + const setupLPClient = () => { + const reqoptions = {}; + reqoptions.uri = extractTemplate(form.href, options.urivars); + reqoptions.rejectUnauthorized = false; + // reqoptions.timeout = 60000; + if (options && options.auth && (options.auth.user || options.auth.bearer)) { + reqoptions.auth = options.auth; + } + reqoptions.method = form.hasOwnProperty("htv:methodName") ? form["htv:methodName"] : "GET"; + if (isBinaryType(form.contentType)) { + reqoptions.encoding = null; + } + node.trace(`LongPoll Request options: ${JSON.stringify(reqoptions)}`); + needReconnect = true; + reqObj = request(reqoptions, (err, res, body) => { + if (err) { + const msg = {}; + msg.payload = `${err.toString()}: ${reqoptions.uri}`; + msg.statusCode = err.code; + node.status({fill:"yellow",shape:"dot",text:"Polling error"}); + node.send(msg); + if (needReconnect) { + reconnectTimeout = setTimeout(setupLPClient, 5000); + } + } else { + const msg = {}; + node.status({fill:"green",shape:"dot",text:"OK"}); + msg.statusCode = res.statusCode; + msg.headers = res.headers; + msg.responseUrl = res.request.uri.href; + if (getResType(form) === "application/json") { + try { + msg.payload = JSON.parse(body); + } catch(e) { + msg.payload = body; + } + } else { + msg.payload = body; + } + // TODO: validation of return value + if (options.outschema) { + const ajv = new Ajv(); + if (!ajv.validate(options.outschema, msg.payload)) { + node.warn(`output schema validation error: ${ajv.errorsText()}`, msg); + } + } + node.send(msg); + if (needReconnect) { + reconnectTimeout = setTimeout(setupLPClient, 5000); + } + } + }); + node.status({fill:"green",shape:"dot",text:"Connecting..."}); + } + setupLPClient(); + node.on("close", () => { + node.status({}); + needReconnect = false; + clearTimeout(reconnectTimeout); + if (reqObj) { + reqObj.abort(); + } + }); + } + + function bindingHttp(node, send, done, form, options={}) { // options.interaction, options.auth, options.reqbody, options.msg + const msg = options.msg || {}; + const reqoptions = {}; + reqoptions.uri = extractTemplate(form.href, options.urivars); + reqoptions.rejectUnauthorized = false; + if (options.hasOwnProperty("auth") && + (options.auth.hasOwnProperty("user") || options.auth.hasOwnProperty("bearer"))) { + reqoptions.auth = options.auth; + } + if (options.interaction === "property-read") { + reqoptions.method = form.hasOwnProperty("htv:methodName") ? form["htv:methodName"] : "GET"; + } else if (options.interaction === "property-write") { + reqoptions.method = form.hasOwnProperty("htv:methodName") ? form["htv:methodName"] : "PUT"; + switch (reqoptions.method) { + case "GET": + break; + case "POST": + case "PUT": + reqoptions.json = form.contentType === "application/json"; + reqoptions.body = options.reqbody; + reqoptions.headers = {'Content-Type': form.contentType}; + break; + } + } else { // assume "action" + reqoptions.method = form.hasOwnProperty("htv:methodName") ? form["htv:methodName"] : "POST"; + switch (reqoptions.method) { + case "GET": + break; + case "POST": + case "PUT": + reqoptions.json = form.contentType === "application/json"; + reqoptions.body = options.reqbody; + reqoptions.headers = {'Content-Type': form.contentType}; + if (form.hasOwnProperty("response") && isBinaryType(form.response.contentType)) { + reqoptions.encoding = null; + } + break; + } + } + if (isBinaryType(form.contentType)) { + reqoptions.encoding = null; + } + node.trace(`HTTP request options: ${JSON.stringify(reqoptions)}`); + request(reqoptions, (err, res, body) => { + if (err) { + node.log(`Error: ${err.toString()}`); + msg.payload = `${err.toString()}: ${reqoptions.uri}`; + msg.statusCode = err.code; + send(msg); + } else { + msg.statusCode = res.statusCode; + msg.headers = res.headers; + msg.responseUrl = res.request.uri.href; + if (getResType(form) === "application/json") { + try { + msg.payload = JSON.parse(body); + } catch(e) { + msg.payload = body; + } + } else { + msg.payload = body; + } + // TODO: validation of return value + if (options.outschema) { + const ajv = new Ajv({allErrors: true}); + if (!ajv.validate(options.outschema, msg.payload)) { + node.warn(`output schema validation error: ${ajv.errorsText()}`, msg); + } + } + send(msg); + } + if (done) { + done(); + } + }); + } + + function makeauth(td, form, username, password, token) { + const scheme = td.securityDefinitions[form.security].scheme; + const auth = {}; + switch (scheme) { + case "basic": + auth.user = username; + auth.pass = password; + auth.sendImmediately = true; + break; + case "digest": + auth.user = username; + auth.pass = password; + auth.sendImmediately = false; + break; + case "bearer": + auth.bearer = token; + break; + } + return auth; + } + + function Node(config) { + RED.nodes.createNode(this, config); + const node = this; + node.interactiontype = config.interactiontype; + node.propname = config.propname; + node.proptype = config.proptype; + node.actname = config.actname; + node.evname = config.evname; + node.formindex = config.formindex; + node.status({}); + node.debug(`node config: ${JSON.stringify(node)}`); + const username = node.credentials.username; + const password = node.credentials.password; + const token = node.credentials.token; + + node.td = {{{tdstr}}}; + const normTd = {{{normtd}}}; + if (node.interactiontype === "property") { + if (node.proptype === "read") { + node.on("input", (msg, send, done) => { + send = send || function() { node.send.apply(node,arguments) }; + const prop = normTd.properties[node.propname]; + const form = prop.forms[node.formindex];// formSelection("property-read", prop.forms); + const auth = makeauth(normTd, form, username, password, token); + const urivars = prop.hasOwnProperty("uriVariables") ? msg.payload : {}; + if (prop.uriVariables) { + const ajv = new Ajv({allErrors: true}); + if (!ajv.validate(prop.uriVariables, urlvars)) { + node.warn(`input schema validation error: ${ajv.errorsText()}`, msg); + } + } + if (form.href.match(/^https?:/)) { + bindingHttp(node, send, done, form, {interaction:"property-read", auth, msg, urivars, outschema: prop}); + } else if (form.href.match(/^coaps?:/)) { + bindingCoap(node, send, done, form, send, done, {interaction:"property-read", auth, msg, urivars, outschema: prop}); + } + }); + } else if (node.proptype === "write") { + node.on("input", (msg, send, done) => { + send = send || function() { node.send.apply(node,arguments) }; + const prop = normTd.properties[node.propname]; + const form = prop.forms[node.formindex];// formSelection("property-write", prop.forms); + const auth = makeauth(normTd, form, username, password, token); + const ajv = new Ajv({allErrors: true}); + if (!ajv.validate(prop, msg.payload)) { + node.warn(`input schema validation error: ${ajv.errorsText()}`, msg); + } + // URI template is not supported, because 'write' doesn't use GET method. + if (form.href.match(/^https?:/)) { + bindingHttp(node, send, done, form, {interaction: "property-write", auth, msg, reqbody: msg.payload}); + } else if (form.href.match(/^coaps?:/)) { + bindingCoap(node, send, done, form, {interaction: "property-write", auth, msg, reqbody: msg.payload}); + } + }); + } else if (node.proptype === "observe") { + const prop = normTd.properties[node.propname]; + const form = prop.forms[node.formindex];// formSelection("property-observe", prop.forms); + const auth = makeauth(normTd, form, username, password, token); + const urivars = prop.hasOwnProperty("uriVariables") ? msg.payload : {}; + if (prop.uriVariables) { + const ajv = new Ajv({allErrors: true}); + if (!ajv.validate(prop.uriVariables, urivars)) { + node.warn(`input schema validation error: ${ajv.errorsText()}`, msg); + } + } + if (form.href.match(/^wss?:/)) { // websocket + bindingWebSocket(node, form, {auth, urivars, outschema: prop}); + } else if (form.href.match(/^coaps?:/)) { // CoAP observe + bindingCoapObserve(node, form, {auth, urivars, outschema: prop}); + } else { // long polling + bindingLongPoll(node, form, {auth, urivars, outschema: prop}); + } + } + } else if (node.interactiontype === "action") { + node.on("input", (msg, send, done) => { + send = send || function() { node.send.apply(node,arguments) }; + const act = normTd.actions[node.actname]; + const form = act.forms[node.formindex];// formSelection("action", act.forms); + const auth = makeauth(normTd, form, username, password, token); + const urivars = act.hasOwnProperty("uriVariables") ? msg.payload : {}; + if (act.uriVariables) { + const ajv = new Ajv({allErrors: true}); + if (!ajv.validate(act.uriVariables, urivars)) { + node.warn(`input schema validation error: ${ajv.errorsText()}`, msg); + } + } + if (act.input) { + const ajv = new Ajv({allErrors: true}); + if (!ajv.validate(act.input, msg.payload)) { + node.warn(`input schema validation error: ${ajv.errorsText()}`, msg); + } + } + if (form.href.match(/^https?:/)) { + bindingHttp(node, send, done, form, {interaction: "action", auth, msg, urivars, reqbody:msg.payload}); + } else if (form.href.match(/^coaps?/)) { + bindingCoap(node, send, done, form, {interaction: "action", auth, msg, urivars, reqbody:msg.payload}); + } + }); + } else if (node.interactiontype === "event") { + const ev = normTd.events[node.evname]; + const form = ev.forms[node.formindex];// formSelection("event", ev.forms); + const auth = makeauth(normTd, form, username, password, token); + const urivars = ev.hasOwnProperty("uriVariables") ? msg.payload : {}; + if (ev.uriVariables) { + const ajv = new Ajv({allErrors: true}); + if (!ajv.validate(ev.uriVariables, urivars)) { + node.warn(`input schema validation error: ${ajv.errorsText()}`, msg); + } + } + if (form.href.match(/^wss?:/)) { // websocket + bindingWebSocket(node, form, {auth, urivars}); + } else if (form.href.match(/^coaps?:/)) { + bindingCoapObserve(node, form, {auth, urivars}); + } else { // long polling + bindingLongPoll(node, form, {auth, urivars}); + } + } + } + + RED.nodes.registerType("{{&nodeName}}", Node, { + credentials: { + token: {type:"password"}, + username: {type:"text"}, + password: {type:"password"} + } + }); +}; \ No newline at end of file diff --git a/templates/webofthings/package.json.mustache b/templates/webofthings/package.json.mustache new file mode 100644 index 0000000..6bcfe55 --- /dev/null +++ b/templates/webofthings/package.json.mustache @@ -0,0 +1,37 @@ +{ + "name": "{{&projectName}}", + "version": "{{&projectVersion}}", + "description": "Node-RED node for {{&nodeName}}", + "main": "node.js", + "scripts": { + "test": "mocha \"test/**/*_spec.js\"" + }, + "node-red": { + "nodes": { + "{{&nodeName}}": "node.js" + } + }, + "keywords": [ + {{#keywords}} + "{{name}}"{{^last}}, {{/last}} + {{/keywords}} + ], + "dependencies": { + "https-proxy-agent": "^3.0.1", + "request": "^2.88.0", + "ws": "^7.2.0", + "url-template": "^2.0.8", + "ajv": "^6.10.2", + "node-coap-client": "^1.0.2" + }, + "devDependencies": { + "node-red": "^1.0.3", + "node-red-node-test-helper": "^0.2.3" + }, + "license": "{{&licenseName}}", + "wot": { + {{#wotmeta}} + "{{name}}": "{{value}}"{{^last}}, {{/last}} + {{/wotmeta}} + } +} \ No newline at end of file diff --git a/test/lib/nodegen_spec.js b/test/lib/nodegen_spec.js index 8b83e9c..6d1a71c 100644 --- a/test/lib/nodegen_spec.js +++ b/test/lib/nodegen_spec.js @@ -116,7 +116,7 @@ describe('nodegen library', function () { nodegen.swagger2node(data, options).then(function (result) { var packageSourceCode = JSON.parse(fs.readFileSync(result + '/package.json')); packageSourceCode.name.should.equal('node-red-contrib-swagger-petstore'); - packageSourceCode.version.should.equal('1.0.0'); + packageSourceCode.version.should.equal('1.0.3'); packageSourceCode.license.should.equal('Apache 2.0'); fs.statSync(result + '/node.html').size.should.be.above(0); fs.statSync(result + '/node.js').size.should.be.above(0); @@ -155,5 +155,48 @@ describe('nodegen library', function () { }); }); }); + + describe('Web of Things node', function() { + it('should have node files', function(done) { + const sourcePath = 'samples/MyLampThing.jsonld'; + const data = { + src: JSON.parse(fs.readFileSync(sourcePath)), + dst: '.' + }; + const options = {}; + nodegen.wottd2node(data, options).then(function (result) { + const packageSourceCode = JSON.parse(fs.readFileSync(result + '/package.json')); + packageSourceCode.name.should.equal('node-red-contrib-wotmylampthing'); + packageSourceCode.version.should.equal('0.0.1'); + fs.statSync(result + '/node.html').size.should.be.above(0); + fs.statSync(result + '/node.js').size.should.be.above(0); + fs.statSync(result + '/README.md').size.should.be.above(0); + fs.statSync(result + '/LICENSE').size.should.be.above(0); + del.sync(result); + done(); + }); + }); + it('should handle options', function (done) { + const sourcePath = 'samples/MyLampThing.jsonld'; + const data = { + src: JSON.parse(fs.readFileSync(sourcePath)), + dst: '.' + }; + const options = { + tgz: true, + obfuscate: true + }; + nodegen.wottd2node(data, options).then(function (result) { + fs.statSync(result).isFile().should.be.eql(true); + del.sync(result); + var jsfile = result.replace(/-[0-9]+\.[0-9]+\.[0-9]+\.tgz$/, '/node.js'); + fs.readFileSync(jsfile).toString().split('\n').length.should.be.eql(1); + result = result.replace(/-[0-9]+\.[0-9]+\.[0-9]+\.tgz$/, ''); + del.sync(result); + done(); + }); + }); + + }); });