From 8552953c0d4f1321ccf97d442dd5ecd700c0b7f9 Mon Sep 17 00:00:00 2001 From: reluc Date: Tue, 27 Jun 2023 18:42:24 +0200 Subject: [PATCH 1/3] refactor(binding-http/server): introduce routes for each TD endpoint **Note**: that we are now use find-my-way for routing, which is a bit more complex than the previous approach. However, it is also more flexible and in the future we can easily add more routes or let user configure them. Another caveat is the trailing slashes in the routes now map to a total different path. For example, `/things` and `/things/` are now different routes. In the future, we might review this behavior. --- package-lock.json | 43 ++ packages/binding-http/package.json | 1 + packages/binding-http/src/http-server.ts | 600 ++---------------- packages/binding-http/src/routes/action.ts | 99 +++ packages/binding-http/src/routes/common.ts | 81 +++ packages/binding-http/src/routes/event.ts | 108 ++++ .../binding-http/src/routes/properties.ts | 79 +++ .../src/routes/property-observe.ts | 98 +++ packages/binding-http/src/routes/property.ts | 112 ++++ .../src/routes/thing-description.ts | 177 ++++++ packages/binding-http/src/routes/things.ts | 45 ++ .../test/http-server-oauth-tests.ts | 6 +- .../binding-http/test/http-server-test.ts | 4 +- 13 files changed, 898 insertions(+), 555 deletions(-) create mode 100644 packages/binding-http/src/routes/action.ts create mode 100644 packages/binding-http/src/routes/common.ts create mode 100644 packages/binding-http/src/routes/event.ts create mode 100644 packages/binding-http/src/routes/properties.ts create mode 100644 packages/binding-http/src/routes/property-observe.ts create mode 100644 packages/binding-http/src/routes/property.ts create mode 100644 packages/binding-http/src/routes/thing-description.ts create mode 100644 packages/binding-http/src/routes/things.ts diff --git a/package-lock.json b/package-lock.json index a3e19d097..d1b9a10b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5087,6 +5087,11 @@ "node": ">=0.4.0" } }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5120,6 +5125,14 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -5246,6 +5259,19 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, + "node_modules/find-my-way": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-7.6.2.tgz", + "integrity": "sha512-0OjHn1b1nCX3eVbm9ByeEHiscPYiHLfhei1wOUU9qffQkk98wE0Lo8VrVYfSGMgnSnDh86DxedduAnBf4nwUEw==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^2.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -10082,6 +10108,14 @@ "node": ">=4" } }, + "node_modules/ret": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", + "engines": { + "node": ">=4" + } + }, "node_modules/retimer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/retimer/-/retimer-3.0.0.tgz", @@ -10178,6 +10212,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-regex2": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-2.0.0.tgz", + "integrity": "sha512-PaUSFsUaNNuKwkBijoAPHAK6/eM6VirvyPWlZ7BAQy4D+hCvh4B6lIG+nPdhbFfIbP+gTGBcrdsOaUs0F+ZBOQ==", + "dependencies": { + "ret": "~0.2.0" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -12676,6 +12718,7 @@ "basic-auth": "2.0.1", "client-oauth2": "^4.2.5", "eventsource": "^2.0.2", + "find-my-way": "^7.6.2", "node-fetch": "^2.6.7", "query-string": "^7.1.1", "rxjs": "5.5.11", diff --git a/packages/binding-http/package.json b/packages/binding-http/package.json index b70038014..686ef5c48 100644 --- a/packages/binding-http/package.json +++ b/packages/binding-http/package.json @@ -60,6 +60,7 @@ "basic-auth": "2.0.1", "client-oauth2": "^4.2.5", "eventsource": "^2.0.2", + "find-my-way": "^7.6.2", "node-fetch": "^2.6.7", "query-string": "^7.1.1", "rxjs": "5.5.11", diff --git a/packages/binding-http/src/http-server.ts b/packages/binding-http/src/http-server.ts index 571853c9c..ebfa8d4ed 100644 --- a/packages/binding-http/src/http-server.ts +++ b/packages/binding-http/src/http-server.ts @@ -32,18 +32,22 @@ import Servient, { Helpers, ExposedThing, ProtocolHelpers, - PropertyContentMap, - Content, createLoggers, } from "@node-wot/core"; import { HttpConfig, HttpForm, OAuth2ServerConfig } from "./http"; import createValidator, { Validator } from "./oauth-token-validation"; import { OAuth2SecurityScheme } from "@node-wot/td-tools"; import slugify from "slugify"; -import { ThingDescription } from "wot-typescript-definitions"; -import * as acceptLanguageParser from "accept-language-parser"; import { ActionElement, EventElement, PropertyElement } from "wot-thing-description-types"; import { MiddlewareRequestHandler } from "./http-server-middleware"; +import Router from "find-my-way"; +import thingsRoute from "./routes/things"; +import thingDescriptionRoute from "./routes/thing-description"; +import propertyRoute from "./routes/property"; +import actionRoute from "./routes/action"; +import eventRoute from "./routes/event"; +import propertiesRoute from "./routes/properties"; +import propertyObserveRoute from "./routes/property-observe"; const { debug, info, warn, error } = createLoggers("binding-http", "http-server"); @@ -70,6 +74,7 @@ export default class HttpServer implements ProtocolServer { private readonly things: Map = new Map(); private servient: Servient = null; private oAuthValidator: Validator; + private router: Router.Instance; constructor(config: HttpConfig = {}) { if (typeof config !== "object") { @@ -104,6 +109,41 @@ export default class HttpServer implements ProtocolServer { this.middleware = config.middleware; } + const router = Router({ + defaultRoute(req, res) { + // url-rewrite feature in use ? + const pathname = req.url; + if (config.urlRewrite) { + const entryUrl = pathname; + const internalUrl = config.urlRewrite[entryUrl]; + if (internalUrl) { + req.url = internalUrl; + router.lookup(req, res, this); + debug("[binding-http]", `URL "${entryUrl}" has been rewritten to "${pathname}"`); + return; + } + } + + // No url-rewrite mapping found -> resource not found + res.writeHead(404); + res.end("Not Found"); + }, + }); + + this.router = router; + + this.router.get("/", thingsRoute); + this.router.get("/:thing", thingDescriptionRoute); + this.router.on(["GET", "HEAD", "OPTIONS"], "/:thing/" + this.PROPERTY_DIR, propertiesRoute); + this.router.on(["GET", "PUT", "HEAD", "OPTIONS"], "/:thing/" + this.PROPERTY_DIR + "/:property", propertyRoute); + this.router.on( + ["GET", "HEAD", "OPTIONS"], + "/:thing/" + this.PROPERTY_DIR + "/:property/" + this.OBSERVABLE_DIR, + propertyObserveRoute + ); + this.router.on(["POST", "OPTIONS"], "/:thing/" + this.ACTION_DIR + "/:action", actionRoute); + this.router.on(["GET", "HEAD", "OPTIONS"], "/:thing/" + this.EVENT_DIR + "/:event", eventRoute); + // TLS if (config.serverKey && config.serverCert) { const options: https.ServerOptions = {}; @@ -209,6 +249,10 @@ export default class HttpServer implements ProtocolServer { return this.server; } + public getThings(): Map { + return this.things; + } + /** returns server port number and indicates that server is running when larger than -1 */ public getPort(): number { if (this.server.address() && typeof this.server.address() === "object") { @@ -455,7 +499,7 @@ export default class HttpServer implements ProtocolServer { } } - private async checkCredentials(thing: ExposedThing, req: http.IncomingMessage): Promise { + public async checkCredentials(thing: ExposedThing, req: http.IncomingMessage): Promise { debug(`HttpServer on port ${this.getPort()} checking credentials for '${thing.id}'`); const creds = this.servient.getCredentials(thing.id); @@ -538,88 +582,6 @@ export default class HttpServer implements ProtocolServer { } } - /** - * Look for language negotiation through the Accept-Language header field of HTTP (e.g., "de", "de-CH", "en-US,en;q=0.5") - * Note: "title" on thing level is mandatory term --> check whether "titles" exists for multi-languages - * Note: HTTP header names are case-insensitive and req.headers seems to contain them in lowercase - * - * - * @param td - * @param thing - * @param req - */ - private negotiateLanguage(td: ThingDescription, thing: ExposedThing, req: http.IncomingMessage) { - if (req.headers["accept-language"] && req.headers["accept-language"] !== "*") { - if (thing.titles) { - const supportedLanguages = Object.keys(thing.titles); // e.g., ['fr', 'en'] - - // the loose option allows partial matching on supported languages (e.g., returns "de" for "de-CH") - const prefLang = acceptLanguageParser.pick(supportedLanguages, req.headers["accept-language"], { - loose: true, - }); - - if (prefLang) { - // if a preferred language can be found use it - debug( - `TD language negotiation through the Accept-Language header field of HTTP leads to "${prefLang}"` - ); - this.resetMultiLangThing(td, prefLang); - } - } - } - } - - private async handleTdRequest(thing: ExposedThing, req: http.IncomingMessage, res: http.ServerResponse) { - const td = thing.getThingDescription(); - const contentSerdes = ContentSerdes.get(); - - // TODO: Parameters need to be considered here as well - const acceptValues = req.headers.accept?.split(",").map((acceptValue) => acceptValue.split(";")[0]) ?? [ - ContentSerdes.TD, - ]; - - // TODO: Better handling of wildcard values - const filteredAcceptValues = acceptValues - .map((acceptValue) => { - if (acceptValue === "*/*") { - return ContentSerdes.TD; - } - - return acceptValue; - }) - .filter((acceptValue) => contentSerdes.isSupported(acceptValue)) - .sort((a, b) => { - // weight function last places weight more than first: application/td+json > application/json > text/html - const aWeight = ["text/html", "application/json", "application/td+json"].findIndex( - (value) => value === a - ); - const bWeight = ["text/html", "application/json", "application/td+json"].findIndex( - (value) => value === b - ); - - return bWeight - aWeight; - }); - - if (filteredAcceptValues.length > 0) { - const contentType = filteredAcceptValues[0]; - - const content = contentSerdes.valueToContent(thing.getThingDescription(), undefined, contentType); - const payload = await content.toBuffer(); - - this.negotiateLanguage(td, thing, req); - res.setHeader("Content-Type", contentType); - res.writeHead(200); - debug(`Sending HTTP response for TD with Content-Type ${contentType}.`); - res.end(payload); - return; - } - - debug(`Request contained an accept header with the values ${acceptValues}, none of which are supported.`); - - res.writeHead(406); - res.end(`Accept header contained no Content-Types supported by this resource. (Was ${acceptValues})`); - } - private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse) { // eslint-disable-next-line node/no-deprecated-api const requestUri = url.parse(req.url); @@ -637,39 +599,6 @@ export default class HttpServer implements ProtocolServer { ); }); - // Handle requests where the path is correct and the HTTP method is not allowed. - function respondUnallowedMethod( - res: http.ServerResponse, - allowed: string, - corsPreflightWithCredentials = false - ): void { - // Always allow OPTIONS to handle CORS pre-flight requests - if (!allowed.includes("OPTIONS")) { - allowed += ", OPTIONS"; - } - if (req.method === "OPTIONS" && req.headers.origin && req.headers["access-control-request-method"]) { - debug( - `HttpServer received an CORS preflight request from ${Helpers.toUriLiteral( - req.socket.remoteAddress - )}:${req.socket.remotePort}` - ); - if (corsPreflightWithCredentials) { - res.setHeader("Access-Control-Allow-Origin", req.headers.origin); - res.setHeader("Access-Control-Allow-Credentials", "true"); - } else { - res.setHeader("Access-Control-Allow-Origin", "*"); - } - res.setHeader("Access-Control-Allow-Methods", allowed); - res.setHeader("Access-Control-Allow-Headers", "content-type, authorization, *"); - res.writeHead(200); - res.end(); - } else { - res.setHeader("Allow", allowed); - res.writeHead(405); - res.end("Method Not Allowed"); - } - } - // Set CORS headers if (this.httpSecurityScheme !== "NoSec" && req.headers.origin) { res.setHeader("Access-Control-Allow-Origin", req.headers.origin); @@ -700,435 +629,6 @@ export default class HttpServer implements ProtocolServer { } } - // url-rewrite feature in use ? - let pathname = requestUri.pathname; - if (this.urlRewrite) { - const entryUrl = pathname; - const internalUrl = this.urlRewrite[entryUrl]; - if (internalUrl) { - pathname = internalUrl; - debug("[binding-http]", `URL "${entryUrl}" has been rewritten to "${pathname}"`); - } - } - - // route request - let segments: string[]; - try { - segments = decodeURI(pathname).split("/"); - } catch (ex) { - // catch URIError, see https://github.com/eclipse-thingweb/node-wot/issues/389 - warn(`HttpServer on port ${this.getPort()} cannot decode URI for '${requestUri.pathname}'`); - res.writeHead(400); - res.end("decodeURI error for " + requestUri.pathname); - return; - } - - if (segments[1] === "") { - // no path -> list all Things - if (req.method === "GET") { - res.setHeader("Content-Type", ContentSerdes.DEFAULT); - res.writeHead(200); - const list = []; - for (const address of Helpers.getAddresses()) { - // FIXME are Iterables really such a non-feature that I need array? - for (const name of Array.from(this.things.keys())) { - // FIXME the undefined check should NOT be necessary (however there seems to be null in it) - if (name) { - list.push( - this.scheme + - "://" + - Helpers.toUriLiteral(address) + - ":" + - this.getPort() + - "/" + - encodeURIComponent(name) - ); - } - } - } - res.end(JSON.stringify(list)); - } else { - respondUnallowedMethod(res, "GET"); - } - // resource found and response sent - return; - } else { - // path -> select Thing - const thing: ExposedThing = this.things.get(segments[1]); - if (thing) { - if (segments.length === 2 || segments[2] === "") { - // Thing root -> send TD - if (req.method === "GET") { - this.handleTdRequest(thing, req, res); - } else { - respondUnallowedMethod(res, "GET"); - } - // resource found and response sent - return; - } else { - let corsPreflightWithCredentials = false; - // Thing Interaction - Access Control - if (this.httpSecurityScheme !== "NoSec" && !(await this.checkCredentials(thing, req))) { - if (req.method === "OPTIONS" && req.headers.origin) { - corsPreflightWithCredentials = true; - } else { - res.setHeader("WWW-Authenticate", `${this.httpSecurityScheme} realm="${thing.id}"`); - res.writeHead(401); - res.end(); - return; - } - } - - if (segments[2] === this.PROPERTY_DIR) { - if (segments.length === 3) { - // all properties - if (req.method === "GET") { - try { - const propMap: PropertyContentMap = await thing.handleReadAllProperties({ - formIndex: 0, - }); - res.setHeader("Content-Type", ContentSerdes.DEFAULT); // contentType handling? - res.writeHead(200); - const recordResponse: Record = {}; - for (const key of propMap.keys()) { - const content: Content = propMap.get(key); - const value = ContentSerdes.get().contentToValue( - { type: ContentSerdes.DEFAULT, body: await content.toBuffer() }, - {} - ); - recordResponse[key] = value; - } - res.end(JSON.stringify(recordResponse)); - } catch (err) { - error( - `HttpServer on port ${this.getPort()} got internal error on invoke '${ - requestUri.pathname - }': ${err.message}` - ); - res.writeHead(500); - res.end(err.message); - } - } else if (req.method === "HEAD") { - res.writeHead(202); - res.end(); - } else { - // may have been OPTIONS that failed the credentials check - // as a result, we pass corsPreflightWithCredentials - respondUnallowedMethod(res, "GET", corsPreflightWithCredentials); - } - // resource found and response sent - return; - } else { - // sub-path -> select Property - const property = thing.properties[segments[3]]; - if (property) { - const options: WoT.InteractionOptions & { formIndex: number } = { - formIndex: ProtocolHelpers.findRequestMatchingFormIndex( - property.forms, - this.scheme, - req.url, - contentType - ), - }; - const uriVariables = Helpers.parseUrlParameters( - req.url, - thing.uriVariables, - property.uriVariables - ); - if (!this.isEmpty(uriVariables)) { - options.uriVariables = uriVariables; - } - - if (req.method === "GET") { - // check if this an observable request (longpoll) - if (segments[4] === this.OBSERVABLE_DIR) { - const listener = async (value: Content) => { - try { - // send property data - value.body.pipe(res); - } catch (err) { - if (err?.code === "ERR_HTTP_HEADERS_SENT") { - thing.handleUnobserveProperty(segments[3], listener, options); - return; - } - warn( - `HttpServer on port ${this.getPort()} cannot process data for Property '${ - segments[3] - }: ${err.message}'` - ); - res.writeHead(500); - res.end("Invalid Property Data"); - } - }; - - await thing.handleObserveProperty(segments[3], listener, options); - - res.on("finish", () => { - debug(`HttpServer on port ${this.getPort()} closed connection`); - thing.handleUnobserveProperty(segments[3], listener, options); - }); - res.setTimeout(60 * 60 * 1000, () => - thing.handleUnobserveProperty(segments[3], listener, options) - ); - return; - } else { - try { - const content = await thing.handleReadProperty(segments[3], options); - res.setHeader("Content-Type", content.type); - res.writeHead(200); - content.body.pipe(res); - } catch (err) { - error( - `HttpServer on port ${this.getPort()} got internal error on read '${ - requestUri.pathname - }': ${err.message}` - ); - res.writeHead(500); - res.end(err.message); - } - return; - } - } else if (req.method === "PUT") { - if (!property.readOnly) { - try { - await thing.handleWriteProperty( - segments[3], - new Content(contentType, req), - options - ); - res.writeHead(204); - res.end("Changed"); - } catch (err) { - error( - `HttpServer on port ${this.getPort()} got internal error on invoke '${ - requestUri.pathname - }': ${err.message}` - ); - res.writeHead(500); - res.end(err.message); - } - } else { - respondUnallowedMethod(res, "GET, PUT"); - } - // resource found and response sent - return; - } else if (req.method === "HEAD") { - // HEAD support for long polling subscription - res.writeHead(202); - res.end(); - return; - } else { - // may have been OPTIONS that failed the credentials check - // as a result, we pass corsPreflightWithCredentials - respondUnallowedMethod(res, "GET, PUT", corsPreflightWithCredentials); - return; - } // Property exists? - } - } - } else if (segments[2] === this.ACTION_DIR) { - // sub-path -> select Action - const action = thing.actions[segments[3]]; - if (action) { - if (req.method === "POST") { - const options: WoT.InteractionOptions & { formIndex: number } = { - formIndex: ProtocolHelpers.findRequestMatchingFormIndex( - action.forms, - this.scheme, - req.url, - contentType - ), - }; - const uriVariables = Helpers.parseUrlParameters( - req.url, - thing.uriVariables, - action.uriVariables - ); - if (!this.isEmpty(uriVariables)) { - options.uriVariables = uriVariables; - } - try { - const output = await thing.handleInvokeAction( - segments[3], - new Content(contentType, req), - options - ); - if (output) { - res.setHeader("Content-Type", output.type); - res.writeHead(200); - output.body.pipe(res); - } else { - res.writeHead(200); - res.end(); - } - } catch (err) { - error( - `HttpServer on port ${this.getPort()} got internal error on invoke '${ - requestUri.pathname - }': ${err.message}` - ); - res.writeHead(500); - res.end(err.message); - } - } else { - // may have been OPTIONS that failed the credentials check - // as a result, we pass corsPreflightWithCredentials - respondUnallowedMethod(res, "POST", corsPreflightWithCredentials); - } - // resource found and response sent - return; - } // Action exists? - } else if (segments[2] === this.EVENT_DIR) { - // sub-path -> select Event - const event = thing.events[segments[3]]; - if (event) { - if (req.method === "GET") { - const options: WoT.InteractionOptions & { formIndex: number } = { - formIndex: ProtocolHelpers.findRequestMatchingFormIndex( - event.forms, - this.scheme, - req.url, - contentType - ), - }; - const uriVariables = Helpers.parseUrlParameters( - req.url, - thing.uriVariables, - event.uriVariables - ); - if (!this.isEmpty(uriVariables)) { - options.uriVariables = uriVariables; - } - - const listener = async (value: Content) => { - try { - // send event data - if (!res.headersSent) { - // We are polite and use the same request as long as the client - // does not close the connection (or we hit the timeout; see below). - // Therefore we are sending the headers - // only if we didn't have sent them before. - res.setHeader("Content-Type", value.type); - res.writeHead(200); - } - value.body.pipe(res); - } catch (err) { - if (err?.code === "ERR_HTTP_HEADERS_SENT") { - thing.handleUnsubscribeEvent(segments[3], listener, options); - return; - } - warn( - `HttpServer on port ${this.getPort()} cannot process data for Event '${ - segments[3] - }: ${err.message}'` - ); - res.writeHead(500); - res.end("Invalid Event Data"); - } - }; - - await thing.handleSubscribeEvent(segments[3], listener, options); - res.on("close", () => { - debug(`HttpServer on port ${this.getPort()} closed Event connection`); - thing.handleUnsubscribeEvent(segments[3], listener, options); - }); - res.setTimeout(60 * 60 * 1000, () => - thing.handleUnsubscribeEvent(segments[3], listener, options) - ); - } else if (req.method === "HEAD") { - // HEAD support for long polling subscription - res.writeHead(202); - res.end(); - } else { - // may have been OPTIONS that failed the credentials check - // as a result, we pass corsPreflightWithCredentials - respondUnallowedMethod(res, "GET", corsPreflightWithCredentials); - } - // resource found and response sent - return; - } // Event exists? - } - } // Interaction? - } // Thing exists? - } - - // resource not found - res.writeHead(404); - res.end("Not Found"); - } - - private isEmpty(obj: Record): boolean { - for (const key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key)) return false; - } - return true; - } - - private resetMultiLangThing(thing: ThingDescription, prefLang: string) { - // TODO can we reset "title" to another name given that title is used in URI creation? - - // set @language in @context - TD.setContextLanguage(thing, prefLang, true); - - // use new language title - if (thing.titles) { - for (const titleLang in thing.titles) { - if (titleLang.startsWith(prefLang)) { - thing.title = thing.titles[titleLang]; - } - } - } - - // use new language description - if (thing.descriptions) { - for (const titleLang in thing.descriptions) { - if (titleLang.startsWith(prefLang)) { - thing.description = thing.descriptions[titleLang]; - } - } - } - - // remove any titles or descriptions and update title / description accordingly - delete thing.titles; - delete thing.descriptions; - - // reset multi-language terms for interactions - this.resetMultiLangInteraction(thing.properties, prefLang); - this.resetMultiLangInteraction(thing.actions, prefLang); - this.resetMultiLangInteraction(thing.events, prefLang); - } - - private resetMultiLangInteraction( - interactions: ThingDescription["properties"] | ThingDescription["actions"] | ThingDescription["events"], - prefLang: string - ) { - if (interactions) { - for (const interName in interactions) { - // unset any current title and/or description - delete interactions[interName].title; - delete interactions[interName].description; - - // use new language title - if (interactions[interName].titles) { - for (const titleLang in interactions[interName].titles) { - if (titleLang.startsWith(prefLang)) { - interactions[interName].title = interactions[interName].titles[titleLang]; - } - } - } - - // use new language description - if (interactions[interName].descriptions) { - for (const descLang in interactions[interName].descriptions) { - if (descLang.startsWith(prefLang)) { - interactions[interName].description = interactions[interName].descriptions[descLang]; - } - } - } - - // unset any multilanguage titles and/or descriptions - delete interactions[interName].titles; - delete interactions[interName].descriptions; - } - } + this.router.lookup(req, res, this); } } diff --git a/packages/binding-http/src/routes/action.ts b/packages/binding-http/src/routes/action.ts new file mode 100644 index 000000000..4cad36d9b --- /dev/null +++ b/packages/binding-http/src/routes/action.ts @@ -0,0 +1,99 @@ +/******************************************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { IncomingMessage, ServerResponse } from "http"; +import { Content, Helpers, ProtocolHelpers, createLoggers } from "@node-wot/core"; +import { isEmpty, respondUnallowedMethod, validOrDefaultRequestContentType } from "./common"; +import HttpServer from "../http-server"; + +const { error, warn } = createLoggers("binding-http", "routes", "action"); + +export default async function actionRoute( + this: HttpServer, + req: IncomingMessage, + res: ServerResponse, + _params: { thing: string; action: string } +): Promise { + const thing = this.getThings().get(_params.thing); + + if (!thing) { + res.writeHead(404); + res.end(); + return; + } + + const contentTypeHeader: string | string[] = req.headers["content-type"]; + let contentType: string = Array.isArray(contentTypeHeader) ? contentTypeHeader[0] : contentTypeHeader; + try { + contentType = validOrDefaultRequestContentType(req, res, contentType); + } catch (error) { + warn( + `HttpServer received unsupported Content-Type from ${Helpers.toUriLiteral(req.socket.remoteAddress)}:${ + req.socket.remotePort + }` + ); + res.writeHead(415); + res.end("Unsupported Media Type"); + return; + } + + const action = thing.actions[_params.action]; + + if (!action) { + res.writeHead(404); + res.end(); + return; + } + // TODO: refactor this part to move into a common place + let corsPreflightWithCredentials = false; + if (this.getHttpSecurityScheme() !== "NoSec" && !(await this.checkCredentials(thing, req))) { + if (req.method === "OPTIONS" && req.headers.origin) { + corsPreflightWithCredentials = true; + } else { + res.setHeader("WWW-Authenticate", `${this.getHttpSecurityScheme()} realm="${thing.id}"`); + res.writeHead(401); + res.end(); + return; + } + } + + if (req.method === "POST") { + const options: WoT.InteractionOptions & { formIndex: number } = { + formIndex: ProtocolHelpers.findRequestMatchingFormIndex(action.forms, this.scheme, req.url, contentType), + }; + const uriVariables = Helpers.parseUrlParameters(req.url, thing.uriVariables, action.uriVariables); + if (!isEmpty(uriVariables)) { + options.uriVariables = uriVariables; + } + try { + const output = await thing.handleInvokeAction(_params.action, new Content(contentType, req), options); + if (output) { + res.setHeader("Content-Type", output.type); + res.writeHead(200); + output.body.pipe(res); + } else { + res.writeHead(200); + res.end(); + } + } catch (err) { + error(`HttpServer on port ${this.getPort()} got internal error on invoke '${req.url}': ${err.message}`); + res.writeHead(500); + res.end(err.message); + } + } else { + // may have been OPTIONS that failed the credentials check + // as a result, we pass corsPreflightWithCredentials + respondUnallowedMethod(req, res, "POST", corsPreflightWithCredentials); + } +} diff --git a/packages/binding-http/src/routes/common.ts b/packages/binding-http/src/routes/common.ts new file mode 100644 index 000000000..d7012347c --- /dev/null +++ b/packages/binding-http/src/routes/common.ts @@ -0,0 +1,81 @@ +/******************************************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { ContentSerdes, Helpers, createLoggers } from "@node-wot/core"; +import { IncomingMessage, ServerResponse } from "http"; + +const { debug, warn } = createLoggers("binding-http", "routes", "common"); + +export function respondUnallowedMethod( + req: IncomingMessage, + res: ServerResponse, + allowed: string, + corsPreflightWithCredentials = false +): void { + // Always allow OPTIONS to handle CORS pre-flight requests + if (!allowed.includes("OPTIONS")) { + allowed += ", OPTIONS"; + } + if (req.method === "OPTIONS" && req.headers.origin && req.headers["access-control-request-method"]) { + debug( + `HttpServer received an CORS preflight request from ${Helpers.toUriLiteral(req.socket.remoteAddress)}:${ + req.socket.remotePort + }` + ); + if (corsPreflightWithCredentials) { + res.setHeader("Access-Control-Allow-Origin", req.headers.origin); + res.setHeader("Access-Control-Allow-Credentials", "true"); + } else { + res.setHeader("Access-Control-Allow-Origin", "*"); + } + res.setHeader("Access-Control-Allow-Methods", allowed); + res.setHeader("Access-Control-Allow-Headers", "content-type, authorization, *"); + res.writeHead(200); + res.end(); + } else { + res.setHeader("Allow", allowed); + res.writeHead(405); + res.end("Method Not Allowed"); + } +} + +export function validOrDefaultRequestContentType( + req: IncomingMessage, + res: ServerResponse, + contentType: string +): string { + if (req.method === "PUT" || req.method === "POST") { + if (!contentType) { + // FIXME should be rejected with 400 Bad Request, as guessing is not good in M2M -> debug/testing flag to allow + // FIXME would need to check if payload is present + warn( + `HttpServer received no Content-Type from ${Helpers.toUriLiteral(req.socket.remoteAddress)}:${ + req.socket.remotePort + }` + ); + return ContentSerdes.DEFAULT; + } else if (ContentSerdes.get().getSupportedMediaTypes().indexOf(ContentSerdes.getMediaType(contentType)) < 0) { + throw new Error("Unsupported Media Type"); + } + return contentType; + } + return contentType; +} + +export function isEmpty(obj: Record): boolean { + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) return false; + } + return true; +} diff --git a/packages/binding-http/src/routes/event.ts b/packages/binding-http/src/routes/event.ts new file mode 100644 index 000000000..4f1c43d55 --- /dev/null +++ b/packages/binding-http/src/routes/event.ts @@ -0,0 +1,108 @@ +/******************************************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { IncomingMessage, ServerResponse } from "http"; +import { Content, Helpers, ProtocolHelpers, createLoggers } from "@node-wot/core"; +import { isEmpty, respondUnallowedMethod } from "./common"; +import HttpServer from "../http-server"; + +const { warn, debug } = createLoggers("binding-http", "routes", "event"); +export default async function eventRoute( + this: HttpServer, + req: IncomingMessage, + res: ServerResponse, + _params: { thing: string; event: string } +): Promise { + const thing = this.getThings().get(_params.thing); + + if (!thing) { + res.writeHead(404); + res.end(); + return; + } + + const contentTypeHeader: string | string[] = req.headers["content-type"]; + const contentType: string = Array.isArray(contentTypeHeader) ? contentTypeHeader[0] : contentTypeHeader; + + const event = thing.events[_params.event]; + if (!event) { + res.writeHead(404); + res.end(); + return; + } + // TODO: refactor this part to move into a common place + let corsPreflightWithCredentials = false; + if (this.getHttpSecurityScheme() !== "NoSec" && !(await this.checkCredentials(thing, req))) { + if (req.method === "OPTIONS" && req.headers.origin) { + corsPreflightWithCredentials = true; + } else { + res.setHeader("WWW-Authenticate", `${this.getHttpSecurityScheme()} realm="${thing.id}"`); + res.writeHead(401); + res.end(); + return; + } + } + + if (req.method === "GET") { + const options: WoT.InteractionOptions & { formIndex: number } = { + formIndex: ProtocolHelpers.findRequestMatchingFormIndex(event.forms, this.scheme, req.url, contentType), + }; + const uriVariables = Helpers.parseUrlParameters(req.url, thing.uriVariables, event.uriVariables); + if (!isEmpty(uriVariables)) { + options.uriVariables = uriVariables; + } + const listener = async (value: Content) => { + try { + // send event data + if (!res.headersSent) { + // We are polite and use the same request as long as the client + // does not close the connection (or we hit the timeout; see below). + // Therefore we are sending the headers + // only if we didn't have sent them before. + res.setHeader("Content-Type", value.type); + res.writeHead(200); + } + value.body.pipe(res); + } catch (err) { + if (err?.code === "ERR_HTTP_HEADERS_SENT") { + thing.handleUnsubscribeEvent(_params.event, listener, options); + return; + } + warn( + `HttpServer on port ${this.getPort()} cannot process data for Event '${_params.event}: ${ + err.message + }'` + ); + res.writeHead(500); + res.end("Invalid Event Data"); + } + }; + + await thing.handleSubscribeEvent(_params.event, listener, options); + res.on("close", () => { + debug(`HttpServer on port ${this.getPort()} closed Event connection`); + thing.handleUnsubscribeEvent(_params.event, listener, options); + }); + res.setTimeout(60 * 60 * 1000, () => thing.handleUnsubscribeEvent(_params.event, listener, options)); + } else if (req.method === "HEAD") { + // HEAD support for long polling subscription + res.writeHead(202); + res.end(); + } else { + // may have been OPTIONS that failed the credentials check + // as a result, we pass corsPreflightWithCredentials + respondUnallowedMethod(req, res, "GET", corsPreflightWithCredentials); + } + // resource found and response sent +} diff --git a/packages/binding-http/src/routes/properties.ts b/packages/binding-http/src/routes/properties.ts new file mode 100644 index 000000000..dd9e5f6c0 --- /dev/null +++ b/packages/binding-http/src/routes/properties.ts @@ -0,0 +1,79 @@ +/******************************************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { IncomingMessage, ServerResponse } from "http"; +import { Content, ContentSerdes, PropertyContentMap, createLoggers } from "@node-wot/core"; +import { respondUnallowedMethod } from "./common"; +import HttpServer from "../http-server"; + +const { error } = createLoggers("binding-http", "routes", "properties"); +export default async function propertiesRoute( + this: HttpServer, + req: IncomingMessage, + res: ServerResponse, + _params: { thing: string } +): Promise { + const thing = this.getThings().get(_params.thing); + + if (!thing) { + res.writeHead(404); + res.end(); + return; + } + + // TODO: refactor this part to move into a common place + let corsPreflightWithCredentials = false; + if (this.getHttpSecurityScheme() !== "NoSec" && !(await this.checkCredentials(thing, req))) { + if (req.method === "OPTIONS" && req.headers.origin) { + corsPreflightWithCredentials = true; + } else { + res.setHeader("WWW-Authenticate", `${this.getHttpSecurityScheme()} realm="${thing.id}"`); + res.writeHead(401); + res.end(); + return; + } + } + + // all properties + if (req.method === "GET") { + try { + const propMap: PropertyContentMap = await thing.handleReadAllProperties({ + formIndex: 0, + }); + res.setHeader("Content-Type", ContentSerdes.DEFAULT); // contentType handling? + res.writeHead(200); + const recordResponse: Record = {}; + for (const key of propMap.keys()) { + const content: Content = propMap.get(key); + const value = ContentSerdes.get().contentToValue( + { type: ContentSerdes.DEFAULT, body: await content.toBuffer() }, + {} + ); + recordResponse[key] = value; + } + res.end(JSON.stringify(recordResponse)); + } catch (err) { + error(`HttpServer on port ${this.getPort()} got internal error on invoke '${req.url}': ${err.message}`); + res.writeHead(500); + res.end(err.message); + } + } else if (req.method === "HEAD") { + res.writeHead(202); + res.end(); + } else { + // may have been OPTIONS that failed the credentials check + // as a result, we pass corsPreflightWithCredentials + respondUnallowedMethod(req, res, "GET", corsPreflightWithCredentials); + } +} diff --git a/packages/binding-http/src/routes/property-observe.ts b/packages/binding-http/src/routes/property-observe.ts new file mode 100644 index 000000000..f31e4eb62 --- /dev/null +++ b/packages/binding-http/src/routes/property-observe.ts @@ -0,0 +1,98 @@ +/******************************************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { IncomingMessage, ServerResponse } from "http"; +import { Content, Helpers, ProtocolHelpers, createLoggers } from "@node-wot/core"; +import { isEmpty, respondUnallowedMethod } from "./common"; +import HttpServer from "../http-server"; + +const { debug, warn } = createLoggers("binding-http", "routes", "property", "observe"); +export default async function propertyObserveRoute( + this: HttpServer, + req: IncomingMessage, + res: ServerResponse, + _params: { thing: string; property: string } +): Promise { + const thing = this.getThings().get(_params.thing); + + if (!thing) { + res.writeHead(404); + res.end(); + return; + } + + const contentTypeHeader: string | string[] = req.headers["content-type"]; + const contentType: string = Array.isArray(contentTypeHeader) ? contentTypeHeader[0] : contentTypeHeader; + const property = thing.properties[_params.property]; + + if (!property) { + res.writeHead(404); + res.end(); + return; + } + + const options: WoT.InteractionOptions & { formIndex: number } = { + formIndex: ProtocolHelpers.findRequestMatchingFormIndex(property.forms, this.scheme, req.url, contentType), + }; + const uriVariables = Helpers.parseUrlParameters(req.url, thing.uriVariables, property.uriVariables); + if (!isEmpty(uriVariables)) { + options.uriVariables = uriVariables; + } + + // TODO: refactor this part to move into a common place + let corsPreflightWithCredentials = false; + if (this.getHttpSecurityScheme() !== "NoSec" && !(await this.checkCredentials(thing, req))) { + if (req.method === "OPTIONS" && req.headers.origin) { + corsPreflightWithCredentials = true; + } else { + res.setHeader("WWW-Authenticate", `${this.getHttpSecurityScheme()} realm="${thing.id}"`); + res.writeHead(401); + res.end(); + return; + } + } + + if (req.method === "GET") { + const listener = async (value: Content) => { + try { + // send property data + value.body.pipe(res); + } catch (err) { + if (err?.code === "ERR_HTTP_HEADERS_SENT") { + thing.handleUnobserveProperty(_params.property, listener, options); + return; + } + warn( + `HttpServer on port ${this.getPort()} cannot process data for Property '${_params.property}: ${ + err.message + }'` + ); + res.writeHead(500); + res.end("Invalid Property Data"); + } + }; + await thing.handleObserveProperty(_params.property, listener, options); + res.on("finish", () => { + debug(`HttpServer on port ${this.getPort()} closed connection`); + thing.handleUnobserveProperty(_params.property, listener, options); + }); + res.setTimeout(60 * 60 * 1000, () => thing.handleUnobserveProperty(_params.property, listener, options)); + } else if (req.method === "HEAD") { + // HEAD support for long polling subscription + res.writeHead(202); + res.end(); + } else { + respondUnallowedMethod(req, res, "GET", corsPreflightWithCredentials); + } +} diff --git a/packages/binding-http/src/routes/property.ts b/packages/binding-http/src/routes/property.ts new file mode 100644 index 000000000..71445a264 --- /dev/null +++ b/packages/binding-http/src/routes/property.ts @@ -0,0 +1,112 @@ +/******************************************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { IncomingMessage, ServerResponse } from "http"; +import { Content, Helpers, ProtocolHelpers, createLoggers } from "@node-wot/core"; +import { isEmpty, respondUnallowedMethod, validOrDefaultRequestContentType } from "./common"; +import HttpServer from "../http-server"; + +const { error, warn } = createLoggers("binding-http", "routes", "property"); +export default async function propertyRoute( + this: HttpServer, + req: IncomingMessage, + res: ServerResponse, + _params: { thing: string; property: string } +): Promise { + const thing = this.getThings().get(_params.thing); + + if (!thing) { + res.writeHead(404); + res.end(); + return; + } + + const contentTypeHeader: string | string[] = req.headers["content-type"]; + let contentType: string = Array.isArray(contentTypeHeader) ? contentTypeHeader[0] : contentTypeHeader; + try { + contentType = validOrDefaultRequestContentType(req, res, contentType); + } catch (error) { + warn( + `HttpServer received unsupported Content-Type from ${Helpers.toUriLiteral(req.socket.remoteAddress)}:${ + req.socket.remotePort + }` + ); + res.writeHead(415); + res.end("Unsupported Media Type"); + return; + } + + const property = thing.properties[_params.property]; + + if (!property) { + res.writeHead(404); + res.end(); + return; + } + + const options: WoT.InteractionOptions & { formIndex: number } = { + formIndex: ProtocolHelpers.findRequestMatchingFormIndex(property.forms, this.scheme, req.url, contentType), + }; + const uriVariables = Helpers.parseUrlParameters(req.url, thing.uriVariables, property.uriVariables); + + if (!isEmpty(uriVariables)) { + options.uriVariables = uriVariables; + } + + // TODO: refactor this part to move into a common place + let corsPreflightWithCredentials = false; + if (this.getHttpSecurityScheme() !== "NoSec" && !(await this.checkCredentials(thing, req))) { + if (req.method === "OPTIONS" && req.headers.origin) { + corsPreflightWithCredentials = true; + } else { + res.setHeader("WWW-Authenticate", `${this.getHttpSecurityScheme()} realm="${thing.id}"`); + res.writeHead(401); + res.end(); + return; + } + } + + if (req.method === "GET") { + try { + const content = await thing.handleReadProperty(_params.property, options); + res.setHeader("Content-Type", content.type); + res.writeHead(200); + content.body.pipe(res); + } catch (err) { + error(`HttpServer on port ${this.getPort()} got internal error on read '${req.url}': ${err.message}`); + res.writeHead(500); + res.end(err.message); + } + } else if (req.method === "PUT") { + if (!property.readOnly) { + try { + await thing.handleWriteProperty(_params.property, new Content(contentType, req), options); + + res.writeHead(204); + res.end("Changed"); + } catch (err) { + error(`HttpServer on port ${this.getPort()} got internal error on invoke '${req.url}': ${err.message}`); + res.writeHead(500); + res.end(err.message); + } + } else { + respondUnallowedMethod(req, res, "GET, PUT"); + } + // resource found and response sent + } else { + // may have been OPTIONS that failed the credentials check + // as a result, we pass corsPreflightWithCredentials + respondUnallowedMethod(req, res, "GET, PUT", corsPreflightWithCredentials); + } // Property exists? +} diff --git a/packages/binding-http/src/routes/thing-description.ts b/packages/binding-http/src/routes/thing-description.ts new file mode 100644 index 000000000..c05e4b133 --- /dev/null +++ b/packages/binding-http/src/routes/thing-description.ts @@ -0,0 +1,177 @@ +/******************************************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { ContentSerdes, createLoggers } from "@node-wot/core"; +import { IncomingMessage, ServerResponse } from "http"; +import { ExposedThing, ThingDescription } from "wot-typescript-definitions"; +import * as acceptLanguageParser from "accept-language-parser"; +import * as TD from "@node-wot/td-tools"; +import HttpServer from "../http-server"; + +const { debug } = createLoggers("binding-http", "routes", "thing-description"); + +function resetMultiLangInteraction( + interactions: ThingDescription["properties"] | ThingDescription["actions"] | ThingDescription["events"], + prefLang: string +) { + if (interactions) { + for (const interName in interactions) { + // unset any current title and/or description + delete interactions[interName].title; + delete interactions[interName].description; + + // use new language title + if (interactions[interName].titles) { + for (const titleLang in interactions[interName].titles) { + if (titleLang.startsWith(prefLang)) { + interactions[interName].title = interactions[interName].titles[titleLang]; + } + } + } + + // use new language description + if (interactions[interName].descriptions) { + for (const descLang in interactions[interName].descriptions) { + if (descLang.startsWith(prefLang)) { + interactions[interName].description = interactions[interName].descriptions[descLang]; + } + } + } + + // unset any multilanguage titles and/or descriptions + delete interactions[interName].titles; + delete interactions[interName].descriptions; + } + } +} + +function resetMultiLangThing(thing: ThingDescription, prefLang: string) { + // TODO can we reset "title" to another name given that title is used in URI creation? + + // set @language in @context + TD.setContextLanguage(thing, prefLang, true); + + // use new language title + if (thing.titles) { + for (const titleLang in thing.titles) { + if (titleLang.startsWith(prefLang)) { + thing.title = thing.titles[titleLang]; + } + } + } + + // use new language description + if (thing.descriptions) { + for (const titleLang in thing.descriptions) { + if (titleLang.startsWith(prefLang)) { + thing.description = thing.descriptions[titleLang]; + } + } + } + + // remove any titles or descriptions and update title / description accordingly + delete thing.titles; + delete thing.descriptions; + + // reset multi-language terms for interactions + resetMultiLangInteraction(thing.properties, prefLang); + resetMultiLangInteraction(thing.actions, prefLang); + resetMultiLangInteraction(thing.events, prefLang); +} + +/** + * Look for language negotiation through the Accept-Language header field of HTTP (e.g., "de", "de-CH", "en-US,en;q=0.5") + * Note: "title" on thing level is mandatory term --> check whether "titles" exists for multi-languages + * Note: HTTP header names are case-insensitive and req.headers seems to contain them in lowercase + * + * + * @param td + * @param thing + * @param req + */ +function negotiateLanguage(td: ThingDescription, thing: ExposedThing, req: IncomingMessage) { + if (req.headers["accept-language"] && req.headers["accept-language"] !== "*") { + if (td.titles) { + const supportedLanguages = Object.keys(td.titles); // e.g., ['fr', 'en'] + // the loose option allows partial matching on supported languages (e.g., returns "de" for "de-CH") + const prefLang = acceptLanguageParser.pick(supportedLanguages, req.headers["accept-language"], { + loose: true, + }); + if (prefLang) { + // if a preferred language can be found use it + debug( + `TD language negotiation through the Accept-Language header field of HTTP leads to "${prefLang}"` + ); + // TODO: reset titles and descriptions to only contain the preferred language + resetMultiLangThing(td, prefLang); + } + } + } +} + +export default async function thingDescriptionRoute( + this: HttpServer, + req: IncomingMessage, + res: ServerResponse, + _params: { thing: string } +): Promise { + const thing = this.getThings().get(_params.thing); + if (!thing) { + res.writeHead(404); + res.end(); + return; + } + + const td = thing.getThingDescription(); + const contentSerdes = ContentSerdes.get(); + + // TODO: Parameters need to be considered here as well + const acceptValues = req.headers.accept?.split(",").map((acceptValue) => acceptValue.split(";")[0]) ?? [ + ContentSerdes.TD, + ]; + // TODO: Better handling of wildcard values + const filteredAcceptValues = acceptValues + .map((acceptValue) => { + if (acceptValue === "*/*") { + return ContentSerdes.TD; + } + return acceptValue; + }) + .filter((acceptValue) => contentSerdes.isSupported(acceptValue)) + .sort((a, b) => { + // weight function last places weight more than first: application/td+json > application/json > text/html + const aWeight = ["text/html", "application/json", "application/td+json"].findIndex((value) => value === a); + const bWeight = ["text/html", "application/json", "application/td+json"].findIndex((value) => value === b); + return bWeight - aWeight; + }); + + if (filteredAcceptValues.length > 0) { + const contentType = filteredAcceptValues[0]; + const content = contentSerdes.valueToContent(td, undefined, contentType); + const payload = await content.toBuffer(); + + negotiateLanguage(td, thing, req); + + res.setHeader("Content-Type", contentType); + res.writeHead(200); + debug(`Sending HTTP response for TD with Content-Type ${contentType}.`); + res.end(payload); + return; + } + + debug(`Request contained an accept header with the values ${acceptValues}, none of which are supported.`); + res.writeHead(406); + res.end(`Accept header contained no Content-Types supported by this resource. (Was ${acceptValues})`); +} diff --git a/packages/binding-http/src/routes/things.ts b/packages/binding-http/src/routes/things.ts new file mode 100644 index 000000000..0a5464e97 --- /dev/null +++ b/packages/binding-http/src/routes/things.ts @@ -0,0 +1,45 @@ +/******************************************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { ContentSerdes, Helpers } from "@node-wot/core"; +import { IncomingMessage, ServerResponse } from "http"; +import HttpServer from "../http-server"; + +export default function thingsRoute( + this: HttpServer, + req: IncomingMessage, + res: ServerResponse, + _params: unknown +): void { + res.setHeader("Content-Type", ContentSerdes.DEFAULT); + res.writeHead(200); + const list = []; + for (const address of Helpers.getAddresses()) { + for (const name in this.getThings()) { + // FIXME the undefined check should NOT be necessary (however there seems to be null in it) + if (name) { + list.push( + this.scheme + + "://" + + Helpers.toUriLiteral(address) + + ":" + + this.getPort() + + "/" + + encodeURIComponent(name) + ); + } + } + } + res.end(JSON.stringify(list)); +} diff --git a/packages/binding-http/test/http-server-oauth-tests.ts b/packages/binding-http/test/http-server-oauth-tests.ts index ecfafdfc7..59adb710f 100644 --- a/packages/binding-http/test/http-server-oauth-tests.ts +++ b/packages/binding-http/test/http-server-oauth-tests.ts @@ -93,7 +93,7 @@ class OAuthServerTests { return true; }; - await fetch("http://localhost:8080/testoauth/TestOAuth"); + await fetch("http://localhost:8080/testoauth/properties/test"); called.should.eql(true); } @@ -107,7 +107,7 @@ class OAuthServerTests { return false; }; - const response = await fetch("http://localhost:8080/testoauth/TestOAuth"); + const response = await fetch("http://localhost:8080/testoauth/properties/test"); called.should.eql(true); @@ -123,7 +123,7 @@ class OAuthServerTests { return false; }; - const response = await fetch("http://localhost:8080/testoauth/TestOAuth"); + const response = await fetch("http://localhost:8080/testoauth/properties/test"); called.should.eql(true); diff --git a/packages/binding-http/test/http-server-test.ts b/packages/binding-http/test/http-server-test.ts index 589a49e4c..ff69c9f66 100644 --- a/packages/binding-http/test/http-server-test.ts +++ b/packages/binding-http/test/http-server-test.ts @@ -779,7 +779,7 @@ class HttpServerTest { await httpServer.expose(testThing); - const uri = `http://localhost:${httpServer.getPort()}/test/`; + const uri = `http://localhost:${httpServer.getPort()}/test`; const testCases = [ { @@ -853,7 +853,7 @@ class HttpServerTest { await httpServer.expose(testThing); - const uri = `http://localhost:${httpServer.getPort()}/test/`; + const uri = `http://localhost:${httpServer.getPort()}/test`; const failedNegotiationResponse = await fetch(uri, { headers: { From 83f1d5b5a8b18fe8e7adb26c73ab6ae88c4860fb Mon Sep 17 00:00:00 2001 From: reluc Date: Wed, 28 Jun 2023 19:09:49 +0200 Subject: [PATCH 2/3] refactor(binding-http/server): remove depecrated url.parse and favor URL --- packages/binding-http/src/http-server.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/binding-http/src/http-server.ts b/packages/binding-http/src/http-server.ts index ebfa8d4ed..d5a4e233b 100644 --- a/packages/binding-http/src/http-server.ts +++ b/packages/binding-http/src/http-server.ts @@ -21,7 +21,6 @@ import * as fs from "fs"; import * as http from "http"; import * as https from "https"; import bauth from "basic-auth"; -import * as url from "url"; import { AddressInfo } from "net"; @@ -583,8 +582,7 @@ export default class HttpServer implements ProtocolServer { } private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse) { - // eslint-disable-next-line node/no-deprecated-api - const requestUri = url.parse(req.url); + const requestUri = new URL(req.url, `${this.scheme}://${req.headers.host}`); debug( `HttpServer on port ${this.getPort()} received '${req.method} ${ From b333da32868ade73f40fd71ffc213677c31ace47 Mon Sep 17 00:00:00 2001 From: reluc Date: Wed, 19 Jul 2023 15:47:35 +0200 Subject: [PATCH 3/3] fix(binding-http/http-server): ignore traling slashes for backward compatibility --- packages/binding-http/src/http-server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/binding-http/src/http-server.ts b/packages/binding-http/src/http-server.ts index d5a4e233b..96009df3c 100644 --- a/packages/binding-http/src/http-server.ts +++ b/packages/binding-http/src/http-server.ts @@ -109,6 +109,7 @@ export default class HttpServer implements ProtocolServer { } const router = Router({ + ignoreTrailingSlash: true, defaultRoute(req, res) { // url-rewrite feature in use ? const pathname = req.url;