From db751ce1cd29466f54dd010f28dee89bcdf4688c Mon Sep 17 00:00:00 2001 From: Ben Francis Date: Fri, 27 Mar 2026 22:13:09 +0000 Subject: [PATCH 1/6] WIP Implement readrproperty operation --- examples/lamp.js | 2 +- src/data-schema.js | 84 +++++++++++++++++ src/form.js | 63 +++++++++++++ src/interaction-affordance.js | 166 ++++++++++++++++++++++++++++++++++ src/property-affordance.js | 93 +++++++++++++++++++ src/server.js | 23 ++--- src/thing-server.js | 54 +++++++++++ src/thing.js | 72 +++++++++++++-- 8 files changed, 539 insertions(+), 18 deletions(-) create mode 100644 src/data-schema.js create mode 100644 src/form.js create mode 100644 src/interaction-affordance.js create mode 100644 src/property-affordance.js create mode 100644 src/thing-server.js 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/src/data-schema.js b/src/data-schema.js new file mode 100644 index 0000000..2c43283 --- /dev/null +++ b/src/data-schema.js @@ -0,0 +1,84 @@ +/** + * Data Schema + * + * Represents a DataSchema from the W3C WoT Thing Description 1.1 specification + * https://www.w3.org/TR/wot-thing-description/#dataschema + */ +class DataSchema { + /** + * @type {string|undefined} + */ + '@type'; + + /** + * @type {string|undefined} + */ + title; + + /** + * @type {Map|undefined} + */ + titles; + + /** + * @type {string|undefined} + */ + description; + + /** + * @type {Map|undefined} + */ + descriptions; + + /** + * @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; + + constructor() { + // TODO: Populate defaults for readOnly and writeOnly + } + +} + +export default DataSchema; \ No newline at end of file diff --git a/src/form.js b/src/form.js new file mode 100644 index 0000000..fb994af --- /dev/null +++ b/src/form.js @@ -0,0 +1,63 @@ +/** + * Form + * + * Represents a Form from the W3C WoT Thing Description 1.1 specification + * https://www.w3.org/TR/wot-thing-description/#form + */ +class Form { + /** + * @type {string} + */ + href = ''; + + /** + * @type {string|undefined} + */ + contentType; + + /** + * @type {string|undefined} + */ + contentCoding; + + /** + * @type {string|Array|undefined} + */ + security; + + /** + * @type {string|Array|undefined} + */ + scopes; + + /** + * @type {Object|undefined} + * + * TODO: Re-consider type + */ + response; + + /** + * @type {Array|undefined} + * + * TODO: Re-consider type + */ + additionalResponses; + + /** + * @type {string|undefined} + */ + subprotocol; + + /** + * @type {string|Array|undefined} + */ + op; + + constructor() { + // TODO: Populate defaults for contentType and op + } + +} + +export default Form; \ No newline at end of file diff --git a/src/interaction-affordance.js b/src/interaction-affordance.js new file mode 100644 index 0000000..9e45bf3 --- /dev/null +++ b/src/interaction-affordance.js @@ -0,0 +1,166 @@ +import DataSchema from './data-schema.js'; +import Form from './form.js'; +import ValidationError from './validation-error.js'; + +/** + * 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) { + this.name = name; + let validationError = new ValidationError([]); + + // Parse title member + try { + this.parseTitleMember(description.title); + } catch (error) { + if (error instanceof ValidationError) { + validationError.validationErrors.push(...error.validationErrors); + } else { + throw error; + } + } + + // Parse @type member + try { + this.parseSemanticTypeMember(description.title); + } catch (error) { + if (error instanceof ValidationError) { + validationError.validationErrors.push(...error.validationErrors); + } else { + throw error; + } + } + } + + /** + * Parse the semantic type member of the property description. + * + * @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; + } + } + + } + + /** + * + * @param {string} 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; + } + +} + +export default InteractionAffordance; \ No newline at end of file diff --git a/src/property-affordance.js b/src/property-affordance.js new file mode 100644 index 0000000..6c52e04 --- /dev/null +++ b/src/property-affordance.js @@ -0,0 +1,93 @@ +import InteractionAffordance from './interaction-affordance.js'; +import DataSchema from './data-schema.js'; + +/** + * 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} propertyName The name of the PropertyAffordance from its + * key in a properties Map. + * @param {Record} propertyDescription PropertyAffordance + * description from a Thing Description. + */ + constructor(propertyName, propertyDescription) { + super(propertyName, propertyDescription); + // TODO: Check type or throw ValidationError + // TODO: Populate defaults + // TODO: Populate values from propertyDescription + } + + /** + * @returns {Record} + */ + getDescription() { + let propertyDescription = {}; + propertyDescription.title = this.title; + // TODO: Generate fill property description + return propertyDescription; + } + +} + +export default PropertyAffordance; \ No newline at end of file diff --git a/src/server.js b/src/server.js index 74476f9..dfcfbc0 100644 --- a/src/server.js +++ b/src/server.js @@ -17,17 +17,18 @@ class ThingServer { 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); - }); + + //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); + //}); } /** diff --git a/src/thing-server.js b/src/thing-server.js new file mode 100644 index 0000000..74476f9 --- /dev/null +++ b/src/thing-server.js @@ -0,0 +1,54 @@ +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.js b/src/thing.js index c34b979..4ffdcf2 100644 --- a/src/thing.js +++ b/src/thing.js @@ -1,14 +1,32 @@ 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 {string | string[] | undefined} + */ + '@type'; + + // TODO: Remove + /** + * @type {Map} + */ + //propertyReadHandlers = new Map(); + + /** + * @type {Map} + */ + properties = new Map(); /** * Construct Thing from partial Thing Description. @@ -127,6 +145,46 @@ 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. * @@ -149,9 +207,11 @@ class Thing { * @param {function} handler A function to handle property reads. */ setPropertyReadHandler(name, handler) { - this.propertyReadHandlers.set(name, handler); + // TODO: Refactor to use Property object + //this.propertyReadHandlers.set(name, handler); } + // TODO: Remove /** * Read Property. * @@ -159,14 +219,14 @@ class Thing { * @returns {any} The current value of the property, with a format conforming * to its data schema in the Thing Description. */ - readProperty(name) { + /*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)(); } - } + }*/ } export default Thing; From 011f56f84fbb1d7fb78d82ed24f22d149c13c33f Mon Sep 17 00:00:00 2001 From: Ben Francis Date: Fri, 27 Mar 2026 22:16:06 +0000 Subject: [PATCH 2/6] Remove duplicate server class --- src/server.js | 55 --------------------------------------------- src/thing-server.js | 4 ++-- 2 files changed, 2 insertions(+), 57 deletions(-) delete mode 100644 src/server.js diff --git a/src/server.js b/src/server.js deleted file mode 100644 index dfcfbc0..0000000 --- a/src/server.js +++ /dev/null @@ -1,55 +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 index 74476f9..e445fa3 100644 --- a/src/thing-server.js +++ b/src/thing-server.js @@ -17,7 +17,7 @@ class ThingServer { response.json(this.thing.getThingDescription()); }); - this.app.get('/properties/:name', async (request, response) => { + /*this.app.get('/properties/:name', async (request, response) => { const name = request.params.name; let value; try { @@ -27,7 +27,7 @@ class ThingServer { return; } response.status(200).json(value); - }); + });*/ } /** From c9823188477bca793a30fb3372e9d26433d89a4a Mon Sep 17 00:00:00 2001 From: Ben Francis Date: Wed, 1 Apr 2026 21:59:42 +0100 Subject: [PATCH 3/6] More work on Form, PropertyAffordance and other classes --- src/data-schema.js | 5 +- src/form.js | 46 +++++++-- src/interaction-affordance.js | 104 ++++++++++++++----- src/property-affordance.js | 181 +++++++++++++++++++++++++++++++--- src/thing-server.js | 20 +++- src/thing.js | 93 +++++++++++------ 6 files changed, 367 insertions(+), 82 deletions(-) diff --git a/src/data-schema.js b/src/data-schema.js index 2c43283..7dfdf08 100644 --- a/src/data-schema.js +++ b/src/data-schema.js @@ -1,6 +1,6 @@ /** * Data Schema - * + * * Represents a DataSchema from the W3C WoT Thing Description 1.1 specification * https://www.w3.org/TR/wot-thing-description/#dataschema */ @@ -78,7 +78,6 @@ class DataSchema { constructor() { // TODO: Populate defaults for readOnly and writeOnly } - } -export default DataSchema; \ No newline at end of file +export default DataSchema; diff --git a/src/form.js b/src/form.js index fb994af..318eaa8 100644 --- a/src/form.js +++ b/src/form.js @@ -1,13 +1,15 @@ +import ValidationError from './validation-error.js'; + /** * Form - * + * * Represents a Form from the W3C WoT Thing Description 1.1 specification * https://www.w3.org/TR/wot-thing-description/#form */ class Form { /** * @type {string} - */ + */ href = ''; /** @@ -32,21 +34,21 @@ class Form { /** * @type {Object|undefined} - * + * * TODO: Re-consider type */ response; /** * @type {Array|undefined} - * + * * TODO: Re-consider type */ additionalResponses; /** * @type {string|undefined} - */ + */ subprotocol; /** @@ -54,10 +56,40 @@ class Form { */ op; - constructor() { + /** + * Construct a Form. + * + * @param {Record|undefined} description + */ + constructor(description) { + if (!description || typeof description != 'object') { + throw new ValidationError([ + { + field: `(root)`, + description: + 'Tried to instantiate an InteractionAffordance with an invalid name or description', + }, + ]); + } // TODO: Populate defaults for contentType and op + // TODO: Properly instantiate form + this.op = description.op; + this.href = description.href; } + /** + * Get a description of the Form. + * + * @returns {Record} + */ + getDescription() { + // TODO: Strip out default op values that aren't needed + let description = { + op: this.op, + href: this.href, + }; + return description; + } } -export default Form; \ No newline at end of file +export default Form; diff --git a/src/interaction-affordance.js b/src/interaction-affordance.js index 9e45bf3..6bb1fe9 100644 --- a/src/interaction-affordance.js +++ b/src/interaction-affordance.js @@ -4,7 +4,7 @@ import ValidationError from './validation-error.js'; /** * Interaction Affordance - * + * * Represents an InteractionAffordance from the W3C WoT Thing Description 1.1 * specification * https://www.w3.org/TR/wot-thing-description/#interactionaffordance @@ -46,20 +46,47 @@ class InteractionAffordance { uriVariables; /** - * - * @param {string} name The name of the InteractionAffordance from its key in + * + * @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. + * @param {Object} description A description of an + * InteractionAffordance, i.e. a PropertyAffordance, ActionAffordance or + * EventAffordance. */ constructor(name, description) { - this.name = name; 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); + this.#parseTitleMember(description.title); } catch (error) { if (error instanceof ValidationError) { validationError.validationErrors.push(...error.validationErrors); @@ -68,9 +95,13 @@ class InteractionAffordance { } } - // Parse @type member + // TODO: Parse titles member + // TODO: Parse forms member + // TOOD: Parse uriVariables member + + // Parse description member try { - this.parseSemanticTypeMember(description.title); + this.#parseDescriptionMember(description.description); } catch (error) { if (error instanceof ValidationError) { validationError.validationErrors.push(...error.validationErrors); @@ -78,15 +109,17 @@ class InteractionAffordance { throw error; } } + + // TODO: Parse descriptions member } /** - * Parse the semantic type member of the property description. - * + * Parse the semantic type member. + * * @param {string|Array|undefined} type The provided value of semantic type. */ - parseSemanticTypeMember(type) { - if(!type) { + #parseSemanticTypeMember(type) { + if (!type) { return; } @@ -101,14 +134,14 @@ class InteractionAffordance { } // If @type is a string then use that value - if(typeof type == 'string') { + 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) { + if (Array.isArray(type)) { + if (Array.length < 1) { return; } /** @@ -127,25 +160,25 @@ class InteractionAffordance { } else { errors.push({ field: `properties.${this.name}['@type']`, - description: '@type member is not string or Array of string' + description: '@type member is not string or Array of string', }); } }); - if(errors.length > 0) { + if (errors.length > 0) { throw new ValidationError(errors); } else { this['@type'] = types; } } - } /** - * - * @param {string} title + * Parse title member. + * + * @param {string|undefined} title */ - parseTitleMember(title) { - if(!title) { + #parseTitleMember(title) { + if (!title) { return; } @@ -161,6 +194,27 @@ class InteractionAffordance { 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; \ No newline at end of file +export default InteractionAffordance; diff --git a/src/property-affordance.js b/src/property-affordance.js index 6c52e04..ede1a58 100644 --- a/src/property-affordance.js +++ b/src/property-affordance.js @@ -1,14 +1,15 @@ import InteractionAffordance from './interaction-affordance.js'; import DataSchema from './data-schema.js'; +import Form from './form.js'; +import ValidationError from './validation-error.js'; /** * Property Affordance - * + * * Represents a PropertyAffordance from the W3C WoT Thing Description 1.1 - * specification https://www.w3.org/TR/wot-thing-description/#propertyaffordance + * specification https://www.w3.org/TR/wot-thing-description/#propertyaffordance */ class PropertyAffordance extends InteractionAffordance { - // *** DataSchema ***/ // TODO: Consider making this a mixin /** @@ -65,29 +66,179 @@ class PropertyAffordance extends InteractionAffordance { /** * Create a new Property. - * - * @param {string} propertyName The name of the PropertyAffordance from its + * + * @param {string} name The name of the PropertyAffordance from its * key in a properties Map. - * @param {Record} propertyDescription PropertyAffordance - * description from a Thing Description. - */ - constructor(propertyName, propertyDescription) { - super(propertyName, propertyDescription); - // TODO: Check type or throw ValidationError - // TODO: Populate defaults - // TODO: Populate values from propertyDescription + * @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) { + // TODO: Parse existing forms + let description = {}; + description.href = `properties/${this.name}`; + if (this.readOnly) { + description.op = ['readproperty']; + } else if (this.writeOnly) { + description.op = ['writeproperty']; + } else { + description.op = ['readproperty', 'writeproperty']; + } + let form; + try { + form = new Form(description); + this.forms.push(form); + } catch (error) { + // TODO: Pass the error up the chain + } + // 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? */ getDescription() { let propertyDescription = {}; + propertyDescription['@type'] = this['@type']; propertyDescription.title = this.title; + propertyDescription.description = this.description; + propertyDescription.forms = new Array(); + this.forms.forEach((form) => { + propertyDescription.forms.push(form.getDescription()); + }); // TODO: Generate fill property description return propertyDescription; } - } -export default PropertyAffordance; \ No newline at end of file +export default PropertyAffordance; diff --git a/src/thing-server.js b/src/thing-server.js index e445fa3..0d66e8e 100644 --- a/src/thing-server.js +++ b/src/thing-server.js @@ -10,6 +10,7 @@ class ThingServer { */ constructor(thing) { this.thing = thing; + // TODO: Set base member of Thing to the server's host this.app = express(); this.server = null; @@ -17,17 +18,28 @@ class ThingServer { response.json(this.thing.getThingDescription()); }); - /*this.app.get('/properties/:name', async (request, response) => { + 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(); + } 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); - });*/ + }); } /** diff --git a/src/thing.js b/src/thing.js index 4ffdcf2..bbfd4c4 100644 --- a/src/thing.js +++ b/src/thing.js @@ -5,7 +5,7 @@ import PropertyAffordance from './property-affordance.js'; * 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 */ @@ -13,20 +13,26 @@ class Thing { DEFAULT_CONTEXT = 'https://www.w3.org/2022/wot/td/v1.1'; /** - * @type {string | string[] | undefined} + * @type {Map} */ - '@type'; + properties = new Map(); - // TODO: Remove /** - * @type {Map} + * @type {Record} + * + * TODO: Change this to Map */ - //propertyReadHandlers = new Map(); + securityDefinitions; /** - * @type {Map} + * @type {string|Array} */ - properties = new Map(); + security; + + /** + * @type {string} + */ + base; /** * Construct Thing from partial Thing Description. @@ -40,7 +46,7 @@ class Thing { // Parse @context member try { - this.parseContextMember(partialTD['@context']); + this.#parseContextMember(partialTD['@context']); } catch (error) { if (error instanceof ValidationError) { validationError.validationErrors.push(...error.validationErrors); @@ -51,7 +57,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); @@ -67,6 +84,12 @@ class Thing { }, }; this.security = 'nosec_sc'; + + // Initially set base to '/', but the ThingServer should reset this once + // it starts serving the Thing at a real URL + this.base = '/'; + + // TODO: Parse other members } /** @@ -75,7 +98,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; @@ -122,7 +145,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([ @@ -147,13 +170,13 @@ class Thing { /** * 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) { + #parsePropertiesMember(propertyDescriptions) { // If the properties member is not set then continue - if(!propertyDescriptions) { + if (!propertyDescriptions) { return; } @@ -168,14 +191,14 @@ class Thing { } // Generate a map of Property objects from property descriptions - for(const propertyName in propertyDescriptions) { - this.addProperty(propertyName, propertyDescriptions[propertyName]) + 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. @@ -191,11 +214,23 @@ class Thing { * @returns {Object} A complete Thing Description for the Thing. */ getThingDescription() { + /** + * @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, + base: this.base, securityDefinitions: this.securityDefinitions, security: this.security, + properties: properties, }; return thingDescription; } @@ -207,11 +242,13 @@ class Thing { * @param {function} handler A function to handle property reads. */ setPropertyReadHandler(name, handler) { - // TODO: Refactor to use Property object - //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); } - // TODO: Remove /** * Read Property. * @@ -219,14 +256,14 @@ class Thing { * @returns {any} The current value of the property, with a format conforming * 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)(); + readProperty(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(); + } } export default Thing; From da7a6762566a8c4794ec5e52cfd1ec68c2455b78 Mon Sep 17 00:00:00 2001 From: Ben Francis Date: Thu, 2 Apr 2026 22:17:31 +0100 Subject: [PATCH 4/6] Add .nvmrc and npm audit fix --- .nvmrc | 1 + package-lock.json | 42 +++++++++++++++++++++++++----------------- 2 files changed, 26 insertions(+), 17 deletions(-) create mode 100644 .nvmrc 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/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" From fea9212ee94d6caab11e9f2de11c1caab2eced96 Mon Sep 17 00:00:00 2001 From: Ben Francis Date: Mon, 6 Apr 2026 21:44:47 +0100 Subject: [PATCH 5/6] Fix some types and set base member --- src/property-affordance.js | 2 +- src/thing-server.js | 66 +++++++++++++++++++++++--------------- src/thing.js | 14 ++++---- 3 files changed, 49 insertions(+), 33 deletions(-) diff --git a/src/property-affordance.js b/src/property-affordance.js index ede1a58..372515c 100644 --- a/src/property-affordance.js +++ b/src/property-affordance.js @@ -225,7 +225,7 @@ class PropertyAffordance extends InteractionAffordance { /** * @returns {Record} * - * // TODO: Rename to getPropertyDescription? + * // TODO: Rename to getPropertyDescription to avoid confusion with description member? */ getDescription() { let propertyDescription = {}; diff --git a/src/thing-server.js b/src/thing-server.js index 0d66e8e..9902415 100644 --- a/src/thing-server.js +++ b/src/thing-server.js @@ -1,6 +1,8 @@ import express from 'express'; +import Thing from './thing.js'; -/** @typedef {import('./thing.js').default} Thing */ +/** @typedef {express.Request} Request */ +/** @typedef {express.Response} Response */ class ThingServer { /** @@ -10,36 +12,50 @@ class ThingServer { */ constructor(thing) { this.thing = thing; - // TODO: Set base member of Thing to the server's host this.app = express(); this.server = null; - this.app.get('/', (request, response) => { - response.json(this.thing.getThingDescription()); - }); + 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', 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(); + 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; } - return; + response.status(200).json(value); } - response.status(200).json(value); - }); + ); } /** diff --git a/src/thing.js b/src/thing.js index bbfd4c4..bbc3aff 100644 --- a/src/thing.js +++ b/src/thing.js @@ -30,7 +30,7 @@ class Thing { security; /** - * @type {string} + * @type {string|undefined} */ base; @@ -85,10 +85,7 @@ class Thing { }; this.security = 'nosec_sc'; - // Initially set base to '/', but the ThingServer should reset this once - // it starts serving the Thing at a real URL - this.base = '/'; - + // TODO: Parse base member // TODO: Parse other members } @@ -211,9 +208,10 @@ class Thing { /** * 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} */ @@ -227,7 +225,9 @@ class Thing { const thingDescription = { '@context': this.context, title: this.title, - base: this.base, + // 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, From 3e8d6177307e4f9314a7e0bb9994a325f5887193 Mon Sep 17 00:00:00 2001 From: Ben Francis Date: Tue, 7 Apr 2026 23:09:06 +0100 Subject: [PATCH 6/6] Add DataSchema and Form classes with type definitions --- src/data-schema.js | 83 ------------------------------ src/form.js | 95 ----------------------------------- src/interaction-affordance.js | 56 ++++++++++++++++++++- src/property-affordance.js | 33 ++++++------ src/thing.js | 47 ++++++++++++++++- 5 files changed, 113 insertions(+), 201 deletions(-) delete mode 100644 src/data-schema.js delete mode 100644 src/form.js diff --git a/src/data-schema.js b/src/data-schema.js deleted file mode 100644 index 7dfdf08..0000000 --- a/src/data-schema.js +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Data Schema - * - * Represents a DataSchema from the W3C WoT Thing Description 1.1 specification - * https://www.w3.org/TR/wot-thing-description/#dataschema - */ -class DataSchema { - /** - * @type {string|undefined} - */ - '@type'; - - /** - * @type {string|undefined} - */ - title; - - /** - * @type {Map|undefined} - */ - titles; - - /** - * @type {string|undefined} - */ - description; - - /** - * @type {Map|undefined} - */ - descriptions; - - /** - * @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; - - constructor() { - // TODO: Populate defaults for readOnly and writeOnly - } -} - -export default DataSchema; diff --git a/src/form.js b/src/form.js deleted file mode 100644 index 318eaa8..0000000 --- a/src/form.js +++ /dev/null @@ -1,95 +0,0 @@ -import ValidationError from './validation-error.js'; - -/** - * Form - * - * Represents a Form from the W3C WoT Thing Description 1.1 specification - * https://www.w3.org/TR/wot-thing-description/#form - */ -class Form { - /** - * @type {string} - */ - href = ''; - - /** - * @type {string|undefined} - */ - contentType; - - /** - * @type {string|undefined} - */ - contentCoding; - - /** - * @type {string|Array|undefined} - */ - security; - - /** - * @type {string|Array|undefined} - */ - scopes; - - /** - * @type {Object|undefined} - * - * TODO: Re-consider type - */ - response; - - /** - * @type {Array|undefined} - * - * TODO: Re-consider type - */ - additionalResponses; - - /** - * @type {string|undefined} - */ - subprotocol; - - /** - * @type {string|Array|undefined} - */ - op; - - /** - * Construct a Form. - * - * @param {Record|undefined} description - */ - constructor(description) { - if (!description || typeof description != 'object') { - throw new ValidationError([ - { - field: `(root)`, - description: - 'Tried to instantiate an InteractionAffordance with an invalid name or description', - }, - ]); - } - // TODO: Populate defaults for contentType and op - // TODO: Properly instantiate form - this.op = description.op; - this.href = description.href; - } - - /** - * Get a description of the Form. - * - * @returns {Record} - */ - getDescription() { - // TODO: Strip out default op values that aren't needed - let description = { - op: this.op, - href: this.href, - }; - return description; - } -} - -export default Form; diff --git a/src/interaction-affordance.js b/src/interaction-affordance.js index 6bb1fe9..3da69c2 100644 --- a/src/interaction-affordance.js +++ b/src/interaction-affordance.js @@ -1,7 +1,59 @@ -import DataSchema from './data-schema.js'; -import Form from './form.js'; 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 * diff --git a/src/property-affordance.js b/src/property-affordance.js index 372515c..351e1bc 100644 --- a/src/property-affordance.js +++ b/src/property-affordance.js @@ -1,8 +1,11 @@ import InteractionAffordance from './interaction-affordance.js'; -import DataSchema from './data-schema.js'; -import Form from './form.js'; import ValidationError from './validation-error.js'; +/** + * @typedef {import('./interaction-affordance.js').DataSchema} DataSchema + * @typedef {import('./interaction-affordance.js').Form} Form + */ + /** * Property Affordance * @@ -180,23 +183,18 @@ class PropertyAffordance extends InteractionAffordance { * @param {Array>} forms */ #parseFormsMember(forms) { - // TODO: Parse existing forms - let description = {}; - description.href = `properties/${this.name}`; + /** @type Form */ + let form = { + 'href': `properties/${this.name}` + }; if (this.readOnly) { - description.op = ['readproperty']; + form.op = ['readproperty']; } else if (this.writeOnly) { - description.op = ['writeproperty']; + form.op = ['writeproperty']; } else { - description.op = ['readproperty', 'writeproperty']; - } - let form; - try { - form = new Form(description); - this.forms.push(form); - } catch (error) { - // TODO: Pass the error up the chain + form.op = ['readproperty', 'writeproperty']; } + this.forms.push(form); // TODO: Populate other members of Form } /** @@ -232,10 +230,7 @@ class PropertyAffordance extends InteractionAffordance { propertyDescription['@type'] = this['@type']; propertyDescription.title = this.title; propertyDescription.description = this.description; - propertyDescription.forms = new Array(); - this.forms.forEach((form) => { - propertyDescription.forms.push(form.getDescription()); - }); + propertyDescription.forms = this.forms; // TODO: Generate fill property description return propertyDescription; } diff --git a/src/thing.js b/src/thing.js index bbc3aff..fbce2c9 100644 --- a/src/thing.js +++ b/src/thing.js @@ -30,7 +30,7 @@ class Thing { security; /** - * @type {string|undefined} + * @type {URL|undefined} */ base; @@ -44,6 +44,17 @@ 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']); @@ -85,10 +96,42 @@ class Thing { }; this.security = 'nosec_sc'; - // TODO: Parse base member // 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', + }, + ]); + } + } + /** * Parse the @context member of a Thing Description. *