diff --git a/package-lock.json b/package-lock.json index 215cc6808..81561862c 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..96009df3c 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"; @@ -32,18 +31,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 +73,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 +108,42 @@ export default class HttpServer implements ProtocolServer { this.middleware = config.middleware; } + const router = Router({ + ignoreTrailingSlash: true, + 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,91 +582,8 @@ 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); + const requestUri = new URL(req.url, `${this.scheme}://${req.headers.host}`); debug( `HttpServer on port ${this.getPort()} received '${req.method} ${ @@ -637,39 +598,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 +628,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: {