diff --git a/CODEOWNERS b/CODEOWNERS index d6a28c580df2..5831c37be04b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -142,6 +142,7 @@ # - Standby owner(s): @deepakrkris @emonddr /packages/rest @deepakrkris @emonddr @hacksparrow /packages/http-server @deepakrkris @emonddr @hacksparrow +/packages/express @raymondfeng /packages/cli/generators/controller @deepakrkris @emonddr @hacksparrow /examples/hello-world @deepakrkris @emonddr @hacksparrow /examples/express-composition @deepakrkris @emonddr @hacksparrow diff --git a/docs/site/Express-middleware.md b/docs/site/Express-middleware.md new file mode 100644 index 000000000000..939d975a2224 --- /dev/null +++ b/docs/site/Express-middleware.md @@ -0,0 +1,343 @@ +--- +lang: en +title: 'Using Express middleware' +keywords: LoopBack 4.0, LoopBack 4, Express, Middleware +sidebar: lb4_sidebar +permalink: /doc/en/lb4/Express-middleware.html +--- + +## Overview + +Express is the most popular web framework for Node.js developers. As quoted +below from the +[Express web site](https://expressjs.com/en/guide/using-middleware.html), +middleware are the basic building blocks for Express applications. + +> Express is a routing and middleware web framework that has minimal +> functionality of its own: An Express application is essentially a series of +> middleware function calls. + +LookBack 4 leverages Express behind the scenes for its REST server +implementation. We decided to not expose middleware capabilities to users while +we pursue an elegant and non-invasive way to fit Express middleware into the +LoopBack 4 programming model nicely. Meanwhile, we have received various +requests and questions from our users on how to use Express middleware with +LoopBack 4 or migrate their usage of Express middleware from LoopBack 3 to +LoopBack 4. + +## Use cases + +The following use cases are identified to allow Express middleware to work with +LoopBack 4: + +### For application developers + +1. Invoke one or more Express middleware handler functions in the REST sequence + before, after, or between existing actions. +2. Apply Express middleware globally to all controllers, or locally to certain + controller classes and methods. +3. Migrate LoopBack 3 applications that use Express middleware +4. Be able to separate Express middleware configuration from its registration + and allow configuration changes at runtime to be effective without restarting + the application. + +### For extension developers + +1. Allow new actions to be added to the REST sequence without requiring code + changes to the application as long as the extension component is mounted to + the application. For example, `@loopback/authentication` contributes an + `Authenticate` action. It should be possible to build an extension module for + [`Helmet`](https://helmetjs.github.io/) to provide better protection for + LoopBack. +2. Allow actions to be invoked in a configurable order. +3. Allow extensions to leverage LoopBack's dependency injection and extension + point/extension capabilities. + +Let's start with a few examples to illustrate how we can bring Express +middleware to LoopBack applications with minimal effort. + +## Use Express middleware within the sequence of actions + +Express middleware can now be plugged into the REST sequence with an +`InvokeMiddleware` action being injected to the default sequence. + +### Invoke Express middleware explicitly in the sequence + +The custom sequence class below invokes two Express middleware +([`helmet`](https://helmetjs.github.io/) and +[`morgan`](https://github.com/expressjs/morgan)) handler functions as the first +step. + +{% include code-caption.html content="src/sequence.ts" %} + +```ts +import helmet from 'helmet'; // For security +import morgan from 'morgan'; // For http access logging + +const middlewareList: ExpressRequestHandler[] = [ + helmet({}), // options for helmet is fixed and cannot be changed at runtime + morgan('combined', {}), // options for morgan is fixed and cannot be changed at runtime +]; + +export class MySequence extends DefaultSequence { + async handle(context: RequestContext): Promise { + try { + const {request, response} = context; + // `this.invokeMiddleware` is an injected function to invoke a list of + // Express middleware handler functions + const finished = await this.invokeMiddleware(context, middlewareList); + if (finished) { + // The http response has already been produced by one of the Express + // middleware. We should not call further actions. + return; + } + const route = this.findRoute(request); + const args = await this.parseParams(request, route); + const result = await this.invoke(route, args); + + this.send(response, result); + } catch (error) { + this.reject(context, error); + } + } +} +``` + +### Register Express middleware to be executed by `InvokeMiddleware` actions + +While the explicit Express middleware invocation is easy and simple, there are +some limitations. + +1. The list of Express middleware has to be hard-coded in `src/sequence.ts`. + It's not easy to plug in a new middleware. +2. The configuration of Express middleware is fixed unless we use dependency + injections for such values in `src/sequence.ts`. + +We provide another option to make invocation of Express middleware more flexible +and extensible. The `InvokeMiddleware` actions within the sequence can discover +registered middleware and invoke them in a chain. + +We first register middleware against the default or a named chain using APIs +from `RestApplication`. It can happen in the constructor of an application. + +{% include code-caption.html content="src/application.ts" %} + +```ts +import morgan from 'morgan'; +import {ApplicationConfig} from '@loopback/core'; +import {RestApplication} from '@loopback/rest'; + +export class MyApplication extends RestApplication { + constructor(config: ApplicationConfig) { + this.expressMiddleware( + morgan, + {}, // default config + { + // Allow configuration to be injected to allow dynamic changes to + // morgan logging by configuring `middleware.morgan` to a new value + injectConfiguration: 'watch', + key: 'middleware.morgan', + }, + ); + } +} +``` + +## Use Express middleware as interceptors for controllers + +The LoopBack global and local interceptors now also serve as an avenue to attach +middleware logic to specific points of controller invocations, such as global, +class, or method levels. + +There are a few options to wrap an Express middleware module into an LoopBack 4 +interceptor. + +- toInterceptor: wraps an Express handler function to a LoopBack interceptor + function +- createInterceptor: creates a LoopBack interceptor function from an Express + factory function with configuration +- defineInterceptorProvider: creates a LoopBack provider class for interceptors + from an Express factory function with configuration. This is only necessary + that injection and/or change of configuration is needed. The provider class + then needs to be bound to the application context hierarchy as a global or + local interceptor. + +Let's walk through a few examples: + +### Adapt an Express middleware handler function to an interceptor + +If the Express middleware only exposes the handler function without a factory or +a single instance is desired, use `toInterceptor`. + +```ts +import {toInterceptor} from '@loopback/express'; +import morgan from 'morgan'; + +const morganInterceptor = toInterceptor(morgan('combined')); +``` + +### Create an interceptor from Express middleware factory function and configuration + +When the Express middleware module exports a factory function that takes an +optional argument for configuration, use `createInterceptor`. + +```ts +import {createInterceptor} from '@loopback/express'; +import helmet, {IHelmetConfiguration} from 'helmet'; +const helmetConfig: IHelmetConfiguration = {}; +const helmetInterceptor = createInterceptor(helmet, helmetConfig); +``` + +If the Express middleware module does not expose a factory function conforming +to the `ExpressMiddlewareFactory` signature, a wrapper can be created. For +example: + +```ts +import morgan from 'morgan'; + +// Register `morgan` express middleware +// Create a middleware factory wrapper for `morgan(format, options)` +const morganFactory = (config?: morgan.Options) => morgan('combined', config); +``` + +### Define a provider class for middleware-based interceptor + +It's often desirable to allow dependency injection of middleware configuration +for the middleware. We can use `defineInterceptorProvider` to simplify +definition of such provider classes. + +```ts +import {defineInterceptorProvider} from '@loopback/express'; +import helmet, {IHelmetConfiguration} from 'helmet'; + +const helmetProviderClass = defineInterceptorProvider( + helmet, + {}, // default config +); +``` + +Alternatively, we can create a subclass of +`ExpressMiddlewareInterceptorProvider`. + +```ts +import {config} from '@loopback/context'; +import { + ExpressMiddlewareInterceptorProvider, + createMiddlewareInterceptorBinding, +} from '@loopback/express'; +import helmet, {IHelmetConfiguration} from 'helmet'; + +class HelmetInterceptorProvider extends ExpressMiddlewareInterceptorProvider< + IHelmetConfiguration +> { + constructor(@config() helmetConfig?: IHelmetConfiguration) { + super(helmet, helmetConfig); + } +} +``` + +The provider class can then be registered to the application. For example, the +code below can be used in the constructor of your `Application` subclass. + +```ts +const binding = createMiddlewareInterceptorBinding(HelmetInterceptorProvider); +this.add(binding); +``` + +### Apply Express middleware as invocation interceptors + +With the ability to wrap Express middleware as LoopBack 4 interceptors, we can +use the same programming model to register middleware as global interceptors or +local interceptors denoted by `@intercept` decorators at class and method +levels. + +The middleware interceptor function can be directly referenced by `@intercept`. + +```ts +import morgan from 'morgan'; +const morganInterceptor = toInterceptor(morgan('combined')); +class MyController { + @intercept(morganInterceptor) + hello(msg: string) { + return `Hello, ${msg}`; + } +} +``` + +It's also possible to bind the middleware to a context as a local or global +interceptor. + +```ts +import helmet, {IHelmetConfiguration} from 'helmet'; +const binding = registerExpressMiddlewareInterceptor( + app, + helmet, + {}, + { + // As a global interceptor + global: true, + key: 'interceptors.helmet', + }, +); +``` + +For a bound local interceptor, the binding key can now be used with +`@intercept`. + +```ts +@intercept('interceptors.helmet') +class MyController { + hello(msg: string) { + return `Hello, ${msg}`; + } +} +``` + +### Use `lb4 interceptor` command to create interceptors for Express middleware + +The `lb4 interceptor` can be used to generate a skeleton implementation of +global or local interceptors. We can update the generated code to plug in +Express middleware. For example, to add +[helmet](https://github.com/helmetjs/helmet) as the security middleware: + +```sh +lb4 interceptor +? Interceptor name: Helmet +? Is it a global interceptor? Yes +? Group name for the global interceptor: ('') middleware + create src/interceptors/helmet.interceptor.ts + update src/interceptors/index.ts + +Interceptor Helmet was created in src/interceptors/ +``` + +Let's update `src/interceptors/helmet.interceptor.ts: + +```ts +import {config, globalInterceptor} from '@loopback/core'; +import helmet, {IHelmetConfiguration} from 'helmet'; +import {ExpressMiddlewareInterceptorProvider} from '@loopback/express'; + +@globalInterceptor('middleware', {tags: {name: 'Helmet'}}) +export class MorganInterceptor extends ExpressMiddlewareInterceptorProvider< + IHelmetConfiguration +> { + constructor( + @config() + options: IHelmetConfiguration = { + hidePoweredBy: true, + }, + ) { + super(helmet, options); + } +} +``` + +## What's behind the scenes + +`Middleware` and `Interceptor` are key concepts that allow Express middleware +into LoopBack seamlessly. Please read the following pages to better understand +the architecture. + +- [Interceptors](Interceptors.md) +- [Middleware](Middleware.md) diff --git a/docs/site/FAQ.md b/docs/site/FAQ.md index 1ceb4e8e94cf..7ea75f527b72 100644 --- a/docs/site/FAQ.md +++ b/docs/site/FAQ.md @@ -79,8 +79,8 @@ statements. ### LoopBack 3 vs LoopBack 4 We recommend that current users migrate to LoopBack 4, which can be done through -following the [migration guide](migration-overview.html) and new users start -with LoopBack 4. See +following the [migration guide](migration/overview.md) and new users start with +LoopBack 4. See [Differences between LoopBack v3 and v4](Understanding-the-differences.md) if you're interested in the differences between the two versions. @@ -91,7 +91,7 @@ LoopBack provides a self-hosted and a redirect to an Documentation to disable both API Explorers: -- [Disable redirect to API Explorer](https://loopback.io/doc/en/lb4/Self-hosted-rest-api-explorer.html#disable-self-hosted-api-explorer) +- [Disable redirect to API Explorer](https://loopback.io/doc/en/lb4/Server.html#disable-redirect-to-api-explorer) - [Disable Self-Hosted API Explorer](https://loopback.io/doc/en/lb4/Self-hosted-rest-api-explorer.html#disable-self-hosted-api-explorer) ### How do I send a custom response? @@ -179,7 +179,7 @@ This will result in a custom response body and a new header, `x-secret-sauce`. ### Where do I find the default binding keys? Binding keys used by `@loopback/*` packages are consolidated under -[Reserved binding keys](https://loopback.io/doc/en/lb4/Reserved-binding-keys.html). +[Reserved binding keys](Reserved-binding-keys.md). ### What is the difference between general and configuration bindings? @@ -190,10 +190,8 @@ create a completely separate key. See ### Can I attach an Express router? -Yes. See -[Mounting an Express router](https://loopback.io/doc/en/lb4/Routes.html#mounting-an-express-router) +Yes. See [Mounting an Express Router](Routes.md#mounting-an-express-router). ### Can I mount an Express middleware? -LoopBack 4 doesn't have first-class support for Express middleware. However, -[there are workarounds](https://github.com/strongloop/loopback-next/issues/1293). +Yes. See [Using Express Middleware](Express-middleware.md). diff --git a/docs/site/MONOREPO.md b/docs/site/MONOREPO.md index 697a0bc1789d..49ba50d30992 100644 --- a/docs/site/MONOREPO.md +++ b/docs/site/MONOREPO.md @@ -38,7 +38,7 @@ The [loopback-next](https://github.com/strongloop/loopback-next) repository uses | [example-todo-list](https://github.com/strongloop/loopback-next/tree/master/examples/todo-list) | @loopback/example-todo-list | Continuation of the todo example using relations in LoopBack 4 | | [example-todo](https://github.com/strongloop/loopback-next/tree/master/examples/todo) | @loopback/example-todo | A basic tutorial for getting started with Loopback 4 | | [example-validation-app](https://github.com/strongloop/loopback-next/tree/master/examples/validation-app) | @loopback/example-validation-app | An example demonstrating how to add validation in a LoopBack 4 application | -| [extension-health](https://github.com/strongloop/loopback-next/tree/master/extensions/health) | @loopback/extension-health | Expose health check related endpoints | +| [express](https://github.com/strongloop/loopback-next/tree/master/packages/express) | @loopback/express | Integrate with Express and expose middleware infrastructure for sequence and interceptors | | [extension-health](https://github.com/strongloop/loopback-next/tree/master/extensions/health) | @loopback/extension-health | Expose health check related endpoints | | [extension-logging](https://github.com/strongloop/loopback-next/tree/master/extensions/logging) | @loopback/extension-logging | Add Winston Logger and Fluentd integration | | [extension-metrics](https://github.com/strongloop/loopback-next/tree/master/extensions/metrics) | @loopback/extension-metrics | Report metrics to Prometheus | | [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! | diff --git a/docs/site/Middleware.md b/docs/site/Middleware.md new file mode 100644 index 000000000000..f3c7feeddb9b --- /dev/null +++ b/docs/site/Middleware.md @@ -0,0 +1,340 @@ +--- +lang: en +title: 'Middleware' +keywords: LoopBack 4.0, LoopBack 4, Express, Middleware +sidebar: lb4_sidebar +permalink: /doc/en/lb4/Middleware.html +--- + +## Overview + +Node.js web frameworks such as `Express` and `Koa` use middleware as the basic +building blocks to compose a pipeline of functions that handles HTTP requests +and responses. + +LookBack 4 leverages Express behind the scenes for its REST server +implementation. We decided to not expose Express's native middleware +capabilities to users while we pursue an elegant and non-invasive way to fit +Express middleware into the LoopBack 4 programming model nicely. Meanwhile, we +have received various requests and questions from our users on how to use +Express middleware with LoopBack 4 or migrate their usage of Express middleware +from LoopBack 3 to LoopBack 4. + +We agree that middleware is a powerful construct for LoopBack developers. Being +able to reuse the great assets from Express's middleware ecosystem is a critical +objective. It's also very important to fully leverage LoopBack's extensibility +and composability to plug middleware into the framework. Let's look at some of +key perspectives to map use cases to our design. + +## Design goals + +The core of LoopBack is built upon patterns of Inversion of Control, Dependency +Injection, as well as Extension Point and Extensions. + +- Open up the [Sequence](Sequence.md) to expose + [extension points](Extension-point-and-extensions.md) that allow custom logic, + such as logging, monitoring, and rate limiting, to be plugged in as middleware + for http request/response processing. It should be possible to contribute + middleware without requiring manual update of the `Sequence` class. Components + should be able to leverage dependency injection to externalize the + configuration. For example, we can build a Helmet component for LoopBack to + add security protection middleware to our Sequence. + +- Embrace and adapt existing Express middleware modules with minimal effort + + - Run an explicit list of Express middleware handler functions + - Support the factory and configuration pattern used by most Express + middleware + - Register middleware in any order with the ability to control the order of + execution + - Allow configuration to be updated at runtime by simply rebinding the + configuration value and make it possible for the middleware to pick up. For + example, adjust logging level from info to debug if necessary. + - Allow Express middleware to used with LoopBack in two tiers: + - As middleware in the sequence to handle all requests/responses + - As global or local interceptors around controller method invocations + +- Build on top of the [Interceptors](Interceptors.md) infrastructure. We have + used interceptors within the `InvokeMethod` action of a sequence. Expanding + such capability to other actions is a natural choice. We're not reinventing + the wheel as we are just extending the interceptor idea to new use cases.The + Middleware and MiddlewareChain are not new concepts. They are just + specialization of GenericInterceptor and GenericInterceptorChain. Being able + to use the same programming model concepts for different tiers provide the + consistency and ease of learning. + +## LoopBack style middleware + +Built on top of idea of interceptors, middleware is specialized functions that +work with the `MiddlewareContext` - a wrapper object for `request` and +`response`. The signature is of `Middleware` is: + +```ts +import {MiddlewareContext} from '@loopback/express'; +import {Next, ValueOrPromise, InvocationResult} from '@loopback/context'; + +(context: MiddlewareContext, next: Next) => ValueOrPromise; +``` + +For example, a simple logging middleware can be created and registered as +follows. + +{% include code-caption.html content="src/middleware/log.middleware.ts" %} + +```ts +const log: Middleware = async (middlewareCtx, next) => { + const {request} = middlewareCtx; + console.log('Request: %s %s', request.method, request.originalUrl); + try { + // Proceed with next middleware + await next(); + // Process response + console.log( + 'Response received for %s %s', + request.method, + request.originalUrl, + ); + } catch (err) { + // Catch errors from downstream middleware + console.error( + 'Error received for %s %s', + request.method, + request.originalUrl, + ); + throw err; + } +}; + +app.middleware(log); +``` + +### Why not use Express middleware as-is? + +You may wonder why we don't expose Express middleware directly. There are some +gaps in Express middleware that we would like to close to better align with +LoopBack's architecture. + +1. Express middleware are one-way handlers that mostly process requests and + there is no first-class facility to handle responses. We would love to + support + [Koa style middleware](https://github.com/koajs/koa/blob/master/docs/guide.md#writing-middleware) + that use `async/await` to allow cascading behaviors. +2. Express middleware are added by the order of `app.use()` and it's hard to + contribute middleware from other modules. +3. Express does not allow dependency injection. We would like to enable + injection of middleware configurations. + +To harvest the best of breeds of both frameworks, we build the integration on +top of the [interceptor](Interceptors.md) with community input as illustrated in +the diagram below: + +![middleware](imgs/middleware.png) + +In the diagram, a specialized type of interceptors is introduced as `Middleware` +for LoopBack. Our middleware is basically a function with the signature: + +```ts +(context: MiddlewareContext, next: Next) => ValueOrPromise; +``` + +LoopBack organizes middleware into one or more extension points exposed by +`InvokeMiddleware` providers. Middleware are registered as extensions. Within +the sequence, when `invokeMiddleware` is executed, a chain is formed with +discovered middleware, which are then invoked in a cascading fashion. + +Please note that `Middleware` and related concepts are inherited from +interceptors. + +![middleware-classes](imgs/middleware-classes.png) + +### Introduce middleware to REST sequence of actions + +There are a few actions involved in the default sequence. See `Sequence.md` for +more details. + +It's often desirable to reuse Express middleware in the sequence to handle API +requests/responses without reinventing the wheel. We now add an +`InvokeMiddleware` action as the first step in the default sequence. The action +itself is an interceptor chain of `MiddlewareContext`. It uses the powerful +[extension point/extension pattern](Extension-point-and-extensions.md) to +discover registered Express middleware and invoke them as a chain. + +#### Default sequence + +The generated `src/sequence.ts` now has a new `middleware` property in +`DefaultSequence` to allow injection of the middleware chain while maintaining +backward compatibility of the constructor. + +{% include code-caption.html content="src/sequence.ts" %} + +```ts +export class DefaultSequence implements SequenceHandler { + /** + * Optional middleware chain + * Invokes registered middleware (injected via SequenceActions.MIDDLEWARE). + */ + @inject(SequenceActions.MIDDLEWARE, {optional: true}) + protected invokeMiddleware: InvokeMiddleware = () => false; + + // ... + async handle(context: RequestContext): Promise { + try { + const {request, response} = context; + const finished = await this.invokeMiddleware(context); + if (finished) return; + const route = this.findRoute(request); + const args = await this.parseParams(request, route); + const result = await this.invoke(route, args); + + debug('%s result -', route.describe(), result); + this.send(response, result); + } catch (error) { + this.reject(context, error); + } + } +} +``` + +#### Extend sequence with more than one middleware actions + +Sometimes we want to add middleware to the sequence in between other actions, +for example, do some post-processing before the result is written to the HTTP +response. This can be achieved by overriding the sequence implementation to call +`invokeMiddleware` as needed. + +{% include code-caption.html content="src/sequence.ts" %} + +```ts +export class SequenceWithMiddleware extends DefaultSequence { + async handle(context: RequestContext): Promise { + try { + const {request, response} = context; + // The default middleware chain + let finished = await this.invokeMiddleware(context); + if (finished) return; + const route = this.findRoute(request); + const args = await this.parseParams(request, route); + const result = await this.invoke(route, args); + + // The result from the invocation can be bound to the request context + // so that downstream middleware can receive the value via dependency + // injection or context lookup + context.bind('invocation.result').to(result); + + // The second middleware chain for post-invocation processing + // POST_INVOCATION_MIDDLEWARE is the name of the extension point + // for post-invocation middleware + finished = await this.invokeMiddleware(context, { + extensionPoint: POST_INVOCATION_MIDDLEWARE, + }); + if (finished) return; + this.send(response, result); + } catch (error) { + this.reject(context, error); + } + } +} +``` + +It's also possible to inject multiple instances of `InvokeMiddleware`, each of +which has its own extension point name. + +{% include code-caption.html content="src/sequence.ts" %} + +```ts +export class SequenceWithMiddleware extends DefaultSequence { + /** + * Optional middleware chain + * Invokes registered middleware (injected via SequenceActions.MIDDLEWARE). + */ + @inject('middleware.postInvoke', {optional: true}) + protected middlewarePostInvoke: InvokeMiddleware = () => {}; + + async handle(context: RequestContext): Promise { + try { + const {request, response} = context; + // The default middleware chain + await this.invokeMiddleware(context); + const route = this.findRoute(request); + const args = await this.parseParams(request, route); + const result = await this.invoke(route, args); + + // The second middleware chain for post-invocation processing + await this.middlewarePostInvoke(context); + this.send(response, result); + } catch (error) { + this.reject(context, error); + } + } +} +``` + +Now we can set up applications to leverage the new sequence: + +{% include code-caption.html content="src/application.ts" %} + +```ts +export class MyApplication extends RestApplication { + constructor(config: ApplicationConfig) { + // Create another middleware phase. This is NOT needed if we call the same + // `invokeMiddleware()` with different extension point (chain) names. + this.bind('middleware.postInvoke') + .toProvider(InvokeMiddlewareProvider) + .tag({[CoreTags.EXTENSION_POINT]: POST_INVOCATION_MIDDLEWARE}); + this.sequence(SequenceWithMiddleware); + + // Register a middleware + // `spy` is the factory function of a middleware + // + const spyBinding = this.expressMiddleware( + spy, + undefined, // configuration will be needed here as we bind it to the context + { + key: 'middleware.spy', + extensionPointName: POST_INVOCATION_MIDDLEWARE, + }, + ) + // Set the scope to be `TRANSIENT` so that the new config can be loaded + .inScope(BindingScope.TRANSIENT); + + this.configure(spyBinding.key).to({action: 'log'}); + } +} +``` + +### Register middleware to be executed by `InvokeMiddleware` actions + +To use existing Express middleware, please check out +[Using Express Middleware](./Express-middleware.md). + +{% include code-caption.html content="src/application.ts" %} + +```ts +import {ApplicationConfig} from '@loopback/core'; +import {RestApplication} from '@loopback/rest'; +import {log} from './middleware/log.middleware'; + +export class MyApplication extends RestApplication { + constructor(config: ApplicationConfig) { + this.middleware(log); + } +} +``` + +Middleware can also be contributed using components. + +```ts +import morgan from 'morgan'; +import {Component} from '@loopback/core'; +import { + createMiddlewareBinding, + defineInterceptorProvider, +} from '@loopback/express'; + +const binding = createMiddlewareBinding(defineInterceptorProvider(morgan)); +class MyComponent implements Component { + bindings = [binding]; +} +app.configure(binding.key).to({}); +app.component(MyComponent); +``` diff --git a/docs/site/express-with-lb4-rest-tutorial.md b/docs/site/express-with-lb4-rest-tutorial.md index c98bde0cece2..3359053c89f9 100644 --- a/docs/site/express-with-lb4-rest-tutorial.md +++ b/docs/site/express-with-lb4-rest-tutorial.md @@ -9,14 +9,15 @@ summary: A simple Express application with LoopBack 4 REST API ## Overview -[Express](https://expressjs.com) is an unopinionated Node.js framework. LoopBack -REST API can be mounted to an Express application and be used as middleware. -This way the user can mix and match features from both frameworks to suit their -needs. +[Express](https://expressjs.com) is an un-opinionated Node.js framework. +LoopBack REST API can be mounted to an Express application and be used as +middleware. This way the user can mix and match features from both frameworks to +suit their needs. {% include note.html content=" If you want to use LoopBack as the host instead and mount your Express application on a LoopBack 4 application, see +[Using Express Middleware](Express-middleware.md) and [Mounting an Express Router](Routes.md#mounting-an-express-router). " %} diff --git a/docs/site/imgs/middleware-classes.png b/docs/site/imgs/middleware-classes.png new file mode 100644 index 000000000000..69de5adfe25b Binary files /dev/null and b/docs/site/imgs/middleware-classes.png differ diff --git a/docs/site/imgs/middleware.png b/docs/site/imgs/middleware.png new file mode 100644 index 000000000000..2d0eee348b8c Binary files /dev/null and b/docs/site/imgs/middleware.png differ diff --git a/docs/site/sidebars/lb4_sidebar.yml b/docs/site/sidebars/lb4_sidebar.yml index 5cd3fd1643d2..4b0ff9643260 100644 --- a/docs/site/sidebars/lb4_sidebar.yml +++ b/docs/site/sidebars/lb4_sidebar.yml @@ -103,6 +103,14 @@ children: url: Parsing-requests.html output: 'web, pdf' + - title: 'Middleware' + url: Middleware.html + output: 'web, pdf' + + - title: 'Using Express Middleware' + url: Express-middleware.html + output: 'web, pdf' + - title: 'Decorators' url: Decorators.html output: 'web, pdf' diff --git a/examples/passport-login/data/db.json b/examples/passport-login/data/db.json index 22fa883bcb82..01dfbc5e7ff2 100644 --- a/examples/passport-login/data/db.json +++ b/examples/passport-login/data/db.json @@ -1,13 +1,17 @@ { "ids": { - "User": 1, - "UserIdentity": 1, + "User": 3, + "UserIdentity": 3463934540288188, "UserCredentials": 1 }, "models": { "User": { + "1": "{\"name\":\"Deepak Rajamohan\",\"username\":\"deepak.r.kris@gmail.com\",\"email\":\"deepak.r.kris@gmail.com\",\"id\":1}", + "2": "{\"name\":\"Open User\",\"username\":\"dplojbdiuc_1585460374@tfbnw.net\",\"email\":\"dplojbdiuc_1585460374@tfbnw.net\",\"id\":2}" }, "UserIdentity": { + "3463934540288186": "{\"id\":\"3463934540288186\",\"provider\":\"facebook\",\"profile\":{\"emails\":[{\"value\":\"deepak.r.kris@gmail.com\"}]},\"authScheme\":\"facebook\",\"created\":\"2020-05-01T02:41:33.452Z\",\"userId\":1}", + "104566694532668": "{\"id\":\"104566694532668\",\"provider\":\"facebook\",\"profile\":{\"emails\":[{\"value\":\"dplojbdiuc_1585460374@tfbnw.net\"}]},\"authScheme\":\"facebook\",\"created\":\"2020-05-01T01:43:23.082Z\",\"userId\":2}" }, "UserCredentials": {} } diff --git a/examples/passport-login/package-lock.json b/examples/passport-login/package-lock.json index 32aa17b72764..92c47dbd4e4f 100644 --- a/examples/passport-login/package-lock.json +++ b/examples/passport-login/package-lock.json @@ -1684,12 +1684,12 @@ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, "passport": { - "version": "0.1.18", - "resolved": "https://registry.npmjs.org/passport/-/passport-0.1.18.tgz", - "integrity": "sha1-yCZEedy2QUytu2Z1LRKzfgtlJaE=", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.4.1.tgz", + "integrity": "sha512-IxXgZZs8d7uFSt3eqNjM9NQ3g3uQCW5avD8mRNoXV99Yig50vjuaez6dQK2qC0kVWPRTujxY0dWgGfT09adjYg==", "requires": { - "pause": "0.0.1", - "pkginfo": "0.2.x" + "passport-strategy": "1.x.x", + "pause": "0.0.1" } }, "passport-facebook": { @@ -1753,6 +1753,17 @@ "openid": "0.5.x", "passport": "~0.1.3", "pkginfo": "0.2.x" + }, + "dependencies": { + "passport": { + "version": "0.1.18", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.1.18.tgz", + "integrity": "sha1-yCZEedy2QUytu2Z1LRKzfgtlJaE=", + "requires": { + "pause": "0.0.1", + "pkginfo": "0.2.x" + } + } } }, "passport-strategy": { diff --git a/examples/passport-login/package.json b/examples/passport-login/package.json index cc15fdb0b823..c6fc6c9173f5 100644 --- a/examples/passport-login/package.json +++ b/examples/passport-login/package.json @@ -40,6 +40,7 @@ "@loopback/boot": "^2.1.2", "@loopback/context": "^3.6.0", "@loopback/core": "^2.4.2", + "@loopback/express": "^1.0.0", "@loopback/openapi-v3": "^3.3.0", "@loopback/repository": "^2.3.0", "@loopback/rest": "^3.3.2", @@ -49,6 +50,7 @@ "@loopback/service-proxy": "^2.1.2", "@types/jsonwebtoken": "8.3.9", "@types/lodash": "^4.14.149", + "@types/passport": "^1.0.3", "@types/passport-facebook": "^2.1.9", "@types/passport-google-oauth": "^1.0.41", "@types/passport-google-oauth2": "^0.1.3", @@ -63,6 +65,7 @@ "jade": "^1.11.0", "lodash": "^4.17.15", "p-event": "^4.1.0", + "passport": "^0.4.1", "passport-facebook": "^3.0.0", "passport-google": "^0.3.0", "passport-google-oauth2": "^0.2.0", diff --git a/examples/passport-login/src/application.ts b/examples/passport-login/src/application.ts index deb090487de2..7156fb2b293a 100644 --- a/examples/passport-login/src/application.ts +++ b/examples/passport-login/src/application.ts @@ -5,7 +5,7 @@ import {BootMixin} from '@loopback/boot'; import {RepositoryMixin} from '@loopback/repository'; -import {RestApplication} from '@loopback/rest'; +import {RestApplication, toInterceptor} from '@loopback/rest'; import {ServiceMixin} from '@loopback/service-proxy'; import {MySequence} from './sequence'; import {AuthenticationComponent} from '@loopback/authentication'; @@ -16,10 +16,12 @@ import { LocalAuthStrategy, SessionStrategy, BasicStrategy, + FacebookOauth, } from './authentication-strategies'; import {PassportUserIdentityService, UserServiceBindings} from './services'; import {ApplicationConfig, createBindingFromClass} from '@loopback/core'; import {CrudRestComponent} from '@loopback/rest-crud'; +import passport from "passport"; export class OAuth2LoginApplication extends BootMixin( ServiceMixin(RepositoryMixin(RestApplication)), @@ -35,6 +37,17 @@ export class OAuth2LoginApplication extends BootMixin( this.component(AuthenticationComponent); this.component(CrudRestComponent); + this.bind('passport-init-mw').to(toInterceptor(passport.initialize())); + this.bind('passport-session-mw').to(toInterceptor(passport.session())); + passport.serializeUser(function(user: any, done) { + done(null, user); + }); + + passport.deserializeUser(function(user: any, done) { + done(null, user); + }); + this.bind('passport-facebook').toProvider(FacebookOauth); + this.projectRoot = __dirname; // Customize @loopback/boot Booter Conventions here this.bootOptions = { diff --git a/examples/passport-login/src/authentication-strategies/types.ts b/examples/passport-login/src/authentication-strategies/types.ts index efbb77101e8e..69acd9f107d3 100644 --- a/examples/passport-login/src/authentication-strategies/types.ts +++ b/examples/passport-login/src/authentication-strategies/types.ts @@ -5,9 +5,15 @@ import axios from 'axios'; import {Profile} from 'passport'; +let passport = require('passport'); import {UserIdentityService} from '@loopback/authentication'; import {User} from '../models'; import {UserProfile, securityId} from '@loopback/security'; +import {toInterceptor} from '@loopback/rest'; +import {StrategyOption} from 'passport-facebook'; +import {inject, Provider, Interceptor} from '@loopback/core'; +import { UserServiceBindings } from '../services'; +import {Strategy as FacebookStrategy} from 'passport-facebook'; export type profileFunction = ( accessToken: string, @@ -91,3 +97,18 @@ export const mapProfile = function (user: User): UserProfile { }; return userProfile; }; + +export class FacebookOauth implements Provider { + constructor( + @inject('facebookOAuth2Options') + public facebookOptions: StrategyOption, + @inject(UserServiceBindings.PASSPORT_USER_IDENTITY_SERVICE) + public userService: UserIdentityService, + ) { + passport.use(new FacebookStrategy(this.facebookOptions, verifyFunctionFactory(this.userService))); + } + + value() { + return toInterceptor(passport.authenticate('facebook')); + } +} diff --git a/examples/passport-login/src/controllers/facebook.controller.ts b/examples/passport-login/src/controllers/facebook.controller.ts new file mode 100644 index 000000000000..90f1f827a31f --- /dev/null +++ b/examples/passport-login/src/controllers/facebook.controller.ts @@ -0,0 +1,58 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/example-passport-login +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + get, + RestBindings, + Response, + RequestWithSession, +} from '@loopback/rest'; +import {inject, intercept} from '@loopback/core'; +import {UserProfile} from '@loopback/security'; + +/** + * Login controller for facebook + */ +export class FacebookController { + constructor( + ) {} + + @intercept('passport-init-mw', 'passport-session-mw', 'passport-facebook') + @get('/auth/facebook') + /** + * Endpoint: '/auth/facebook' + */ + loginToThirdParty( + @inject('authentication.redirect.url') + redirectUrl: string, + @inject('authentication.redirect.status') + status: number, + @inject(RestBindings.Http.RESPONSE) + response: Response, + ) { + response.statusCode = status || 302; + response.setHeader('Location', redirectUrl); + response.end(); + return response; + } + + @intercept('passport-init-mw', 'passport-session-mw', 'passport-facebook') + @get('/facebook/callback') + /** + * Endpoint: '/auth/facebook/callback' + */ + async thirdPartyCallBack( + @inject(RestBindings.Http.REQUEST) request: RequestWithSession, + @inject(RestBindings.Http.RESPONSE) response: Response, + ) { + let user: any = request.user; + const profile = { + ...user, + }; + request.session.user = profile; + response.redirect('/auth/account'); + return response; + } +} diff --git a/examples/passport-login/src/controllers/index.ts b/examples/passport-login/src/controllers/index.ts index 84232fb79d88..6f92defa4850 100644 --- a/examples/passport-login/src/controllers/index.ts +++ b/examples/passport-login/src/controllers/index.ts @@ -6,3 +6,4 @@ export * from './oauth2.controller'; export * from './user.controller'; export * from './ping.controller'; +export * from './facebook.controller'; diff --git a/examples/passport-login/tsconfig.json b/examples/passport-login/tsconfig.json index ec89e209689a..b57a17c97d8f 100644 --- a/examples/passport-login/tsconfig.json +++ b/examples/passport-login/tsconfig.json @@ -33,6 +33,9 @@ { "path": "../../packages/core/tsconfig.json" }, + { + "path": "../../packages/express/tsconfig.json" + }, { "path": "../../packages/openapi-v3/tsconfig.json" }, diff --git a/examples/passport-login/web-application/views/pages/login.jade b/examples/passport-login/web-application/views/pages/login.jade index 5dc15916f817..560c9a53b8a8 100644 --- a/examples/passport-login/web-application/views/pages/login.jade +++ b/examples/passport-login/web-application/views/pages/login.jade @@ -26,7 +26,7 @@ block content h1 Other Login Options ul.list-inline.list-unstyled li - a.btn.btn-primary(href="/api/auth/thirdparty/facebook") Login with Facebook + a.btn.btn-primary(href="/api/auth/facebook") Login with Facebook li a.btn.btn-primary(href="/api/auth/thirdparty/google") Login with Google li diff --git a/examples/todo/package-lock.json b/examples/todo/package-lock.json index c7997617e56d..fa7586354429 100644 --- a/examples/todo/package-lock.json +++ b/examples/todo/package-lock.json @@ -30,18 +30,59 @@ "js-tokens": "^4.0.0" } }, + "@types/body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, "@types/color-name": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", "dev": true }, + "@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==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/eslint-visitor-keys": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", "integrity": "sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==", "dev": true }, + "@types/express": { + "version": "4.17.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.6.tgz", + "integrity": "sha512-n/mr9tZI83kd4azlPG5y997C/M4DNABK9yErhFM6hKdym4kkmd9j0vtsJyjFIwfRBxtrxZtAfGZCNRIBMFLK5w==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "*", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.5", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.5.tgz", + "integrity": "sha512-578YH5Lt88AKoADy0b2jQGwJtrBxezXtVe/MBqWXKZpqx91SnC0pVkVCcxcytz3lWW+cHBYDi3Ysh0WXc+rAYw==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/range-parser": "*" + } + }, "@types/json-schema": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.4.tgz", @@ -54,12 +95,49 @@ "integrity": "sha512-kMNLM5JBcasgYscD9x/Gvr6lTAv2NVgsKtet/hm93qMyf/D1pt+7jeEZklKJKxMVmXjxbRVQQGfqDSfipYCO6w==", "dev": true }, + "@types/mime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", + "integrity": "sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==", + "dev": true + }, + "@types/morgan": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.0.tgz", + "integrity": "sha512-warrzirh5dlTMaETytBTKR886pRXwr+SMZD87ZE13gLMR8Pzz69SiYFkvoDaii78qGP1iyBIUYz5GiXyryO//A==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/node": { "version": "10.17.21", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.21.tgz", "integrity": "sha512-PQKsydPxYxF1DsAFWmunaxd3sOi3iMt6Zmx/tgaagHYmwJ/9cRH91hQkeJZaUGWbvn0K5HlSVEXkn5U/llWPpQ==", "dev": true }, + "@types/qs": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.1.tgz", + "integrity": "sha512-lhbQXx9HKZAPgBkISrBcmAcMpZsmpe/Cd/hY7LGZS5OfkySUBItnPZHgQPssWYUET8elF+yCFBbP1Q0RZPTdaw==", + "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==", + "dev": true + }, + "@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==", + "dev": true, + "requires": { + "@types/express-serve-static-core": "*", + "@types/mime": "*" + } + }, "@typescript-eslint/eslint-plugin": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.30.0.tgz", @@ -265,6 +343,21 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, + "basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "requires": { + "safe-buffer": "5.1.2" + }, + "dependencies": { + "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==" + } + } + }, "bcp47": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/bcp47/-/bcp47-1.1.2.tgz", @@ -417,6 +510,11 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -435,6 +533,11 @@ "safer-buffer": "^2.1.0" } }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1189,6 +1292,33 @@ "minimist": "^1.2.5" } }, + "morgan": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", + "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", + "requires": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.0.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=" + } + } + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -1224,6 +1354,19 @@ "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" }, + "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" + } + }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/examples/todo/package.json b/examples/todo/package.json index 5b14566d0b92..44bbfb7f92bb 100644 --- a/examples/todo/package.json +++ b/examples/todo/package.json @@ -45,6 +45,7 @@ "@loopback/rest-explorer": "^2.1.2", "@loopback/service-proxy": "^2.1.2", "loopback-connector-rest": "^3.6.0", + "morgan": "^1.10.0", "tslib": "^1.11.1" }, "devDependencies": { @@ -56,6 +57,7 @@ "@types/node": "^10.17.21", "@typescript-eslint/eslint-plugin": "^2.30.0", "@typescript-eslint/parser": "^2.30.0", + "@types/morgan": "^1.9.0", "eslint": "^6.8.0", "eslint-config-prettier": "^6.11.0", "eslint-plugin-eslint-plugin": "^2.2.1", diff --git a/examples/todo/src/__tests__/acceptance/todo.acceptance.ts b/examples/todo/src/__tests__/acceptance/todo.acceptance.ts index 9c75e5dd6db4..d95fe1bc7a1a 100644 --- a/examples/todo/src/__tests__/acceptance/todo.acceptance.ts +++ b/examples/todo/src/__tests__/acceptance/todo.acceptance.ts @@ -11,6 +11,7 @@ import { givenHttpServerConfig, toJSON, } from '@loopback/testlab'; +import morgan from 'morgan'; import {TodoListApplication} from '../../application'; import {Todo} from '../../models/'; import {TodoRepository} from '../../repositories/'; @@ -184,6 +185,23 @@ describe('TodoApplication', () => { }); }); + context('allows logging to be reconfigured', () => { + it('logs http requests', async () => { + const logs: string[] = []; + const logToArray = (str: string) => { + logs.push(str); + }; + app.configure('middleware.morgan').to({ + stream: { + write: logToArray, + }, + }); + await client.get('/todos'); + expect(logs.length).to.eql(1); + expect(logs[0]).to.match(/"GET \/todos HTTP\/1\.1" 200 - "-"/); + }); + }); + it('queries todos with a filter', async () => { await givenTodoInstance({title: 'wake up', isComplete: true}); diff --git a/examples/todo/src/application.ts b/examples/todo/src/application.ts index 2290f971ce7a..eb883fc2246e 100644 --- a/examples/todo/src/application.ts +++ b/examples/todo/src/application.ts @@ -9,6 +9,7 @@ import {RepositoryMixin} from '@loopback/repository'; import {RestApplication} from '@loopback/rest'; import {RestExplorerComponent} from '@loopback/rest-explorer'; import {ServiceMixin} from '@loopback/service-proxy'; +import morgan from 'morgan'; import path from 'path'; import {MySequence} from './sequence'; @@ -36,5 +37,29 @@ export class TodoListApplication extends BootMixin( nested: true, }, }; + + this.setupLogging(); + } + + private setupLogging() { + // Register `morgan` express middleware + // Create a middleware factory wrapper for `morgan(format, options)` + const morganFactory = (config?: morgan.Options) => { + this.debug('Morgan configuration', config); + return morgan('combined', config); + }; + + // Print out logs using `debug` + const defaultConfig: morgan.Options = { + stream: { + write: str => { + this._debug(str); + }, + }, + }; + this.expressMiddleware(morganFactory, defaultConfig, { + injectConfiguration: 'watch', + key: 'middleware.morgan', + }); } } diff --git a/examples/todo/src/sequence.ts b/examples/todo/src/sequence.ts index 2cf45180d379..db85cb4d7d17 100644 --- a/examples/todo/src/sequence.ts +++ b/examples/todo/src/sequence.ts @@ -3,10 +3,11 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Context, inject} from '@loopback/context'; +import {inject} from '@loopback/context'; import { FindRoute, InvokeMethod, + InvokeMiddleware, ParseParams, Reject, RequestContext, @@ -18,8 +19,14 @@ import { const SequenceActions = RestBindings.SequenceActions; export class MySequence implements SequenceHandler { + /** + * Optional invoker for registered middleware in a chain. + * To be injected via SequenceActions.INVOKE_MIDDLEWARE. + */ + @inject(SequenceActions.INVOKE_MIDDLEWARE, {optional: true}) + protected invokeMiddleware: InvokeMiddleware = () => false; + constructor( - @inject(RestBindings.Http.CONTEXT) public ctx: Context, @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute, @inject(SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams, @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, @@ -30,12 +37,13 @@ export class MySequence implements SequenceHandler { async handle(context: RequestContext) { try { const {request, response} = context; + await this.invokeMiddleware(context); const route = this.findRoute(request); const args = await this.parseParams(request, route); const result = await this.invoke(route, args); this.send(response, result); - } catch (error) { - this.reject(context, error); + } catch (err) { + this.reject(context, err); } } } diff --git a/packages/cli/generators/app/templates/src/sequence.ts.ejs b/packages/cli/generators/app/templates/src/sequence.ts.ejs index 24339017ff1e..e99e267a51e5 100644 --- a/packages/cli/generators/app/templates/src/sequence.ts.ejs +++ b/packages/cli/generators/app/templates/src/sequence.ts.ejs @@ -2,6 +2,7 @@ import {inject} from '@loopback/context'; import { FindRoute, InvokeMethod, + InvokeMiddleware, ParseParams, Reject, RequestContext, @@ -13,6 +14,13 @@ import { const SequenceActions = RestBindings.SequenceActions; export class MySequence implements SequenceHandler { + /** + * Optional invoker for registered middleware in a chain. + * To be injected via SequenceActions.INVOKE_MIDDLEWARE. + */ + @inject(SequenceActions.INVOKE_MIDDLEWARE, {optional: true}) + protected invokeMiddleware: InvokeMiddleware = () => false; + constructor( @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute, @inject(SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams, @@ -24,6 +32,8 @@ export class MySequence implements SequenceHandler { async handle(context: RequestContext) { try { const {request, response} = context; + const finished = await this.invokeMiddleware(context); + if (finished) return; const route = this.findRoute(request); const args = await this.parseParams(request, route); const result = await this.invoke(route, args); diff --git a/packages/express/.npmrc b/packages/express/.npmrc new file mode 100644 index 000000000000..cafe685a112d --- /dev/null +++ b/packages/express/.npmrc @@ -0,0 +1 @@ +package-lock=true diff --git a/packages/express/LICENSE b/packages/express/LICENSE new file mode 100644 index 000000000000..dc2da40f9aa5 --- /dev/null +++ b/packages/express/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) IBM Corp. 2020. +Node module: @loopback/express +This project is licensed under the MIT License, full text below. + +-------- + +MIT License + +MIT License Copyright (c) IBM Corp. 2020 + +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 (including the next +paragraph) 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/README.md b/packages/express/README.md new file mode 100644 index 000000000000..24a250bec15e --- /dev/null +++ b/packages/express/README.md @@ -0,0 +1,153 @@ +# @loopback/express + +This package adds middleware support for LoopBack 4 and allows Express +middleware to be plugged into LoopBack seamlessly. It's used by `@loopback/rest` +to support Express middleware with `InvokeMiddleware` action within the REST +sequence. + +See [documentation](https://loopback.io/doc/en/lb4/Express-middleware.html) for +more details. + +## Overview + +This module provides the following APIs: + +- Helper + +- new custom routing engine (special thanks to @bajtos)! +- tools for defining your application routes +- OpenAPI 3.0 spec (`openapi.json`/`openapi.yaml`) generation using + `@loopback/openapi-v3` +- a default sequence implementation to manage the request and response lifecycle + +## Installation + +To use this package, you'll need to install `@loopback/express`. + +```sh +npm i @loopback/express +``` + +## Basic Use + +1. Adapt an Express middleware + +The registration can happen in the constructor of an application. + +{% include code-caption.html content="src/application.ts" %} + +```ts +import morgan from 'morgan'; + +export class MyApplication extends RestApplication { + constructor(options: ApplicationConfig = {}) { + super(options); + + // Register `morgan` express middleware + this.expressMiddleware('middleware.morgan', morgan('combined')); + } +} +``` + +2. Create your own middleware + +The LoopBack middleware is defined as a function with the following signature: + +```ts +(context: MiddlewareContext, next: Next) => ValueOrPromise; +``` + +It's very easy to write a simple logging middleware using `async/await`: + +```ts +const log: Middleware = async (middlewareCtx, next) => { + const {request} = middlewareCtx; + console.log('Request: %s %s', request.method, request.originalUrl); + try { + // Proceed with next middleware + await next(); + // Process response + console.log( + 'Response received for %s %s', + request.method, + request.originalUrl, + ); + } catch (err) { + // Catch errors from downstream middleware + console.error( + 'Error received for %s %s', + request.method, + request.originalUrl, + ); + throw err; + } +}; +``` + +3. Use Express middleware as interceptors + +With the ability to wrap Express middleware as LoopBack 4 interceptors, we can +use the same programming model to register middleware as global interceptors or +local interceptors denoted by `@intercept` decorators at class and method +levels. + +The middleware interceptor function can be directly referenced by `@intercept`. + +```ts +import morgan from 'morgan'; +const morganInterceptor = toInterceptor(morgan('combined')); +class MyController { + @intercept(morganInterceptor) + hello(msg: string) { + return `Hello, ${msg}`; + } +} +``` + +It's also possible to bind the middleware to a context as a local or global +interceptor. + +```ts +// Register `morgan` express middleware +// Create a middleware factory wrapper for `morgan(format, options)` +const morganFactory = (config?: morgan.Options) => morgan('combined', config); +const binding = registerExpressMiddlewareInterceptor( + app, + morganFactory, + {}, // morgan options + { + // As a global interceptor + global: true, + }, +); +``` + +For a bound local interceptor with `{global: false}`, the binding key can now be +used with `@intercept`. + +```ts +@intercept('interceptors.morgan') +class MyController { + hello(msg: string) { + return `Hello, ${msg}`; + } +} +``` + +## 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/index.d.ts b/packages/express/index.d.ts new file mode 100644 index 000000000000..c1fc0bb183b0 --- /dev/null +++ b/packages/express/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// 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/index.js b/packages/express/index.js new file mode 100644 index 000000000000..a5d74e30fe29 --- /dev/null +++ b/packages/express/index.js @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// 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/index.ts b/packages/express/index.ts new file mode 100644 index 000000000000..362689217c3d --- /dev/null +++ b/packages/express/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './src'; diff --git a/packages/express/package-lock.json b/packages/express/package-lock.json new file mode 100644 index 000000000000..2605b0e1d255 --- /dev/null +++ b/packages/express/package-lock.json @@ -0,0 +1,600 @@ +{ + "name": "@loopback/express", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==", + "requires": { + "@types/connect": "*", + "@types/node": "*" + }, + "dependencies": { + "@types/node": { + "version": "13.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.4.tgz", + "integrity": "sha512-x26ur3dSXgv5AwKS0lNfbjpCakGIduWU1DU91Zz58ONRWrIKGunmZBNv4P7N+e27sJkiGDsw/3fT4AtsqQBrBA==" + } + } + }, + "@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.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.4.tgz", + "integrity": "sha512-x26ur3dSXgv5AwKS0lNfbjpCakGIduWU1DU91Zz58ONRWrIKGunmZBNv4P7N+e27sJkiGDsw/3fT4AtsqQBrBA==" + } + } + }, + "@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.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.6.tgz", + "integrity": "sha512-n/mr9tZI83kd4azlPG5y997C/M4DNABK9yErhFM6hKdym4kkmd9j0vtsJyjFIwfRBxtrxZtAfGZCNRIBMFLK5w==", + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "*", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.5", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.5.tgz", + "integrity": "sha512-578YH5Lt88AKoADy0b2jQGwJtrBxezXtVe/MBqWXKZpqx91SnC0pVkVCcxcytz3lWW+cHBYDi3Ysh0WXc+rAYw==", + "requires": { + "@types/node": "*", + "@types/range-parser": "*" + }, + "dependencies": { + "@types/node": { + "version": "13.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.4.tgz", + "integrity": "sha512-x26ur3dSXgv5AwKS0lNfbjpCakGIduWU1DU91Zz58ONRWrIKGunmZBNv4P7N+e27sJkiGDsw/3fT4AtsqQBrBA==" + } + } + }, + "@types/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-4KCE/agIcoQ9bIfa4sBxbZdnORzRjIw8JNQPLfqoNv7wQl/8f8mRbW68Q8wBsQFoJkPUHGlQYZ9sqi5WpfGSEQ==" + }, + "@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.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.21.tgz", + "integrity": "sha512-PQKsydPxYxF1DsAFWmunaxd3sOi3iMt6Zmx/tgaagHYmwJ/9cRH91hQkeJZaUGWbvn0K5HlSVEXkn5U/llWPpQ==", + "dev": true + }, + "@types/on-finished": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@types/on-finished/-/on-finished-2.3.1.tgz", + "integrity": "sha512-mzVYaYcFs5Jd2n/O6uYIRUsFRR1cHyZLRvkLCU0E7+G5WhY0qBDAR5fUCeZbvecYOSh9ikhlesyi2UfI8B9ckQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/qs": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.1.tgz", + "integrity": "sha512-lhbQXx9HKZAPgBkISrBcmAcMpZsmpe/Cd/hY7LGZS5OfkySUBItnPZHgQPssWYUET8elF+yCFBbP1Q0RZPTdaw==" + }, + "@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" + } + }, + "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" + } + } + } + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "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=" + }, + "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" + }, + "dependencies": { + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "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" + } + } + } + }, + "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" + } + } + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "dependencies": { + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + } + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, + "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.44.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", + "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==" + }, + "mime-types": { + "version": "2.1.27", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", + "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", + "requires": { + "mime-db": "1.44.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" + }, + "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.6", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz", + "integrity": "sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==", + "requires": { + "forwarded": "~0.1.2", + "ipaddr.js": "1.9.1" + } + }, + "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" + }, + "dependencies": { + "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" + } + } + } + }, + "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==" + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-support": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", + "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "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==" + }, + "tslib": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz", + "integrity": "sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==" + }, + "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" + } + }, + "typescript": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.8.3.tgz", + "integrity": "sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==", + "dev": true + }, + "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/package.json b/packages/express/package.json new file mode 100644 index 000000000000..62130d2901dc --- /dev/null +++ b/packages/express/package.json @@ -0,0 +1,65 @@ +{ + "name": "@loopback/express", + "version": "1.0.0", + "description": "@loopback/express", + "keywords": [ + "loopback-extension", + "loopback" + ], + "main": "index.js", + "engines": { + "node": ">=10" + }, + "scripts": { + "build": "lb-tsc", + "build:watch": "lb-tsc --watch", + "pretest": "npm run clean && npm run build", + "test": "lb-mocha --allow-console-logs \"dist/__tests__\"", + "test:dev": "lb-mocha --allow-console-logs dist/__tests__/**/*.js", + "clean": "lb-clean dist *.tsbuildinfo .eslintcache" + }, + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git", + "directory": "packages/express" + }, + "author": "IBM Corp.", + "license": "MIT", + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist", + "src", + "!*/__tests__" + ], + "dependencies": { + "@loopback/context": "^3.6.0", + "@loopback/core": "^2.4.2", + "@loopback/http-server": "^2.1.2", + "@types/body-parser": "^1.19.0", + "@types/express": "^4.17.6", + "@types/express-serve-static-core": "^4.17.5", + "@types/http-errors": "^1.6.3", + "body-parser": "^1.19.0", + "debug": "^4.1.1", + "express": "^4.17.1", + "http-errors": "^1.7.3", + "on-finished": "^2.3.0", + "tslib": "^1.10.0" + }, + "devDependencies": { + "@loopback/build": "^5.3.0", + "@loopback/testlab": "^3.1.2", + "@types/debug": "^4.1.5", + "@types/node": "^10.17.21", + "@types/on-finished": "^2.3.1", + "http-errors": "^1.7.3", + "source-map-support": "^0.5.19", + "typescript": "~3.8.3" + }, + "copyright.owner": "IBM Corp.", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/express/src/__tests__/acceptance/invoke-middleware.acceptance.ts b/packages/express/src/__tests__/acceptance/invoke-middleware.acceptance.ts new file mode 100644 index 000000000000..797724c44029 --- /dev/null +++ b/packages/express/src/__tests__/acceptance/invoke-middleware.acceptance.ts @@ -0,0 +1,37 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {ExpressServer, invokeExpressMiddleware} from '../../'; +import {spy, TestHelper} from './test-helpers'; + +describe('Express middleware registry', () => { + let helper: TestHelper; + let server: ExpressServer; + + beforeEach(givenTestApp); + afterEach(() => helper?.stop()); + + it('invokes Express middleware', async () => { + server.middleware( + async (ctx, next) => { + const finished = await invokeExpressMiddleware( + ctx, + spy({action: 'log'}), + ); + if (finished) return; + return next(); + }, + {key: 'middleware.listOfExpressHandlers'}, + ); + await helper.assertSpyLog(); + }); + + async function givenTestApp() { + helper = new TestHelper(); + helper.bindController(); + await helper.start(); + server = helper.app.expressServer; + } +}); diff --git a/packages/express/src/__tests__/acceptance/middleware-action.acceptance.ts b/packages/express/src/__tests__/acceptance/middleware-action.acceptance.ts new file mode 100644 index 000000000000..571a13584443 --- /dev/null +++ b/packages/express/src/__tests__/acceptance/middleware-action.acceptance.ts @@ -0,0 +1,45 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {registerExpressMiddleware} from '../../'; +import {SpyAction} from '../fixtures/spy-config'; +import {spy, SpyConfig, TestFunction, TestHelper} from './test-helpers'; + +describe('Middleware request interceptor', () => { + let helper: TestHelper; + + function runTests(action: SpyAction, testFn: TestFunction) { + describe(`registerMiddleware - ${action}`, () => { + const spyConfig: SpyConfig = {action}; + beforeEach(givenTestApp); + afterEach(() => helper?.stop()); + + it('registers a middleware interceptor provider class by factory', () => { + const binding = registerExpressMiddleware(helper.app, spy, spyConfig); + return testFn(binding); + }); + + it('registers a middleware interceptor as handler function', () => { + const binding = registerExpressMiddleware(helper.app, spy, spyConfig, { + injectConfiguration: false, + key: 'interceptors.middleware.spy', + }); + expect(binding.key).to.eql('interceptors.middleware.spy'); + return testFn(binding); + }); + }); + } + + runTests('log', binding => helper.testSpyLog(binding)); + runTests('mock', binding => helper.testSpyMock(binding)); + runTests('reject', binding => helper.testSpyReject(binding)); + + function givenTestApp() { + helper = new TestHelper(); + helper.bindController(); + return helper.start(); + } +}); diff --git a/packages/express/src/__tests__/acceptance/middleware-interceptor.acceptance.ts b/packages/express/src/__tests__/acceptance/middleware-interceptor.acceptance.ts new file mode 100644 index 000000000000..fa823c76d454 --- /dev/null +++ b/packages/express/src/__tests__/acceptance/middleware-interceptor.acceptance.ts @@ -0,0 +1,222 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + config, + Constructor, + Context, + ContextView, + createBindingFromClass, + Interceptor, + Provider, +} from '@loopback/context'; +import {InterceptorOrKey} from '@loopback/core'; +import {expect} from '@loopback/testlab'; +import { + createInterceptor, + createMiddlewareInterceptorBinding, + defineInterceptorProvider, + ExpressMiddlewareInterceptorProvider, + registerExpressMiddlewareInterceptor, + toInterceptor, +} from '../..'; +import {ExpressMiddlewareFactory} from '../../types'; +import {SpyAction} from '../fixtures/spy-config'; +import {spy, SpyConfig, TestFunction, TestHelper} from './test-helpers'; + +describe('Middleware interceptor', () => { + let helper: TestHelper; + + context('helpers', () => { + let spyConfig: SpyConfig; + before(() => { + spyConfig = { + action: 'log', + }; + helper = new TestHelper(); + }); + before(givenTestApp); + after(() => helper?.stop()); + + it('wraps a middleware handler to interceptor', async () => { + const fn = spy(spyConfig); + const interceptor = toInterceptor(fn); + await testLocalSpyLog(interceptor); + }); + + it('wraps multiple middleware handlers to interceptor', async () => { + const log = spy({action: 'log'}); + const mock = spy({action: 'mock'}); + + // Chain two Express middleware into one interceptor + const interceptor = toInterceptor(log, mock); + helper.bindController(interceptor); + + await helper.client + .post('/hello') + .send('"World"') + .set('content-type', 'application/json') + .expect(200, 'Hello, Spy') + .expect('x-spy-log', 'POST /hello') + .expect('x-spy-mock', 'POST /hello'); + }); + + it('wraps a middleware factory to interceptor', async () => { + const interceptor = createInterceptor(spy, spyConfig); + await testLocalSpyLog(interceptor); + }); + }); + + context('defineInterceptorProvider', () => { + it('defines a middleware interceptor provider class by factory', () => { + const cls = defineInterceptorProvider(spy); + expect(cls.name).to.eql('spyMiddlewareFactory'); + assertProviderClass(cls); + }); + + it('defines a middleware interceptor provider class with name', () => { + const cls = defineInterceptorProvider(spy, undefined, { + providerClassName: 'SpyMiddlewareInterceptorProvider', + }); + expect(cls.name).to.eql('SpyMiddlewareInterceptorProvider'); + assertProviderClass(cls); + }); + + function assertProviderClass(cls: Constructor>) { + const ctx = new Context(); + const binding = createBindingFromClass(cls); + ctx.add(binding); + const corsFn = ctx.getSync(binding.key); + expect(corsFn).to.be.a.Function(); + } + }); + + context('defineInterceptorProvider with watch', () => { + let spyConfig: SpyConfig; + before(() => { + spyConfig = { + action: 'log', + }; + helper = new TestHelper(); + }); + before(givenTestApp); + after(() => helper?.stop()); + + it('reloads config changes', async () => { + const providerClass = defineInterceptorProvider(spy, spyConfig, { + injectConfiguration: 'watch', + }); + const binding = createMiddlewareInterceptorBinding(providerClass); + helper.app.add(binding); + await testLocalSpyLog(binding.key); + + helper.app.configure(binding.key).to({action: 'mock'}); + await testLocalSpyMock(binding.key); + }); + }); + + function runTests(action: SpyAction, testFn: TestFunction) { + describe(`registerMiddleware - ${action}`, () => { + const spyConfig: SpyConfig = {action}; + beforeEach(givenTestApp); + beforeEach(() => helper.bindController()); + afterEach(() => helper?.stop()); + + it('registers a middleware interceptor provider class by factory', () => { + const binding = registerExpressMiddlewareInterceptor( + helper.app, + spy, + spyConfig, + { + global: true, + }, + ); + return testFn(binding); + }); + + it('registers a middleware interceptor as handler function', () => { + const binding = registerExpressMiddlewareInterceptor( + helper.app, + spy, + spyConfig, + { + global: true, + injectConfiguration: false, + key: 'interceptors.middleware.spy', + }, + ); + expect(binding.key).to.eql('interceptors.middleware.spy'); + return testFn(binding); + }); + + it('registers a middleware interceptor as handler function with name', () => { + const namedSpyFactory: ExpressMiddlewareFactory = cfg => + spy(cfg); + const binding = registerExpressMiddlewareInterceptor( + helper.app, + namedSpyFactory, + spyConfig, + { + global: true, + injectConfiguration: false, + }, + ); + expect(binding.key).to.eql('interceptors.middleware.namedSpyFactory'); + return testFn(binding); + }); + + it('registers a middleware interceptor as handler function without key', () => { + const binding = registerExpressMiddlewareInterceptor( + helper.app, + spy, + spyConfig, + { + global: true, + injectConfiguration: false, + }, + ); + expect(binding.key).to.match(/^interceptors\.middleware\./); + return testFn(binding); + }); + + it('registers a middleware interceptor provider class', () => { + class SpyInterceptorProvider extends ExpressMiddlewareInterceptorProvider< + SpyConfig + > { + constructor(@config.view() configView?: ContextView) { + super(spy, configView); + } + } + const binding = createMiddlewareInterceptorBinding( + SpyInterceptorProvider, + ); + expect(binding.key).to.eql('interceptors.SpyInterceptorProvider'); + helper.app.add(binding); + return testFn(binding); + }); + }); + } + + runTests('log', binding => helper.testSpyLog(binding)); + runTests('mock', binding => helper.testSpyMock(binding)); + runTests('reject', binding => helper.testSpyReject(binding)); + + function givenTestApp() { + helper = new TestHelper(); + return helper.start(); + } + + async function testLocalSpyLog(interceptor: InterceptorOrKey) { + helper.bindController(interceptor); + + await helper.assertSpyLog(); + } + + async function testLocalSpyMock(interceptor: InterceptorOrKey) { + helper.bindController(interceptor); + + await helper.assertSpyMock(); + } +}); diff --git a/packages/express/src/__tests__/acceptance/middleware-registeration.acceptance.ts b/packages/express/src/__tests__/acceptance/middleware-registeration.acceptance.ts new file mode 100644 index 000000000000..6d0cee55ef21 --- /dev/null +++ b/packages/express/src/__tests__/acceptance/middleware-registeration.acceptance.ts @@ -0,0 +1,129 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {config, Provider} from '@loopback/core'; +import {Client, expect} from '@loopback/testlab'; +import {ExpressServer, Middleware} from '../../'; +import {SpyAction} from '../fixtures/spy-config'; +import {spy, SpyConfig, TestFunction, TestHelper} from './test-helpers'; + +describe('Express middleware registry', () => { + let helper: TestHelper; + let server: ExpressServer; + let client: Client; + + beforeEach(givenTestApp); + afterEach(() => helper?.stop()); + + function runTests(action: SpyAction, testFn: TestFunction) { + describe(`app.expressMiddleware - ${action}`, () => { + const spyConfig: SpyConfig = {action}; + + it('registers a middleware interceptor provider class by factory', () => { + const binding = server.expressMiddleware(spy, spyConfig); + return testFn(binding); + }); + + it('registers a middleware interceptor as handler function', () => { + const binding = server.expressMiddleware(spy, spyConfig, { + injectConfiguration: false, + key: 'interceptors.middleware.spy', + }); + expect(binding.key).to.eql('interceptors.middleware.spy'); + return testFn(binding); + }); + + it('registers a middleware interceptor as handler function without a key', () => { + const binding = server.expressMiddleware(spy, spyConfig, { + injectConfiguration: false, + }); + expect(binding.key).to.match(/^middleware\./); + return testFn(binding); + }); + }); + } + + runTests('log', binding => helper.testSpyLog(binding)); + runTests('mock', binding => helper.testSpyMock(binding)); + runTests('reject', binding => helper.testSpyReject(binding)); + + describe('LoopBack middleware registry', () => { + const spyMiddleware: Middleware = async (middlewareCtx, next) => { + const {request, response} = middlewareCtx; + response.set('x-spy-log-req', `${request.method} ${request.path}`); + await next(); + response.set('x-spy-log-res', `${request.method} ${request.path}`); + }; + + it('registers a LoopBack middleware handler', async () => { + server.middleware(spyMiddleware, { + key: 'middleware.spy', + }); + await testSpyLog(); + }); + + it('registers a LoopBack middleware provider', async () => { + class SpyMiddlewareProvider implements Provider { + value() { + return spyMiddleware; + } + } + server.middleware(SpyMiddlewareProvider, { + key: 'middleware.spy', + }); + await testSpyLog(); + }); + + it('registers a LoopBack middleware provider with config injection', async () => { + type SpyConfig = {headerName: string}; + class SpyMiddlewareProviderWithConfig implements Provider { + @config() + private options: SpyConfig; + value(): Middleware { + return async ({request, response}, next) => { + response.set( + `${this.options.headerName}-req`, + `${request.method} ${request.path}`, + ); + await next(); + response.set( + `${this.options.headerName}-res`, + `${request.method} ${request.path}`, + ); + }; + } + } + const binding = server.middleware(SpyMiddlewareProviderWithConfig, { + key: 'middleware.spy', + }); + server.configure(binding.key).to({headerName: 'x-spy'}); + await client + .post('/hello') + .send('"World"') + .set('content-type', 'application/json') + .expect(200, 'Hello, World') + .expect('x-spy-req', 'POST /hello') + .expect('x-spy-res', 'POST /hello'); + }); + + async function testSpyLog() { + await client + .post('/hello') + .send('"World"') + .set('content-type', 'application/json') + .expect(200, 'Hello, World') + .expect('x-spy-log-req', 'POST /hello') + .expect('x-spy-log-res', 'POST /hello'); + } + }); + + async function givenTestApp() { + helper = new TestHelper(); + helper.bindController(); + await helper.start(); + server = helper.app.expressServer; + client = helper.client; + } +}); diff --git a/packages/express/src/__tests__/acceptance/test-helpers.ts b/packages/express/src/__tests__/acceptance/test-helpers.ts new file mode 100644 index 000000000000..27b082f67c92 --- /dev/null +++ b/packages/express/src/__tests__/acceptance/test-helpers.ts @@ -0,0 +1,129 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + Binding, + createProxyWithInterceptors, + intercept, + InterceptorOrKey, +} from '@loopback/context'; +import {Client, givenHttpServerConfig, supertest} from '@loopback/testlab'; +import bodyParser from 'body-parser'; +import {ExpressApplication} from '../../express.application'; +import {SpyAction, SpyConfig} from '../fixtures/spy-config'; +import spyFactory from '../fixtures/spy.middleware'; +export const spy = spyFactory; +export {SpyConfig} from '../fixtures/spy-config'; + +export type TestFunction = (spyBinding: Binding) => Promise; + +export class TestHelper { + readonly app: ExpressApplication; + client: Client; + + constructor() { + this.app = new ExpressApplication({ + express: { + ...givenHttpServerConfig(), + settings: { + env: 'test', + }, + }, + }); + this.app.expressServer.expressMiddleware( + bodyParser.json, + {strict: false}, + {key: 'middleware.bodyParser'}, + ); + } + + async start() { + await this.app.start(); + this.client = supertest(this.app.expressServer.url); + } + + stop() { + return this.app.stop(); + } + + bindController(interceptor?: InterceptorOrKey) { + const interceptors: InterceptorOrKey[] = []; + if (interceptor) interceptors.push(interceptor); + class MyController { + @intercept(...interceptors) + hello(msg: string) { + return `Hello, ${msg}`; + } + } + const binding = this.app.controller(MyController); + this.app.expressServer.expressApp.post('/hello', async (req, res, next) => { + try { + const controller = await this.app.get(binding.key); + const proxy = createProxyWithInterceptors( + controller, + this.app.expressServer.getMiddlewareContext(req), + undefined, + { + type: 'route', + value: controller, + }, + ); + res.send(await proxy.hello(req.body)); + } catch (err) { + next(err); + } + }); + } + + private configureSpy( + spyBinding: Binding, + action: SpyAction = 'log', + ) { + this.app.configure(spyBinding.key).to({action}); + } + + async testSpyLog(spyBinding: Binding) { + this.configureSpy(spyBinding); + + await this.assertSpyLog(); + } + + async assertSpyLog() { + await this.client + .post('/hello') + .send('"World"') + .set('content-type', 'application/json') + .expect(200, 'Hello, World') + .expect('x-spy-log', 'POST /hello'); + } + + async testSpyMock(spyBinding: Binding) { + this.configureSpy(spyBinding, 'mock'); + await this.assertSpyMock(); + } + + async assertSpyMock() { + await this.client + .post('/hello') + .send('"World"') + .set('content-type', 'application/json') + .expect(200, 'Hello, Spy') + .expect('x-spy-mock', 'POST /hello'); + } + + async testSpyReject(spyBinding: Binding) { + this.configureSpy(spyBinding, 'reject'); + await this.assertSpyReject(); + } + + private async assertSpyReject() { + await this.client + .post('/hello') + .send('"World"') + .set('content-type', 'application/json') + .expect(400) + .expect('x-spy-reject', 'POST /hello'); + } +} diff --git a/packages/express/src/__tests__/fixtures/spy-config.ts b/packages/express/src/__tests__/fixtures/spy-config.ts new file mode 100644 index 000000000000..3a82f06f0061 --- /dev/null +++ b/packages/express/src/__tests__/fixtures/spy-config.ts @@ -0,0 +1,21 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export type SpyAction = 'log' | 'mock' | 'reject'; +/** + * Configuration for `spy` middleware + */ +export interface SpyConfig { + /** + * Action for the spy to enforce + * - `log`: set `x-spy-log` http response header and proceed with the + * invocation + * - `mock`: set `x-spy-mock` http response header and return a mock response + * without calling the target + * - `reject`: set `x-spy-reject` http response header and reject the request + * with 400 status code + */ + action: SpyAction; +} diff --git a/packages/express/src/__tests__/fixtures/spy.middleware.ts b/packages/express/src/__tests__/fixtures/spy.middleware.ts new file mode 100644 index 000000000000..ffa3c71af857 --- /dev/null +++ b/packages/express/src/__tests__/fixtures/spy.middleware.ts @@ -0,0 +1,41 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import debugFactory from 'debug'; +import HttpErrors from 'http-errors'; +import {ExpressMiddlewareFactory} from '../..'; +import {SpyConfig} from './spy-config'; + +const debug = debugFactory('loopback:middleware:spy'); + +/** + * An Express middleware factory function that creates a handler to spy on + * requests + */ +const spyMiddlewareFactory: ExpressMiddlewareFactory = config => { + const options: SpyConfig = {action: 'log', ...config}; + return function spy(req, res, next) { + debug('config', options); + switch (options?.action) { + case 'mock': + debug('spy - MOCK'); + res.set('x-spy-mock', `${req.method} ${req.path}`); + res.send('Hello, Spy'); + break; + case 'reject': + debug('spy - REJECT'); + res.set('x-spy-reject', `${req.method} ${req.path}`); + next(new HttpErrors.BadRequest('Request rejected by spy')); + break; + default: + debug('spy - LOG'); + res.set('x-spy-log', `${req.method} ${req.path}`); + next(); + break; + } + }; +}; + +export = spyMiddlewareFactory; diff --git a/packages/express/src/express.application.ts b/packages/express/src/express.application.ts new file mode 100644 index 000000000000..154b5b3e9b86 --- /dev/null +++ b/packages/express/src/express.application.ts @@ -0,0 +1,23 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Application, ApplicationConfig} from '@loopback/core'; +import {ExpressServer} from './express.server'; + +/** + * A LoopBack application with Express server + */ +export class ExpressApplication extends Application { + /** + * Embedded Express Server + */ + readonly expressServer: ExpressServer; + + constructor(readonly config?: ApplicationConfig) { + super(config); + const binding = this.server(ExpressServer); + this.expressServer = this.getSync(binding.key); + } +} diff --git a/packages/express/src/express.server.ts b/packages/express/src/express.server.ts new file mode 100644 index 000000000000..881e31a9723e --- /dev/null +++ b/packages/express/src/express.server.ts @@ -0,0 +1,118 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Context, CoreBindings, inject, Server} from '@loopback/core'; +import {HttpServer, HttpServerOptions} from '@loopback/http-server'; +import debugFactory from 'debug'; +import express from 'express'; +import {toExpressMiddleware} from './middleware'; +import {BaseMiddlewareRegistry} from './middleware-registry'; +import {MiddlewareContext, MIDDLEWARE_CONTEXT, Request} from './types'; + +const debug = debugFactory('loopback:middleware'); + +/** + * Configuration for a LoopBack based Express server + */ +export type ExpressServerConfig = HttpServerOptions & { + /** + * Base path to mount the LoopBack middleware chain + */ + basePath?: string; + /** + * Express settings + */ + settings?: Record; +}; + +/** + * An Express server that provides middleware composition and injection + */ +export class ExpressServer extends BaseMiddlewareRegistry implements Server { + /** + * Base path to mount middleware + */ + readonly basePath: string; + /** + * Embedded Express application + */ + readonly expressApp: express.Application; + + /** + * HTTP/HTTPS server + */ + protected httpServer: HttpServer; + constructor( + @inject(CoreBindings.APPLICATION_CONFIG.deepProperty('express')) + protected readonly config?: ExpressServerConfig, + @inject(CoreBindings.APPLICATION_INSTANCE) + parent?: Context, + ) { + super(parent); + let basePath = config?.basePath ?? ''; + // Trim leading and trailing `/` + basePath = basePath.replace(/(^\/)|(\/$)/, ''); + if (basePath) basePath = '/' + basePath; + this.basePath = basePath; + + this.expressApp = express(); + if (config?.settings) { + for (const p in config?.settings) { + this.expressApp.set(p, config?.settings[p]); + } + } + this.httpServer = new HttpServer(this.expressApp, config); + + // Set up the middleware chain as the 1st Express middleware + this.expressApp.use(this.basePath, toExpressMiddleware(this)); + } + + /** + * Some of the methods below are copied from RestServer + * TODO(rfeng): We might want to refactor some methods from RestServer into + * the base ExpressServer. + */ + get listening(): boolean { + return this.httpServer ? this.httpServer.listening : false; + } + + /** + * The base url for the server, including the basePath if set. For example, + * the value will be 'http://localhost:3000/api' if `basePath` is set to + * '/api'. + */ + get url(): string | undefined { + let serverUrl = this.rootUrl; + if (!serverUrl) return serverUrl; + serverUrl = serverUrl + this.basePath; + return serverUrl; + } + + /** + * The root url for the server without the basePath. For example, the value + * will be 'http://localhost:3000' regardless of the `basePath`. + */ + get rootUrl(): string | undefined { + return this.httpServer && this.httpServer.url; + } + + async start() { + await this.httpServer.start(); + debug('ExpressServer listening at %s', this.httpServer.url); + } + + stop() { + return this.httpServer.stop(); + } + + /** + * Retrieve the middleware context from the request + * @param request - Request object + */ + getMiddlewareContext(request: Request): MiddlewareContext | undefined { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (request as any)[MIDDLEWARE_CONTEXT]; + } +} diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts new file mode 100644 index 000000000000..ec933fcbb9bd --- /dev/null +++ b/packages/express/src/index.ts @@ -0,0 +1,34 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +/** + * The Express integration package for loopback-next. + * + * @remarks + * + * This module introduces Koa style cascading middleware that leverage + * `async/await`. It also enables the ability to plug in existing Express + * middleware as LoopBack middleware or interceptors so that they can be used at + * sequence/action, global interceptor, and local interceptor tiers. + * + * This module also serves as a standalone extension to Express to provide + * extensibility and composability for large-scale Express applications by + * leveraging LoopBack's Dependency Injection and Extension Point/Extension + * pattern. + * + * @packageDocumentation + */ + +// Re-export express namespace from `express` +export * as express from 'express'; +export * from './express.application'; +export * from './express.server'; +export * from './keys'; +export * from './middleware'; +export * from './middleware-interceptor'; +export * from './middleware-registry'; +export * from './mixins/middleware.mixin'; +export * from './providers/invoke-middleware.provider'; +export * from './types'; diff --git a/packages/express/src/keys.ts b/packages/express/src/keys.ts new file mode 100644 index 000000000000..7d00385d505b --- /dev/null +++ b/packages/express/src/keys.ts @@ -0,0 +1,16 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {BindingKey} from '@loopback/context'; +import {MiddlewareContext} from './types'; + +export namespace MiddlewareBindings { + /** + * Binding key for setting and injecting the http request context + */ + export const CONTEXT = BindingKey.create( + 'middleware.http.context', + ); +} diff --git a/packages/express/src/middleware-interceptor.ts b/packages/express/src/middleware-interceptor.ts new file mode 100644 index 000000000000..62796d3a13ab --- /dev/null +++ b/packages/express/src/middleware-interceptor.ts @@ -0,0 +1,424 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + asGlobalInterceptor, + Binding, + BindingKey, + BindingScope, + config, + Constructor, + Context, + ContextTags, + ContextView, + createBindingFromClass, + GenericInterceptor, + GenericInterceptorChain, + inject, + Interceptor, + InvocationContext, + NamespacedReflect, + Provider, + transformValueOrPromise, +} from '@loopback/core'; +import assert from 'assert'; +import debugFactory from 'debug'; +import onFinished from 'on-finished'; +import {promisify} from 'util'; +import {MiddlewareBindings} from './keys'; +import { + ExpressMiddlewareFactory, + ExpressRequestHandler, + MiddlewareContext, + MiddlewareCreationOptions, + MiddlewareInterceptorBindingOptions, + Request, + Response, +} from './types'; + +const debug = debugFactory('loopback:middleware'); + +const onFinishedAsync = promisify(onFinished); + +/** + * Execute an Express-style callback-based request handler. + * + * @param handler - Express middleware handler function + * @param request + * @param response + * @returns A promise resolved to: + * - `true` when the request was handled + * - `false` when the handler called `next()` to proceed to the next + * handler (middleware) in the chain. + */ +export function executeExpressRequestHandler( + handler: ExpressRequestHandler, + request: Request, + response: Response, +): Promise { + const responseWritten = onFinishedAsync(response).then(() => true); + const handlerFinished = new Promise((resolve, reject) => { + handler(request, response, err => { + if (err) { + reject(err); + } else { + // Express router called next, which means no route was matched + debug('[%s] Handler calling next()', handler.name, err); + resolve(false); + } + }); + }); + /** + * Express middleware may handle the response by itself and not call + * `next`. We use `Promise.race()` to determine if we need to proceed + * with next interceptor in the chain or just return. + */ + return Promise.race([handlerFinished, responseWritten]); +} + +/** + * Wrap an express middleware handler function as an interceptor + * + * @example + * ```ts + * toInterceptor(fn); + * toInterceptor(fn1, fn2, fn3); + * ``` + * @param firstHandler - An Express middleware handler + * @param additionalHandlers - A list of Express middleware handler function + * + * @typeParam CTX - Context type + */ +export function toInterceptor( + firstHandler: ExpressRequestHandler, + ...additionalHandlers: ExpressRequestHandler[] +): GenericInterceptor { + if (additionalHandlers.length === 0) { + const handlerFn = firstHandler; + return toInterceptorFromExpressMiddleware(handlerFn); + } + const handlers = [firstHandler, ...additionalHandlers]; + const interceptorList = handlers.map(handler => toInterceptor(handler)); + return async (invocationCtx, next) => { + const middlewareCtx = await invocationCtx.get( + MiddlewareBindings.CONTEXT, + ); + const middlewareChain = new GenericInterceptorChain( + invocationCtx, + interceptorList, + ); + const result = middlewareChain.invokeInterceptors(); + return transformValueOrPromise(result, val => + val === middlewareCtx.response ? val : next(), + ); + }; +} + +function toInterceptorFromExpressMiddleware< + CTX extends Context = InvocationContext +>(handlerFn: ExpressRequestHandler): GenericInterceptor { + return async (context, next) => { + const middlewareCtx = await context.get( + MiddlewareBindings.CONTEXT, + ); + const finished = await executeExpressRequestHandler( + handlerFn, + middlewareCtx.request, + middlewareCtx.response, + ); + if (!finished) { + debug('[%s] Proceed with downstream interceptors', handlerFn.name); + const val = await next(); + debug( + '[%s] Result received from downstream interceptors', + handlerFn.name, + ); + return val; + } + // Return response to indicate the response has been produced + return middlewareCtx.response; + }; +} + +/** + * Create an interceptor function from express middleware. + * @param middlewareFactory - Express middleware factory function. A wrapper + * can be created if the Express middleware module does not conform to the + * factory pattern and signature. + * @param middlewareConfig - Configuration for the Express middleware + * + * @typeParam CFG - Configuration type + * @typeParam CTX - Context type + */ +export function createInterceptor( + middlewareFactory: ExpressMiddlewareFactory, + middlewareConfig?: CFG, +): GenericInterceptor { + const handlerFn = middlewareFactory(middlewareConfig); + return toInterceptor(handlerFn); +} + +/** + * Base class for MiddlewareInterceptor provider classes + * + * @example + * + * To inject the configuration without automatic reloading: + * + * ```ts + * class SpyInterceptorProvider extends ExpressMiddlewareInterceptorProvider< + * SpyConfig + * > { + * constructor(@config() spyConfig?: SpyConfig) { + * super(spy, spyConfig); + * } + * } + * ``` + * + * To inject the configuration without automatic reloading: + * ```ts + * class SpyInterceptorProvider extends ExpressMiddlewareInterceptorProvider< + * SpyConfig + * > { + * constructor(@config.view() configView?: ContextView) { + * super(spy, configView); + * } + * } + * ``` + * + * @typeParam CFG - Configuration type + */ +export abstract class ExpressMiddlewareInterceptorProvider< + CFG, + CTX extends Context = InvocationContext +> implements Provider> { + protected middlewareConfigView?: ContextView; + protected middlewareConfig?: CFG; + + constructor( + protected middlewareFactory: ExpressMiddlewareFactory, + middlewareConfig?: CFG | ContextView, + ) { + if (middlewareConfig != null && middlewareConfig instanceof ContextView) { + this.middlewareConfigView = middlewareConfig; + } else { + this.middlewareConfig = middlewareConfig; + } + this.setupConfigView(); + } + + // Inject current binding for debugging + @inject.binding() + private binding?: Binding>; + + /** + * Cached interceptor instance. It has three states: + * + * - undefined: Not initialized + * - null: To be recreated as the configuration is changed + * - function: The interceptor function created from the latest configuration + */ + private interceptor?: GenericInterceptor | null; + + private setupConfigView() { + if (this.middlewareConfigView) { + // Set up a listener to reset the cached interceptor function for the + // first time + this.middlewareConfigView.on('refresh', () => { + if (this.binding != null) { + debug( + 'Configuration change is detected for binding %s.' + + ' The Express middleware handler function will be recreated.', + this.binding.key, + ); + } + this.interceptor = null; + }); + } + } + + value(): GenericInterceptor { + return async (ctx, next) => { + // Get the latest configuration + if (this.middlewareConfigView != null) { + this.middlewareConfig = + (await this.middlewareConfigView.singleValue()) ?? + this.middlewareConfig; + } + + if (this.interceptor == null) { + // Create a new interceptor for the first time or recreate it if it + // was reset to `null` when its configuration changed + debug( + 'Creating interceptor for %s with config', + this.middlewareFactory.name, + this.middlewareConfig, + ); + this.interceptor = createInterceptor( + this.middlewareFactory, + this.middlewareConfig, + ); + } + return this.interceptor(ctx, next); + }; + } +} + +/** + * Define a provider class that wraps the middleware as an interceptor + * @param middlewareFactory - Express middleware factory function + * @param defaultMiddlewareConfig - Default middleware config + * @param className - Class name for the generated provider class + * + * @typeParam CFG - Configuration type + * @typeParam CTX - Context type + */ +export function defineInterceptorProvider< + CFG, + CTX extends Context = InvocationContext +>( + middlewareFactory: ExpressMiddlewareFactory, + defaultMiddlewareConfig?: CFG, + options?: MiddlewareCreationOptions, +): Constructor>> { + let className = options?.providerClassName; + className = buildName(middlewareFactory, className); + assert(className, 'className is missing and it cannot be inferred.'); + + const defineNamedClass = new Function( + 'middlewareFactory', + 'defaultMiddlewareConfig', + 'MiddlewareInterceptorProvider', + 'createInterceptor', + `return class ${className} extends MiddlewareInterceptorProvider { + constructor(middlewareConfig) { + super( + middlewareFactory, + middlewareConfig, + ); + if (this.middlewareConfig == null) { + this.middlewareConfig = defaultMiddlewareConfig; + } + } + };`, + ); + + const cls = defineNamedClass( + middlewareFactory, + defaultMiddlewareConfig, + ExpressMiddlewareInterceptorProvider, + createInterceptor, + ); + if (options?.injectConfiguration === 'watch') { + // Inject the config view + config.view()(cls, '', 0); + new NamespacedReflect().metadata('design:paramtypes', [ContextView])(cls); + } else { + // Inject the config + config()(cls, '', 0); + } + return cls; +} + +/** + * Build a name for the middleware + * @param middlewareFactory - Express middleware factory function + * @param providedName - Provided name + * @param suffix - Suffix + */ +export function buildName( + middlewareFactory: ExpressMiddlewareFactory, + providedName?: string, + suffix?: string, +) { + if (!providedName) { + let name = middlewareFactory.name; + name = name.replace(/[^\w]/g, '_'); + if (name) { + providedName = `${name}${suffix ?? ''}`; + } + } + return providedName; +} + +/** + * Bind a middleware interceptor to the given context + * + * @param ctx - Context object + * @param middlewareFactory - Express middleware factory function + * @param middlewareConfig - Express middleware config + * @param options - Options for registration + * + * @typeParam CFG - Configuration type + */ +export function registerExpressMiddlewareInterceptor( + ctx: Context, + middlewareFactory: ExpressMiddlewareFactory, + middlewareConfig?: CFG, + options: MiddlewareInterceptorBindingOptions = {}, +) { + options = { + injectConfiguration: true, + global: true, + group: 'middleware', + ...options, + }; + if (!options.injectConfiguration) { + let key = options.key; + if (!key) { + const name = buildName(middlewareFactory); + key = name + ? `interceptors.middleware.${name}` + : BindingKey.generate('interceptors.middleware'); + } + const binding = ctx + .bind(key!) + .to(createInterceptor(middlewareFactory, middlewareConfig)); + if (options.global) { + binding.tag({[ContextTags.GLOBAL_INTERCEPTOR_SOURCE]: 'route'}); + binding.apply(asGlobalInterceptor(options.group)); + } + return binding; + } + const providerClass = defineInterceptorProvider( + middlewareFactory, + middlewareConfig, + options, + ); + const binding = createMiddlewareInterceptorBinding( + providerClass, + options, + ); + ctx.add(binding); + return binding; +} + +/** + * Create a binding for the middleware based interceptor + * + * @param middlewareProviderClass - Middleware provider class + * @param options - Options to create middlewareFactory interceptor binding + * + * @typeParam CFG - Configuration type + */ +export function createMiddlewareInterceptorBinding( + middlewareProviderClass: Constructor>, + options: MiddlewareInterceptorBindingOptions = {}, +) { + options = { + global: true, + group: 'middleware', + ...options, + }; + const binding = createBindingFromClass(middlewareProviderClass, { + defaultScope: BindingScope.SINGLETON, + namespace: 'interceptors', + }); + if (options.global) { + binding.tag({[ContextTags.GLOBAL_INTERCEPTOR_SOURCE]: 'route'}); + binding.apply(asGlobalInterceptor(options.group)); + } + return binding; +} diff --git a/packages/express/src/middleware-registry.ts b/packages/express/src/middleware-registry.ts new file mode 100644 index 000000000000..db10fb5607bb --- /dev/null +++ b/packages/express/src/middleware-registry.ts @@ -0,0 +1,92 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + Binding, + BindingAddress, + Constructor, + Context, + Provider, +} from '@loopback/core'; +import {MiddlewareMixin} from './mixins/middleware.mixin'; +import { + ExpressMiddlewareFactory, + ExpressRequestHandler, + Middleware, + MiddlewareBindingOptions, +} from './types'; + +/** + * A context that allows middleware registration + */ +export interface MiddlewareRegistry { + /** + * Bind an Express middleware to this server context + * + * @example + * ```ts + * import myExpressMiddlewareFactory from 'my-express-middleware'; + * const myExpressMiddlewareConfig= {}; + * const myExpressMiddleware = myExpressMiddlewareFactory(myExpressMiddlewareConfig); + * server.expressMiddleware('middleware.express.my', myExpressMiddleware); + * // Or + * server.expressMiddleware('middleware.express.my', [myExpressMiddleware]); + * ``` + * @param key - Middleware binding key + * @param middleware - Express middleware handler function(s) + * + */ + expressMiddleware( + key: BindingAddress, + middleware: ExpressRequestHandler | ExpressRequestHandler[], + options?: MiddlewareBindingOptions, + ): Binding; + + /** + * Bind an Express middleware to this server context + * + * @example + * ```ts + * import myExpressMiddlewareFactory from 'my-express-middleware'; + * const myExpressMiddlewareConfig= {}; + * server.expressMiddleware(myExpressMiddlewareFactory, myExpressMiddlewareConfig); + * ``` + * @param middlewareFactory - Middleware module name or factory function + * @param middlewareConfig - Middleware config + * @param options - Options for registration + * + * @typeParam CFG - Configuration type + */ + expressMiddleware( + middlewareFactory: ExpressMiddlewareFactory, + middlewareConfig?: CFG, + options?: MiddlewareBindingOptions, + ): Binding; + + /** + * Register a middleware function or provider class + * + * @example + * ```ts + * const log: Middleware = async (requestCtx, next) { + * // ... + * } + * server.middleware(log); + * ``` + * + * @param middleware - Middleware function or provider class + * @param options - Middleware binding options + */ + middleware( + middleware: Middleware | Constructor>, + options?: MiddlewareBindingOptions, + ): Binding; +} + +/** + * Base Context that provides APIs to register middleware + */ +export abstract class BaseMiddlewareRegistry extends MiddlewareMixin(Context) + implements MiddlewareRegistry {} diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts new file mode 100644 index 000000000000..8f2a7c3c28f2 --- /dev/null +++ b/packages/express/src/middleware.ts @@ -0,0 +1,296 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + Binding, + BindingKey, + BindingScope, + BindingTemplate, + compareBindingsByTag, + Constructor, + Context, + createBindingFromClass, + InvocationResult, + isProviderClass, + Provider, + transformValueOrPromise, + ValueOrPromise, +} from '@loopback/context'; +import {extensionFilter, extensionFor} from '@loopback/core'; +import debugFactory from 'debug'; +import { + buildName, + createInterceptor, + defineInterceptorProvider, + toInterceptor, +} from './middleware-interceptor'; +import { + DEFAULT_MIDDLEWARE_CHAIN, + ExpressMiddlewareFactory, + ExpressRequestHandler, + InvokeMiddlewareOptions, + Middleware, + MiddlewareBindingOptions, + MiddlewareChain, + MiddlewareContext, + MIDDLEWARE_CONTEXT, +} from './types'; + +const debug = debugFactory('loopback:middleware'); + +/** + * An adapter function to create a LoopBack middleware that invokes the list + * of Express middleware handler functions in the order of their positions + * @example + * ```ts + * toMiddleware(fn); + * toMiddleware(fn1, fn2, fn3); + * ``` + * @param firstHandler - An Express middleware handler + * @param additionalHandlers A list of Express middleware handler functions + * @returns A LoopBack middleware function that wraps the list of Express + * middleware + */ +export function toMiddleware( + firstHandler: ExpressRequestHandler, + ...additionalHandlers: ExpressRequestHandler[] +): Middleware { + if (additionalHandlers.length === 0) return toInterceptor(firstHandler); + const handlers = [firstHandler, ...additionalHandlers]; + const middlewareList = handlers.map(handler => + toInterceptor(handler), + ); + return (middlewareCtx, next) => { + if (middlewareList.length === 1) { + return middlewareList[0](middlewareCtx, next); + } + const middlewareChain = new MiddlewareChain(middlewareCtx, middlewareList); + const result = middlewareChain.invokeInterceptors(); + return transformValueOrPromise(result, val => + val === middlewareCtx.response ? val : next(), + ); + }; +} + +/** + * An adapter function to create a LoopBack middleware from Express middleware + * factory function and configuration object. + * + * @param middlewareFactory - Express middleware factory function + * @param middlewareConfig - Express middleware config + * + * @returns A LoopBack middleware function that wraps the Express middleware + */ +export function createMiddleware( + middlewareFactory: ExpressMiddlewareFactory, + middlewareConfig?: CFG, +): Middleware { + return createInterceptor( + middlewareFactory, + middlewareConfig, + ); +} + +/** + * Bind a Express middleware to the given context + * + * @param ctx - Context object + * @param middlewareFactory - Middleware module name or factory function + * @param middlewareConfig - Middleware config + * @param options - Options for registration + * + * @typeParam CFG - Configuration type + */ +export function registerExpressMiddleware( + ctx: Context, + middlewareFactory: ExpressMiddlewareFactory, + middlewareConfig?: CFG, + options: MiddlewareBindingOptions = {}, +): Binding { + options = {injectConfiguration: true, ...options}; + options.chain = options.chain ?? DEFAULT_MIDDLEWARE_CHAIN; + if (!options.injectConfiguration) { + let key = options.key; + if (!key) { + const name = buildName(middlewareFactory); + key = name ? `interceptors.${name}` : BindingKey.generate('interceptors'); + } + const middleware = createMiddleware(middlewareFactory, middlewareConfig); + return registerMiddleware(ctx, middleware, options); + } + + const providerClass = defineInterceptorProvider( + middlewareFactory, + middlewareConfig, + options, + ); + return registerMiddleware(ctx, providerClass, options); +} + +/** + * Template function for middleware bindings + * @param options - Options to configure the binding + */ +export function asMiddleware( + options: MiddlewareBindingOptions = {}, +): BindingTemplate { + return function middlewareBindingTemplate(binding) { + binding + .apply(extensionFor(options.chain ?? DEFAULT_MIDDLEWARE_CHAIN)) + .tag({group: options.group ?? ''}); + }; +} + +/** + * Bind the middleware function or provider class to the context + * @param ctx - Context object + * @param middleware - Middleware function or provider class + * @param options - Middleware binding options + */ +export function registerMiddleware( + ctx: Context, + middleware: Middleware | Constructor>, + options: MiddlewareBindingOptions, +) { + if (isProviderClass(middleware as Constructor>)) { + const binding = createMiddlewareBinding( + middleware as Constructor>, + options, + ); + ctx.add(binding); + return binding; + } + const key = options.key ?? BindingKey.generate('middleware'); + return ctx + .bind(key) + .to(middleware as Middleware) + .apply(asMiddleware(options)); +} + +/** + * Create a binding for the middleware provider class + * + * @param middlewareProviderClass - Middleware provider class + * @param options - Options to create middleware binding + * + */ +export function createMiddlewareBinding( + middlewareProviderClass: Constructor>, + options: MiddlewareBindingOptions = {}, +) { + options.chain = options.chain ?? DEFAULT_MIDDLEWARE_CHAIN; + const binding = createBindingFromClass(middlewareProviderClass, { + defaultScope: BindingScope.TRANSIENT, + namespace: 'middleware', + key: options.key, + }).apply(asMiddleware(options)); + return binding; +} + +/** + * Discover and invoke registered middleware in a chain for the given extension + * point. + * + * @param middlewareCtx - Middleware context + * @param options - Options to invoke the middleware chain + */ +export function invokeMiddleware( + middlewareCtx: MiddlewareContext, + options?: InvokeMiddlewareOptions, +): ValueOrPromise { + debug( + 'Invoke middleware chain for %s %s with options', + middlewareCtx.request.method, + middlewareCtx.request.originalUrl, + options, + ); + const {chain = DEFAULT_MIDDLEWARE_CHAIN, orderedGroups} = options ?? {}; + // Find extensions for the given extension point binding + const filter = extensionFilter(chain); + if (debug.enabled) { + debug( + 'Middleware for extension point "%s":', + chain, + middlewareCtx.find(filter).map(b => b.key), + ); + } + const _middlewareChain = new MiddlewareChain( + middlewareCtx, + filter, + compareBindingsByTag('group', orderedGroups), + ); + return _middlewareChain.invokeInterceptors(); +} + +/** + * Invoke a list of Express middleware handler functions + * + * @example + * ```ts + * import cors from 'cors'; + * import helmet from 'helmet'; + * import morgan from 'morgan'; + * import {MiddlewareContext, invokeExpressMiddleware} from '@loopback/express'; + * + * // ... Either an instance of `MiddlewareContext` is passed in or a new one + * // can be instantiated from Express request and response objects + * + * const middlewareCtx = new MiddlewareContext(request, response); + * const finished = await invokeExpressMiddleware( + * middlewareCtx, + * cors(), + * helmet(), + * morgan('combined')); + * + * if (finished) { + * // Http response is sent by one of the middleware + * } else { + * // Http response is yet to be produced + * } + * ``` + * @param middlewareCtx - Middleware context + * @param handlers - A list of Express middleware handler functions + */ +export function invokeExpressMiddleware( + middlewareCtx: MiddlewareContext, + ...handlers: ExpressRequestHandler[] +): ValueOrPromise { + if (handlers.length === 0) { + throw new Error('No Express middleware handler function is provided.'); + } + const middleware = toMiddleware(handlers[0], ...handlers.slice(1)); + debug( + 'Invoke Express middleware for %s %s', + middlewareCtx.request.method, + middlewareCtx.request.originalUrl, + ); + // Invoke the middleware with a no-op next() + const result = middleware(middlewareCtx, () => undefined); + // Check if the response is finished + return transformValueOrPromise(result, val => val === middlewareCtx.response); +} + +/** + * An adapter function to create an Express middleware handler to discover and + * invoke registered LoopBack-style middleware in the context. + * @param ctx - Context object to discover registered middleware + */ +export function toExpressMiddleware(ctx: Context): ExpressRequestHandler { + return async (req, res, next) => { + const middlewareCtx = new MiddlewareContext(req, res, ctx); + // Set the middleware context to `request` object + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (req as any)[MIDDLEWARE_CONTEXT] = middlewareCtx; + + try { + const result = await invokeMiddleware(middlewareCtx); + if (result !== res) { + next(); + } + } catch (err) { + next(err); + } + }; +} diff --git a/packages/express/src/mixins/middleware.mixin.ts b/packages/express/src/mixins/middleware.mixin.ts new file mode 100644 index 000000000000..85544d16a18c --- /dev/null +++ b/packages/express/src/mixins/middleware.mixin.ts @@ -0,0 +1,162 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + Binding, + BindingAddress, + Constructor, + Context, + isBindingAddress, + Provider, +} from '@loopback/core'; +import { + registerExpressMiddleware, + registerMiddleware, + toMiddleware, +} from '../middleware'; +import {MiddlewareRegistry} from '../middleware-registry'; +import { + ExpressMiddlewareFactory, + ExpressRequestHandler, + Middleware, + MiddlewareBindingOptions, +} from '../types'; + +function extendsFrom( + subClass: Constructor, + baseClass: Constructor, +) { + let cls = subClass; + while (cls) { + if (cls === baseClass) return true; + cls = Object.getPrototypeOf(cls); + } + return false; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function MiddlewareMixin>(superClass: T) { + if (!extendsFrom(superClass, Context)) { + throw new TypeError('The super class does not extend from Context'); + } + return class extends superClass implements MiddlewareRegistry { + /** + * Bind an Express middleware to this server context + * + * @example + * ```ts + * import myExpressMiddlewareFactory from 'my-express-middleware'; + * const myExpressMiddlewareConfig= {}; + * const myExpressMiddleware = myExpressMiddlewareFactory(myExpressMiddlewareConfig); + * server.expressMiddleware('middleware.express.my', myExpressMiddleware); + * // Or + * server.expressMiddleware('middleware.express.my', [myExpressMiddleware]); + * ``` + * @param key - Middleware binding key + * @param middleware - Express middleware handler function(s) + * + */ + expressMiddleware( + key: BindingAddress, + middleware: ExpressRequestHandler | ExpressRequestHandler[], + options?: MiddlewareBindingOptions, + ): Binding; + + /** + * Bind an Express middleware to this server context + * + * @example + * ```ts + * import myExpressMiddlewareFactory from 'my-express-middleware'; + * const myExpressMiddlewareConfig= {}; + * server.expressMiddleware(myExpressMiddlewareFactory, myExpressMiddlewareConfig); + * ``` + * @param middlewareFactory - Middleware module name or factory function + * @param middlewareConfig - Middleware config + * @param options - Options for registration + * + * @typeParam CFG - Configuration type + */ + expressMiddleware( + middlewareFactory: ExpressMiddlewareFactory, + middlewareConfig?: CFG, + options?: MiddlewareBindingOptions, + ): Binding; + + /** + * @internal + * + * This signature is only used by RestApplication for delegation + */ + expressMiddleware( + factoryOrKey: ExpressMiddlewareFactory | BindingAddress, + configOrHandler: CFG | ExpressRequestHandler | ExpressRequestHandler[], + options?: MiddlewareBindingOptions, + ): Binding; + + /** + * @internal + * Implementation of `expressMiddleware` + */ + expressMiddleware( + factoryOrKey: ExpressMiddlewareFactory | BindingAddress, + configOrHandlers: CFG | ExpressRequestHandler | ExpressRequestHandler[], + options: MiddlewareBindingOptions = {}, + ): Binding { + const key = factoryOrKey as BindingAddress; + if (isBindingAddress(key)) { + const handlers = Array.isArray(configOrHandlers) + ? configOrHandlers + : [configOrHandlers as ExpressRequestHandler]; + // Create middleware that wraps all Express handlers + if (handlers.length === 0) { + throw new Error( + 'No Express middleware handler function is provided.', + ); + } + return registerMiddleware( + (this as unknown) as Context, + toMiddleware(handlers[0], ...handlers.slice(1)), + { + ...options, + key, + }, + ); + } else { + return registerExpressMiddleware( + (this as unknown) as Context, + factoryOrKey as ExpressMiddlewareFactory, + configOrHandlers as CFG, + options, + ); + } + } + + /** + * Register a middleware function or provider class + * + * @example + * ```ts + * const log: Middleware = async (requestCtx, next) { + * // ... + * } + * server.middleware(log); + * ``` + * + * @param middleware - Middleware function or provider class + * @param options - Middleware binding options + */ + middleware( + middleware: Middleware | Constructor>, + options: MiddlewareBindingOptions = {}, + ): Binding { + return registerMiddleware( + (this as unknown) as Context, + middleware, + options, + ); + } + }; +} diff --git a/packages/express/src/providers/invoke-middleware.provider.ts b/packages/express/src/providers/invoke-middleware.provider.ts new file mode 100644 index 000000000000..14d69e30cfb0 --- /dev/null +++ b/packages/express/src/providers/invoke-middleware.provider.ts @@ -0,0 +1,77 @@ +// Copyright IBM Corp. 2020. 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 { + Binding, + config, + CoreTags, + extensionPoint, + inject, + Provider, +} from '@loopback/core'; +import debugFactory from 'debug'; +import {invokeExpressMiddleware, invokeMiddleware} from '../middleware'; +import { + DEFAULT_MIDDLEWARE_CHAIN, + ExpressRequestHandler, + InvokeMiddleware, + InvokeMiddlewareOptions, + MiddlewareContext, +} from '../types'; +const debug = debugFactory('loopback:rest:middleware'); + +/** + * Extension point for middleware to be run as part of the sequence actions + */ +@extensionPoint(DEFAULT_MIDDLEWARE_CHAIN) +export class InvokeMiddlewareProvider implements Provider { + /** + * Inject the binding so that we can access `extensionPoint` tag + */ + @inject.binding() + protected binding: Binding; + + /** + * Default options for invoking the middleware chain + */ + @config() + protected defaultOptions: InvokeMiddlewareOptions = { + chain: DEFAULT_MIDDLEWARE_CHAIN, + orderedGroups: ['cors', 'apiSpec', ''], + }; + + value(): InvokeMiddleware { + debug('Binding', this.binding); + return ( + middlewareCtx: MiddlewareContext, + optionsOrHandlers?: InvokeMiddlewareOptions | ExpressRequestHandler[], + ) => { + if (Array.isArray(optionsOrHandlers)) { + return this.action(middlewareCtx, optionsOrHandlers); + } + const options = optionsOrHandlers; + let chain = options?.chain; + const orderedGroups = options?.orderedGroups; + chain = + chain ?? + this.binding?.tagMap[CoreTags.EXTENSION_POINT] ?? + this.defaultOptions.chain; + return this.action(middlewareCtx, { + chain, + orderedGroups: orderedGroups ?? this.defaultOptions.orderedGroups, + }); + }; + } + + async action( + middlewareCtx: MiddlewareContext, + optionsOrHandlers?: InvokeMiddlewareOptions | ExpressRequestHandler[], + ) { + if (Array.isArray(optionsOrHandlers)) { + return invokeExpressMiddleware(middlewareCtx, ...optionsOrHandlers); + } + return invokeMiddleware(middlewareCtx, optionsOrHandlers); + } +} diff --git a/packages/express/src/types.ts b/packages/express/src/types.ts new file mode 100644 index 000000000000..c702b73ccc77 --- /dev/null +++ b/packages/express/src/types.ts @@ -0,0 +1,241 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + BindingAddress, + Context, + GenericInterceptor, + GenericInterceptorChain, + InvocationContext, + ValueOrPromise, +} from '@loopback/core'; +import {Request, RequestHandler, Response} from 'express'; +import onFinished from 'on-finished'; +import {MiddlewareBindings} from './keys'; + +export {Request, Response} from 'express'; + +/** + * An object holding HTTP request, response and other data + * needed to handle an incoming HTTP request. + */ +export interface HandlerContext { + readonly request: Request; + readonly response: Response; +} + +/** + * Type alias for Express RequestHandler + */ +export type ExpressRequestHandler = RequestHandler; + +/** + * A per-request Context for middleware to combine an IoC container with handler + * context (request, response, etc.). + */ +export class MiddlewareContext extends Context implements HandlerContext { + /** + * Constructor for `MiddlewareContext` + * @param request - Express request object + * @param response - Express response object + * @param parent - Parent context + * @param name - Name of the middleware context + */ + constructor( + public readonly request: Request, + public readonly response: Response, + parent?: Context, + name?: string, + ) { + super(parent, name); + this.setupBindings(); + onFinished(this.response, () => { + // Close the request context when the http response is finished so that + // it can be recycled by GC + this.emit('close'); + this.close(); + }); + } + + protected setupBindings() { + this.bind(MiddlewareBindings.CONTEXT).to(this).lock(); + } +} + +/** + * Interface LoopBack 4 middleware to be executed within sequence of actions. + * A middleware for LoopBack is basically a generic interceptor that uses + * `RequestContext`. + * + * The signature of a middleware function is as follows. It's very much the same + * as {@link https://github.com/koajs/koa/blob/master/docs/guide.md#writing-middleware | Koa middleware}. + * ```ts + * (context: MiddlewareContext, next: Next) => ValueOrPromise; + * ``` + * + * @example + * ```ts + * const log: Middleware = async (requestCtx, next) => { + * const {request} = requestCtx; + * console.log('Request: %s %s', request.method, request.originalUrl); + * try { + * // Proceed with next middleware + * await next(); + * console.log('Response received for %s %s', request.method, request.originalUrl); + * } catch(err) { + * console.error('Error received for %s %s', request.method, request.originalUrl); + * throw err; + * } + * } + * ``` + */ +export interface Middleware extends GenericInterceptor {} + +/** + * An interceptor chain of middleware. This represents a list of cascading + * middleware functions to be executed by the order of `group` names. + */ +export class MiddlewareChain extends GenericInterceptorChain< + MiddlewareContext +> {} + +/** + * Default extension point name for middleware + */ +export const DEFAULT_MIDDLEWARE_CHAIN = 'middlewareChain.default'; + +/** + * Options for `InvokeMiddleware` + */ +export interface InvokeMiddlewareOptions { + /** + * Name of the extension point. Default to the `extensionPoint` tag value + * from the binding + */ + chain?: string; + /** + * An array of group names to denote the order of execution + */ + orderedGroups?: string[]; +} + +/** + * Interface for the invoker of middleware registered under the an extension + * point name. + */ +export interface InvokeMiddleware { + /** + * Invoke the request interceptors in the chain. + * @param middlewareCtx - Middleware Context + * @param options - Options for the invocation + */ + ( + middlewareCtx: MiddlewareContext, + options?: InvokeMiddlewareOptions, + ): ValueOrPromise; + + /** + * Invoke a list of Express middleware handler functions + * + * @example + * ```ts + * import cors from 'cors'; + * import helmet from 'helmet'; + * import morgan from 'morgan'; + * + * + * const finished = await this.invokeMiddleware( + * middlewareCtx, [ + * cors(), + * helmet(), + * morgan('combined'), + * ]); + * + * if (finished) { + * // Http response is sent by one of the middleware + * } else { + * // Http response is yet to be produced + * } + * ``` + * @param middlewareCtx - Middleware context + * @param handlers - A list of Express middleware handler functions + */ + ( + middlewareCtx: MiddlewareContext, + handlers: ExpressRequestHandler[], + ): ValueOrPromise; +} + +/** + * Options for defining a middleware + */ +export interface MiddlewareCreationOptions { + /** + * A flag to control if configuration for the middleware can be injected + * lazily. + * + * - `true` (default): creates a provider class with `@config` + * - `false`: No configuration injection is supported + * - 'watch': creates a provider class with `@config.view` + */ + injectConfiguration?: boolean | 'watch'; + /** + * Class name for the created provider class. It's only used if + * `injectConfiguration` is not set to `false`. + */ + providerClassName?: string; +} + +/** + * Options to create a middleware binding for the sequence action or interceptor. + * @typeParam CTX - Context class + */ +export interface BaseMiddlewareBindingOptions + extends MiddlewareCreationOptions { + /** + * Binding key for the middleware. + */ + key?: BindingAddress>; + /** + * An optional `group` name to be used for order of executions + */ + group?: string; +} + +/** + * Options to bind a middleware as an interceptor to the context + */ +export interface MiddlewareInterceptorBindingOptions + extends BaseMiddlewareBindingOptions { + /** + * A flag to control if the interceptor should be global. Default to `true`. + */ + global?: boolean; +} + +/** + * Options to bind middleware as a request context based interceptor within an + * `InvokeMiddleware` action of the sequence. + */ +export interface MiddlewareBindingOptions + extends BaseMiddlewareBindingOptions { + /** + * Name of the middleware extension point. Default to `DEFAULT_MIDDLEWARE_CHAIN`. + */ + chain?: string; +} + +/** + * Interface for an express middleware factory + * @typeParam C - Configuration type + */ +export interface ExpressMiddlewareFactory { + (middlewareConfig?: C): ExpressRequestHandler; +} + +/** + * A symbol to store `MiddlewareContext` on the request object + */ +export const MIDDLEWARE_CONTEXT = Symbol('loopback.middleware.context'); diff --git a/packages/express/tsconfig.json b/packages/express/tsconfig.json new file mode 100644 index 000000000000..c61b1782f6a8 --- /dev/null +++ b/packages/express/tsconfig.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "@loopback/build/config/tsconfig.common.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "composite": true + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../testlab/tsconfig.json" + }, + { + "path": "../context/tsconfig.json" + }, + { + "path": "../core/tsconfig.json" + }, + { + "path": "../http-server/tsconfig.json" + } + ] +} diff --git a/packages/rest/package.json b/packages/rest/package.json index a0fa5a80e27c..a7858ddd6e38 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -23,6 +23,7 @@ "@loopback/core": "^2.4.2", "@loopback/http-server": "^2.1.2", "@loopback/openapi-v3": "^3.3.0", + "@loopback/express": "^1.0.0", "@openapi-contrib/openapi-schema-to-json-schema": "^3.0.0", "@types/body-parser": "^1.19.0", "@types/cors": "^2.8.6", diff --git a/packages/rest/src/__tests__/acceptance/middleware/middleware-registeration.acceptance.ts b/packages/rest/src/__tests__/acceptance/middleware/middleware-registeration.acceptance.ts new file mode 100644 index 000000000000..c51cfd557f7e --- /dev/null +++ b/packages/rest/src/__tests__/acceptance/middleware/middleware-registeration.acceptance.ts @@ -0,0 +1,135 @@ +// Copyright IBM Corp. 2020. 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 {Component, config, Provider} from '@loopback/core'; +import { + createMiddlewareBinding, + defineInterceptorProvider, + Middleware, +} from '@loopback/express'; +import {SpyAction} from '@loopback/express/src/__tests__/fixtures/spy-config'; +import {Client, expect} from '@loopback/testlab'; +import {RestApplication} from '../../..'; +import {spy, SpyConfig, TestFunction, TestHelper} from './test-helpers'; + +describe('Express middleware registry', () => { + let helper: TestHelper; + let app: RestApplication; + let client: Client; + + beforeEach(givenTestApp); + afterEach(() => helper?.stop()); + + function runTests(action: SpyAction, testFn: TestFunction) { + describe(`app.expressMiddleware - ${action}`, () => { + const spyConfig: SpyConfig = {action}; + + it('registers an Express middleware interceptor provider class by factory', () => { + const binding = app.expressMiddleware(spy, spyConfig); + return testFn(binding); + }); + + it('registers an Express middleware by component', () => { + const binding = createMiddlewareBinding(defineInterceptorProvider(spy)); + class MyComponent implements Component { + bindings = [binding]; + } + app.component(MyComponent); + return testFn(binding); + }); + + it('registers an Express middleware interceptor as handler function', () => { + const binding = app.expressMiddleware(spy, spyConfig, { + injectConfiguration: false, + key: 'interceptors.middleware.spy', + }); + expect(binding.key).to.eql('interceptors.middleware.spy'); + return testFn(binding); + }); + }); + } + + runTests('log', binding => helper.testSpyLog(binding)); + runTests('mock', binding => helper.testSpyMock(binding)); + runTests('reject', binding => helper.testSpyReject(binding)); + + describe('LoopBack middleware registry', () => { + const spyMiddleware: Middleware = async (middlewareCtx, next) => { + const {request, response} = middlewareCtx; + response.set('x-spy-log-req', `${request.method} ${request.path}`); + await next(); + response.set('x-spy-log-res', `${request.method} ${request.path}`); + }; + + it('registers a LoopBack middleware handler', async () => { + app.middleware(spyMiddleware, { + key: 'middleware.spy', + }); + await testSpyLog(); + }); + + it('registers a LoopBack middleware provider', async () => { + class SpyMiddlewareProvider implements Provider { + value() { + return spyMiddleware; + } + } + app.middleware(SpyMiddlewareProvider, { + key: 'middleware.spy', + }); + await testSpyLog(); + }); + + it('registers a LoopBack middleware provider with config injection', async () => { + type SpyConfig = {headerName: string}; + class SpyMiddlewareProviderWithConfig implements Provider { + @config() + private options: SpyConfig; + value(): Middleware { + return async ({request, response}, next) => { + response.set( + `${this.options.headerName}-req`, + `${request.method} ${request.path}`, + ); + await next(); + response.set( + `${this.options.headerName}-res`, + `${request.method} ${request.path}`, + ); + }; + } + } + const binding = app.middleware(SpyMiddlewareProviderWithConfig, { + key: 'middleware.spy', + }); + app.configure(binding.key).to({headerName: 'x-spy'}); + await client + .post('/hello') + .send('"World"') + .set('content-type', 'application/json') + .expect(200, 'Hello, World') + .expect('x-spy-req', 'POST /hello') + .expect('x-spy-res', 'POST /hello'); + }); + + async function testSpyLog() { + await client + .post('/hello') + .send('"World"') + .set('content-type', 'application/json') + .expect(200, 'Hello, World') + .expect('x-spy-log-req', 'POST /hello') + .expect('x-spy-log-res', 'POST /hello'); + } + }); + + async function givenTestApp() { + helper = new TestHelper(); + helper.bindController(); + await helper.start(); + app = helper.app; + client = helper.client; + } +}); diff --git a/packages/rest/src/__tests__/acceptance/middleware/middleware-sequence.acceptance.ts b/packages/rest/src/__tests__/acceptance/middleware/middleware-sequence.acceptance.ts new file mode 100644 index 000000000000..34631d6ca224 --- /dev/null +++ b/packages/rest/src/__tests__/acceptance/middleware/middleware-sequence.acceptance.ts @@ -0,0 +1,177 @@ +// Copyright IBM Corp. 2020. 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 {BindingScope, Constructor, CoreTags, inject} from '@loopback/core'; +import {InvokeMiddleware, InvokeMiddlewareProvider} from '@loopback/express'; +import {RequestContext} from '../../../request-context'; +import {DefaultSequence} from '../../../sequence'; +import {SpyAction} from '../../fixtures/middleware/spy-config'; +import {spy, TestHelper} from './test-helpers'; + +const POST_INVOCATION_MIDDLEWARE = 'middleware.postInvocation'; +describe('Middleware in sequence', () => { + let helper: TestHelper; + + beforeEach(givenTestApp); + afterEach(() => helper?.stop()); + + it('registers a middleware in default slot', () => { + const binding = helper.app.expressMiddleware(spy, undefined); + return helper.testSpyLog(binding); + }); + + it('registers a middleware in postInvoke slot', () => { + const binding = helper.app.expressMiddleware(spy, undefined, { + chain: POST_INVOCATION_MIDDLEWARE, + }); + return helper.testSpyLog(binding); + }); + + it('registers a middleware in both slots', async () => { + const firstSpy = helper.app.expressMiddleware(spy, undefined, { + key: 'middleware.firstSpy', + }); + const secondSpy = helper.app + .expressMiddleware(spy, undefined, { + key: 'middleware.secondSpy', + chain: POST_INVOCATION_MIDDLEWARE, + }) + // Set the scope to be `TRANSIENT` so that the new config can be loaded + .inScope(BindingScope.TRANSIENT); + await helper.testSpyLog(firstSpy); + await helper.testSpyReject(secondSpy); + }); + + it('registers a middleware in default slot with sequence 2', () => { + helper.app.sequence(SequenceWithTwoInvokeMiddleware); + const binding = helper.app.expressMiddleware(spy, undefined); + return helper.testSpyLog(binding); + }); + + function givenTestApp() { + helper = new TestHelper(); + // Create another middleware phase + helper.app + .bind('middleware.postInvoke') + .toProvider(InvokeMiddlewareProvider) + // Configure a different extension point name + .tag({[CoreTags.EXTENSION_POINT]: POST_INVOCATION_MIDDLEWARE}); + helper.app.sequence(SequenceWithOneInvokeMiddleware); + helper.bindController(); + return helper.start(); + } + + /** + * Use `invokeMiddleware` to invoke two sets of middleware + */ + class SequenceWithOneInvokeMiddleware extends DefaultSequence { + async handle(context: RequestContext): Promise { + try { + const {request, response} = context; + // The default middleware chain + let finished = await this.invokeMiddleware(context); + if (finished) return; + const route = this.findRoute(request); + const args = await this.parseParams(request, route); + const result = await this.invoke(route, args); + + // The second middleware chain for post-invocation processing + context.bind('invocation.result').to(result); + + // Invoke another chain of middleware + finished = await this.invokeMiddleware(context, { + chain: POST_INVOCATION_MIDDLEWARE, + }); + if (finished) return; + this.send(response, result); + } catch (error) { + this.reject(context, error); + } + } + } + + /** + * Use another injected `invokeMiddleware` to invoke middleware after the + * invocation returns. + */ + class SequenceWithTwoInvokeMiddleware extends DefaultSequence { + /** + * Inject another middleware chain for post invocation + */ + @inject('middleware.postInvoke', {optional: true}) + protected invokeMiddlewareAfterInvoke: InvokeMiddleware = () => false; + + async handle(context: RequestContext): Promise { + try { + const {request, response} = context; + // The default middleware chain + let finished = await this.invokeMiddleware(context); + if (finished) return; + + const route = this.findRoute(request); + const args = await this.parseParams(request, route); + const result = await this.invoke(route, args); + + // The second middleware chain for post-invocation processing + context.bind('invocation.result').to(result); + finished = await this.invokeMiddlewareAfterInvoke(context); + if (finished) return; + this.send(response, result); + } catch (error) { + this.reject(context, error); + } + } + } +}); + +describe('Invoke a list of Express Middleware in sequence', () => { + let helper: TestHelper; + + beforeEach(givenTestApp); + afterEach(() => helper?.stop()); + + function runTest(action: SpyAction, fn: () => Promise) { + it(`invokes a ${action} middleware`, () => { + helper.app.sequence(givenSequence(action)); + return fn(); + }); + } + + runTest('log', () => helper.assertSpyLog()); + runTest('mock', () => helper.assertSpyMock()); + runTest('reject', () => helper.assertSpyReject()); + + function givenTestApp() { + helper = new TestHelper(); + helper.bindController(); + return helper.start(); + } + + function givenSequence(action: SpyAction): Constructor { + /** + * Use `invokeMiddleware` to invoke two sets of middleware + */ + class SequenceWithExpressMiddleware extends DefaultSequence { + async handle(context: RequestContext): Promise { + try { + const {request, response} = context; + // The default middleware chain + const finished = await this.invokeMiddleware(context, [ + spy({action}), + ]); + if (finished) return; + const route = this.findRoute(request); + const args = await this.parseParams(request, route); + const result = await this.invoke(route, args); + + this.send(response, result); + } catch (error) { + this.reject(context, error); + } + } + } + return SequenceWithExpressMiddleware; + } +}); diff --git a/packages/rest/src/__tests__/acceptance/middleware/test-helpers.ts b/packages/rest/src/__tests__/acceptance/middleware/test-helpers.ts new file mode 100644 index 000000000000..7e505ad73386 --- /dev/null +++ b/packages/rest/src/__tests__/acceptance/middleware/test-helpers.ts @@ -0,0 +1,120 @@ +// Copyright IBM Corp. 2020. 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 {Binding, intercept, InterceptorOrKey} from '@loopback/context'; +import {post, requestBody} from '@loopback/openapi-v3'; +import { + Client, + createRestAppClient, + givenHttpServerConfig, +} from '@loopback/testlab'; +import {RestApplication} from '../../..'; +import {SpyConfig} from '../../fixtures/middleware/spy-config'; +import spyFactory from '../../fixtures/middleware/spy.middleware'; +export const spy = spyFactory; +export {SpyConfig} from '../../fixtures/middleware/spy-config'; + +export type TestFunction = (spyBinding: Binding) => Promise; + +export class TestHelper { + readonly app: RestApplication; + client: Client; + + constructor() { + this.app = new RestApplication({rest: givenHttpServerConfig()}); + } + + async start() { + await this.app.start(); + this.client = createRestAppClient(this.app); + } + + stop() { + return this.app.stop(); + } + + bindController(interceptor?: InterceptorOrKey) { + const interceptors: InterceptorOrKey[] = []; + if (interceptor) interceptors.push(interceptor); + class MyController { + @intercept(...interceptors) + @post('/hello', { + responses: { + '200': { + content: {'application/json': {schema: {type: 'string'}}}, + }, + }, + }) + hello( + @requestBody({ + content: { + 'application/json': { + schema: {type: 'string'}, + }, + }, + }) + msg: string, + ) { + return `Hello, ${msg}`; + } + } + return this.app.controller(MyController); + } + + async testSpyLog(spyBinding: Binding) { + // We have to re-configure at restServer level + // as `this.app.middleware()` delegates to `restServer` + this.app.restServer + .configure(spyBinding.key) + .to({action: 'log'}); + + await this.assertSpyLog(); + } + + async assertSpyLog() { + await this.client + .post('/hello') + .send('"World"') + .set('content-type', 'application/json') + .expect(200, 'Hello, World') + .expect('x-spy-log', 'POST /hello'); + } + + async testSpyMock(spyBinding: Binding) { + // We have to re-configure at restServer level + // as `this.app.middleware()` delegates to `restServer` + this.app.restServer + .configure(spyBinding.key) + .to({action: 'mock'}); + await this.assertSpyMock(); + } + + async assertSpyMock() { + await this.client + .post('/hello') + .send('"World"') + .set('content-type', 'application/json') + .expect(200, 'Hello, Spy') + .expect('x-spy-mock', 'POST /hello'); + } + + async testSpyReject(spyBinding: Binding) { + // We have to re-configure at restServer level + // as `this.app.middleware()` delegates to `restServer` + this.app.restServer + .configure(spyBinding.key) + .to({action: 'reject'}); + await this.assertSpyReject(); + } + + async assertSpyReject() { + await this.client + .post('/hello') + .send('"World"') + .set('content-type', 'application/json') + .expect(400) + .expect('x-spy-reject', 'POST /hello'); + } +} diff --git a/packages/rest/src/__tests__/fixtures/middleware/spy-config.ts b/packages/rest/src/__tests__/fixtures/middleware/spy-config.ts new file mode 100644 index 000000000000..9083cea61f2c --- /dev/null +++ b/packages/rest/src/__tests__/fixtures/middleware/spy-config.ts @@ -0,0 +1,21 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export type SpyAction = 'log' | 'mock' | 'reject'; +/** + * Configuration for `spy` middleware + */ +export interface SpyConfig { + /** + * Action for the spy to enforce + * - `log`: set `x-spy-log` http response header and proceed with the + * invocation + * - `mock`: set `x-spy-mock` http response header and return a mock response + * without calling the target + * - `reject`: set `x-spy-reject` http response header and reject the request + * with 400 status code + */ + action: SpyAction; +} diff --git a/packages/rest/src/__tests__/fixtures/middleware/spy.middleware.ts b/packages/rest/src/__tests__/fixtures/middleware/spy.middleware.ts new file mode 100644 index 000000000000..b641bccf7730 --- /dev/null +++ b/packages/rest/src/__tests__/fixtures/middleware/spy.middleware.ts @@ -0,0 +1,41 @@ +// Copyright IBM Corp. 2020. 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 {ExpressMiddlewareFactory} from '@loopback/express'; +import debugFactory from 'debug'; +import {HttpErrors} from '../../..'; +import {SpyConfig} from './spy-config'; + +const debug = debugFactory('loopback:rest:middleware:spy'); + +/** + * An Express middleware factory function that creates a handler to spy on + * requests + */ +const spyMiddlewareFactory: ExpressMiddlewareFactory = config => { + const options: SpyConfig = {action: 'log', ...config}; + return function spy(req, res, next) { + debug('config', options); + switch (options?.action) { + case 'mock': + debug('spy - MOCK'); + res.set('x-spy-mock', `${req.method} ${req.path}`); + res.send('Hello, Spy'); + break; + case 'reject': + debug('spy - REJECT'); + res.set('x-spy-reject', `${req.method} ${req.path}`); + next(new HttpErrors.BadRequest('Request rejected by spy')); + break; + default: + debug('spy - LOG'); + res.set('x-spy-log', `${req.method} ${req.path}`); + next(); + break; + } + }; +}; + +export = spyMiddlewareFactory; diff --git a/packages/rest/src/__tests__/integration/rest.server.integration.ts b/packages/rest/src/__tests__/integration/rest.server.integration.ts index 0644b3f84887..cda86856788b 100644 --- a/packages/rest/src/__tests__/integration/rest.server.integration.ts +++ b/packages/rest/src/__tests__/integration/rest.server.integration.ts @@ -9,6 +9,7 @@ import { anOpenApiSpec, anOperationSpec, } from '@loopback/openapi-spec-builder'; +import {invokeMiddleware} from '@loopback/express'; import { createClientForHandler, createRestAppClient, @@ -19,7 +20,6 @@ import { supertest, } from '@loopback/testlab'; import fs from 'fs'; -import {IncomingMessage, ServerResponse} from 'http'; import yaml from 'js-yaml'; import path from 'path'; import {is} from 'type-is'; @@ -1262,7 +1262,7 @@ paths: it('controls server urls', async () => { const response = await createClientForHandler(server.requestHandler).get( - '/openapi.json', + '/api/openapi.json', ); expect(response.body.servers).to.containEql({url: '/api'}); }); @@ -1270,7 +1270,7 @@ paths: it('controls server urls even when set via server.basePath() API', async () => { server.basePath('/v2'); const response = await createClientForHandler(server.requestHandler).get( - '/openapi.json', + '/v2/openapi.json', ); expect(response.body.servers).to.containEql({url: '/v2'}); }); @@ -1296,11 +1296,10 @@ paths: return app.getServer(RestServer); } - function dummyRequestHandler(handler: { - request: IncomingMessage; - response: ServerResponse; - }) { - const {response} = handler; + async function dummyRequestHandler(requestContext: RequestContext) { + const {response} = requestContext; + const result = await invokeMiddleware(requestContext); + if (result === response) return; response.write('Hello'); response.end(); } diff --git a/packages/rest/src/keys.ts b/packages/rest/src/keys.ts index 650edb855d3b..9dd212391544 100644 --- a/packages/rest/src/keys.ts +++ b/packages/rest/src/keys.ts @@ -5,6 +5,7 @@ import {BindingKey, Context} from '@loopback/context'; import {CoreBindings} from '@loopback/core'; +import {InvokeMiddleware} from '@loopback/express'; import {HttpProtocol} from '@loopback/http-server'; import {OpenApiSpec, OperationObject} from '@loopback/openapi-v3'; import https from 'https'; @@ -181,6 +182,12 @@ export namespace RestBindings { * Bindings for potential actions that could be used in a sequence */ export namespace SequenceActions { + /** + * Binding key for setting and injecting a route finding function + */ + export const INVOKE_MIDDLEWARE = BindingKey.create( + 'rest.sequence.actions.invokeMiddleware', + ); /** * Binding key for setting and injecting a route finding function */ diff --git a/packages/rest/src/providers/index.ts b/packages/rest/src/providers/index.ts index ab4294803b9e..c3c8b25b821b 100644 --- a/packages/rest/src/providers/index.ts +++ b/packages/rest/src/providers/index.ts @@ -8,6 +8,6 @@ export * from './find-route.provider'; export * from './get-from-context.provider'; export * from './invoke-method.provider'; export * from './log-error.provider'; -export * from './reject.provider'; export * from './parse-params.provider'; +export * from './reject.provider'; export * from './send.provider'; diff --git a/packages/rest/src/request-context.ts b/packages/rest/src/request-context.ts index fdc7316a31fe..8a04b275b6be 100644 --- a/packages/rest/src/request-context.ts +++ b/packages/rest/src/request-context.ts @@ -4,16 +4,21 @@ // License text available at https://opensource.org/licenses/MIT import {Context} from '@loopback/context'; -import onFinished from 'on-finished'; +import { + HandlerContext, + MiddlewareContext, + Request, + Response, +} from '@loopback/express'; import {RestBindings} from './keys'; import {RestServerResolvedConfig} from './rest.server'; -import {HandlerContext, Request, Response} from './types'; /** * A per-request Context combining an IoC container with handler context * (request, response, etc.). */ -export class RequestContext extends Context implements HandlerContext { +export class RequestContext extends MiddlewareContext + implements HandlerContext { /** * Get the protocol used by the client to make the request. * Please note this protocol may be different from what we are observing @@ -97,20 +102,13 @@ export class RequestContext extends Context implements HandlerContext { public readonly serverConfig: RestServerResolvedConfig, name?: string, ) { - super(parent, name); - this._setupBindings(request, response); - onFinished(this.response, () => { - // Close the request context when the http response is finished so that - // it can be recycled by GC - this.close(); - }); + super(request, response, parent, name); } - private _setupBindings(request: Request, response: Response) { - this.bind(RestBindings.Http.REQUEST).to(request).lock(); - - this.bind(RestBindings.Http.RESPONSE).to(response).lock(); - + protected setupBindings() { + super.setupBindings(); + this.bind(RestBindings.Http.REQUEST).to(this.request).lock(); + this.bind(RestBindings.Http.RESPONSE).to(this.response).lock(); this.bind(RestBindings.Http.CONTEXT).to(this).lock(); } } diff --git a/packages/rest/src/rest.application.ts b/packages/rest/src/rest.application.ts index 0445295cc961..6cb7b91580e2 100644 --- a/packages/rest/src/rest.application.ts +++ b/packages/rest/src/rest.application.ts @@ -3,8 +3,20 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Binding, BindingAddress, Constructor, Context} from '@loopback/context'; +import { + Binding, + BindingAddress, + Constructor, + Context, + Provider, +} from '@loopback/context'; import {Application, ApplicationConfig, Server} from '@loopback/core'; +import { + ExpressMiddlewareFactory, + ExpressRequestHandler, + Middleware, + MiddlewareBindingOptions, +} from '@loopback/express'; import {OpenApiSpec, OperationObject} from '@loopback/openapi-v3'; import {PathParams} from 'express-serve-static-core'; import {ServeStaticOptions} from 'serve-static'; @@ -13,12 +25,7 @@ import {BodyParser} from './body-parsers'; import {RestBindings} from './keys'; import {RestComponent} from './rest.component'; import {HttpRequestListener, HttpServerLike, RestServer} from './rest.server'; -import { - ControllerClass, - ControllerFactory, - ExpressRequestHandler, - RouteEntry, -} from './router'; +import {ControllerClass, ControllerFactory, RouteEntry} from './router'; import {RouterSpec} from './router/router-spec'; import {SequenceFunction, SequenceHandler} from './sequence'; @@ -133,6 +140,80 @@ export class RestApplication extends Application implements HttpServerLike { this.restServer.basePath(path); } + /** + * Bind an Express middleware to this server context + * + * @example + * ```ts + * import myExpressMiddlewareFactory from 'my-express-middleware'; + * const myExpressMiddlewareConfig= {}; + * const myExpressMiddleware = myExpressMiddlewareFactory(myExpressMiddlewareConfig); + * server.expressMiddleware('middleware.express.my', myExpressMiddleware); + * ``` + * @param key - Middleware binding key + * @param middleware - Express middleware handler function(s) + * + */ + expressMiddleware( + key: BindingAddress, + middleware: ExpressRequestHandler | ExpressRequestHandler[], + options?: MiddlewareBindingOptions, + ): Binding; + + /** + * Bind an Express middleware to this server context + * + * @example + * ```ts + * import myExpressMiddlewareFactory from 'my-express-middleware'; + * const myExpressMiddlewareConfig= {}; + * server.expressMiddleware(myExpressMiddlewareFactory, myExpressMiddlewareConfig); + * ``` + * @param middlewareFactory - Middleware module name or factory function + * @param middlewareConfig - Middleware config + * @param options - Options for registration + * + * @typeParam CFG - Configuration type + */ + expressMiddleware( + middlewareFactory: ExpressMiddlewareFactory, + middlewareConfig?: CFG, + options?: MiddlewareBindingOptions, + ): Binding; + + expressMiddleware( + factoryOrKey: ExpressMiddlewareFactory | BindingAddress, + configOrHandlers: CFG | ExpressRequestHandler | ExpressRequestHandler[], + options: MiddlewareBindingOptions = {}, + ): Binding { + return this.restServer.expressMiddleware( + factoryOrKey, + configOrHandlers, + options, + ); + } + + /** + * Register a middleware function or provider class + * + * @example + * ```ts + * const log: Middleware = async (requestCtx, next) { + * // ... + * } + * server.middleware(log); + * ``` + * + * @param middleware - Middleware function or provider class + * @param options - Middleware binding options + */ + middleware( + middleware: Middleware | Constructor>, + options: MiddlewareBindingOptions = {}, + ): Binding { + return this.restServer.middleware(middleware, options); + } + /** * Register a new Controller-based route. * diff --git a/packages/rest/src/rest.component.ts b/packages/rest/src/rest.component.ts index 53e4fde8251c..4b1d4ed0350f 100644 --- a/packages/rest/src/rest.component.ts +++ b/packages/rest/src/rest.component.ts @@ -16,6 +16,7 @@ import { ProviderMap, Server, } from '@loopback/core'; +import {InvokeMiddlewareProvider} from '@loopback/express'; import {createEmptyApiSpec} from '@loopback/openapi-v3'; import { JsonBodyParser, @@ -49,6 +50,8 @@ import {AjvFactoryProvider} from './validation/ajv-factory.provider'; export class RestComponent implements Component { providers: ProviderMap = { [RestBindings.SequenceActions.LOG_ERROR.key]: LogErrorProvider, + [RestBindings.SequenceActions.INVOKE_MIDDLEWARE + .key]: InvokeMiddlewareProvider, [RestBindings.SequenceActions.FIND_ROUTE.key]: FindRouteProvider, [RestBindings.SequenceActions.INVOKE_METHOD.key]: InvokeMethodProvider, [RestBindings.SequenceActions.REJECT.key]: RejectProvider, diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index 81e99532ab1e..5ccc6cd2fa7c 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -8,7 +8,6 @@ import { BindingAddress, BindingScope, Constructor, - Context, ContextObserver, createBindingFromClass, filterByKey, @@ -17,6 +16,7 @@ import { Subscription, } from '@loopback/context'; import {Application, CoreBindings, Server} from '@loopback/core'; +import {BaseMiddlewareRegistry, ExpressRequestHandler} from '@loopback/express'; import {HttpServer, HttpServerOptions} from '@loopback/http-server'; import { getControllerSpec, @@ -49,7 +49,6 @@ import { ControllerRoute, createControllerFactoryForBinding, createRoutesForController, - ExpressRequestHandler, ExternalExpressRoutes, RedirectRoute, RestRouterOptions, @@ -60,16 +59,7 @@ import { } from './router'; import {assignRouterSpec} from './router/router-spec'; import {DefaultSequence, SequenceFunction, SequenceHandler} from './sequence'; -import { - FindRoute, - InvokeMethod, - ParseParams, - Reject, - Request, - RequestBodyParserOptions, - Response, - Send, -} from './types'; +import {Request, RequestBodyParserOptions, Response} from './types'; const debug = debugFactory('loopback:rest:server'); @@ -111,7 +101,8 @@ const SequenceActions = RestBindings.SequenceActions; * const server = await app.get('servers.foo'); * ``` */ -export class RestServer extends Context implements Server, HttpServerLike { +export class RestServer extends BaseMiddlewareRegistry + implements Server, HttpServerLike { /** * Handle incoming HTTP(S) request by invoking the corresponding * Controller method via the configured Sequence. @@ -251,7 +242,11 @@ export class RestServer extends Context implements Server, HttpServerLike { // Allow CORS support for all endpoints so that users // can test with online SwaggerUI instance - this._expressApp.use(cors(this.config.cors)); + this.expressMiddleware(cors, this.config.cors, { + injectConfiguration: false, + key: 'middleware.cors', + group: 'cors', + }); // Set up endpoints for OpenAPI spec/ui this._setupOpenApiSpecEndpoints(); @@ -311,49 +306,52 @@ export class RestServer extends Context implements Server, HttpServerLike { */ protected _setupOpenApiSpecEndpoints() { if (this.config.openApiSpec.disabled) return; + const router = express.Router(); const mapping = this.config.openApiSpec.endpointMapping!; // Serving OpenAPI spec for (const p in mapping) { - this.addOpenApiSpecEndpoint(p, mapping[p]); + this.addOpenApiSpecEndpoint(p, mapping[p], router); } - const explorerPaths = ['/swagger-ui', '/explorer']; - this._expressApp.get(explorerPaths, (req, res, next) => + router.get(explorerPaths, (req, res, next) => this._redirectToSwaggerUI(req, res, next), ); + this.expressMiddleware('middleware.apiSpec.defaults', router, { + group: 'apiSpec', + }); } /** * Add a new non-controller endpoint hosting a form of the OpenAPI spec. * * @param path Path at which to host the copy of the OpenAPI - * @param form Form that should be renedered from that path + * @param form Form that should be rendered from that path */ - addOpenApiSpecEndpoint(path: string, form: OpenApiSpecForm) { - if (this._expressApp) { - // if the app is already started, try to hot-add it - // this only actually "works" mid-startup, once this._handleHttpRequest - // has been added to express, adding any later routes won't work - - // NOTE(bajtos) Regular routes are handled through Sequence. - // IMO, this built-in endpoint should not run through a Sequence, - // because it's not part of the application API itself. - // E.g. if the app implements access/audit logs, I don't want - // this endpoint to trigger a log entry. If the server implements - // content-negotiation to support XML clients, I don't want the OpenAPI - // spec to be converted into an XML response. - this._expressApp.get(path, (req, res) => - this._serveOpenApiSpec(req, res, form), - ); - } else { - // if the app is not started, add the mapping to the config - const mapping = this.config.openApiSpec.endpointMapping!; - if (path in mapping) { + addOpenApiSpecEndpoint( + path: string, + form: OpenApiSpecForm, + router?: express.Router, + ) { + if (router == null) { + const key = `middleware.apiSpec.${path}.${form}`; + if (this.contains(key)) { throw new Error( `The path ${path} is already configured for OpenApi hosting`, ); } - mapping[path] = form; + const newRouter = express.Router(); + newRouter.get(path, (req, res) => this._serveOpenApiSpec(req, res, form)); + this.expressMiddleware( + () => newRouter, + {}, + { + injectConfiguration: false, + key: `middleware.apiSpec.${path}.${form}`, + group: 'apiSpec', + }, + ); + } else { + router.get(path, (req, res) => this._serveOpenApiSpec(req, res, form)); } } @@ -883,21 +881,8 @@ export class RestServer extends Context implements Server, HttpServerLike { */ public handler(handlerFn: SequenceFunction) { class SequenceFromFunction extends DefaultSequence { - // NOTE(bajtos) Unfortunately, we have to duplicate the constructor - // in order for our DI/IoC framework to inject constructor arguments - constructor( - @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute, - @inject(SequenceActions.PARSE_PARAMS) - protected parseParams: ParseParams, - @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, - @inject(SequenceActions.SEND) public send: Send, - @inject(SequenceActions.REJECT) public reject: Reject, - ) { - super(findRoute, parseParams, invoke, send, reject); - } - async handle(context: RequestContext): Promise { - await Promise.resolve(handlerFn(context, this)); + return handlerFn(context, this); } } diff --git a/packages/rest/src/router/external-express-routes.ts b/packages/rest/src/router/external-express-routes.ts index 64448bf33efc..9422ef9f3e4b 100644 --- a/packages/rest/src/router/external-express-routes.ts +++ b/packages/rest/src/router/external-express-routes.ts @@ -4,30 +4,25 @@ // License text available at https://opensource.org/licenses/MIT import {Context} from '@loopback/context'; +import { + executeExpressRequestHandler, + ExpressRequestHandler, + Request, +} from '@loopback/express'; import { OpenApiSpec, OperationObject, SchemasObject, } from '@loopback/openapi-v3'; -import express, {RequestHandler} from 'express'; +import express from 'express'; import {PathParams} from 'express-serve-static-core'; import HttpErrors from 'http-errors'; -import onFinished from 'on-finished'; import {ServeStaticOptions} from 'serve-static'; -import {promisify} from 'util'; import {RequestContext} from '../request-context'; -import { - OperationArgs, - OperationRetval, - PathParameterValues, - Request, - Response, -} from '../types'; +import {OperationArgs, OperationRetval, PathParameterValues} from '../types'; import {ResolvedRoute, RouteEntry} from './route-entry'; import {assignRouterSpec, RouterSpec} from './router-spec'; -export type ExpressRequestHandler = express.RequestHandler; - /** * A registry of external, Express-style routes. These routes are invoked * _after_ no LB4 route (controller or handler based) matched the incoming @@ -99,14 +94,14 @@ class ExternalRoute implements RouteEntry, ResolvedRoute { {request, response}: RequestContext, args: OperationArgs, ): Promise { - let handled = await executeRequestHandler( + let handled = await executeExpressRequestHandler( this._externalRouter, request, response, ); if (handled) return; - handled = await executeRequestHandler( + handled = await executeExpressRequestHandler( this._staticAssets, request, response, @@ -142,35 +137,3 @@ export function rebaseOpenApiSpec>( return spec; } - -const onFinishedAsync = promisify(onFinished); - -/** - * Execute an Express-style callback-based request handler. - * - * @param handler - * @param request - * @param response - * @returns A promise resolved to: - * - `true` when the request was handled - * - `false` when the handler called `next()` to proceed to the next - * handler (middleware) in the chain. - */ -function executeRequestHandler( - handler: RequestHandler, - request: Request, - response: Response, -): Promise { - const responseWritten = onFinishedAsync(response).then(() => true); - const handlerFinished = new Promise((resolve, reject) => { - handler(request, response, err => { - if (err) { - reject(err); - } else { - // Express router called next, which means no route was matched - resolve(false); - } - }); - }); - return Promise.race([handlerFinished, responseWritten]); -} diff --git a/packages/rest/src/sequence.ts b/packages/rest/src/sequence.ts index 2e29674bee25..b3c05f9c9496 100644 --- a/packages/rest/src/sequence.ts +++ b/packages/rest/src/sequence.ts @@ -4,7 +4,8 @@ // License text available at https://opensource.org/licenses/MIT const debug = require('debug')('loopback:rest:sequence'); -import {inject} from '@loopback/context'; +import {inject, ValueOrPromise} from '@loopback/context'; +import {InvokeMiddleware} from '@loopback/express'; import {RestBindings} from './keys'; import {RequestContext} from './request-context'; import {FindRoute, InvokeMethod, ParseParams, Reject, Send} from './types'; @@ -18,7 +19,7 @@ const SequenceActions = RestBindings.SequenceActions; export type SequenceFunction = ( context: RequestContext, sequence: DefaultSequence, -) => Promise | void; +) => ValueOrPromise; /** * A sequence handler is a class implementing sequence of actions @@ -53,6 +54,13 @@ export interface SequenceHandler { * ``` */ export class DefaultSequence implements SequenceHandler { + /** + * Optional invoker for registered middleware in a chain. + * To be injected via SequenceActions.INVOKE_MIDDLEWARE. + */ + @inject(SequenceActions.INVOKE_MIDDLEWARE, {optional: true}) + protected invokeMiddleware: InvokeMiddleware = () => false; + /** * Constructor: Injects findRoute, invokeMethod & logError * methods as promises. @@ -95,6 +103,8 @@ export class DefaultSequence implements SequenceHandler { async handle(context: RequestContext): Promise { try { const {request, response} = context; + // Invoke registered Express middleware + await this.invokeMiddleware(context); const route = this.findRoute(request); const args = await this.parseParams(request, route); const result = await this.invoke(route, args); diff --git a/packages/rest/src/types.ts b/packages/rest/src/types.ts index c0a621e84738..0d9fb6013f03 100644 --- a/packages/rest/src/types.ts +++ b/packages/rest/src/types.ts @@ -4,6 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {Binding, BoundValue} from '@loopback/context'; +import {HandlerContext, Request, Response} from '@loopback/express'; import {ReferenceObject, SchemaObject} from '@loopback/openapi-v3'; import ajv, {Ajv, FormatDefinition, KeywordDefinition} from 'ajv'; import { @@ -12,19 +13,12 @@ import { OptionsText, OptionsUrlencoded, } from 'body-parser'; -import {Request, Response} from 'express'; import {ResolvedRoute, RouteEntry} from './router'; -export {Request, Response}; - /** - * An object holding HTTP request, response and other data - * needed to handle an incoming HTTP request. + * Re-export types from `./middleware` */ -export interface HandlerContext { - readonly request: Request; - readonly response: Response; -} +export * from '@loopback/express'; /** * Find a route matching the incoming request. diff --git a/packages/rest/tsconfig.json b/packages/rest/tsconfig.json index 5206f41c7f42..4b7c3fb651ed 100644 --- a/packages/rest/tsconfig.json +++ b/packages/rest/tsconfig.json @@ -30,6 +30,9 @@ }, { "path": "../openapi-v3/tsconfig.json" + }, + { + "path": "../express/tsconfig.json" } ] } diff --git a/tsconfig.json b/tsconfig.json index f06e59237d8d..52d3cad8fc19 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -118,6 +118,9 @@ { "path": "packages/core/tsconfig.json" }, + { + "path": "packages/express/tsconfig.json" + }, { "path": "packages/http-caching-proxy/tsconfig.json" },