diff --git a/CODEOWNERS b/CODEOWNERS index 6809ff0b51aa..7c891a128ce5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -21,6 +21,7 @@ packages/cli/* @raymondfeng @bajtos packages/context/* @bajtos @raymondfeng packages/core/* @bajtos @raymondfeng packages/eslint-config/* @raymondfeng +packages/express-middleware/* @raymondfeng packages/metadata/* @raymondfeng packages/model-api-builder/* @bajtos @nabdelgadir packages/openapi-spec-builder/* @bajtos @raymondfeng diff --git a/docs/site/MONOREPO.md b/docs/site/MONOREPO.md index f4ae301cca50..bb4bc4b444b8 100644 --- a/docs/site/MONOREPO.md +++ b/docs/site/MONOREPO.md @@ -32,6 +32,7 @@ The [loopback-next](https://github.com/strongloop/loopback-next) repository uses | [example-todo](https://github.com/strongloop/loopback-next/tree/master/examples/todo) | @loopback/example-todo | A basic tutorial for getting started with Loopback 4 | | [http-caching-proxy](https://github.com/strongloop/loopback-next/tree/master/packages/http-caching-proxy) | @loopback/http-caching-proxy | A caching HTTP proxy for integration tests. NOT SUITABLE FOR PRODUCTION USE! | | [http-server](https://github.com/strongloop/loopback-next/tree/master/packages/http-server) | @loopback/http-server | A wrapper for creating HTTP/HTTPS servers | +| [express-middleware](https://github.com/strongloop/loopback-next/tree/master/packages/express-middleware) | @loopback/express-middleware | Extensions to manage Express middleware | | [metadata](https://github.com/strongloop/loopback-next/tree/master/packages/metadata) | @loopback/metadata | Utilities to help developers implement TypeScript decorators, define/merge metadata, and inspect metadata | | [model-api-builder](https://github.com/strongloop/loopback-next/tree/master/packages/model-api-builder) | @loopback/model-api-builder | Types and helpers for packages contributing Model API builders. | | [openapi-spec-builder](https://github.com/strongloop/loopback-next/tree/master/packages/openapi-spec-builder) | @loopback/openapi-spec-builder | Builders to create OpenAPI (Swagger) specification documents in tests | diff --git a/docs/site/Reserved-binding-keys.md b/docs/site/Reserved-binding-keys.md index e27fa818ab44..4d07bd799f5d 100644 --- a/docs/site/Reserved-binding-keys.md +++ b/docs/site/Reserved-binding-keys.md @@ -43,3 +43,4 @@ the prefix. Example: `@loopback/authentication` component uses the prefix - [RestBindings](https://loopback.io/doc/en/lb4/apidocs.rest.restbindings.html) - [RestBindings.Http](https://loopback.io/doc/en/lb4/apidocs.rest.http.html) - [RestBindings.SequenceActions](https://loopback.io/doc/en/lb4/apidocs.rest.sequenceactions.html) +- [ExpressBindings](https://loopback.io/doc/en/lb4/apidocs.express-middleware.expressbindings.html) diff --git a/packages/express-middleware/LICENSE b/packages/express-middleware/LICENSE new file mode 100644 index 000000000000..d26c83c5d446 --- /dev/null +++ b/packages/express-middleware/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2017,2019. All Rights Reserved. +Node module: @loopback/express-middleware +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/express-middleware/README.md b/packages/express-middleware/README.md new file mode 100644 index 000000000000..a9035a839015 --- /dev/null +++ b/packages/express-middleware/README.md @@ -0,0 +1,128 @@ +# @loopback/express-middleware + +[Express middleware](https://expressjs.com/en/guide/using-middleware.html) are +key building blocks for Express applications. Simple APIs are provided by +Express to set up middleware. It works for applications where the middleware +chain registration can be centralized, such as: + +```js +app.use(middlewareHandler1); +app.use(middlewareHandler2); +// ... +app.use(middlewareHandlerN); +``` + +The middleware handlers will be executed by Express in the order of `app.use()` +is called. There are obvious limitations of this approach if your application +allows multiple modules to contribute middleware. + +1. It's hard to align middleware in the right order +2. It's hard to separate configuration of middleware + +This module introduces a simple extension that utilizes LoopBack 4's inversion +of controller (Ioc) dependency injection (DI) container offered by +`@loopback/context`. + +## Overview + +We introduce `MiddlewareRegistry` to manage middleware registration and provide +a handler function for Express applications. + +1. `MiddlewareRegistry` allows us to bind middleware handlers with optional + settings such as `path` and `phase` as tags. Each binding is tagged with + `middleware` so that it can be discovered. + +2. `MiddlewareRegistry` sorts the middleware entries using `phase`. The order of + phases can be configured. + +3. `MiddlewareRegistry` exposes `requestHandler` function which is an Express + middleware handler function that can be mounted to Express applications. + +4. `MiddlewareRegistry` observes binding events to keep track of latest list of + middleware and rebuilds the chain if necessary. + +## Installation + +To use this package, you'll need to install `@loopback/express-middleware`. + +```sh +npm i @loopback/express-middleware +``` + +## Basic Use + +### Register middleware handler + +Handler functions compatible with Express middleware can be registered as +follows: + +```ts +const middlewareRegistry = new MiddlewareRegistry(); +middlewareRegistry.middleware(cors(), {phase: 'cors'}); +const helloRoute: RequestHandler = (req, res, next) => { + res.setHeader('content-type', 'text/plain'); + res.send('Hello world!'); +}; +middlewareRegistry.middleware(helloRoute, {phase: 'route', path: '/api'}); +``` + +Please note we allow `phase` to be set for a given middleware. + +### Register middleware provider class + +To leverage dependency injection, middleware can be wrapped as a provider class. +For example: + +```ts +@middleware({phase: 'log', name: 'logger'}) +class LogMiddlewareProvider implements Provider { + constructor( + @config() + private options: Record = {}, + ) {} + + value(): RequestHandler { + /** We use wrap existing express middleware here too + * such as: + * const m = require('existingExpressMiddleware'); + * return m(this.options); + */ + return (req, res, next) => { + recordReq(req, this.options.name || 'logger'); + next(); + }; + } +} +``` + +Then it can be configured and registered to the express context: + +```ts +middlewareRegistry.middlewareProvider(LogMiddlewareProvider); +middlewareRegistry.configure('middleware.logger').to({name: 'my-logger'}); +``` + +### Mount to Express application + +```ts +const expressApp = express(); +expressApp.use(middlewareRegistry.requestHandler); +``` + +## Contributions + +- [Guidelines](https://github.com/strongloop/loopback-next/blob/master/docs/CONTRIBUTING.md) +- [Join the team](https://github.com/strongloop/loopback-next/issues/110) + +## Tests + +Run `npm test` from the root folder. + +## Contributors + +See +[all contributors](https://github.com/strongloop/loopback-next/graphs/contributors). + +## License + +MIT diff --git a/packages/express-middleware/index.d.ts b/packages/express-middleware/index.d.ts new file mode 100644 index 000000000000..857f677fba68 --- /dev/null +++ b/packages/express-middleware/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/express-middleware +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './dist'; diff --git a/packages/express-middleware/index.js b/packages/express-middleware/index.js new file mode 100644 index 000000000000..2319ce64ed42 --- /dev/null +++ b/packages/express-middleware/index.js @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/express-middleware +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +module.exports = require('./dist'); diff --git a/packages/express-middleware/index.ts b/packages/express-middleware/index.ts new file mode 100644 index 000000000000..c5b31ab270b7 --- /dev/null +++ b/packages/express-middleware/index.ts @@ -0,0 +1,8 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/express-middleware +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// DO NOT EDIT THIS FILE +// Add any additional (re)exports to src/index.ts instead. +export * from './src'; diff --git a/packages/express-middleware/package-lock.json b/packages/express-middleware/package-lock.json new file mode 100644 index 000000000000..b0fcf1d612fd --- /dev/null +++ b/packages/express-middleware/package-lock.json @@ -0,0 +1,547 @@ +{ + "name": "@loopback/express-middleware", + "version": "0.0.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/body-parser": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.17.1.tgz", + "integrity": "sha512-RoX2EZjMiFMjZh9lmYrwgoP9RTpAjSHiJxdp4oidAQVO02T7HER3xj9UKue5534ULWeqVEkujhWcyvUce+d68w==", + "requires": { + "@types/connect": "*", + "@types/node": "*" + }, + "dependencies": { + "@types/node": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.7.0.tgz", + "integrity": "sha512-GnZbirvmqZUzMgkFn70c74OQpTTUcCzlhQliTzYjQMqg+hVKcDnxdL19Ne3UdYzdMA/+W3eb646FWn/ZaT1NfQ==" + } + } + }, + "@types/connect": { + "version": "3.4.33", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.33.tgz", + "integrity": "sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==", + "requires": { + "@types/node": "*" + }, + "dependencies": { + "@types/node": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.7.0.tgz", + "integrity": "sha512-GnZbirvmqZUzMgkFn70c74OQpTTUcCzlhQliTzYjQMqg+hVKcDnxdL19Ne3UdYzdMA/+W3eb646FWn/ZaT1NfQ==" + } + } + }, + "@types/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-invOmosX0DqbpA+cE2yoHGUlF/blyf7nB0OGYBBiH27crcVm5NmFaZkLP4Ta1hGaesckCi5lVLlydNJCxkTOSg==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, + "@types/debug": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.5.tgz", + "integrity": "sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==", + "dev": true + }, + "@types/express": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.2.tgz", + "integrity": "sha512-5mHFNyavtLoJmnusB8OKJ5bshSzw+qkMIBAobLrIM48HJvunFva9mOa6aBwh64lBFyNwBbs0xiEFuj4eU/NjCA==", + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.2.tgz", + "integrity": "sha512-El9yMpctM6tORDAiBwZVLMcxoTMcqqRO9dVyYcn7ycLWbvR8klrDn8CAOwRfZujZtWD7yS/mshTdz43jMOejbg==", + "requires": { + "@types/node": "*", + "@types/range-parser": "*" + }, + "dependencies": { + "@types/node": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.7.0.tgz", + "integrity": "sha512-GnZbirvmqZUzMgkFn70c74OQpTTUcCzlhQliTzYjQMqg+hVKcDnxdL19Ne3UdYzdMA/+W3eb646FWn/ZaT1NfQ==" + } + } + }, + "@types/mime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", + "integrity": "sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==" + }, + "@types/node": { + "version": "10.17.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.14.tgz", + "integrity": "sha512-G0UmX5uKEmW+ZAhmZ6PLTQ5eu/VPaT+d/tdLd5IFsKRPcbe6lPxocBtcYBFSaLaCW8O60AX90e91Nsp8lVHCNw==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", + "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==" + }, + "@types/serve-static": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.3.tgz", + "integrity": "sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g==", + "requires": { + "@types/express-serve-static-core": "*", + "@types/mime": "*" + } + }, + "accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "requires": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + } + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", + "requires": { + "bytes": "3.1.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "on-finished": "~2.3.0", + "qs": "6.7.0", + "raw-body": "2.4.0", + "type-is": "~1.6.17" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" + }, + "content-disposition": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", + "requires": { + "safe-buffer": "5.1.2" + } + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, + "express": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", + "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", + "requires": { + "accepts": "~1.3.7", + "array-flatten": "1.1.1", + "body-parser": "1.19.0", + "content-disposition": "0.5.3", + "content-type": "~1.0.4", + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.1.2", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.5", + "qs": "6.7.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.1.2", + "send": "0.17.1", + "serve-static": "1.14.1", + "setprototypeof": "1.1.1", + "statuses": "~1.5.0", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "http-errors": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", + "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "ipaddr.js": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.0.tgz", + "integrity": "sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==" + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "mime-db": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz", + "integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==" + }, + "mime-types": { + "version": "2.1.26", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz", + "integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==", + "requires": { + "mime-db": "1.43.0" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "proxy-addr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.5.tgz", + "integrity": "sha512-t/7RxHXPH6cJtP0pRG6smSr9QJidhB+3kXu0KgXnbGYMgzEnUxRQ4/LDdfOwZEMyIh3/xHb8PX3t+lfL9z+YVQ==", + "requires": { + "forwarded": "~0.1.2", + "ipaddr.js": "1.9.0" + } + }, + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", + "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", + "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.7.2", + "mime": "1.6.0", + "ms": "2.1.1", + "on-finished": "~2.3.0", + "range-parser": "~1.2.1", + "statuses": "~1.5.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, + "serve-static": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", + "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.17.1" + } + }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + } + } +} diff --git a/packages/express-middleware/package.json b/packages/express-middleware/package.json new file mode 100644 index 000000000000..74b7caf58bda --- /dev/null +++ b/packages/express-middleware/package.json @@ -0,0 +1,52 @@ +{ + "name": "@loopback/express-middleware", + "version": "0.0.1", + "description": "LoopBack extension for Express middleware", + "engines": { + "node": ">=8.9" + }, + "scripts": { + "acceptance": "lb-mocha \"dist/__tests__/acceptance/**/*.js\"", + "build": "lb-tsc", + "clean": "lb-clean loopback-express-middleware*.tgz dist tsconfig.build.tsbuildinfo package", + "pretest": "npm run build", + "test": "lb-mocha \"dist/__tests__/**/*.js\"", + "unit": "lb-mocha \"dist/__tests__/unit/**/*.js\"", + "verify": "npm pack && tar xf loopback-express-middleware*.tgz && tree package && npm run clean" + }, + "author": "IBM Corp.", + "copyright.owner": "IBM Corp.", + "license": "MIT", + "dependencies": { + "@loopback/context": "^2.0.0", + "@types/express": "^4.17.2", + "@types/express-serve-static-core": "^4.17.2", + "debug": "^4.1.1", + "express": "^4.17.1" + }, + "devDependencies": { + "@loopback/build": "^3.1.0", + "@loopback/eslint-config": "^5.0.2", + "@loopback/testlab": "^1.10.2", + "@types/cors": "^2.8.6", + "@types/debug": "^4.1.5", + "@types/node": "^10.17.14", + "cors": "^2.8.5" + }, + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist", + "src", + "!*/__tests__" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git", + "directory": "packages/express-middleware" + } +} diff --git a/packages/express-middleware/src/__tests__/acceptance/middleware-registry.acceptance.ts b/packages/express-middleware/src/__tests__/acceptance/middleware-registry.acceptance.ts new file mode 100644 index 000000000000..4813d703cabb --- /dev/null +++ b/packages/express-middleware/src/__tests__/acceptance/middleware-registry.acceptance.ts @@ -0,0 +1,154 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Context, inject, Provider} from '@loopback/context'; +import {Client, createClientForHandler, expect} from '@loopback/testlab'; +import cors from 'cors'; +import express from 'express'; +import {RequestHandler} from 'express'; +import {ExpressBindings} from '../../keys'; +import {middleware} from '../../middleware'; +import {MiddlewareRegistry} from '../../middleware-registry'; + +describe('HttpHandler mounted as an express router', () => { + let client: Client; + let expressApp: express.Application; + let middlewareRegistry: MiddlewareRegistry; + let events: string[]; + + beforeEach(givenMiddlewareRegistry); + beforeEach(givenExpressApp); + beforeEach(givenClient); + + it('handles simple "GET /hello" requests', async () => { + await client + .get('/hello') + .expect(200) + .expect('Hello world!'); + expect(events).to.eql([ + 'my-logger: get /hello', + 'auth: get /hello', + 'route: get /hello', + ]); + }); + + it('handles simple "GET /greet" requests', async () => { + await client + .get('/greet') + .expect(200) + .expect('Greeting, world!'); + // No 'auth: get /greet' is recorded as the path does not match + expect(events).to.eql(['my-logger: get /greet', 'greet: get /greet']); + }); + + it('handles simple "GET /not-found" requests', async () => { + await client + .get('/not-found') + .expect(404) + .expect('Not found'); + expect(events).to.eql([ + 'my-logger: get /not-found', + 'route: get /not-found', + 'error: get /not-found', + ]); + }); + + /** + * This class illustrates how to use DI for express middleware + */ + @middleware({phase: 'log', name: 'logger'}) + class LogMiddlewareProvider implements Provider { + constructor( + // Make it possible to inject middleware options + @inject('middleware.logger.options', {optional: true}) + private options: Record = {}, + ) {} + + value(): RequestHandler { + /** We use wrap existing express middleware here too + * such as: + * const m = require('existingExpressMiddleware'); + * return m(this.options); + */ + return (req, res, next) => { + recordReq(req, this.options.name || 'logger'); + next(); + }; + } + } + + async function givenMiddlewareRegistry() { + events = []; + const ctx = new Context('server'); + ctx + .bind(ExpressBindings.EXPRESS_MIDDLEWARE_REGISTRY) + .toClass(MiddlewareRegistry); + + ctx.configure(ExpressBindings.EXPRESS_MIDDLEWARE_REGISTRY).to({ + phasesByOrder: ['log', 'cors', 'auth', 'route'], + }); + + middlewareRegistry = await ctx.get( + ExpressBindings.EXPRESS_MIDDLEWARE_REGISTRY, + ); + + middlewareRegistry.middleware( + (req, res, next) => { + recordReq(req, 'auth'); + next(); + }, + {phase: 'auth', path: '/hello'}, + ); + + middlewareRegistry.middleware(cors(), {phase: 'cors'}); + + middlewareRegistry.middleware( + (req, res, next) => { + recordReq(req, 'greet'); + res.setHeader('content-type', 'text/plain'); + res.send('Greeting, world!'); + }, + {phase: 'route', method: 'get', path: '/greet'}, + ); + + middlewareRegistry.middleware( + (req, res, next) => { + recordReq(req, 'route'); + if (req.originalUrl === '/not-found') { + throw new Error('Not found'); + } + res.setHeader('content-type', 'text/plain'); + res.send('Hello world!'); + }, + {phase: 'route'}, + ); + + middlewareRegistry.middlewareProvider(LogMiddlewareProvider); + ctx.bind('middleware.logger.options').to({name: 'my-logger'}); + + middlewareRegistry.errorMiddleware((err, req, res, next) => { + recordReq(req, 'error'); + res.setHeader('content-type', 'text/plain'); + res.status(err.statusCode || 404); + res.send(err.message); + }); + } + + function recordReq(req: express.Request, name: string) { + events.push(`${name}: ${req.method.toLowerCase()} ${req.originalUrl}`); + } + + /** + * Create an express app + */ + function givenExpressApp() { + expressApp = express(); + expressApp.use(middlewareRegistry.requestHandler); + } + + function givenClient() { + client = createClientForHandler(expressApp); + } +}); diff --git a/packages/express-middleware/src/__tests__/unit/middleware-registry.unit.ts b/packages/express-middleware/src/__tests__/unit/middleware-registry.unit.ts new file mode 100644 index 000000000000..d9cf73869e48 --- /dev/null +++ b/packages/express-middleware/src/__tests__/unit/middleware-registry.unit.ts @@ -0,0 +1,91 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Context, inject, Provider} from '@loopback/context'; +import { + expect, + ShotRequestOptions, + stubExpressContext, +} from '@loopback/testlab'; +import {Request, RequestHandler, Response, Router} from 'express'; +import {asMiddlewareBinding, MiddlewareRegistry, MiddlewareSpec} from '../..'; + +describe('MiddlewareRegistry', () => { + const DUMMY_RESPONSE = ({ + setHeader: () => {}, + end: () => {}, + } as unknown) as Response; + let ctx: Context; + let registry: MiddlewareRegistry; + let events: string[]; + + beforeEach(givenContext); + beforeEach(givenMiddlewareRegistry); + + it('builds an express router', async () => { + registry.setPhasesByOrder(['cors', 'auth', 'route', 'final']); + + givenMiddleware('rest', {phase: 'route', path: '/api'}); + givenMiddlewareProvider('cors', {phase: 'cors'}); + givenMiddleware('token', {phase: 'auth'}); + await testRouter('get', '/api/orders'); + expect(events).to.eql(['token: GET /api/orders', 'rest: GET /orders']); + }); + + function givenContext() { + events = []; + ctx = new Context('app'); + } + + async function givenMiddlewareRegistry() { + ctx.bind('middleware.registry').toClass(MiddlewareRegistry); + registry = await ctx.get('middleware.registry'); + } + + function givenMiddleware(name: string, spec?: MiddlewareSpec) { + const middleware: RequestHandler = (req, res, next) => { + events.push(`${name}: ${req.method} ${req.url}`); + next(); + }; + ctx + .bind(`middleware.${name}`) + .to(middleware) + .apply(asMiddlewareBinding(spec)); + } + + function givenMiddlewareProvider(moduleName: string, spec?: MiddlewareSpec) { + class MiddlewareProvider implements Provider { + private middlewareModule: Function; + constructor( + @inject(`middleware.${moduleName}.options`, {optional: true}) + private options: object = {}, + ) { + this.middlewareModule = require(moduleName); + } + + value() { + return this.middlewareModule(this.options); + } + } + ctx + .bind(`middleware.${moduleName}`) + .toProvider(MiddlewareProvider) + .apply(asMiddlewareBinding(spec)); + } + + function givenRequest(options?: ShotRequestOptions): Request { + return stubExpressContext(options).request; + } + + async function testRouter(method: string, url: string) { + const router = await registry.mountMiddleware(Router()); + await new Promise((resolve, reject) => { + router(givenRequest({url, method}), DUMMY_RESPONSE, err => { + if (err) reject(err); + else resolve(); + }); + }); + } +}); diff --git a/packages/express-middleware/src/index.ts b/packages/express-middleware/src/index.ts new file mode 100644 index 000000000000..8cb19d142a8e --- /dev/null +++ b/packages/express-middleware/src/index.ts @@ -0,0 +1,9 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/express-middleware +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './types'; +export * from './keys'; +export * from './middleware'; +export * from './middleware-registry'; diff --git a/packages/express-middleware/src/keys.ts b/packages/express-middleware/src/keys.ts new file mode 100644 index 000000000000..5ae6cfed4d4f --- /dev/null +++ b/packages/express-middleware/src/keys.ts @@ -0,0 +1,16 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/express-middleware +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {BindingKey} from '@loopback/context'; +import {MiddlewareRegistry} from './middleware-registry'; + +export namespace ExpressBindings { + /** + * Binding key for express middleware registry + */ + export const EXPRESS_MIDDLEWARE_REGISTRY = BindingKey.create< + MiddlewareRegistry + >('express.middleware-registry'); +} diff --git a/packages/express-middleware/src/middleware-registry.ts b/packages/express-middleware/src/middleware-registry.ts new file mode 100644 index 000000000000..e6cf8582da40 --- /dev/null +++ b/packages/express-middleware/src/middleware-registry.ts @@ -0,0 +1,214 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/express-middleware +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + compareBindingsByTag, + config, + Constructor, + Context, + ContextView, + createBindingFromClass, + inject, + Provider, +} from '@loopback/context'; +import * as express from 'express'; +import {Router} from 'express'; +import {ExpressBindings} from './keys'; +import {asMiddlewareBinding, middlewareFilter} from './middleware'; +import { + ExpressRequestMethod, + MiddlewareErrorHandler, + MiddlewareHandler, + MiddlewareRegistryOptions, + MiddlewareRequestHandler, + MiddlewareSpec, +} from './types'; +import debugFactory = require('debug'); +const debug = debugFactory('loopback:express:middleware'); + +interface UpdateRouter { + (router: Router): void; +} + +/** + * A context-based registry for express middleware + */ +export class MiddlewareRegistry { + public static readonly ERROR_PHASE = '$error'; + public static readonly FINAL_PHASE = '$final'; + + private _routerUpdates: UpdateRouter[] | undefined; + private _middlewareNameKey = 1; + private _router?: express.Router; + + public readonly requestHandler: express.RequestHandler; + private middlewareView: ContextView; + + constructor( + @inject.context() private context: Context, + @config() + protected options: MiddlewareRegistryOptions = { + parallel: false, + phasesByOrder: [], + }, + ) { + this.middlewareView = this.context.createView(middlewareFilter, (a, b) => + compareBindingsByTag('phase', this.getPhasesByOrder())(a, b), + ); + this.requestHandler = async (req, res, next) => { + if (!this._router) { + this._router = express.Router(); + await this.mountMiddleware(this._router); + } + return this._router(req, res, next); + }; + this.middlewareView.on('refresh', () => { + this._router = undefined; + this._routerUpdates = undefined; + }); + } + + setPhasesByOrder(phases: string[]) { + this.options.phasesByOrder = phases || []; + } + + /** + * Build a list of phase names for the order. Two special phases are added + * to the end of the list + */ + private getPhasesByOrder() { + const phasesByOrder = this.options.phasesByOrder.filter( + p => + p !== MiddlewareRegistry.ERROR_PHASE && + p !== MiddlewareRegistry.FINAL_PHASE, + ); + phasesByOrder.push( + MiddlewareRegistry.ERROR_PHASE, + MiddlewareRegistry.FINAL_PHASE, + ); + return phasesByOrder; + } + + /** + * Mount middleware to the express router + * + * @param expressRouter An express router. If not provided, a new one + * will be created. + */ + async mountMiddleware(expressRouter = express.Router()): Promise { + const tasks: UpdateRouter[] = await this.buildRouterUpdatesIfNeeded(); + for (const updateFn of tasks) { + updateFn(expressRouter); + } + return expressRouter; + } + + /** + * Create an array of functions that add middleware to an express router + */ + private async buildRouterUpdatesIfNeeded() { + if (this._routerUpdates) return this._routerUpdates; + const middleware = await this.middlewareView.values(); + const bindings = this.middlewareView.bindings; + + this._routerUpdates = []; + for (const binding of bindings) { + const index = bindings.indexOf(binding); + const handler = middleware[index]; + const path = binding.tagMap.path; + if (path) { + // Add the middleware to the given path + debug( + 'Adding middleware (binding: %s): %j', + binding.key, + binding.tagMap, + ); + if (binding.tagMap.method) { + // For regular express routes, such as `all`, `get`, or `post` + // It corresponds to `app.get('/hello', ...);` + const method = binding.tagMap.method as ExpressRequestMethod; + this._routerUpdates.push(router => router[method](path, handler)); + } else { + // For middleware, such as `app.use('/api', ...);` + // The handler function can be an error handler too + this._routerUpdates.push(router => router.use(path, handler)); + } + } else { + // Add the middleware without a path + if (debug.enabled) { + debug( + 'Adding middleware (binding: %s): %j', + binding.key, + binding.tagMap || {}, + ); + } + this._routerUpdates.push(router => router.use(handler)); + } + } + return this._routerUpdates; + } + + setMiddlewareRegistryOptions(options: MiddlewareRegistryOptions) { + this.context + .configure(ExpressBindings.EXPRESS_MIDDLEWARE_REGISTRY) + .to(options); + this._router = undefined; + this._routerUpdates = undefined; + } + + /** + * Register a middleware handler function + * @param handler + * @param spec + */ + middleware(handler: MiddlewareRequestHandler, spec: MiddlewareSpec = {}) { + this.validateSpec(spec); + const name = spec.name || `_${this._middlewareNameKey++}`; + this.context + .bind(`middleware.${name}`) + .to(handler) + .apply(asMiddlewareBinding(spec)); + } + + private validateSpec(spec: MiddlewareSpec = {}) { + if (spec.method && !spec.path) { + throw new Error(`Route spec for ${spec.method} must have a path.`); + } + } + + /** + * Register an middleware error handler function + * @param errHandler Error handler + * @param spec + */ + errorMiddleware( + errHandler: MiddlewareErrorHandler, + spec: MiddlewareSpec = {}, + ) { + spec = Object.assign(spec, {phase: MiddlewareRegistry.ERROR_PHASE}); + const name = spec.name || `_${this._middlewareNameKey++}`; + this.context + .bind(`middleware.${name}`) + .to(errHandler) + .apply(asMiddlewareBinding(spec)); + } + + /** + * Register a middleware provider class + * @param providerClass + * @param spec + */ + middlewareProvider( + providerClass: Constructor>, + spec: MiddlewareSpec = {}, + ) { + const binding = createBindingFromClass(providerClass, { + namespace: 'middleware', + name: spec.name, + }).apply(asMiddlewareBinding(spec)); + this.validateSpec(binding.tagMap); + this.context.add(binding); + } +} diff --git a/packages/express-middleware/src/middleware.ts b/packages/express-middleware/src/middleware.ts new file mode 100644 index 000000000000..67b2ea65f563 --- /dev/null +++ b/packages/express-middleware/src/middleware.ts @@ -0,0 +1,44 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/express-middleware +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + bind, + Binding, + BindingFilter, + BindingScope, + BindingSpec, + BindingTemplate, + filterByTag, +} from '@loopback/context'; +import {MiddlewareSpec} from './types'; + +/** + * Configure the binding as express middleware + * @param binding - Binding + */ +export function asMiddlewareBinding( + spec?: MiddlewareSpec, +): BindingTemplate { + const tags = Object.assign({}, spec); + return (binding: Binding) => { + return binding + .tag('middleware') + .inScope(BindingScope.SINGLETON) + .tag(tags); + }; +} + +/** + * A sugar `@middleware` decorator to simplify `@bind` for middleware classes + * @param spec - Middleware spec + */ +export function middleware(spec?: MiddlewareSpec, ...specs: BindingSpec[]) { + return bind({tags: spec}, asMiddlewareBinding(spec), ...specs); +} + +/** + * A filter function to find all middleware bindings + */ +export const middlewareFilter: BindingFilter = filterByTag('middleware'); diff --git a/packages/express-middleware/src/types.ts b/packages/express-middleware/src/types.ts new file mode 100644 index 000000000000..03d03b27738b --- /dev/null +++ b/packages/express-middleware/src/types.ts @@ -0,0 +1,51 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/express-middleware +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {ErrorRequestHandler, RequestHandler} from 'express'; +import {PathParams} from 'express-serve-static-core'; + +export type ExpressRequestMethod = + | 'all' + | 'get' + | 'post' + | 'put' + | 'delete' + | 'patch' + | 'options' + | 'head'; + +/** + * Spec for a middleware entry + */ +export interface MiddlewareSpec { + name?: string; + // Path to be mounted + path?: PathParams; + // Optional phase for ordering + phase?: string; + // Optional method for route handlers + method?: ExpressRequestMethod; +} + +/** + * Express normal request middleware + */ +export type MiddlewareRequestHandler = RequestHandler | RequestHandler[]; + +/** + * Express error request middleware + */ +export type MiddlewareErrorHandler = + | ErrorRequestHandler + | ErrorRequestHandler[]; + +export type MiddlewareHandler = + | MiddlewareRequestHandler + | MiddlewareErrorHandler; + +export type MiddlewareRegistryOptions = { + phasesByOrder: string[]; + parallel?: boolean; +}; diff --git a/packages/express-middleware/tsconfig.build.json b/packages/express-middleware/tsconfig.build.json new file mode 100644 index 000000000000..c7b8e49eaca5 --- /dev/null +++ b/packages/express-middleware/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "@loopback/build/config/tsconfig.common.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/rest/package.json b/packages/rest/package.json index b2751a0de251..419182069b32 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -23,6 +23,7 @@ "@loopback/core": "^1.12.2", "@loopback/http-server": "^1.5.2", "@loopback/openapi-v3": "^1.12.0", + "@loopback/express-middleware": "^1.0.0-1", "@types/body-parser": "^1.17.1", "@types/cors": "^2.8.6", "@types/express": "^4.17.2", diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index bfb6f3d9409f..d9cc6ecc6751 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -12,6 +12,10 @@ import { inject, } from '@loopback/context'; import {Application, CoreBindings, Server} from '@loopback/core'; +import { + ExpressBindings, + MiddlewareRegistry, +} from '@loopback/express-middleware'; import {HttpServer, HttpServerOptions} from '@loopback/http-server'; import { getControllerSpec, @@ -223,24 +227,50 @@ export class RestServer extends Context implements Server, HttpServerLike { this._applyExpressSettings(); this._requestHandler = this._expressApp; + if (!this.isBound(ExpressBindings.EXPRESS_MIDDLEWARE_REGISTRY)) { + // Set up the default express middleware registry + this.bind(ExpressBindings.EXPRESS_MIDDLEWARE_REGISTRY).toClass( + MiddlewareRegistry, + ); + } + const middlewareRegistry = this.getSync( + ExpressBindings.EXPRESS_MIDDLEWARE_REGISTRY, + ); + middlewareRegistry.setMiddlewareRegistryOptions({ + phasesByOrder: ['cors', 'openapi-spec', 'rest'], + }); + // Allow CORS support for all endpoints so that users // can test with online SwaggerUI instance - this._expressApp.use(cors(this.config.cors)); + middlewareRegistry.middleware(cors(this.config.cors), { + phase: 'cors', + name: 'cors', + }); // Set up endpoints for OpenAPI spec/ui - this._setupOpenApiSpecEndpoints(); + this._setupOpenApiSpecEndpoints(middlewareRegistry); // Mount our router & request handler - this._expressApp.use(this._basePath, (req, res, next) => { - this._handleHttpRequest(req, res).catch(next); - }); + middlewareRegistry.middleware( + (req, res, next) => { + this._handleHttpRequest(req, res).catch(next); + }, + { + path: this._basePath, + phase: 'rest', + name: 'rest', + }, + ); // Mount our error handler - this._expressApp.use( + middlewareRegistry.errorMiddleware( (err: Error, req: Request, res: Response, next: Function) => { this._onUnhandledError(req, res, err); }, + {name: 'error'}, ); + + this._expressApp.use(middlewareRegistry.requestHandler); } /** @@ -260,17 +290,29 @@ export class RestServer extends Context implements Server, HttpServerLike { * Mount /openapi.json, /openapi.yaml for specs and /swagger-ui, /explorer * to redirect to externally hosted API explorer */ - protected _setupOpenApiSpecEndpoints() { + protected _setupOpenApiSpecEndpoints(middlewareRegistry: MiddlewareRegistry) { if (this.config.openApiSpec.disabled) return; const mapping = this.config.openApiSpec.endpointMapping!; // Serving OpenAPI spec for (const p in mapping) { - this.addOpenApiSpecEndpoint(p, mapping[p]); + middlewareRegistry.middleware( + (req, res) => this._serveOpenApiSpec(req, res, mapping[p]), + { + path: p, + method: 'get', + phase: 'openapi-spec', + }, + ); } const explorerPaths = ['/swagger-ui', '/explorer']; - this._expressApp.get(explorerPaths, (req, res, next) => - this._redirectToSwaggerUI(req, res, next), + middlewareRegistry.middleware( + (req, res, next) => this._redirectToSwaggerUI(req, res, next), + { + path: explorerPaths, + method: 'get', + phase: 'openapi-spec', + }, ); }