From 3713d6ebb527497f782be3805cf8365f5473d024 Mon Sep 17 00:00:00 2001 From: Ben Francis Date: Thu, 8 Aug 2024 18:59:35 +0100 Subject: [PATCH] Serve a Thing Description for the gateway - closes #2927 --- src/controllers/api_root_controller.ts | 32 +++ src/controllers/well-known_controller.ts | 28 +++ src/models/gateway.ts | 244 +++++++++++++++++++++++ src/models/thing.ts | 62 +++--- src/models/things.ts | 4 +- src/router.ts | 7 +- 6 files changed, 347 insertions(+), 30 deletions(-) create mode 100644 src/controllers/api_root_controller.ts create mode 100644 src/models/gateway.ts diff --git a/src/controllers/api_root_controller.ts b/src/controllers/api_root_controller.ts new file mode 100644 index 000000000..45c5ac0f4 --- /dev/null +++ b/src/controllers/api_root_controller.ts @@ -0,0 +1,32 @@ +/** + * API Root Controller. + * + * Handles requests to /. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import express from 'express'; +import Gateway from '../models/gateway'; + +function build(): express.Router { + const controller = express.Router(); + + /** + * WoT Thing Description Directory + * https://www.w3.org/TR/wot-discovery/#exploration-directory + */ + controller.get('/', (request, response) => { + const host = request.headers.host; + const secure = request.secure; + const td = Gateway.getDescription(host, secure); + response.set('Content-type', 'application/td+json'); + response.status(200).send(td); + }); + + return controller; +} + +export default build; diff --git a/src/controllers/well-known_controller.ts b/src/controllers/well-known_controller.ts index 726a7a880..592b79210 100644 --- a/src/controllers/well-known_controller.ts +++ b/src/controllers/well-known_controller.ts @@ -6,12 +6,14 @@ import express from 'express'; import * as Constants from '../constants'; +import Gateway from '../models/gateway'; function build(): express.Router { const controller = express.Router(); /** * OAuth 2.0 Authorization Server Metadata (RFC 8414) + * https://datatracker.ietf.org/doc/html/rfc8414 */ controller.get('/oauth-authorization-server', (request, response) => { const origin = `${request.protocol}://${request.headers.host}`; @@ -25,6 +27,32 @@ function build(): express.Router { }); }); + /** + * WoT Thing Description Directory + * https://www.w3.org/TR/wot-discovery/#exploration-directory + */ + controller.get('/wot', (request, response) => { + const host = request.headers.host; + const secure = request.secure; + + // Get a Thing Description of the gateway + let td = Gateway.getDescription(host, secure); + + // Add a link to root as the canonical URL of the Thing Description + if (td.links === undefined) { + td.links = []; + } + td.links.push({ + rel: 'canonical', + href: '/', + type: 'application/td+json' + }); + + // Send the Thing Description in response + response.set('Content-type', 'application/td+json'); + response.status(200).send(td); + }); + return controller; } diff --git a/src/models/gateway.ts b/src/models/gateway.ts new file mode 100644 index 000000000..12cb9e439 --- /dev/null +++ b/src/models/gateway.ts @@ -0,0 +1,244 @@ +/** + * Gateway Model. + * + * Represents the gateway and its interaction affordances, including + * acting as a Thing Description Directory. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import * as Constants from '../constants'; +import { ThingDescription } from './thing'; + +export default class Gateway { + + /** + * + * Get a JSON Thing Description for this gateway. + * + * @param {String} reqHost request host, if coming via HTTP + * @param {Boolean} reqSecure whether or not the request is secure, i.e. TLS + * @returns {ThingDescription} A Thing Description describing the gateway. + */ + static getDescription(reqHost?: string, reqSecure?: boolean): ThingDescription { + const origin = `${reqSecure ? 'https' : 'http'}://${reqHost}`; + const desc: ThingDescription = { + '@context': [ + 'https://www.w3.org/2022/wot/td/v1.1', + 'https://www.w3.org/2022/wot/discovery' + ], + '@type': 'ThingDirectory', + 'id': origin, + 'base': origin, + 'title': 'WebThings Gateway', + 'securityDefinitions': { + 'oauth2_sc': { + 'scheme': 'oauth2', + 'flow': 'code', + 'authorization': `${origin}${Constants.OAUTH_PATH}/authorize`, + 'token': `${origin}${Constants.OAUTH_PATH}/token`, + 'scopes': [Constants.THINGS_PATH, `${Constants.THINGS_PATH}:readwrite`], + } + }, + 'security': 'oauth2_sc', + 'properties': { + 'things': { + 'title': 'Things', + 'description': 'Retrieve all Thing Descriptions', + 'type': 'array', + 'items': { + 'type': 'object' + }, + 'forms': [ + { + 'href': '/things', + 'htv:methodName': 'GET', + 'response': { + 'description': 'Success response', + 'htv:statusCodeValue': 200, + 'contentType': 'application/json' + }, + 'additionalResponses': [ + { + 'description': 'Token must contain scope', + 'htv:statusCodeValue': 400 + } + ] + } + ] + } + }, + 'actions': { + 'createAnonymousThing': { + 'description': 'Create a Thing Description', + 'input': { + 'type': 'object' + }, + 'forms': [ + { + 'href': '/things', + 'htv:methodName': 'POST', + 'contentType': 'application/json', + 'response': { + 'htv:statusCodeValue': 201 + }, + 'additionalResponses': [ + { + 'description': 'Invalid or duplicate Thing Description', + 'htv:statusCodeValue': 400 + }, + { + 'description': 'Internal error saving new Thing Description', + 'htv:statusCodeValue': 500 + } + ] + } + ] + }, + 'retrieveThing': { + 'description': 'Retrieve a Thing Description', + 'uriVariables': { + 'id': { + '@type': 'ThingID', + 'title': 'Thing Description ID', + 'type': 'string', + 'format': 'iri-reference' + } + }, + 'output': { + 'description': 'The schema is implied by the content type', + 'type': 'object' + }, + 'safe': true, + 'idempotent': true, + 'forms': [ + { + 'href': '/things/{id}', + 'htv:methodName': 'GET', + 'response': { + 'description': 'Success response', + 'htv:statusCodeValue': 200, + 'contentType': 'application/json' + }, + 'additionalResponses': [ + { + 'description': 'TD with the given id not found', + 'htv:statusCodeValue': 404 + } + ] + } + ] + }, + 'updateThing': { + 'description': 'Update a Thing Description', + 'uriVariables': { + 'id': { + '@type': 'ThingID', + 'title': 'Thing Description ID', + 'type': 'string', + 'format': 'iri-reference' + } + }, + 'input': { + 'type': 'object' + }, + 'forms': [ + { + 'href': '/things/{id}', + 'htv:methodName': 'PUT', + 'contentType': 'application/json', + 'response': { + 'description': 'Success response', + 'htv:statusCodeValue': 200 + }, + 'additionalResponses': [ + { + 'description': 'Invalid serialization or TD', + 'htv:statusCodeValue': 400 + }, + { + 'description': 'Failed to update Thing', + 'htv:statusCodeValue': 500 + } + ] + } + ] + }, + 'partiallyUpdateThing': { + 'description': 'Partially update a Thing Description', + 'uriVariables': { + 'id': { + '@type': 'ThingID', + 'title': 'Thing Description ID', + 'type': 'string', + 'format': 'iri-reference' + } + }, + 'input': { + 'type': 'object' + }, + 'forms': [ + { + 'href': '/things/{id}', + 'htv:methodName': 'PATCH', + 'contentType': 'application/merge-patch+json', + 'response': { + 'description': 'Success response', + 'htv:statusCodeValue': 200 + }, + 'additionalResponses': [ + { + 'description': 'Request body missing required parameters', + 'htv:statusCodeValue': 400 + }, + { + 'description': 'TD with the given id not found', + 'htv:statusCodeValue': 404 + }, + { + 'description': 'Failed to update Thing', + 'htv:statusCodeValue': 500 + } + ] + } + ] + }, + 'deleteThing': { + 'description': 'Delete a Thing Description', + 'uriVariables': { + 'id': { + '@type': 'ThingID', + 'title': 'Thing Description ID', + 'type': 'string', + 'format': 'iri-reference' + } + }, + 'forms': [ + { + 'href': '/things/{id}', + 'htv:methodName': 'DELETE', + 'response': { + 'description': 'Success response', + 'htv:statusCodeValue': 204 + }, + 'additionalResponses': [ + { + 'description': 'TD with the given id not found', + 'htv:statusCodeValue': 404 + }, + { + 'description': 'Failed to remove Thing', + 'htv:statusCodeValue': 500 + } + ] + } + ] + } + } + } + + return desc; + } +} \ No newline at end of file diff --git a/src/models/thing.ts b/src/models/thing.ts index 314aae9a2..448cb2179 100644 --- a/src/models/thing.ts +++ b/src/models/thing.ts @@ -35,27 +35,27 @@ export interface ThingDescription { id: string; title: string; '@context': string | string[]; - '@type': string[]; - profile: string | string[]; - description: string; - base: string; - baseHref: string; - href: string; - properties: Record; - actions: Record; - events: Record; - links: Link[]; - forms: Form[]; - floorplanVisibility: boolean; - floorplanX: number; - floorplanY: number; - layoutIndex: number; - selectedCapability: string; - iconHref: string | null; - iconData: IconData; + '@type'?: string | string[]; + profile?: string | string[]; + description?: string; + base?: string; + baseHref?: string; + href?: string; + properties?: Record; + actions?: Record; + events?: Record; + links?: Link[]; + forms?: Form[]; + floorplanVisibility?: boolean; + floorplanX?: number; + floorplanY?: number; + layoutIndex?: number; + selectedCapability?: string; + iconHref?: string | null; + iconData?: IconData; security: string; securityDefinitions: SecurityDefinition; - groupId: string | null; + groupId?: string | null; } interface IconData { @@ -82,7 +82,7 @@ export default class Thing extends EventEmitter { private '@context': string | string[]; - private '@type': string[]; + private '@type': string | string[]; private profile: string | string[]; @@ -100,15 +100,15 @@ export default class Thing extends EventEmitter { private eventsDispatched: Event[]; - private floorplanVisibility: boolean; + private floorplanVisibility: boolean | undefined; - private floorplanX: number; + private floorplanX: number | undefined; - private floorplanY: number; + private floorplanY: number | undefined; private layoutIndex: number; - private selectedCapability: string; + private selectedCapability: string | undefined; private links: Link[]; @@ -267,7 +267,11 @@ export default class Thing extends EventEmitter { this.floorplanVisibility = description.floorplanVisibility; this.floorplanX = description.floorplanX; this.floorplanY = description.floorplanY; - this.layoutIndex = description.layoutIndex; + if (description.layoutIndex === undefined) { + this.layoutIndex = Infinity; + } else { + this.layoutIndex = description.layoutIndex; + } this.selectedCapability = description.selectedCapability; this.links = []; @@ -281,7 +285,7 @@ export default class Thing extends EventEmitter { router.addProxyServer(this.id, description.baseHref); } - if (description.hasOwnProperty('links')) { + if (description.hasOwnProperty('links') && description.links != undefined) { for (const link of description.links) { // For backwards compatibility if (link.mediaType) { @@ -673,6 +677,9 @@ export default class Thing extends EventEmitter { href: `${reqSecure ? 'wss' : 'ws'}://${reqHost}${this.href}`, }; + if(desc.links === undefined) { + desc.links = []; + } desc.links.push(wsLink); desc.id = `${reqSecure ? 'https' : 'http'}://${reqHost}${this.href}`; @@ -928,6 +935,9 @@ export default class Thing extends EventEmitter { } // Update the UI href + if (description.links === undefined) { + description.links = []; + } if (description.hasOwnProperty('links')) { for (const link of description.links) { // For backwards compatibility diff --git a/src/models/things.ts b/src/models/things.ts index 4c6456712..59193f0bd 100644 --- a/src/models/things.ts +++ b/src/models/things.ts @@ -340,7 +340,7 @@ class Things extends EventEmitter { index = Math.min(things.length - 1, Math.max(0, index)); const movePromises = things.map((t) => { - if (thing.getLayoutIndex() < t.getLayoutIndex() && t.getLayoutIndex() <= index) { + if ((thing.getLayoutIndex() < t.getLayoutIndex()) && t.getLayoutIndex() <= index) { return t.setLayoutIndex(t.getLayoutIndex() - 1); } else if (index <= t.getLayoutIndex() && t.getLayoutIndex() < thing.getLayoutIndex()) { return t.setLayoutIndex(t.getLayoutIndex() + 1); @@ -461,7 +461,7 @@ class Things extends EventEmitter { throw new HttpErrorWithCode('Thing not found', 404); } - if (!thing.properties.hasOwnProperty(propertyName)) { + if (!thing.properties || !thing.properties.hasOwnProperty(propertyName)) { throw new HttpErrorWithCode('Property not found', 404); } diff --git a/src/router.ts b/src/router.ts index 335f1152b..20af2fcd4 100644 --- a/src/router.ts +++ b/src/router.ts @@ -41,6 +41,7 @@ import GroupsController from './controllers/groups_controller'; import UpdatesController from './controllers/updates_controller'; import UploadsController from './controllers/uploads_controller'; import UsersController from './controllers/users_controller'; +import APIRootController from './controllers/api_root_controller'; const nocache = NoCache(); const auth = jwtMiddleware.middleware(); @@ -88,8 +89,8 @@ class Router { app.use(Constants.UPLOADS_PATH, express.static(UserProfile.uploadsDir)); app.use(Constants.EXTENSIONS_PATH, nocache, ExtensionsController()); app.use((request, response, next) => { - if (request.path === '/' && request.accepts('html')) { - // We need this to hit RootController. + if (request.path === '/') { + // We need this to hit RootController or APIRootController. next(); } else { staticHandler(request, response, next); @@ -120,6 +121,7 @@ class Router { } else if ( (!request.accepts('html') && request.accepts('json')) || (!request.accepts('html') && request.accepts('text/event-stream')) || + (!request.accepts('html') && request.accepts('application/td+json')) || request.headers['content-type'] === 'application/json' || request.get('Upgrade') === 'websocket' || request.is('multipart/form-data') || @@ -152,6 +154,7 @@ class Router { app.use(`${APP_PREFIX}/*`, RootController()); // Unauthenticated API routes + app.use(`${API_PREFIX}/`, nocache, APIRootController()); app.use(API_PREFIX + Constants.LOGIN_PATH, nocache, LoginController()); app.use(API_PREFIX + Constants.SETTINGS_PATH, nocache, SettingsController()); app.use(API_PREFIX + Constants.USERS_PATH, nocache, UsersController());