From 7ce68311549c62956952a58b042b468eda7420d8 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 | 240 +++++++++++++++++++++++ src/models/thing.ts | 62 +++--- src/models/things.ts | 2 +- src/router.ts | 7 +- src/test/integration/oauth-test.ts | 21 -- src/test/integration/root-test.ts | 18 ++ src/test/integration/well-known-test.ts | 37 ++++ 9 files changed, 397 insertions(+), 50 deletions(-) create mode 100644 src/controllers/api_root_controller.ts create mode 100644 src/models/gateway.ts create mode 100644 src/test/integration/root-test.ts create mode 100644 src/test/integration/well-known-test.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..6f83edbf2 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 + const td = Gateway.getDescription(host, secure); + + // Add a link to root as the canonical URL of the Thing Description + if (typeof 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..ad6c5c0a6 --- /dev/null +++ b/src/models/gateway.ts @@ -0,0 +1,240 @@ +/** + * 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; + } +} diff --git a/src/models/thing.ts b/src/models/thing.ts index 314aae9a2..a5dc6c2a4 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 (typeof 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') && typeof 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 (typeof 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 (typeof 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..e5877132a 100644 --- a/src/models/things.ts +++ b/src/models/things.ts @@ -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()); diff --git a/src/test/integration/oauth-test.ts b/src/test/integration/oauth-test.ts index b25a8e3d3..a86f1aed5 100644 --- a/src/test/integration/oauth-test.ts +++ b/src/test/integration/oauth-test.ts @@ -162,27 +162,6 @@ describe('oauth/', function () { customCallbackHandler = customCallbackHandlerProvided || null; } - it('serves OAuth metadata', async () => { - const res = await chai - .request(server) - .keepOpen() - .get('/.well-known/oauth-authorization-server') - .set('Accept', 'application/json'); - expect(res.status).toEqual(200); - expect(res.body).toHaveProperty('issuer'); - expect(res.body).toHaveProperty('authorization_endpoint'); - expect(res.body.authorization_endpoint).toEqual(expect.stringContaining('authorize')); - expect(res.body).toHaveProperty('token_endpoint'); - expect(res.body.token_endpoint).toEqual(expect.stringContaining('token')); - expect(res.body).toHaveProperty('response_types_supported'); - expect(res.body.response_types_supported.length).toEqual(1); - expect(res.body.response_types_supported[0]).toEqual('code'); - expect(res.body).toHaveProperty('scopes_supported'); - expect(res.body.scopes_supported.length).toEqual(2); - expect(res.body.scopes_supported).toContain('/things'); - expect(res.body.scopes_supported).toContain('/things:readwrite'); - }); - it('rejects request with no JWT', async () => { setupOAuth(); diff --git a/src/test/integration/root-test.ts b/src/test/integration/root-test.ts new file mode 100644 index 000000000..b67a99f11 --- /dev/null +++ b/src/test/integration/root-test.ts @@ -0,0 +1,18 @@ +import { server, chai } from '../common'; + +describe('/', function () { + it('Serves a Thing Description for the gateway', async () => { + const res = await chai.request(server).get('/').set('Accept', 'application/td+json'); + expect(res.status).toEqual(200); + expect(res.header['content-type']).toEqual(expect.stringContaining('application/td+json')); + const td = res.body; + expect(td['@context']).toContain('https://www.w3.org/2022/wot/discovery'); + expect(td['@type']).toContain('ThingDirectory'); + }); + + it('Serves the HTML web interface', async () => { + const res = await chai.request(server).get('/').set('Accept', 'text/html'); + expect(res.status).toEqual(200); + expect(res.header['content-type']).toEqual(expect.stringContaining('text/html')); + }); +}); diff --git a/src/test/integration/well-known-test.ts b/src/test/integration/well-known-test.ts new file mode 100644 index 000000000..1509cb573 --- /dev/null +++ b/src/test/integration/well-known-test.ts @@ -0,0 +1,37 @@ +import { server, chai } from '../common'; + +describe('/.well-known', function () { + it('Serves OAuth metadata at a well-known URL', async () => { + const res = await chai + .request(server) + .keepOpen() + .get('/.well-known/oauth-authorization-server') + .set('Accept', 'application/json'); + expect(res.status).toEqual(200); + expect(res.body).toHaveProperty('issuer'); + expect(res.body).toHaveProperty('authorization_endpoint'); + expect(res.body.authorization_endpoint).toEqual(expect.stringContaining('authorize')); + expect(res.body).toHaveProperty('token_endpoint'); + expect(res.body.token_endpoint).toEqual(expect.stringContaining('token')); + expect(res.body).toHaveProperty('response_types_supported'); + expect(res.body.response_types_supported.length).toEqual(1); + expect(res.body.response_types_supported[0]).toEqual('code'); + expect(res.body).toHaveProperty('scopes_supported'); + expect(res.body.scopes_supported.length).toEqual(2); + expect(res.body.scopes_supported).toContain('/things'); + expect(res.body.scopes_supported).toContain('/things:readwrite'); + }); + + it('Serves a Thing Description for the gateway at a well-known URL', async () => { + const res = await chai + .request(server) + .get('/.well-known/wot') + .set('Accept', 'application/td+json'); + expect(res.status).toEqual(200); + expect(res.header['content-type']).toEqual(expect.stringContaining('application/td+json')); + const td = res.body; + expect(td['@context']).toContain('https://www.w3.org/2022/wot/discovery'); + expect(td['@type']).toContain('ThingDirectory'); + expect(td.links[0].rel).toEqual('canonical'); + }); +});