diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..75aa048 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug lamp example", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/examples/lamp.js", + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "skipFiles": ["/**"] + } + ] +} diff --git a/examples/lamp.js b/examples/lamp.js index 00ca34a..9d98f67 100644 --- a/examples/lamp.js +++ b/examples/lamp.js @@ -1,6 +1,9 @@ import Thing from '../src/thing.js'; import ThingServer from '../src/thing-server.js'; +/** @import {PartialThingDescription} from '../src/types.js' */ + +/** @satisfies {PartialThingDescription} */ const partialTD = { title: 'My Lamp', description: 'A web connected lamp', @@ -11,7 +14,7 @@ const partialTD = { description: 'Whether the lamp is turned on', }, level: { - type: 'integer', + type: 'number', title: 'Brightness', description: 'The level of light from 0-100', unit: 'percent', @@ -29,7 +32,7 @@ const partialTD = { properties: { level: { title: 'Brightness', - type: 'integer', + type: 'number', minimum: 0, maximum: 100, unit: 'percent', @@ -78,6 +81,42 @@ thing.setPropertyWriteHandler('level', async function (value) { return; }); +thing.setActionHandler('fade', async function (input) { + if ( + typeof input.level !== 'number' || + !Number.isFinite(input.level) || + input.level < 0 || + input.level > 100 || + typeof input.duration !== 'number' || + !Number.isFinite(input.duration) || + input.duration < 0 + ) { + throw new Error('BadRequest'); + } + + const startLevel = currentLevelValue; + const targetLevel = input.level; + const duration = input.duration; + const startTime = performance.now(); + + if (duration === 0) { + currentLevelValue = targetLevel; + return; + } + + let elapsed = 0; + while (elapsed < duration) { + await new Promise((resolve) => + setTimeout(resolve, Math.min(100, duration - elapsed)), + ); + elapsed = Math.min(performance.now() - startTime, duration); + currentLevelValue = + startLevel + (targetLevel - startLevel) * (elapsed / duration); + } + + currentLevelValue = targetLevel; +}); + const server = new ThingServer(thing); server.start(8080); diff --git a/src/action-affordance.js b/src/action-affordance.js new file mode 100644 index 0000000..e2e6944 --- /dev/null +++ b/src/action-affordance.js @@ -0,0 +1,260 @@ +import InteractionAffordance from './interaction-affordance.js'; +import ValidationError from './validation-error.js'; + +/** @import {DataSchema, PartialActionDescription, ActionDescription, Form, + * ActionStatus} from "./types.js" + */ + +/** + * Action Affordance + * + * Represents an ActionAffordance from the W3C WoT Thing Description 1.1 + * specification https://www.w3.org/TR/wot-thing-description/#actionaffordance + */ +class ActionAffordance extends InteractionAffordance { + /** + * @type {DataSchema|undefined} + */ + input; + + /** + * @type {DataSchema|undefined} + */ + output; + + /** + * @type {boolean|undefined} + */ + safe; + + /** + * @type {boolean|undefined} + */ + idempotent; + + /** + * @type {boolean|undefined} + */ + synchronous; + + /** + * Create a new Action. + * + * @param {string} name The name of the ActionAffordance from its + * key in an actions Map. + * @param {PartialActionDescription} metadata Metadata describing an + * ActionAffordance from a partial Thing Description. + */ + constructor(name, metadata) { + super(name, metadata); + + let validationError = new ValidationError([]); + + // Parse input member + try { + this.#parseInputMember(metadata.input); + } catch (error) { + validationError.merge(error); + } + + // Parse output member + try { + this.#parseOutputMember(metadata.output); + } catch (error) { + validationError.merge(error); + } + + // Parse safe member + try { + this.#parseSafeMember(metadata.safe); + } catch (error) { + validationError.merge(error); + } + + // Parse idempotent member + try { + this.#parseIdempotentMember(metadata.idempotent); + } catch (error) { + validationError.merge(error); + } + + // Parse synchonrous member + try { + this.#parseSynchronousMember(metadata.synchronous); + } catch (error) { + validationError.merge(error); + } + + if (validationError.validationErrors.length > 0) { + throw validationError; + } + } + + /** + * Parse input member. + * + * @param {DataSchema|undefined} input + */ + #parseInputMember(input) { + if (!(input === undefined || typeof input === 'object')) { + throw new ValidationError([ + { + field: `actions.${this.name}.input`, + description: 'input member is not valid', + }, + ]); + } + // TODO: Validate DataSchema + this.input = input; + } + + /** + * Parse output member. + * @param {DataSchema|undefined} output + */ + #parseOutputMember(output) { + if (!(output === undefined || typeof output === 'object')) { + throw new ValidationError([ + { + field: `actions.${this.name}.output`, + description: 'output member is not valid', + }, + ]); + } + // TODO: Validate DataSchema + this.output = output; + } + + /** + * Parse safe member. + * + * @param {boolean|undefined} safe + * + * TODO: Consider omitting this member if not specified + */ + #parseSafeMember(safe) { + // Throw an error if not a boolean or undefined + if (!(safe === undefined || typeof safe == 'boolean')) { + throw new ValidationError([ + { + field: `actions.${this.name}.safe`, + description: 'safe member is not a boolean', + }, + ]); + } + // If undefined then default to false + if (safe === undefined) { + this.safe = false; + // Otherwise set the provided value + } else { + this.safe = safe; + } + } + + /** + * Parse idempotent member. + * + * @param {boolean|undefined} idempotent + * + * TODO: Consider omitting this member if not specified + */ + #parseIdempotentMember(idempotent) { + // Throw an error if not a boolean or undefined + if (!(idempotent === undefined || typeof idempotent == 'boolean')) { + throw new ValidationError([ + { + field: `actions.${this.name}.idempotent`, + description: 'idempotent member is not a boolean', + }, + ]); + } + // If undefined then default to false + if (idempotent === undefined) { + this.idempotent = false; + // Otherwise set the provided value + } else { + this.idempotent = idempotent; + } + } + + /** + * Parse synchronous member. + * + * @param {boolean|undefined} synchronous + */ + #parseSynchronousMember(synchronous) { + // Throw an error if not a boolean or undefined + if (!(synchronous === undefined || typeof synchronous == 'boolean')) { + throw new ValidationError([ + { + field: `actions.${this.name}.synchronous`, + description: 'synchronous member is not a boolean', + }, + ]); + } + this.synchronous = synchronous; + } + + /** + * Set invoke handler function. + * + * @param {(value: any) => Promise} handler An asynchronous function to action invocations. + */ + setInvokeHandler(handler) { + this.invokeHandler = handler; + } + + /** + * Invoke the action. + * + * @param {any} input The input to the action, conforming to the input data + * schema. + * @returns {Promise} A Promise which resolves with the output of the + * action (if any), conforming to the output data schema. + * + * Note: All actions are currently treated as synchronous. + */ + async invoke(input) { + // TODO: Validate input against data schema + if (!this.invokeHandler) { + console.error(`No invoke handler set for action ${this.name}`); + throw new Error('InternalError'); + } + return this.invokeHandler(input); + } + + /** + * @returns {ActionDescription} + */ + getMetadata() { + let metadata = /** @type {ActionDescription} */ (super.getMetadata()); + if (this.input != undefined) { + metadata.input = this.input; + } + if (this.output != undefined) { + metadata.output = this.output; + } + // Only set safe member if explicitly set to true because false is default + if (this.safe === true) { + metadata.safe = true; + } + // Only set idempotent member if explicitly set to true because false is default + if (this.idempotent === true) { + metadata.idempotent = true; + } + // Only set idempotent member if explicitly set to true or false + if (this.synchronous === true || this.synchronous === false) { + metadata.synchronous = this.synchronous; + } + /** @type {Array
} */ + metadata.forms = []; + const invokeActionForm = { + href: `actions/${this.name}`, + op: 'invokeaction', + }; + metadata.forms.push(invokeActionForm); + return metadata; + } +} + +export default ActionAffordance; diff --git a/src/interaction-affordance.js b/src/interaction-affordance.js index 652599e..beca839 100644 --- a/src/interaction-affordance.js +++ b/src/interaction-affordance.js @@ -1,5 +1,5 @@ import ValidationError from './validation-error.js'; -/** @import {DataSchema, Form} from "./types.js" */ +/** @import {Form, InteractionDescription} from "./types.js" */ /** * Interaction Affordance @@ -208,6 +208,22 @@ class InteractionAffordance { this.description = description; } + /** + * @returns {InteractionDescription} + */ + getMetadata() { + let metadata = {}; + if (this['@type']) { + metadata['@type'] = this['@type']; + } + if (this.title) { + metadata.title = this.title; + } + if (this.description) { + metadata.description = this.description; + } + return metadata; + } } export default InteractionAffordance; diff --git a/src/property-affordance.js b/src/property-affordance.js index e13f8c6..1049751 100644 --- a/src/property-affordance.js +++ b/src/property-affordance.js @@ -55,7 +55,7 @@ class PropertyAffordance extends InteractionAffordance { format; /** - * @type {('object'|'array'|'string'|'number'|'integer'|'bool * @property {Array} formsean'|'null')|undefined} + * @type {('object'|'array'|'string'|'number'|'integer'|'boolean'|'null')|undefined} */ type; @@ -83,22 +83,14 @@ class PropertyAffordance extends InteractionAffordance { try { this.#parseReadOnlyMember(metadata.readOnly); } catch (error) { - if (error instanceof ValidationError) { - validationError.validationErrors.push(...error.validationErrors); - } else { - throw error; - } + validationError.merge(error); } // Parse writeOnly member try { this.#parseWriteOnlyMember(metadata.writeOnly); } catch (error) { - if (error instanceof ValidationError) { - validationError.validationErrors.push(...error.validationErrors); - } else { - throw error; - } + validationError.merge(error); } // Check that readOnly and writeOnly are not both set @@ -106,13 +98,24 @@ class PropertyAffordance extends InteractionAffordance { let readWriteError = new ValidationError([ { field: `properties.${this.name}.readOnly`, - description: 'readOnly member is not a boolean', + description: 'property can not be readOnly and writeOnly', }, ]); - validationError.validationErrors.push(...readWriteError.validationErrors); + validationError.merge(readWriteError); + } + + // Parse type member + try { + this.#parseTypeMember(metadata.type); + } catch (error) { + validationError.merge(error); } // TODO: Parse other members + + if (validationError.validationErrors.length > 0) { + throw validationError; + } } /** @@ -165,6 +168,47 @@ class PropertyAffordance extends InteractionAffordance { } } + /** + * Parse type member. + * + * @param {string|undefined} type + */ + #parseTypeMember(type) { + // Throw an error if not a boolean or undefined + if (type === undefined) { + return; + } + if (typeof type != 'string') { + throw new ValidationError([ + { + field: `properties.${this.name}.type`, + description: 'type is set but is not a string', + }, + ]); + } + + if ( + !( + type == 'object' || + type == 'array' || + type == 'string' || + type == 'number' || + type == 'integer' || + type == 'boolean' || + type == 'null' + ) + ) { + throw new ValidationError([ + { + field: `properties.${this.name}.type`, + description: 'Invalid value', + }, + ]); + } + + this.type = type; + } + /** * Set read handler function. * @@ -204,7 +248,7 @@ class PropertyAffordance extends InteractionAffordance { * @returns {Promise} A Promise. */ async write(value) { - // TODO: Check value against type in TD + // TODO: Validate value against data schema if (this.writeHandler) { return this.writeHandler(value); } else { @@ -217,26 +261,37 @@ class PropertyAffordance extends InteractionAffordance { * @returns {PropertyDescription} */ getMetadata() { - let metadata = {}; - if (this['@type']) { - metadata['@type'] = this['@type']; + let metadata = /** @type {PropertyDescription} */ (super.getMetadata()); + /** @type {Array} */ + metadata.forms = []; + + // Only set type member if explicitly set + if (this.type != undefined) { + metadata.type = this.type; } - if (this.title) { - metadata.title = this.title; + + // Only set readOnly member if explicitly set to true since false is default + if (this.readOnly === true) { + metadata.readOnly = true; } - if (this.description) { - metadata.description = this.description; + + // Only set writeOnly member if explicitly set to true since false is default + if (this.writeOnly === true) { + metadata.writeOnly = true; } - /** @type {Array} */ - metadata.forms = []; + // Generate Form + const propertyForm = { + href: `properties/${this.name}`, + op: /** @type {Array} */ ([]), + }; if (this.writeOnly !== true) { - const readPropertyForm = { - href: `properties/${this.name}`, - op: 'readproperty', - }; - metadata.forms.push(readPropertyForm); + propertyForm.op.push('readproperty'); + } + if (this.readOnly !== true) { + propertyForm.op.push('writeproperty'); } + metadata.forms.push(propertyForm); return metadata; } diff --git a/src/thing-server.js b/src/thing-server.js index 8ac0886..8aa001c 100644 --- a/src/thing-server.js +++ b/src/thing-server.js @@ -20,6 +20,8 @@ class ThingServer { this.app.get( '/', /** + * Get Thing Description + * * @param {Request} request * @param {Response} response */ @@ -32,6 +34,8 @@ class ThingServer { this.app.get( '/properties/:name', /** + * Read Property + * * @param {Request} request * @param {Response} response */ @@ -65,6 +69,8 @@ class ThingServer { this.app.put( '/properties/:name', /** + * Write Property + * * @param {Request} request * @param {Response} response */ @@ -83,6 +89,9 @@ class ThingServer { case 'NotFoundError': response.status(404).send(); break; + case 'BadRequest': + response.status(400).send(); + break; case 'InternalError': response.status(500).send(); break; @@ -94,6 +103,51 @@ class ThingServer { response.status(204).send(); }, ); + + this.app.post( + '/actions/:name', + /** + * Invoke Action + * + * @param {Request} request + * @param {Response} response + * + * Note: All actions are currently treated as synchronous. + */ + async (request, response) => { + // Make sure name is a string since param can also be array + const name = Array.isArray(request.params.name) + ? request.params.name[0] + : request.params.name; + const input = request.body; + let output; + try { + output = await this.thing.invokeAction(name, input); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'InternalError'; + switch (errorMessage) { + case 'NotFoundError': + response.status(404).send(); + break; + case 'BadRequest': + response.status(400).send(); + break; + case 'InternalError': + response.status(500).send(); + break; + default: + response.status(500).send(); + } + return; + } + if (output != undefined) { + response.status(200).send(output); + } else { + response.status(204).send(); + } + }, + ); } /** diff --git a/src/thing.js b/src/thing.js index cb39bb3..c83f75b 100644 --- a/src/thing.js +++ b/src/thing.js @@ -1,6 +1,10 @@ import ValidationError from './validation-error.js'; import PropertyAffordance from './property-affordance.js'; -/** @import {SecurityScheme, PartialPropertyDescription, PropertyDescription, PartialThingDescription, ThingDescription} from "./types.js" */ +import ActionAffordance from './action-affordance.js'; +/** @import {SecurityScheme, PartialPropertyDescription, PropertyDescription, + * PartialActionDescription, ActionDescription, PartialThingDescription, + * ThingDescription, ActionStatus} from "./types.js" + */ /** * Thing @@ -28,6 +32,11 @@ class Thing { */ properties = new Map(); + /** + * @type {Map} + */ + actions = new Map(); + /** * @type {Record} */ @@ -55,46 +64,37 @@ class Thing { // Parse base member try { - this.#parseBaseMember(partialTD['base']); + this.#parseBaseMember(partialTD.base); } catch (error) { - if (error instanceof ValidationError) { - validationError.validationErrors.push(...error.validationErrors); - } else { - throw error; - } + validationError.merge(error); } // Parse @context member try { this.#parseContextMember(partialTD['@context']); } catch (error) { - if (error instanceof ValidationError) { - validationError.validationErrors.push(...error.validationErrors); - } else { - throw error; - } + validationError.merge(error); } // Parse title member try { this.#parseTitleMember(partialTD.title); } catch (error) { - if (error instanceof ValidationError) { - validationError.validationErrors.push(...error.validationErrors); - } else { - throw error; - } + validationError.merge(error); } // Parse properties member try { this.#parsePropertiesMember(partialTD.properties); } catch (error) { - if (error instanceof ValidationError) { - validationError.validationErrors.push(...error.validationErrors); - } else { - throw error; - } + validationError.merge(error); + } + + // Parse actions member + try { + this.#parseActionsMember(partialTD.actions); + } catch (error) { + validationError.merge(error); } // Hard code the nosec security scheme for now @@ -106,6 +106,10 @@ class Thing { this.security = 'nosec_sc'; // TODO: Parse other members + + if (validationError.validationErrors.length > 0) { + throw validationError; + } } /** @@ -247,6 +251,34 @@ class Thing { } } + /** + * Parse the actions member of a Thing Description. + * + * @param {Record|undefined} actionDescriptions Map of action + * descriptions provided in a partial TD, indexed by action name. + */ + #parseActionsMember(actionDescriptions) { + // If the actions member is not set then continue + if (!actionDescriptions) { + return; + } + + // If the provided actions member is not an object then throw a validation error + if (typeof actionDescriptions !== 'object') { + throw new ValidationError([ + { + field: 'actions', + description: 'actions member is not an object', + }, + ]); + } + + // Generate a map of Action objects from action descriptions + for (const actionName in actionDescriptions) { + this.addAction(actionName, actionDescriptions[actionName]); + } + } + /** * Add a Property. * @@ -259,6 +291,18 @@ class Thing { this.properties.set(propertyName, property); } + /** + * Add an Action. + * + * @param {string} actionName The name of the action to add. + * @param {PartialActionDescription} actionDescription A description of an + * ActionAffordance from a Thing Description. + */ + addAction(actionName, actionDescription) { + let action = new ActionAffordance(actionName, actionDescription); + this.actions.set(actionName, action); + } + /** * Get Thing Description. * @@ -277,13 +321,23 @@ class Thing { } } + /** + * @type {Record} + */ + let actions = {}; + for (const actionName of this.actions.keys()) { + const action = this.actions.get(actionName); + if (action) { + actions[actionName] = action.getMetadata(); + } + } + /** @type {ThingDescription} */ const thingDescription = { '@context': this.context, title: this.title, securityDefinitions: this.securityDefinitions, security: this.security, - properties: properties, // TODO: generate top level forms }; // If a base argument is provided then use that, otherwise use the base provided in the @@ -293,6 +347,14 @@ class Thing { } else if (this.base) { thingDescription.base = this.base.href; } + // If properties are defined then add a properties member + if (Object.keys(properties).length > 0) { + thingDescription.properties = properties; + } + // If actions are defined then add an actions member + if (Object.keys(actions).length > 0) { + thingDescription.actions = actions; + } return thingDescription; } @@ -314,7 +376,7 @@ class Thing { * Set Property Write Handler. * * @param {string} name The name of the property to handle. - * @param {(value: any) => Promise} handler A function to handle property writes. + * @param {(value: any) => Promise} handler An async function to handle property writes. */ setPropertyWriteHandler(name, handler) { let property = this.properties.get(name); @@ -324,6 +386,20 @@ class Thing { property.setWriteHandler(handler); } + /** + * Set Action Handler. + * + * @param {string} name The name of the action to handle. + * @param {(value: any) => Promise} handler An async function to handle action invocations. + */ + setActionHandler(name, handler) { + let action = this.actions.get(name); + if (!action) { + throw new Error(`No action called ${name} could be found`); + } + action.setInvokeHandler(handler); + } + /** * Read Property. * @@ -345,10 +421,10 @@ class Thing { * * @param {string} name The name of the property to write. * @param {any} value The property value to write. - * @returns {any} The current value of the property, with a format conforming - * to its data schema in the Thing Description. + * @returns {Promise} A Promise that resolves once the property has been + * written successfully. */ - writeProperty(name, value) { + async writeProperty(name, value) { let property = this.properties.get(name); if (!property) { console.error(`No property called ${name} could be found`); @@ -356,6 +432,25 @@ class Thing { } return property.write(value); } + + /** + * Invoke Action. + * + * @param {string} name The name of the action to invoke. + * @param {any} input The action input. + * @returns {Promise} A Promise resolving to the output of the action, + * conforming to the output data schema. + * + * Note: All actions are currently treated as synchronous. + */ + async invokeAction(name, input) { + let action = this.actions.get(name); + if (!action) { + console.error(`No action called ${name} could be found`); + throw new Error('NotFoundError'); + } + return action.invoke(input); + } } export default Thing; diff --git a/src/types.js b/src/types.js index 732419f..0138ba2 100644 --- a/src/types.js +++ b/src/types.js @@ -40,6 +40,11 @@ * const?: any, * default?: any, * unit?: string, + * minimum?: number, + * maximum?: number, + * properties?: Record, + * required?: Array, + * items?: DataSchema, * oneOf?: Array, * enum?: Array, * readOnly?: boolean, @@ -67,15 +72,19 @@ // TODO: Constrain set of possible values for op /** - * Property Description - * * @typedef {{ * '@type'?: string|Array, - * title?: string|undefined, - * description?: string|undefined, + * title?: string, + * description?: string + * }} InteractionDescription + */ + +/** + * Property Description + * + * @typedef {InteractionDescription & DataSchema & { * forms: Array, - * readOnly?: boolean, - * writeOnly?: boolean + * observeable?: boolean|undefined * }} PropertyDescription */ @@ -84,16 +93,64 @@ * * Same as PropertyDescription but forms is optional * - * @typedef {{ - * '@type'?: string|Array, - * title?: string|undefined, - * description?: string|undefined, + * @typedef {InteractionDescription & DataSchema & { * forms?: Array, - * readOnly?: boolean, - * writeOnly?: boolean + * observeable?: boolean|undefined * }} PartialPropertyDescription */ +/** + * Action Description + * + * @typedef { InteractionDescription & { + * forms: Array, + * input?: DataSchema, + * output?: DataSchema, + * safe?: boolean, + * idempotent?: boolean, + * synchronous?: boolean, + * }} ActionDescription + */ + +/** + * Partial Action Description + * + * Same as ActionDescription but forms is optional + * + * @typedef { InteractionDescription & { + * forms?: Array, + * input?: DataSchema, + * output?: DataSchema, + * safe?: boolean, + * idempotent?: boolean, + * synchronous?: boolean, + * }} PartialActionDescription + */ + +/** + * Event Description + * + * @typedef {InteractionDescription & { + * forms: Array, + * subscription?: DataSchema, + * data?: DataSchema, + * dataResponse?: DataSchema, + * cancellation?: DataSchema, + * }} EventDescription + */ + +/** + * Partial Event Description + * + * @typedef {InteractionDescription & { + * forms?: Array, + * subscription?: DataSchema, + * data?: DataSchema, + * dataResponse?: DataSchema, + * cancellation?: DataSchema, + * }} PartialEventDescription + */ + /** * Thing Description * @@ -105,6 +162,8 @@ * description?: string, * base?: string, * properties?: Record, + * actions?: Record, + * events?: Record, * security: string|Array, * securityDefinitions: string|Record * }} ThingDescription @@ -124,7 +183,36 @@ * description?: string, * base?: string, * properties?: Record, + * actions?: Record, + * events?: Record, * security?: string|Array, * securityDefinitions?: string|Record * }} PartialThingDescription */ + +/** + * Action Status + * + * @typedef {{ + * actionID: string, + * state: 'pending'|'running'|'completed'|'failed', + * output?: any, + * error: ProblemDetails + * timeRequested?: string, + * timeEnded?: string + * }} ActionStatus + */ + +/** + * Problem Details + * + * Conforming to RFC 9457 https://www.rfc-editor.org/info/rfc9457/ + * + * @typedef {{ + * type?: string, + * title?: string, + * status?: number, + * detail?: string, + * instance?: string + * }} ProblemDetails + */ diff --git a/src/validation-error.js b/src/validation-error.js index 40a316a..cddb395 100644 --- a/src/validation-error.js +++ b/src/validation-error.js @@ -28,6 +28,19 @@ class ValidationError extends Error { super(...params); this.validationErrors = validationErrors; } + + /** + * Merge a list of validation errors into this ValidationError. + * + * @param {any} error + */ + merge(error) { + if (error instanceof ValidationError) { + this.validationErrors.push(...error.validationErrors); + } else { + throw error; + } + } } export default ValidationError; diff --git a/test/action-affordance.test.js b/test/action-affordance.test.js new file mode 100644 index 0000000..99c5c20 --- /dev/null +++ b/test/action-affordance.test.js @@ -0,0 +1,108 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import ActionAffordance from '../src/action-affordance.js'; +import ValidationError from '../src/validation-error.js'; + +describe('ActionAffordance', () => { + describe('constructor', () => { + it('should parse valid action metadata', () => { + const action = new ActionAffordance('toggle', { + title: 'Toggle', + description: 'Toggle the light', + input: { type: 'boolean' }, + output: { type: 'boolean' }, + safe: true, + idempotent: true, + synchronous: false, + }); + + assert.equal(action.name, 'toggle'); + assert.equal(action.title, 'Toggle'); + assert.deepEqual(action.input, { type: 'boolean' }); + assert.deepEqual(action.output, { type: 'boolean' }); + assert.equal(action.safe, true); + assert.equal(action.idempotent, true); + assert.equal(action.synchronous, false); + }); + + it('should default safe and idempotent to false when omitted', () => { + const action = new ActionAffordance('reboot', {}); + + assert.equal(action.safe, false); + assert.equal(action.idempotent, false); + assert.equal(action.synchronous, undefined); + }); + + it('should reject invalid member types', () => { + assert.throws( + () => new ActionAffordance('badInput', { input: 'not-an-object' }), + (error) => { + assert.ok(error instanceof ValidationError); + assert.equal( + error.validationErrors[0].field, + 'actions.badInput.input', + ); + assert.equal( + error.validationErrors[0].description, + 'input member is not valid', + ); + return true; + }, + ); + + assert.throws( + () => new ActionAffordance('badSafe', { safe: 'yes' }), + (error) => { + assert.ok(error instanceof ValidationError); + assert.equal(error.validationErrors[0].field, 'actions.badSafe.safe'); + assert.equal( + error.validationErrors[0].description, + 'safe member is not a boolean', + ); + return true; + }, + ); + }); + }); + + describe('getMetadata', () => { + it('should return the action metadata with the generated invocation form', () => { + const action = new ActionAffordance('blink', { + title: 'Blink', + output: { type: 'string' }, + safe: true, + idempotent: true, + synchronous: true, + }); + + assert.deepEqual(action.getMetadata(), { + title: 'Blink', + output: { type: 'string' }, + safe: true, + idempotent: true, + synchronous: true, + forms: [{ href: 'actions/blink', op: 'invokeaction' }], + }); + }); + }); + + describe('invoke', () => { + it('should call the registered invoke handler with the action input', async () => { + const action = new ActionAffordance('setColor', { + input: { type: 'string' }, + }); + action.setInvokeHandler(async (value) => ({ value })); + + const result = await action.invoke('red'); + assert.deepEqual(result, { value: 'red' }); + }); + + it('should reject when no invoke handler has been registered', async () => { + const action = new ActionAffordance('setColor', { + input: { type: 'string' }, + }); + + await assert.rejects(() => action.invoke('red'), /InternalError/); + }); + }); +}); diff --git a/test/property-affordance.test.js b/test/property-affordance.test.js new file mode 100644 index 0000000..92527ec --- /dev/null +++ b/test/property-affordance.test.js @@ -0,0 +1,149 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import PropertyAffordance from '../src/property-affordance.js'; +import ValidationError from '../src/validation-error.js'; + +describe('PropertyAffordance', () => { + describe('constructor', () => { + it('should parse valid property metadata', () => { + const property = new PropertyAffordance('on', { + title: 'On/Off', + description: 'Whether the light is on', + type: 'boolean', + readOnly: true, + }); + + assert.equal(property.name, 'on'); + assert.equal(property.title, 'On/Off'); + assert.equal(property.type, 'boolean'); + assert.equal(property.readOnly, true); + assert.equal(property.writeOnly, false); + }); + + it('should default readOnly and writeOnly to false when omitted', () => { + const property = new PropertyAffordance('status', { type: 'string' }); + + assert.equal(property.readOnly, false); + assert.equal(property.writeOnly, false); + assert.equal(property.type, 'string'); + }); + + it('should reject invalid member types', () => { + assert.throws( + () => new PropertyAffordance('badReadOnly', { readOnly: 'yes' }), + (error) => { + assert.ok(error instanceof ValidationError); + assert.equal( + error.validationErrors[0].field, + 'properties.badReadOnly.readOnly', + ); + assert.equal( + error.validationErrors[0].description, + 'readOnly member is not a boolean', + ); + return true; + }, + ); + + assert.throws( + () => new PropertyAffordance('badType', { type: 42 }), + (error) => { + assert.ok(error instanceof ValidationError); + assert.equal( + error.validationErrors[0].field, + 'properties.badType.type', + ); + assert.equal( + error.validationErrors[0].description, + 'type is set but is not a string', + ); + return true; + }, + ); + }); + + it('should reject properties that are both readOnly and writeOnly', () => { + assert.throws( + () => + new PropertyAffordance('conflict', { + readOnly: true, + writeOnly: true, + }), + (error) => { + assert.ok(error instanceof ValidationError); + assert.equal( + error.validationErrors[0].field, + 'properties.conflict.readOnly', + ); + assert.equal( + error.validationErrors[0].description, + 'property can not be readOnly and writeOnly', + ); + return true; + }, + ); + }); + }); + + describe('getMetadata', () => { + it('should generate default forms', () => { + const property = new PropertyAffordance('brightness', { + type: 'number', + }); + + assert.deepEqual(property.getMetadata(), { + type: 'number', + forms: [ + { + href: 'properties/brightness', + op: ['readproperty', 'writeproperty'], + }, + ], + }); + }); + + it('should generate read-only form operations for readOnly properties', () => { + const property = new PropertyAffordance('temperature', { + type: 'number', + readOnly: true, + }); + + assert.deepEqual(property.getMetadata(), { + type: 'number', + readOnly: true, + forms: [{ href: 'properties/temperature', op: ['readproperty'] }], + }); + }); + + it('should generate write-only form operations for writeOnly properties', () => { + const property = new PropertyAffordance('token', { + type: 'string', + writeOnly: true, + }); + + assert.deepEqual(property.getMetadata(), { + type: 'string', + writeOnly: true, + forms: [{ href: 'properties/token', op: ['writeproperty'] }], + }); + }); + }); + + describe('read and write', () => { + it('should call the registered read and write handlers', async () => { + const property = new PropertyAffordance('level', { type: 'number' }); + property.setReadHandler(async () => 42); + property.setWriteHandler(async (value) => value); + + assert.equal(await property.read(), 42); + assert.equal(await property.write(7), 7); + }); + + it('should reject when no read or write handler is registered', async () => { + const property = new PropertyAffordance('level', { type: 'number' }); + + await assert.rejects(() => property.read(), /InternalError/); + await assert.rejects(() => property.write(1), /InternalError/); + }); + }); +}); diff --git a/test/thing-server.test.js b/test/thing-server.test.js index 3c23530..f491f36 100644 --- a/test/thing-server.test.js +++ b/test/thing-server.test.js @@ -13,6 +13,11 @@ describe('ThingServer', () => { title: 'On/Off', }, }, + actions: { + blink: { + title: 'Blink', + }, + }, }; let server; @@ -25,6 +30,7 @@ describe('ThingServer', () => { thing.setPropertyWriteHandler('on', async (value) => { currentValue = value; }); + thing.setActionHandler('blink', async (input) => input); server = new ThingServer(thing); // Listen on a random available port to avoid port conflicts await new Promise((resolve) => { @@ -98,4 +104,34 @@ describe('ThingServer', () => { assert.strictEqual(response.status, 404); }); }); + + describe('POST /actions/:name', () => { + it('should invoke the action and return its output', async () => { + const input = { duration: 3 }; + const response = await fetch(baseUrl + '/actions/blink', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + }); + + assert.strictEqual(response.status, 200); + assert.deepEqual(await response.json(), input); + }); + }); + + describe('POST /actions/:invalidname', () => { + it('should return 404 when the action does not exist', async () => { + const response = await fetch(baseUrl + '/actions/foo', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + + assert.strictEqual(response.status, 404); + }); + }); }); diff --git a/test/thing.test.js b/test/thing.test.js index 72d79e0..6808e82 100644 --- a/test/thing.test.js +++ b/test/thing.test.js @@ -12,6 +12,17 @@ describe('Thing', () => { title: 'On/Off', }, }, + actions: { + blink: { + title: 'Blink', + input: { + type: 'object', + properties: { + count: { type: 'integer' }, + }, + }, + }, + }, }; describe('constructor', () => { @@ -40,10 +51,23 @@ describe('Thing', () => { forms: [ { href: 'properties/on', - op: 'readproperty', + op: ['readproperty', 'writeproperty'], }, ], title: 'On/Off', + type: 'boolean', + }, + }, + actions: { + blink: { + forms: [{ href: 'actions/blink', op: 'invokeaction' }], + title: 'Blink', + input: { + type: 'object', + properties: { + count: { type: 'integer' }, + }, + }, }, }, }); @@ -51,13 +75,6 @@ describe('Thing', () => { }); describe('setPropertyReadHandler', () => { - it('should register a handler for an existing property', async () => { - const thing = new Thing(partialTD); - thing.setPropertyReadHandler('on', async () => true); - const value = await thing.readProperty('on'); - assert.strictEqual(value, true); - }); - it('should throw when the property does not exist', () => { const thing = new Thing(partialTD); assert.throws( @@ -82,13 +99,6 @@ describe('Thing', () => { }); describe('setPropertyWriteHandler', () => { - it('should register a handler for an existing property', async () => { - const thing = new Thing(partialTD); - thing.setPropertyWriteHandler('on', async (value) => value); - const value = await thing.writeProperty('on', true); - assert.strictEqual(value, true); - }); - it('should throw when the property does not exist', () => { const thing = new Thing(partialTD); assert.throws( @@ -114,4 +124,33 @@ describe('Thing', () => { ); }); }); + + describe('setActionHandler', () => { + it('should throw when the action does not exist', () => { + const thing = new Thing(partialTD); + assert.throws( + () => thing.setActionHandler('missing', async () => {}), + /No action called missing could be found/, + ); + }); + }); + + describe('invokeAction', () => { + it('should return the result from the action handler', async () => { + const thing = new Thing(partialTD); + thing.setActionHandler('blink', async (input) => input.count); + + const result = await thing.invokeAction('blink', { count: 3 }); + assert.equal(result, 3); + }); + + it('should reject when the action does not exist', async () => { + const thing = new Thing(partialTD); + + await assert.rejects( + () => thing.invokeAction('missing', {}), + /NotFoundError/, + ); + }); + }); });