diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..0a47c85 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +lts/iron \ No newline at end of file diff --git a/examples/lamp.js b/examples/lamp.js index d1b5326..5cc09ba 100644 --- a/examples/lamp.js +++ b/examples/lamp.js @@ -1,5 +1,5 @@ import Thing from '../src/thing.js'; -import ThingServer from '../src/server.js'; +import ThingServer from '../src/thing-server.js'; const partialTD = { title: 'My Lamp', diff --git a/package-lock.json b/package-lock.json index a400a15..24a24d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -385,23 +385,27 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/brace-expansion": { @@ -1080,15 +1084,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ignore": { @@ -1425,9 +1433,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -1484,9 +1492,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/src/interaction-affordance.js b/src/interaction-affordance.js new file mode 100644 index 0000000..3da69c2 --- /dev/null +++ b/src/interaction-affordance.js @@ -0,0 +1,272 @@ +import ValidationError from './validation-error.js'; + +/** + * Expected Response + * + * @typedef {Object} ExpectedResponse + * @property {string} contentType + */ + +/** + * Additional Expected Response + * + * @typedef {Object} AdditionalExpectedResponse + * @property {boolean} [success] + * @property {string} [contentType] + * @property {string} [schema] + */ + +/** + * Data Schema + * + * @typedef {Object} DataSchema + * @ts-ignore + * @property {string|Array} ['@type'] + * @property {string} [title] + * @property {Record} [titles] + * @property {string} [description] + * @property {Record} [descriptions] + * @property {any} [const] + * @property {any} [default] + * @property {string} [unit] + * @property {Array} [oneOf] + * @property {Array} [enum] + * @property {boolean} [readOnly] + * @property {boolean} [writeOnly] + * @property {string} [format] + * @property {'object'|'array'|'string'|'number'|'integer'|'boolean'|'null'} [type] + */ + +/** + * Form + * + * @typedef {Object} Form + * @property {string} href + * @property {string} [contentType] + * @property {string} [contentCoding] + * @property {string|Array} [security] + * @property {string|Array} [scopes] + * @property {ExpectedResponse} [response] + * @property {Array} [additionalResponses] + * @property {string} [subprotocol] + * @property {string|Array} [op] + */ + +// TODO: Constrain set of possible values for op + +/** + * Interaction Affordance + * + * Represents an InteractionAffordance from the W3C WoT Thing Description 1.1 + * specification + * https://www.w3.org/TR/wot-thing-description/#interactionaffordance + */ +class InteractionAffordance { + /** + * @type {string|Array|undefined} + */ + '@type'; + + /** + * @type {string|undefined} + */ + title; + + /** + * @type {Map|undefined} + */ + titles; + + /** + * @type {string|undefined} + */ + description; + + /** + * @type {Map|undefined} + */ + descriptions; + + /** + * @type {Array
} + */ + forms = []; + + /** + * @type{Map|undefined} + */ + uriVariables; + + /** + * + * @param {string} name The name of the InteractionAffordance from its key in + * a properties, actions or events Map. + * @param {Object} description A description of an + * InteractionAffordance, i.e. a PropertyAffordance, ActionAffordance or + * EventAffordance. + */ + constructor(name, description) { + let validationError = new ValidationError([]); + + if ( + !name || + !description || + typeof name != 'string' || + typeof description != 'object' + ) { + throw new ValidationError([ + { + field: `(root)`, + description: + 'Tried to instantiate an InteractionAffordance with an invalid name or description', + }, + ]); + } + + this.name = name; + + // Parse @type member + try { + this.#parseSemanticTypeMember(description['@type']); + } catch (error) { + if (error instanceof ValidationError) { + validationError.validationErrors.push(...error.validationErrors); + } else { + throw error; + } + } + + // Parse title member + try { + this.#parseTitleMember(description.title); + } catch (error) { + if (error instanceof ValidationError) { + validationError.validationErrors.push(...error.validationErrors); + } else { + throw error; + } + } + + // TODO: Parse titles member + // TODO: Parse forms member + // TOOD: Parse uriVariables member + + // Parse description member + try { + this.#parseDescriptionMember(description.description); + } catch (error) { + if (error instanceof ValidationError) { + validationError.validationErrors.push(...error.validationErrors); + } else { + throw error; + } + } + + // TODO: Parse descriptions member + } + + /** + * Parse the semantic type member. + * + * @param {string|Array|undefined} type The provided value of semantic type. + */ + #parseSemanticTypeMember(type) { + if (!type) { + return; + } + + // Check that @type has a valid type + if (!(typeof type == 'string' || Array.isArray(type))) { + throw new ValidationError([ + { + field: `properties.${this.name}['@type']`, + description: '@type member is not a string or Array', + }, + ]); + } + + // If @type is a string then use that value + if (typeof type == 'string') { + this['@type'] = type; + return; + } + + // If @type is an array then validate its contents then set this['@type'] + if (Array.isArray(type)) { + if (Array.length < 1) { + return; + } + /** + * @type {Array} + */ + let types = []; + + /** + * @type {Array} + */ + let errors = []; + + type.forEach((typeItem) => { + if (typeof typeItem == 'string') { + types.push(typeItem); + } else { + errors.push({ + field: `properties.${this.name}['@type']`, + description: '@type member is not string or Array of string', + }); + } + }); + if (errors.length > 0) { + throw new ValidationError(errors); + } else { + this['@type'] = types; + } + } + } + + /** + * Parse title member. + * + * @param {string|undefined} title + */ + #parseTitleMember(title) { + if (!title) { + return; + } + + if (typeof title !== 'string') { + throw new ValidationError([ + { + field: `properties.${this.name}.title`, + description: 'title member is not a string', + }, + ]); + } + + this.title = title; + } + + /** + * Parse description member. + * + * @param {string|undefined} description + */ + #parseDescriptionMember(description) { + if (!description) { + return; + } + + if (typeof description !== 'string') { + throw new ValidationError([ + { + field: `properties.${this.name}.description`, + description: 'description member is not a string', + }, + ]); + } + + this.description = description; + } +} + +export default InteractionAffordance; diff --git a/src/property-affordance.js b/src/property-affordance.js new file mode 100644 index 0000000..351e1bc --- /dev/null +++ b/src/property-affordance.js @@ -0,0 +1,239 @@ +import InteractionAffordance from './interaction-affordance.js'; +import ValidationError from './validation-error.js'; + +/** + * @typedef {import('./interaction-affordance.js').DataSchema} DataSchema + * @typedef {import('./interaction-affordance.js').Form} Form + */ + +/** + * Property Affordance + * + * Represents a PropertyAffordance from the W3C WoT Thing Description 1.1 + * specification https://www.w3.org/TR/wot-thing-description/#propertyaffordance + */ +class PropertyAffordance extends InteractionAffordance { + // *** DataSchema ***/ + // TODO: Consider making this a mixin + /** + * @type {any} + */ + const; + + /** + * @type {any} + */ + default; + + /** + * @type {string|undefined} + */ + unit; + + /** + * @type{Array|undefined} + */ + oneOf; + + /** + * @type {Array|undefined} + */ + enum; + + /** + * @type {boolean|undefined} + */ + readOnly; + + /** + * @type {boolean|undefined} + */ + writeOnly; + + /** + * @type {string|undefined} + */ + format; + + /** + * @type {('object'|'array'|'string'|'number'|'integer'|'boolean'|'null')|undefined} + */ + type; + + // *** End of DataSchema *** + + /** + * @type {boolean|undefined} + */ + observeable; + + /** + * Create a new Property. + * + * @param {string} name The name of the PropertyAffordance from its + * key in a properties Map. + * @param {Record} description PropertyAffordance description + * from a Thing Description. + */ + constructor(name, description) { + super(name, description); + + let validationError = new ValidationError([]); + + // Parse readOnly member + try { + this.#parseReadOnlyMember(description.readOnly); + } catch (error) { + if (error instanceof ValidationError) { + validationError.validationErrors.push(...error.validationErrors); + } else { + throw error; + } + } + + // Parse writeOnly member + try { + this.#parseWriteOnlyMember(description.writeOnly); + } catch (error) { + if (error instanceof ValidationError) { + validationError.validationErrors.push(...error.validationErrors); + } else { + throw error; + } + } + + // Check that readOnly and writeOnly are not both set + if (this.readOnly && this.writeOnly) { + let readWriteError = new ValidationError([ + { + field: `properties.${this.name}.readOnly`, + description: 'readOnly member is not a boolean', + }, + ]); + validationError.validationErrors.push(...readWriteError.validationErrors); + } + + // Parse writeOnly member + try { + this.#parseFormsMember(description.forms); + } catch (error) { + if (error instanceof ValidationError) { + validationError.validationErrors.push(...error.validationErrors); + } else { + throw error; + } + } + + // TODO: Parse other members + } + + /** + * Parse readOnly member. + * + * @param {boolean|undefined} readOnly + */ + #parseReadOnlyMember(readOnly) { + // Throw an error if not a boolean or undefined + if (!(readOnly === undefined || typeof readOnly == 'boolean')) { + throw new ValidationError([ + { + field: `properties.${this.name}.readOnly`, + description: 'readOnly member is not a boolean', + }, + ]); + } + + // If undefined then default to false + if (readOnly === undefined) { + this.readOnly = false; + // Otherwise set the provided value + } else { + this.readOnly = readOnly; + } + } + + /** + * Parse writeOnly member. + * + * @param {boolean|undefined} writeOnly + */ + #parseWriteOnlyMember(writeOnly) { + // Throw an error if not a boolean or undefined + if (!(writeOnly === undefined || typeof writeOnly == 'boolean')) { + throw new ValidationError([ + { + field: `properties.${this.name}.writeOnly`, + description: 'writeOnly member is not a boolean', + }, + ]); + } + + // If undefined then default to false + if (writeOnly === undefined) { + this.writeOnly = false; + // Otherwise set the provided value + } else { + this.writeOnly = writeOnly; + } + } + + /** + * Parse forms member. + * + * @param {Array>} forms + */ + #parseFormsMember(forms) { + /** @type Form */ + let form = { + 'href': `properties/${this.name}` + }; + if (this.readOnly) { + form.op = ['readproperty']; + } else if (this.writeOnly) { + form.op = ['writeproperty']; + } else { + form.op = ['readproperty', 'writeproperty']; + } + this.forms.push(form); + // TODO: Populate other members of Form + } + /** + * Set read handler function. + * + * @param {function} handler A function to handle property reads. + */ + setReadHandler(handler) { + this.readHandler = handler; + } + + /** + * Read the property. + * + * @returns {any} The current value of the property. + */ + read() { + if (this.readHandler) { + return this.readHandler(); + } else { + console.error(`No read handler set for property ${this.name}`); + throw new Error('InternalError'); + } + } + + /** + * @returns {Record} + * + * // TODO: Rename to getPropertyDescription to avoid confusion with description member? + */ + getDescription() { + let propertyDescription = {}; + propertyDescription['@type'] = this['@type']; + propertyDescription.title = this.title; + propertyDescription.description = this.description; + propertyDescription.forms = this.forms; + // TODO: Generate fill property description + return propertyDescription; + } +} + +export default PropertyAffordance; diff --git a/src/server.js b/src/server.js deleted file mode 100644 index 74476f9..0000000 --- a/src/server.js +++ /dev/null @@ -1,54 +0,0 @@ -import express from 'express'; - -/** @typedef {import('./thing.js').default} Thing */ - -class ThingServer { - /** - * Construct the Thing Server. - * - * @param {Thing} thing The Thing to serve. - */ - constructor(thing) { - this.thing = thing; - this.app = express(); - this.server = null; - - this.app.get('/', (request, response) => { - response.json(this.thing.getThingDescription()); - }); - - this.app.get('/properties/:name', async (request, response) => { - const name = request.params.name; - let value; - try { - value = await this.thing.readProperty(name); - } catch { - response.status(404).send(); - return; - } - response.status(200).json(value); - }); - } - - /** - * Start the Thing Server. - * - * @param {number} port The TCP port number to listen on. - */ - start(port) { - this.server = this.app.listen(port, () => { - console.log(`Web Thing being served on port ${port}`); - }); - } - - /** - * Stop the Thing Server. - */ - stop() { - if (this.server) { - this.server.close(); - } - } -} - -export default ThingServer; diff --git a/src/thing-server.js b/src/thing-server.js new file mode 100644 index 0000000..9902415 --- /dev/null +++ b/src/thing-server.js @@ -0,0 +1,82 @@ +import express from 'express'; +import Thing from './thing.js'; + +/** @typedef {express.Request} Request */ +/** @typedef {express.Response} Response */ + +class ThingServer { + /** + * Construct the Thing Server. + * + * @param {Thing} thing The Thing to serve. + */ + constructor(thing) { + this.thing = thing; + this.app = express(); + this.server = null; + + this.app.get( + '/', + /** + * @param {Request} request + * @param {Response} response + */ + (request, response) => { + const host = request.headers.host; + response.json(this.thing.getThingDescription(host)); + } + ); + + this.app.get( + '/properties/:name', + /** + * @param {Request} request + * @param {Response} response + */ + async (request, response) => { + const name = request.params.name; + let value; + try { + value = await this.thing.readProperty(name); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'InternalError'; + switch (errorMessage) { + case 'NotFoundError': + response.status(404).send(); + break; + case 'InternalError': + response.status(500).send(); + break; + default: + response.status(500).send(); + } + return; + } + response.status(200).json(value); + } + ); + } + + /** + * Start the Thing Server. + * + * @param {number} port The TCP port number to listen on. + */ + start(port) { + this.server = this.app.listen(port, () => { + console.log(`Web Thing being served on port ${port}`); + }); + } + + /** + * Stop the Thing Server. + */ + stop() { + if (this.server) { + this.server.close(); + } + } +} + +export default ThingServer; diff --git a/src/thing.js b/src/thing.js index c34b979..fbce2c9 100644 --- a/src/thing.js +++ b/src/thing.js @@ -1,14 +1,38 @@ import ValidationError from './validation-error.js'; +import PropertyAffordance from './property-affordance.js'; /** - * Thing. + * Thing * - * Represents a W3C WoT Web Thing. + * Represents a Web Thing. + * + * Implements a Thing from the W3C WoT Thing Description 1.1 specification. + * https://www.w3.org/TR/wot-thing-description/#thing */ class Thing { DEFAULT_CONTEXT = 'https://www.w3.org/2022/wot/td/v1.1'; - propertyReadHandlers = new Map(); + /** + * @type {Map} + */ + properties = new Map(); + + /** + * @type {Record} + * + * TODO: Change this to Map + */ + securityDefinitions; + + /** + * @type {string|Array} + */ + security; + + /** + * @type {URL|undefined} + */ + base; /** * Construct Thing from partial Thing Description. @@ -20,9 +44,20 @@ class Thing { // Create an empty validation error to collect errors during parsing. let validationError = new ValidationError([]); + // Parse base member + try { + this.#parseBaseMember(partialTD['base']); + } catch (error) { + if (error instanceof ValidationError) { + validationError.validationErrors.push(...error.validationErrors); + } else { + throw error; + } + } + // Parse @context member try { - this.parseContextMember(partialTD['@context']); + this.#parseContextMember(partialTD['@context']); } catch (error) { if (error instanceof ValidationError) { validationError.validationErrors.push(...error.validationErrors); @@ -33,7 +68,18 @@ class Thing { // Parse title member try { - this.parseTitleMember(partialTD.title); + this.#parseTitleMember(partialTD.title); + } catch (error) { + if (error instanceof ValidationError) { + validationError.validationErrors.push(...error.validationErrors); + } else { + throw error; + } + } + + // Parse properties member + try { + this.#parsePropertiesMember(partialTD.properties); } catch (error) { if (error instanceof ValidationError) { validationError.validationErrors.push(...error.validationErrors); @@ -49,6 +95,41 @@ class Thing { }, }; this.security = 'nosec_sc'; + + // TODO: Parse other members + } + + /** + * Parse the base member of a Thing Description. + * + * Note: If being served with ThingServer, the base can automatically be + * derived from the Host header of an HTTP request for the Thing Description + * so does not need to be provided in the partialTD when instantiating the + * Thing. + * + * @param {string} base The base URL, if any, provided in the partialTD. + * @throws {ValidationError} A validation error. + */ + #parseBaseMember(base) { + // If no base member is provided then assume it will be automatically + // generated and continue. + if (base === undefined) { + return; + } + + // Test whether the provided base member is a valid URL + try { + const baseURL = new URL(base); + this.base = baseURL; + } catch(error) { + console.error(`Error instantiating URL from provided base member: ${error}`); + throw new ValidationError([ + { + field: 'base', + description: 'base is not a valid URL', + }, + ]); + } } /** @@ -57,7 +138,7 @@ class Thing { * @param {any} context The @context, if any, provided in the partialTD. * @throws {ValidationError} A validation error. */ - parseContextMember(context) { + #parseContextMember(context) { // If no @context provided then set it to the default if (context === undefined) { this.context = this.DEFAULT_CONTEXT; @@ -104,7 +185,7 @@ class Thing { * @param {string} title The title provided in the partialTD. * @throws {ValidationError} A validation error. */ - parseTitleMember(title) { + #parseTitleMember(title) { // Require the user to provide a title if (!title) { throw new ValidationError([ @@ -127,17 +208,72 @@ class Thing { this.title = title; } + /** + * Parse the properties member of a Thing Description. + * + * @param {Object} propertyDescriptions Map of property + * descriptions provided in a partial TD, indexed by property name. + */ + #parsePropertiesMember(propertyDescriptions) { + // If the properties member is not set then continue + if (!propertyDescriptions) { + return; + } + + // If the provided properties member is not an object then throw a validation error + if (typeof propertyDescriptions !== 'object') { + throw new ValidationError([ + { + field: 'properties', + description: 'properties member is not an object', + }, + ]); + } + + // Generate a map of Property objects from property descriptions + for (const propertyName in propertyDescriptions) { + this.addProperty(propertyName, propertyDescriptions[propertyName]); + } + } + + /** + * Add a Property. + * + * @param {string} propertyName The name of the property to add. + * @param {Record} propertyDescription A description of a + * PropertyAffordance from a Thing Description. + */ + addProperty(propertyName, propertyDescription) { + let property = new PropertyAffordance(propertyName, propertyDescription); + this.properties.set(propertyName, property); + } + /** * Get Thing Description. * + * @param {string|undefined} host The host at which the Thing is being served. * @returns {Object} A complete Thing Description for the Thing. */ - getThingDescription() { + getThingDescription(host) { + /** + * @type {Record} + */ + let properties = {}; + for (const propertyName of this.properties.keys()) { + const property = this.properties.get(propertyName); + if (property) { + properties[propertyName] = property.getDescription(); + } + } const thingDescription = { '@context': this.context, title: this.title, + // If a base argument is provided then use that, otherwise use the base provided in the + // partial Thing Description. + base: host ? `http://${host}/` : this.base, securityDefinitions: this.securityDefinitions, security: this.security, + properties: properties, }; return thingDescription; } @@ -149,7 +285,11 @@ class Thing { * @param {function} handler A function to handle property reads. */ setPropertyReadHandler(name, handler) { - this.propertyReadHandlers.set(name, handler); + let property = this.properties.get(name); + if (!property) { + throw new Error(`No property called ${name} could be found`); + } + property.setReadHandler(handler); } /** @@ -160,12 +300,12 @@ class Thing { * to its data schema in the Thing Description. */ readProperty(name) { - if (!this.propertyReadHandlers.has(name)) { - console.error('No property read handler for the property ' + name); - throw new Error(); - } else { - return this.propertyReadHandlers.get(name)(); + let property = this.properties.get(name); + if (!property) { + console.error(`No property called ${name} could be found`); + throw new Error('NotFoundError'); } + return property.read(); } }