From d998040c603b4f3f8819086c0f2697f766e61015 Mon Sep 17 00:00:00 2001 From: Luca Bertelli Date: Fri, 7 Jul 2023 11:31:02 +0200 Subject: [PATCH] feat(binding-http): add http server middleware (#1027) * feat(binding-http): add http server middleware Add a middleware for the http server to handle raw http requests * docs(binding-http): add http middleware example * fix(binding-http): export http middleware * docs: http middleware in binding-http and cli readme * fix(binding-http): middleware as a function * docs(binding-http): remove port, fix HTTPConfig --- packages/binding-http/README.md | 57 ++++++++++++++--- .../src/http-server-middleware.ts | 62 +++++++++++++++++++ packages/binding-http/src/http-server.ts | 21 ++++++- packages/binding-http/src/http.ts | 3 + .../binding-http/test/http-server-test.ts | 51 +++++++++++++++ packages/cli/README.md | 2 + 6 files changed, 184 insertions(+), 12 deletions(-) create mode 100644 packages/binding-http/src/http-server-middleware.ts diff --git a/packages/binding-http/README.md b/packages/binding-http/README.md index c03b904ba..6357dbd3b 100644 --- a/packages/binding-http/README.md +++ b/packages/binding-http/README.md @@ -176,14 +176,15 @@ The protocol binding can be configured using his constructor or trough servient ```ts { - port?: number; // TCP Port to listen on - address?: string; // IP address or hostname of local interface to bind to - proxy?: HttpProxyConfig; // proxy configuration - allowSelfSigned?: boolean; // Accept self signed certificates - serverKey?: string; // HTTPs server secret key file - serverCert?: string; // HTTPs server certificate file - security?: TD.SecurityScheme; // Security scheme of the server - baseUri?: string // A Base URI to be used in the TD in cases where the client will access a different URL than the actual machine serving the thing. [See Using BaseUri below] + port?: number; // TCP Port to listen on + address?: string; // IP address or hostname of local interface to bind to + proxy?: HttpProxyConfig; // proxy configuration + allowSelfSigned?: boolean; // Accept self signed certificates + serverKey?: string; // HTTPs server secret key file + serverCert?: string; // HTTPs server certificate file + security?: TD.SecurityScheme; // Security scheme of the server + baseUri?: string // A Base URI to be used in the TD in cases where the client will access a different URL than the actual machine serving the thing. [See Using BaseUri below] + middleware?: MiddlewareRequestHandler; // the MiddlewareRequestHandler function. See [Adding a middleware] section below. } ``` @@ -303,10 +304,46 @@ The exposed thing on the internal server will product form URLs such as: "href": "https://wot.w3.org/things/smart-coffee-machine/actions/makeDrink" ``` -**baseUrt vs address** +**baseUri vs address** > `baseUri` tells the producer to prefix URLs which may include hostnames, network interfaces, and URI prefixes which are not local to the machine exposing the Thing. -> `address` tells the HttpServer a specific ocal network interface to bind its TCP listener. + +> `address` tells the HttpServer a specific local network interface to bind its TCP listener. + +### Adding a middleware + +HttpServer supports the addition of **middleware** to handle the raw HTTP requests before they hit the Servient. In the middleware function, you can run some logic to filter and eventually reject HTTP requests (e.g. based on some custom headers). + +This can be done by passing a middleware function to the HttpServer constructor. + +```js +const { Servient } = require("@node-wot/core"); +const { HttpServer } = require("@node-wot/binding-http"); + +const servient = new Servient(); + +const middleware = async (req, res, next) => { + // For example, reject requests in which the X-Custom-Header header is missing + // by replying with 400 Bad Request + if (!req.headers["x-custom-header"]) { + res.statusCode = 400; + res.end("Bad Request"); + return; + } + // Pass all other requests to the WoT Servient + next(); +}; + +const httpServer = new HttpServer({ + middleware, +}); + +servient.addServer(httpServer); + +servient.start().then(async (WoT) => { + // ... +}); +``` ## Feature matrix diff --git a/packages/binding-http/src/http-server-middleware.ts b/packages/binding-http/src/http-server-middleware.ts new file mode 100644 index 000000000..aad8c2e5d --- /dev/null +++ b/packages/binding-http/src/http-server-middleware.ts @@ -0,0 +1,62 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +/** + * HTTP Middleware for the HTTP Server + */ + +import * as http from "http"; + +/** + * A middleware function for the HTTP server, which can be used to intercept requests before they are handled by the WoT Servient. + * + * Example: + * ```javascript + * import { Servient } from "@node-wot/core"; + * import { HttpServer, MiddlewareRequestHandler } from "@node-wot/binding-http"; + * + * const servient = new Servient(); + * + * const middleware: MiddlewareRequestHandler = async (req, res, next) => { + * // For example, reject requests in which the X-Custom-Header header is missing +* // by replying with 400 Bad Request + * if (!req.headers["x-custom-header"]) { + * res.statusCode = 400; + * res.end("Bad Request"); + * return; + * } + * // Pass all other requests to the WoT Servient + * next(); + * }; + + * const httpServer = new HttpServer({ + * middleware, + * }); + * + * servient.addServer(httpServer); + * + * servient.start().then(async (WoT) => { + * // ... + * }); + * ``` + * @param req The HTTP request. + * @param res The HTTP response. + * @param next Call this function to pass the request to the WoT Servient. + */ +export type MiddlewareRequestHandler = ( + req: http.IncomingMessage, + res: http.ServerResponse, + next: () => void +) => Promise; diff --git a/packages/binding-http/src/http-server.ts b/packages/binding-http/src/http-server.ts index 44eecfc3e..571853c9c 100644 --- a/packages/binding-http/src/http-server.ts +++ b/packages/binding-http/src/http-server.ts @@ -43,6 +43,7 @@ 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"; const { debug, info, warn, error } = createLoggers("binding-http", "http-server"); @@ -65,6 +66,7 @@ export default class HttpServer implements ProtocolServer { private readonly httpSecurityScheme: string = "NoSec"; // HTTP header compatible string private readonly validOAuthClients: RegExp = /.*/g; private readonly server: http.Server | https.Server = null; + private readonly middleware: MiddlewareRequestHandler = null; private readonly things: Map = new Map(); private servient: Servient = null; private oAuthValidator: Validator; @@ -98,6 +100,9 @@ export default class HttpServer implements ProtocolServer { if (config.urlRewrite !== undefined) { this.urlRewrite = config.urlRewrite; } + if (config.middleware !== undefined) { + this.middleware = config.middleware; + } // TLS if (config.serverKey && config.serverCert) { @@ -106,12 +111,24 @@ export default class HttpServer implements ProtocolServer { options.cert = fs.readFileSync(config.serverCert); this.scheme = "https"; this.server = https.createServer(options, (req, res) => { - this.handleRequest(req, res); + if (this.middleware) { + this.middleware(req, res, () => { + this.handleRequest(req, res); + }); + } else { + this.handleRequest(req, res); + } }); } else { this.scheme = "http"; this.server = http.createServer((req, res) => { - this.handleRequest(req, res); + if (this.middleware) { + this.middleware(req, res, () => { + this.handleRequest(req, res); + }); + } else { + this.handleRequest(req, res); + } }); } diff --git a/packages/binding-http/src/http.ts b/packages/binding-http/src/http.ts index f1045bd2a..260716ce0 100644 --- a/packages/binding-http/src/http.ts +++ b/packages/binding-http/src/http.ts @@ -15,11 +15,13 @@ import * as TD from "@node-wot/td-tools"; import { Method } from "./oauth-token-validation"; +import { MiddlewareRequestHandler } from "./http-server-middleware"; export { default as HttpServer } from "./http-server"; export { default as HttpClient } from "./http-client"; export { default as HttpClientFactory } from "./http-client-factory"; export { default as HttpsClientFactory } from "./https-client-factory"; +export { MiddlewareRequestHandler } from "./http-server-middleware"; export * from "./http-server"; export * from "./http-client"; export * from "./http-client-factory"; @@ -43,6 +45,7 @@ export interface HttpConfig { serverKey?: string; serverCert?: string; security?: TD.SecurityScheme; + middleware?: MiddlewareRequestHandler; } export interface OAuth2ServerConfig extends TD.SecurityScheme { diff --git a/packages/binding-http/test/http-server-test.ts b/packages/binding-http/test/http-server-test.ts index 2386d03ee..589a49e4c 100644 --- a/packages/binding-http/test/http-server-test.ts +++ b/packages/binding-http/test/http-server-test.ts @@ -27,6 +27,7 @@ import { Content, createLoggers, ExposedThing, Helpers } from "@node-wot/core"; import { DataSchemaValue, InteractionInput, InteractionOptions } from "wot-typescript-definitions"; import chaiAsPromised from "chai-as-promised"; import { Readable } from "stream"; +import { MiddlewareRequestHandler } from "../src/http-server-middleware"; const { debug, error } = createLoggers("binding-http", "http-server-test"); @@ -49,6 +50,56 @@ class HttpServerTest { expect(httpServer.getPort()).to.eq(-1); // from getPort() when not listening } + @test async "should use middleware if provided"() { + const middleware: MiddlewareRequestHandler = async (req, res, next) => { + if (req.url.endsWith("testMiddleware")) { + res.statusCode = 401; + res.end("Unauthorized"); + } else { + next(); + } + }; + + const httpServer = new HttpServer({ + port, + middleware, + }); + + await httpServer.start(null); + + const testThing = new ExposedThing(null, { + title: "Test", + properties: { + testMiddleware: { + forms: [], + }, + testPassthrough: { + forms: [], + }, + }, + actions: {}, + }); + + let test: DataSchemaValue; + testThing.setPropertyReadHandler("testMiddleware", () => Promise.resolve(test)); + testThing.setPropertyReadHandler("testPassthrough", () => Promise.resolve(test)); + + await httpServer.expose(testThing); + + const uri = `http://localhost:${httpServer.getPort()}/test/`; + let resp; + + debug(`Testing ${uri}`); + + resp = await fetch(uri + "properties/testMiddleware"); + expect(resp.status).to.equal(401); + + resp = await fetch(uri + "properties/testPassthrough"); + expect(resp.status).to.equal(200); + + return httpServer.stop(); + } + @test async "should be able to destroy a thing"() { const httpServer = new HttpServer({ port: 0 }); diff --git a/packages/cli/README.md b/packages/cli/README.md index 75428beeb..d9f22ef5f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -133,6 +133,8 @@ VAR2=Value2 Additionally, you can look at [the JSON Schema](https://github.com/eclipse-thingweb/node-wot/blob/master/packages/cli/src/wot-servient-schema.conf.json) to understand possible values for each field. +> In the current implementation, the **middleware** option (that you can use to handle raw HTTP requests _before_ they hit the Servient) is only available when using the `@node-wot/binding-http` package as a library. See [Adding a middleware](../binding-http/README.md#adding-a-middleware) for more information. + ### Environment variables If your scripts needs to access environment variables those must be supplied in a particular file. Node-wot cli uses [dotenv](https://github.com/motdotla/dotenv) library to load `.env` files located at the current working directory. For example, providing the following `.env` file will fill variables `PORT` and `ADDRESS` in scripts `process.env` field: