diff --git a/docs/site/Authentication-component-action.md b/docs/site/Authentication-component-action.md index 1675f53c8017..d70d3a3d0e75 100644 --- a/docs/site/Authentication-component-action.md +++ b/docs/site/Authentication-component-action.md @@ -6,6 +6,12 @@ sidebar: lb4_sidebar permalink: /doc/en/lb4/Authentication-component-action.html --- +{% include note.html content=" +This is not needed for [middleware-based +sequence](REST-middleware-sequence.md) as the authentication is enforced by a +middleware that's automatically discovered and added to the sequence. +" %} + ## Adding an Authentication Action to a Custom Sequence In a LoopBack 4 application with REST API endpoints, each request passes through diff --git a/docs/site/Authentication-overview.md b/docs/site/Authentication-overview.md index 800b80af91fa..572a84ae2c09 100644 --- a/docs/site/Authentication-overview.md +++ b/docs/site/Authentication-overview.md @@ -44,6 +44,12 @@ snippet: - Decorate the controller endpoint with `@authenticate()` and inject the user passed from the authentication layer. +{% include note.html content=" +For [middleware-based sequence](REST-middleware-sequence.md), there is no longer +needed to add the authenticate action as the authentication is enforced by a +middleware that's automatically discovered and added to the sequence. +" %} + The rest will be handled by the authentication component `@loopback/authentication`, which incorporates the authentication mechanism, and the JWT extension `@loopback/jwt-authentication`, which helps in implementing diff --git a/docs/site/REST-action-sequence.md b/docs/site/REST-action-sequence.md new file mode 100644 index 000000000000..65bdbb9feba4 --- /dev/null +++ b/docs/site/REST-action-sequence.md @@ -0,0 +1,592 @@ +--- +lang: en +title: 'Action based Sequence for REST Server' +keywords: LoopBack 4.0, LoopBack 4, Node.js, TypeScript, OpenAPI +sidebar: lb4_sidebar +permalink: /doc/en/lb4/REST-action-based-sequence.html +--- + +{% include warning.html content="Action-based sequence is now being phased out. +Please use [middleware-based sequence](REST-middleware-sequence.md)."%} + +## What is a Sequence? + +A `Sequence` is a stateless grouping of [Actions](#actions) that control how a +`Server` responds to requests. + +The contract of a `Sequence` is simple: it must produce a response to a request. +Creating your own `Sequence` gives you full control over how your `Server` +instances handle requests and responses. The `DefaultSequence` looks like this: + +```ts +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. + * + * @param findRoute - Finds the appropriate controller method, + * spec and args for invocation (injected via SequenceActions.FIND_ROUTE). + * @param parseParams - The parameter parsing function (injected + * via SequenceActions.PARSE_PARAMS). + * @param invoke - Invokes the method specified by the route + * (injected via SequenceActions.INVOKE_METHOD). + * @param send - The action to merge the invoke result with the response + * (injected via SequenceActions.SEND) + * @param reject - The action to take if the invoke returns a rejected + * promise result (injected via SequenceActions.REJECT). + */ + 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, + ) {} + + /** + * Runs the default sequence. Given a handler context (request and response), + * running the sequence will produce a response or an error. + * + * Default sequence executes these steps + * - Executes middleware for CORS, OpenAPI spec endpoints + * - Finds the appropriate controller method, swagger spec + * and args for invocation + * - Parses HTTP request to get API argument list + * - Invokes the API which is defined in the Application Controller + * - Writes the result from API into the HTTP response + * - Error is caught and logged using 'logError' if any of the above steps + * in the sequence fails with an error. + * + * @param context - The request context: HTTP request and response objects, + * per-request IoC container and more. + */ + async handle(context: RequestContext): Promise { + try { + const {request, response} = context; + // Invoke registered Express middleware + const finished = await this.invokeMiddleware(context); + if (finished) { + // The response been produced by the middleware chain + 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); + } + } +} +``` + +## Elements + +In the example above, `route`, `params`, and `result` are all Elements. When +building sequences, you use LoopBack Elements to respond to a request: + +- [`InvokeMiddleware`](https://loopback.io/doc/en/lb4/apidocs.express.invokemiddleware.html) +- [`FindRoute`](https://loopback.io/doc/en/lb4/apidocs.rest.findroute.html) +- [`Request`](http://apidocs.strongloop.com/loopback-next/) - (TBD) missing API + docs link +- [`Response`](http://apidocs.strongloop.com/loopback-next/) - (TBD) missing API + docs link +- [`OperationRetVal`](https://loopback.io/doc/en/lb4/apidocs.rest.operationretval.html) +- [`ParseParams`](https://loopback.io/doc/en/lb4/apidocs.rest.parseparams.html) +- [`OpenAPISpec`](https://loopback.io/doc/en/lb4/apidocs.openapi-v3.openapispec.html) + +## Actions + +Actions are JavaScript functions that only accept or return `Elements`. Since +the input of one action (an Element) is the output of another action (Element) +you can easily compose them. Below is an example that uses several built-in +Actions: + +```ts +class MySequence extends DefaultSequence { + async handle(context: RequestContext) { + try { + // Invoke registered Express middleware + const finished = await this.invokeMiddleware(context); + if (finished) { + // The response been produced by the middleware chain + return; + } + // findRoute() produces an element + const route = this.findRoute(context.request); + // parseParams() uses the route element and produces the params element + const params = await this.parseParams(context.request, route); + // invoke() uses both the route and params elements to produce the result (OperationRetVal) element + const result = await this.invoke(route, params); + // send() uses the result element + this.send(context.response, result); + } catch (error) { + this.reject(context, error); + } + } +} +``` + +{% include warning.html content="Starting from v4.0.0 of `@loopback/rest`. The +sequence adds an `InvokeMiddleware` action for CORS and OpenAPI spec endpoints +as well as other middleware. See [Middleware](Middleware.md) and +[Express Middleware](Express-middleware.md) for more details. For applications +generated using old version of `lb4`, the `src/sequence.ts` needs to be manually +updated with the code above." %} + +## Custom Sequences + +Most use cases can be accomplished with `DefaultSequence` or by slightly +customizing it. When an app is generated by the command `lb4 app`, a sequence +file extending `DefaultSequence` at `src/sequence.ts` is already generated and +bound for you so that you can easily customize it. + +Here is an example where the application logs out a message before and after a +request is handled: + +```ts +import {DefaultSequence, Request, Response} from '@loopback/rest'; + +class MySequence extends DefaultSequence { + log(msg: string) { + console.log(msg); + } + async handle(context: RequestContext) { + this.log('before request'); + await super.handle(context); + this.log('after request'); + } +} +``` + +In order for LoopBack to use your custom sequence, you must register it before +starting your `Application`: + +```js +import {RestApplication} from '@loopback/rest'; + +const app = new RestApplication(); +app.sequence(MySequencce); + +app.start(); +``` + +## Advanced topics + +### Customizing Sequence Actions + +There might be scenarios where the default sequence _ordering_ is not something +you want to change, but rather the individual actions that the sequence will +execute. + +To do this, you'll need to override one or more of the sequence action bindings +used by the `RestServer`, under the `RestBindings.SequenceActions` constants. + +As an example, we'll implement a custom sequence action to replace the default +"send" action. This action is responsible for returning the response from a +controller to the client making the request. + +To do this, we'll register a custom send action by binding a +[Provider](https://loopback.io/doc/en/lb4/apidocs.context.provider.html) to the +`RestBindings.SequenceActions.SEND` key. + +First, let's create our `CustomSendProvider` class, which will provide the send +function upon injection. + +{% include code-caption.html content="/src/providers/custom-send.provider.ts" %} +**custom-send.provider.ts** + +```ts +import {Send, Response} from '@loopback/rest'; +import {Provider, BoundValue, inject} from '@loopback/core'; +import {writeResultToResponse, RestBindings, Request} from '@loopback/rest'; + +// Note: This is an example class; we do not provide this for you. +import {Formatter} from '../utils'; + +export class CustomSendProvider implements Provider { + // In this example, the injection key for formatter is simple + constructor( + @inject('utils.formatter') public formatter: Formatter, + @inject(RestBindings.Http.REQUEST) public request: Request, + ) {} + + value() { + // Use the lambda syntax to preserve the "this" scope for future calls! + return (response: Response, result: OperationRetval) => { + this.action(response, result); + }; + } + /** + * Use the mimeType given in the request's Accept header to convert + * the response object! + * @param response - The response object used to reply to the client. + * @param result - The result of the operation carried out by the controller's + * handling function. + */ + action(response: Response, result: OperationRetval) { + if (result) { + // Currently, the headers interface doesn't allow arbitrary string keys! + const headers = (this.request.headers as any) || {}; + const header = headers.accept || 'application/json'; + const formattedResult = this.formatter.convertToMimeType(result, header); + response.setHeader('Content-Type', header); + response.end(formattedResult); + } else { + response.end(); + } + } +} +``` + +Our custom provider will automatically read the `Accept` header from the request +context, and then transform the result object so that it matches the specified +MIME type. + +Next, in our application class, we'll inject this provider on the +`RestBindings.SequenceActions.SEND` key. + +{% include code-caption.html content="/src/application.ts" %} + +```ts +import {RestApplication, RestBindings} from '@loopback/rest'; +import { + RepositoryMixin, + Class, + Repository, + juggler, +} from '@loopback/repository'; +import {CustomSendProvider} from './providers/custom-send.provider'; +import {Formatter} from './utils'; +import {BindingScope} from '@loopback/core'; + +export class YourApp extends RepositoryMixin(RestApplication) { + constructor() { + super(); + // Assume your controller setup and other items are in here as well. + this.bind('utils.formatter') + .toClass(Formatter) + .inScope(BindingScope.SINGLETON); + this.bind(RestBindings.SequenceActions.SEND).toProvider(CustomSendProvider); + } +} +``` + +As a result, whenever the send action of the +[`DefaultSequence`](https://loopback.io/doc/en/lb4/apidocs.rest.defaultsequence.html) +is called, it will make use of your function instead! You can use this approach +to override any of the actions listed under the `RestBindings.SequenceActions` +namespace. + +### Query string parameters and path parameters + +OAI 3.0.x describes the data from a request’s header, query and path in an +operation specification’s parameters property. In a Controller method, such an +argument is typically decorated by @param(). We've made multiple shortcuts +available to the `@param()` decorator in the form of +`@param..`. Using this notation, path +parameters can be described as `@param.path.string`. Here is an example of a +controller method which retrieves a Note model instance by obtaining the `id` +from the path object. + +```ts +@get('/notes/{id}', { + responses: { + '200': { + description: 'Note model instance', + content: { + 'application/json': { + schema: getModelSchemaRef(Note, {includeRelations: true}), + }, + }, + }, + }, +}) +async findById( + @param.path.string('id') id: string, + @param.filter(Note, {exclude: 'where'}) filter?: FilterExcludingWhere +): Promise { + return this.noteRepository.findById(id, filter); +} +``` + +(Notice: the filter for `findById()` method only supports the `include` clause +for now.) + +You can also specify a parameter which is an object value encoded as a JSON +string or in multiple nested keys. For a JSON string, a sample value would be +`location={"lang": 23.414, "lat": -98.1515}`. For the same `location` object, it +can also be represented as `location[lang]=23.414&location[lat]=-98.1515`. Here +is the equivalent usage for `@param.query.object()` decorator. It takes in the +name of the parameter and an optional schema or reference object for it. + +```ts +@param.query.object('location', { + type: 'object', + properties: {lat: {type: 'number', format: 'float'}, long: {type: 'number', format: 'float'}}, +}) +``` + +The parameters are retrieved as the result of `parseParams` Sequence action. +Please note that deeply nested properties are not officially supported by OAS +yet and is tracked by +[OAI/OpenAPI-Specification#1706](https://github.com/OAI/OpenAPI-Specification/issues/1706). +Therefore, our REST API Explorer does not allow users to provide values for such +parameters and unfortunately has no visible indication of that. This problem is +tracked and discussed in +[swagger-api/swagger-js#1385](https://github.com/swagger-api/swagger-js/issues/1385). + +### Parsing Requests + +Parsing and validating arguments from the request url, headers, and body. See +page [Parsing requests](Parsing-requests.md). + +### Invoking controller methods + +The `invoke` sequence action simply takes the parsed request parameters from the +`parseParams` action along with non-decorated arguments, calls the corresponding +controller method or route handler method, and returns the value from it. The +default implementation of +[invoke](https://github.com/strongloop/loopback-next/blob/6bafa0774662991199090219913c3dc77ad5b149/packages/rest/src/providers/invoke-method.provider.ts) +action calls the handler function for the route with the request specific +context and the arguments for the function. It is important to note that +controller methods use `invokeMethod` from `@loopback/core` and can be used with +global and custom interceptors. See +[Interceptor docs](Interceptors.md#use-invokemethod-to-apply-interceptors) for +more details. The request flow for two route flavours is explained below. + +For controller methods: + +- A controller instance is instantiated from the context. As part of the + instantiation, constructor and property dependencies are injected. The + appropriate controller method is invoked via the chain of interceptors. +- Arguments decorated with `@param` are resolved using data parsed from the + request. Arguments decorated with `@inject` are resolved from the context. + Arguments with no decorators are set to undefined, which is replaced by the + argument default value if it's provided. + +For route handlers, the handler function is invoked via the chain of +interceptors. The array of method arguments is constructed using OpenAPI spec +provided at handler registration time (either via `.api()` for full schema or +`.route()` for individual route registration). + +### Writing the response + +The +[send](https://github.com/strongloop/loopback-next/blob/6bafa0774662991199090219913c3dc77ad5b149/packages/rest/src/providers/send.provider.ts) +sequence action is responsible for writing the result of the `invoke` action to +the HTTP response object. The default sequence calls send with (transformed) +data. Under the hood, send performs all steps required to send back the +response, from content-negotiation to serialization of the response body. In +Express, the handler is responsible for setting response status code, headers +and writing the response body. In LoopBack, controller methods and route +handlers return data describing the response and it's the responsibility of the +Sequence to send that data back to the client. This design makes it easier to +transform the response before it is sent. + +LoopBack 4 does not yet provide first-class support for streaming responses, see +[Issue#2230](https://github.com/strongloop/loopback-next/issues/2230). As a +short-term workaround, controller methods are allowed to send the response +directly, effectively bypassing send action. The default implementation of send +is prepared to handle this case +[here](https://github.com/strongloop/loopback-next/blob/bf07ff959a1f90577849b61221b292d3127696d6/packages/rest/src/writer.ts#L22-L26). + +### Handling errors + +There are many reasons why the application may not be able to handle an incoming +request: + +- The requested endpoint (method + URL path) was not found. +- Parameters provided by the client were not valid. +- A backend database or a service cannot be reached. +- The response object cannot be converted to JSON because of cyclic + dependencies. +- A programmer made a mistake and a `TypeError` is thrown by the runtime. +- And so on. + +In the Sequence implementation described above, all errors are handled by a +single `catch` block at the end of the sequence, using the Sequence Action +called `reject`. + +The default implementation of `reject` does the following steps: + +- Call + [strong-error-handler](https://github.com/strongloop/strong-error-handler) to + send back an HTTP response describing the error. +- Log the error to `stderr` if the status code was 5xx (an internal server + error, not a bad request). + +To prevent the application from leaking sensitive information like filesystem +paths and server addresses, the error handler is configured to hide error +details. + +- For 5xx errors, the output contains only the status code and the status name + from the HTTP specification. For example: + + ```json + { + "error": { + "statusCode": 500, + "message": "Internal Server Error" + } + } + ``` + +- For 4xx errors, the output contains the full error message (`error.message`) + and the contents of the `details` property (`error.details`) that + `ValidationError` typically uses to provide machine-readable details about + validation problems. It also includes `error.code` to allow a machine-readable + error code to be passed through which could be used, for example, for + translation. + + ```json + { + "error": { + "statusCode": 422, + "name": "Unprocessable Entity", + "message": "Missing required fields", + "code": "MISSING_REQUIRED_FIELDS" + } + } + ``` + +During development and testing, it may be useful to see all error details in the +HTTP response returned by the server. This behavior can be enabled by enabling +the `debug` flag in error-handler configuration as shown in the code example +below. See strong-error-handler +[docs](https://github.com/strongloop/strong-error-handler#options) for a list of +all available options. + +```ts +app.bind(RestBindings.ERROR_WRITER_OPTIONS).to({debug: true}); +``` + +An example error message when the debug mode is enabled: + +```json +{ + "error": { + "statusCode": 500, + "name": "Error", + "message": "ENOENT: no such file or directory, open '/etc/passwords'", + "errno": -2, + "syscall": "open", + "code": "ENOENT", + "path": "/etc/passwords", + "stack": "Error: a test error message\n at Object.openSync (fs.js:434:3)\n at Object.readFileSync (fs.js:339:35)" + } +} +``` + +### Keeping your Sequences + +For most use cases, the +[default](https://github.com/strongloop/loopback-next/blob/6bafa0774662991199090219913c3dc77ad5b149/packages/rest/src/sequence.ts) +sequence supplied with LoopBack 4 applications is good enough for +request-response handling pipeline. Check out +[Custom Sequences](#custom-sequences) on how to extend it and implement custom +actions. + +## Working with Express middleware + +{% include warning.html content="First-class support for Express middleware has +been added to LoopBack since v4.0.0 of `@loopback/rest`. Please refer to +[Using Express Middleware](Express-middleware.md). The following information +only applies to earlier versions of `@loopback/rest`." %} + +Under the hood, LoopBack leverages [Express](https://expressjs.com) framework +and its concept of middleware. To avoid common pitfalls, it is not possible to +mount Express middleware directly on a LoopBack application. Instead, LoopBack +provides and enforces a higher-level structure. + +In a typical Express application, there are four kinds of middleware invoked in +the following order: + +1. Request-preprocessing middleware like + [cors](https://www.npmjs.com/package/cors) or + [body-parser](https://www.npmjs.com/package/body-parser). +2. Route handlers handling requests and producing responses. +3. Middleware serving static assets (files). +4. Error handling middleware. + +In LoopBack, we handle the request in the following steps: + +1. The built-in request-preprocessing middleware is invoked. +2. The registered Sequence is started. The default implementation of `findRoute` + and `invoke` actions will try to match the incoming request against the + following resources: + 1. Native LoopBack routes (controller methods, route handlers). + 2. External Express routes (registered via `mountExpressRouter` API) + 3. Static assets +3. Errors are handled by the Sequence using `reject` action. + +Let's see how different kinds of Express middleware can be mapped to LoopBack +concepts: + +### Request-preprocessing middleware + +At the moment, LoopBack does not provide API for mounting arbitrary middleware, +we are discussing this feature in issues +[#1293](https://github.com/strongloop/loopback-next/issues/1293) and +[#2035](https://github.com/strongloop/loopback-next/issues/2035). Please up-vote +them if you are interested in using Express middleware in LoopBack applications. + +All applications come with [cors](https://www.npmjs.com/package/cors) enabled, +this middleware can be configured via RestServer options - see +[Customize CORS](Server.md#customize-cors). + +While it is not possible to add additional middleware to a LoopBack application, +it is possible to mount the entire LoopBack application as component of a parent +top-level Express application where you can add arbitrary middleware as needed. +You can find more details about this approach in +[Creating an Express Application with LoopBack REST API](express-with-lb4-rest-tutorial.md) + +### Route handlers + +In Express, a route handler is a middleware function that serves the response +and does not call `next()`. Handlers can be registered using APIs like +`app.get()`, `app.post()`, but also a more generic `app.use()`. + +In LoopBack, we typically use [Controllers](Controllers.md) and +[Route handlers](Routes.md) to implement request handling logic. + +To support interoperability with Express, it is also possible to take an Express +Router instance and add it to a LoopBack application as an external router - see +[Mounting an Express Router](Routes.md#mounting-an-express-router). This way it +is possible to implement server endpoints using Express APIs. + +### Static files + +LoopBack provides native API for registering static assets as described in +[Serve static files](Application.md#serve-static-files). Under the hood, static +assets are served by [serve-static](https://www.npmjs.com/package/serve-static) +middleware from Express. + +The main difference between LoopBack and vanilla Express applications: LoopBack +ensures that static-asset middleware is always invoked as the last one, only +when no other route handled the request. This is important for performance +reasons to avoid costly filesystem calls. + +### Error handling middleware + +In Express, errors are handled by a special form of middleware, one that's +accepting four arguments: `err`, `request`, `response`, `next`. It's up to the +application developer to ensure that error handler is registered as the last +middleware in the chain, otherwise not all errors may be routed to it. + +In LoopBack, we use async functions instead of callbacks and thus can use simple +`try`/`catch` flow to receive both sync and async errors from individual +sequence actions. A typical Sequence implementation then passes these errors to +the Sequence action `reject`. + +You can learn more about error handling in +[Handling errors](Sequence.md#handling-errors). diff --git a/docs/site/REST-middleware-sequence.md b/docs/site/REST-middleware-sequence.md new file mode 100644 index 000000000000..f75566bac377 --- /dev/null +++ b/docs/site/REST-middleware-sequence.md @@ -0,0 +1,985 @@ +--- +lang: en +title: 'Middleware-based Sequence for REST Server' +keywords: LoopBack 4.0, LoopBack 4, Node.js, TypeScript, OpenAPI, Middleware +sidebar: lb4_sidebar +permalink: /doc/en/lb4/REST-middleware-sequence.html +--- + +## What is a Sequence? + +A `Sequence` is a series of steps to control how a specific type of `Server` +responds to incoming requests. Each types of servers, such as RestServer, +GraphQLServer, GRPCServer, and WebSocketServer, will have its own flavor of +sequence. The sequence represents the pipeline for inbound connections. + +The contract of a `Sequence` is simple: it must produce a response for a +request. The signature will vary by server types. + +Each server type has a default sequence. It's also possible to create your own +`Sequence` to have full control over how your `Server` instances handle requests +and responses. + +This page describes the middleware-based sequence for REST server. + +The `handle` method receives an instance of `RequestContext`, which is a +subclass of `Context` that wraps the `Request` and `Response` objects from the +underlying Express server. + +## Use the sequence for your REST Application + +When a LoopBack application is scaffolded using `lb4 app` command, a +`MySequence` class is generated in `src/sequence.ts`. + +```ts +import {MiddlewareSequence} from '@loopback/rest'; + +export class MySequence extends MiddlewareSequence {} +``` + +`MySequence` is then used by the `RestApplication` in `src/application.ts`: + +```ts +import {BootMixin} from '@loopback/boot'; +import {ApplicationConfig} from '@loopback/core'; +import {RepositoryMixin} from '@loopback/repository'; +import {RestApplication} from '@loopback/rest'; +import {ServiceMixin} from '@loopback/service-proxy'; +import {MySequence} from './sequence'; + +export {ApplicationConfig}; + +export class TodoListApplication extends BootMixin( + ServiceMixin(RepositoryMixin(RestApplication)), +) { + constructor(options: ApplicationConfig = {}) { + super(options); + + // Set up the custom sequence + this.sequence(MySequence); + + // ... + } +} +``` + +## The default sequence + +Since version 6.0.0 of `@loopback/rest`, we have switched to a middleware-based +sequence as the default for flexibility, composability, and consistency. The +sequence itself is basically a named middleware chain. Each middleware serves as +an action within the sequence. The `handle` function executes registered +middleware in cascading fashion. + +```ts +/** + * A sequence implementation using middleware chains + */ +export class MiddlewareSequence implements SequenceHandler { + static defaultOptions: InvokeMiddlewareOptions = { + chain: 'middlewareChain.rest', + orderedGroups: [ + // Please note that middleware is cascading. The `sendResponse` is + // added first to invoke downstream middleware to get the result or + // catch errors so that it can produce the http response. + 'sendResponse', + + // default + 'cors', + 'apiSpec', + + // default + 'middleware', + + // rest + 'findRoute', + + // authentication + 'authentication', + + // rest + 'parseParams', + 'invokeMethod', + ], + }; + + /** + * Constructor: Injects `InvokeMiddleware` and `InvokeMiddlewareOptions` + * + * @param invokeMiddleware - invoker for registered middleware in a chain. + * To be injected via SequenceActions.INVOKE_MIDDLEWARE. + */ + constructor( + @inject(SequenceActions.INVOKE_MIDDLEWARE) + readonly invokeMiddleware: InvokeMiddleware, + @config() + readonly options: InvokeMiddlewareOptions = MiddlewareSequence.defaultOptions, + ) {} + + /** + * Runs the default sequence. Given a handler context (request and response), + * running the sequence will produce a response or an error. + * + * @param context - The request context: HTTP request and response objects, + * per-request IoC container and more. + */ + async handle(context: RequestContext): Promise { + debug( + 'Invoking middleware chain %s with groups %s', + this.options.chain, + this.options.orderedGroups, + ); + await this.invokeMiddleware(context, this.options); + } +} +``` + +![middleware-sequence](imgs/middleware-sequence.png) + +## Middleware as actions + +The middleware function is responsible for processing HTTP requests and +responses. It typically includes the following logic. + +1. Process the request from the server or upstream middleware with one of the + following outcomes: + + - Reject the request by throwing an error if the request is invalid + + ```ts + import {Middleware} from '@loopback/rest'; + const middleware = async (ctx, next) => { + // validate input + throw new Error('invalid input'); + }; + ``` + + - Produce a response by itself, such as from the cache + + ```ts + import {Middleware} from '@loopback/rest'; + const middleware = async (ctx, next) => { + // Find the response from cache + const cachedResponse = {}; + return cachedResponse; + }; + ``` + + - Proceed by calling `await next()` to invoke downstream middleware. When + `await next()` returns, it goes to step 2. If an error thrown from + `await next()`, step 3 handles the error. + + ```ts + import {Middleware} from '@loopback/rest'; + const middleware = async (ctx, next) => { + const result = await next(); + return result; + }; + ``` + +2. Process the response from downstream middleware or the target operation with + one the following outcomes: + + - Reject the response by throwing an error + + ```ts + import {Middleware} from '@loopback/rest'; + const middleware = async (ctx, next) => { + const result = await next(); + // validate result + throw new Error('...'); + }; + ``` + + - Transform the response to a different value + + ```ts + import {Middleware} from '@loopback/rest'; + const middleware = async (ctx, next) => { + const result = await next(); + return {data: result}; + }; + ``` + + - Return the response to upstream middleware + + ```ts + import {Middleware} from '@loopback/rest'; + const middleware = async (ctx, next) => { + const result = await next(); + return result; + }; + ``` + +3. Catch the error thrown from `await next()`. If the `catch` block does not + exist, the error will be bubbled up to upstream middleware. + + ```ts + import {Middleware} from '@loopback/rest'; + const middleware = async (ctx, next) => { + try { + const result = await next(); + return result; + } catch (err) { + // handle err + // either return a new value or throw an error + throw err; + } + }; + ``` + +![cascading-middleware](imgs/cascading-middleware.png) + +Default sequence executes these groups of middleware in order: + +- `cors`: Enforces `CORS` +- `openApiSpec`: Serves OpenAPI specs +- `findRoute`: Finds the appropriate controller method, swagger spec and args + for invocation +- `parseParams`: Parses HTTP request to get API argument list +- `invokeMethod`: Invokes the API which is defined in the Application controller + method + +In front of the groups above, we have a special middleware called +`sendResponse`, which first invokes downstream middleware to get a result and +handles the result or error respectively. + +- Writes the result from API into the HTTP response (if the HTTP response has + not been produced yet by the middleware chain. +- Catches error logs it using 'logError' if any of the above steps in the + sequence fails with an error. + +## Migrate from legacy sequence + +The `Sequence` generated before `@loopback/rest@6.0.0` comes with hard-coded +actions as follows: + +```ts +import {inject} from '@loopback/core'; +import { + FindRoute, + InvokeMethod, + InvokeMiddleware, + ParseParams, + Reject, + RequestContext, + RestBindings, + Send, + SequenceHandler, +} from '@loopback/rest'; + +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, + @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, + @inject(SequenceActions.SEND) public send: Send, + @inject(SequenceActions.REJECT) public reject: Reject, + ) {} + + 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); + this.send(response, result); + } catch (err) { + this.reject(context, err); + } + } +} +``` + +The legacy `Sequence` is now deprecated but it should continue to work without +any changes. + +If you have never customized `src/sequence.ts` generated by `lb4 app`, or you +have only modified the code by adding +[`authenticate`](Authentication-component-action.md) action per our docs, you +can simply migrate to the middleware based sequence by replacing the content of +`src/sequence.ts` with the code below. + +```ts +import {MiddlewareSequence} from '@loopback/rest'; + +export class MySequence extends MiddlewareSequence {} +``` + +If you have other actions in your sequence, you'll have to write a middleware to +wrap your action and register it with the middleware chain for the +middleware-based sequence. For example, the `invokeMethod` action is a function +with the following signature: + +```ts +export type InvokeMethod = ( + route: RouteEntry, + args: OperationArgs, +) => Promise; +``` + +As part of the legacy action-based sequence, the `invoke` function takes `route` +and `args` as input parameters and returns `result`. + +```ts +const route = this.findRoute(request); +const args = await this.parseParams(request, route); +const result = await this.invoke(route, args); +this.send(response, result); +``` + +The corresponding middleware for `invokeMethod` looks like the following. It now +uses the `context` to retrieve `route` and `params` instead. The return value is +also bound to `RestBindings.Operation.RETURN_VALUE` to expose to other +middleware in the chain. + +```ts +async (ctx, next) => { + // Locate or inject input parameters from the request context + const route: RouteEntry = await ctx.get(RestBindings.Operation.ROUTE); + const params: OperationArgs = await ctx.get(RestBindings.Operation.PARAMS); + const retVal = await this.invokeMethod(route, params); + // Bind the return value to the request context + ctx.bind(RestBindings.Operation.RETURN_VALUE).to(retVal); + return retVal; +}; +``` + +## Extend the middleware sequence + +The middleware based sequence is a middleware chain that accepts contribution of +middleware against `RestTags.REST_MIDDLEWARE_CHAIN`. + +### Add middleware to the chain + +LoopBack middleware (including Express middleware) can be added to the sequence. +See +[Middleware](Middleware.md#register-middleware-to-be-executed-by-invokemiddleware-actions) +for more details. + +### Sort middleware by groups + +The middleware for the sequence are executed in the order of groups in a +cascading style. The order of groups is determined by two factors: + +1. The relative order specified for a middleware binding. + + - upstreamGroups: An array of group names that should be upstream to this + middleware + + ```ts + @bind( + asMiddleware({ + chain: RestTags.REST_MIDDLEWARE_CHAIN, + group: 'authentication', + upstreamGroups: ['cors', 'findRoute'], + }), + ) + export class AuthenticationMiddlewareProvider + implements Provider {} + ``` + + - downstreamGroups: An array of group names that should be downstream to + this middleware + + ```ts + @bind( + asMiddleware({ + group: 'sendResponse', + downstreamGroups: ['cors', 'invokeMethod'], + chain: RestTags.REST_MIDDLEWARE_CHAIN, + }), + ) + export class SendResponseMiddlewareProvider + implements Provider {} + ``` + +2. The overall order of groups for the sequence + +- It can be set as in `InvokeMiddlewareOptions`, which is the configuration for + the middleware-based sequence. For example: + + ```ts + import {BootMixin} from '@loopback/boot'; + import {ApplicationConfig} from '@loopback/core'; + import {RepositoryMixin} from '@loopback/repository'; + import { + InvokeMiddlewareOptions, + Request, + Response, + RestApplication, + RestTags, + } from '@loopback/rest'; + import {RestExplorerComponent} from '@loopback/rest-explorer'; + import {ServiceMixin} from '@loopback/service-proxy'; + import {MySequence} from './sequence'; + + export class TodoListApplication extends BootMixin( + ServiceMixin(RepositoryMixin(RestApplication)), + ) { + constructor(options: ApplicationConfig = {}) { + super(options); + + const middlewareOptions: InvokeMiddlewareOptions = { + chain: 'middlewareChain.rest', + orderedGroups: [ + // Please note that middleware is cascading. The `sendResponse` is + // added first to invoke downstream middleware to get the result or + // catch errors so that it can produce the http response. + + 'sendResponse', + + // default + 'cors', + 'apiSpec', + + // default + 'middleware', + + // rest + 'findRoute', + + // authentication + 'authentication', + + // rest + 'parseParams', + 'invokeMethod', + ], + }; + this.configure(RestBindings.SEQUENCE).to(middlewareOptions); // Set up the custom sequence + this.sequence(MySequence); + } + } + ``` + +When each middleware is added to the chain, its settings of `downstreamGroups` +and `upstreamGroups` are honored in conjunction with the overall order. If there +is a conflict, an error will be thrown. + +Here are some examples: + +1. Form a middleware chain with the execution order of + `sendResponse => group2 => cors => group1`: + +``` +orderedGroups: ['sendResponse', 'cors'] +middleware 1: + - group: 'group1' + - upstreamGroups: ['cors'] + +middleware 2: + - group: 'group2' + - downstreamGroups: ['cors'] +``` + +2. a middleware chain with the execution order of + `sendResponse => group2 => cors => group1`: + +``` +orderedGroups: ['sendResponse', 'cors'] +middleware 1: + - group: 'group1' + - upstreamGroups: ['group2', 'cors'] + +middleware 2: + - group: 'group2' + - downstreamGroups: ['cors'] +``` + +2. a middleware chain with an invalid order as `group1` and `group2` creates a + circular dependency: + +``` +orderedGroups: ['sendResponse', 'cors'] +middleware 1: + - group: 'group1' + - upstreamGroups: ['group2', 'cors'] + +middleware 2: + - group: 'group2' + - downstreamGroups: ['group1'] +``` + +## Custom Sequences + +Most use cases can be accomplished with `MiddlewareSequence`. When an app is +generated by the command `lb4 app`, a sequence file extending +`MiddlewareSequence` at `src/sequence.ts` is already generated and bound for you +so that you can easily customize it. + +A `Sequence` class for REST server is required to implement the +`SequenceHandler` interface: + +```ts +import {RequestContext} from '@loopback/rest'; +/** + * A sequence handler is a class implementing sequence of actions + * required to handle an incoming request. + */ +export interface SequenceHandler { + /** + * Handle the request by running the configured sequence of actions. + * + * @param context - The request context: HTTP request and response objects, + * per-request IoC container and more. + */ + handle(context: RequestContext): Promise; +} +``` + +Here is an example where the application logs out a message before and after a +request is handled: + +```ts +import {MiddlewareSequence, Request, Response} from '@loopback/rest'; + +class MySequence extends MiddlewareSequence { + log(msg: string) { + console.log(msg); + } + async handle(context: RequestContext) { + this.log('before request'); + await super.handle(context); + this.log('after request'); + } +} +``` + +In order for LoopBack to use your custom sequence, you must register it before +starting your `Application`: + +```js +import {RestApplication} from '@loopback/rest'; + +const app = new RestApplication(); +app.sequence(MySequence); + +app.start(); +``` + +## Advanced topics + +### Customizing Sequence Actions + +There might be scenarios where the default sequence _ordering_ is not something +you want to change, but rather the individual actions that the sequence will +execute. + +To do this, you'll need to override one or more of the sequence action bindings +used by the `RestServer`, under the `RestBindings.SequenceActions` constants. + +As an example, we'll implement a custom sequence action to replace the default +"send" action. This action is responsible for returning the response from a +controller to the client making the request. + +To do this, we'll register a custom send action by binding a +[Provider](https://loopback.io/doc/en/lb4/apidocs.context.provider.html) to the +`RestBindings.SequenceActions.SEND` key. + +First, let's create our `CustomSendProvider` class, which will provide the send +function upon injection. + +{% include code-caption.html content="/src/providers/custom-send.provider.ts" %} +**custom-send.provider.ts** + +```ts +import {Send, Response} from '@loopback/rest'; +import {Provider, BoundValue, inject} from '@loopback/core'; +import {writeResultToResponse, RestBindings, Request} from '@loopback/rest'; + +// Note: This is an example class; we do not provide this for you. +import {Formatter} from '../utils'; + +export class CustomSendProvider implements Provider { + // In this example, the injection key for formatter is simple + constructor( + @inject('utils.formatter') public formatter: Formatter, + @inject(RestBindings.Http.REQUEST) public request: Request, + ) {} + + value() { + // Use the lambda syntax to preserve the "this" scope for future calls! + return (response: Response, result: OperationRetval) => { + this.action(response, result); + }; + } + /** + * Use the mimeType given in the request's Accept header to convert + * the response object! + * @param response - The response object used to reply to the client. + * @param result - The result of the operation carried out by the controller's + * handling function. + */ + action(response: Response, result: OperationRetval) { + if (result) { + // Currently, the headers interface doesn't allow arbitrary string keys! + const headers = (this.request.headers as any) || {}; + const header = headers.accept || 'application/json'; + const formattedResult = this.formatter.convertToMimeType(result, header); + response.setHeader('Content-Type', header); + response.end(formattedResult); + } else { + response.end(); + } + } +} +``` + +Our custom provider will automatically read the `Accept` header from the request +context, and then transform the result object so that it matches the specified +MIME type. + +Next, in our application class, we'll inject this provider on the +`RestBindings.SequenceActions.SEND` key. + +{% include code-caption.html content="/src/application.ts" %} + +```ts +import {RestApplication, RestBindings} from '@loopback/rest'; +import { + RepositoryMixin, + Class, + Repository, + juggler, +} from '@loopback/repository'; +import {CustomSendProvider} from './providers/custom-send.provider'; +import {Formatter} from './utils'; +import {BindingScope} from '@loopback/core'; + +export class YourApp extends RepositoryMixin(RestApplication) { + constructor() { + super(); + // Assume your controller setup and other items are in here as well. + this.bind('utils.formatter') + .toClass(Formatter) + .inScope(BindingScope.SINGLETON); + this.bind(RestBindings.SequenceActions.SEND).toProvider(CustomSendProvider); + } +} +``` + +As a result, whenever the send action of the +[`DefaultSequence`](https://loopback.io/doc/en/lb4/apidocs.rest.defaultsequence.html) +is called, it will make use of your function instead! You can use this approach +to override any of the actions listed under the `RestBindings.SequenceActions` +namespace. + +### Query string parameters and path parameters + +OAI 3.0.x describes the data from a request’s header, query and path in an +operation specification’s parameters property. In a Controller method, such an +argument is typically decorated by @param(). We've made multiple shortcuts +available to the `@param()` decorator in the form of +`@param..`. Using this notation, path +parameters can be described as `@param.path.string`. Here is an example of a +controller method which retrieves a Note model instance by obtaining the `id` +from the path object. + +```ts +@get('/notes/{id}', { + responses: { + '200': { + description: 'Note model instance', + content: { + 'application/json': { + schema: getModelSchemaRef(Note, {includeRelations: true}), + }, + }, + }, + }, +}) +async findById( + @param.path.string('id') id: string, + @param.filter(Note, {exclude: 'where'}) filter?: FilterExcludingWhere +): Promise { + return this.noteRepository.findById(id, filter); +} +``` + +(Notice: the filter for `findById()` method only supports the `include` clause +for now.) + +You can also specify a parameter which is an object value encoded as a JSON +string or in multiple nested keys. For a JSON string, a sample value would be +`location={"lang": 23.414, "lat": -98.1515}`. For the same `location` object, it +can also be represented as `location[lang]=23.414&location[lat]=-98.1515`. Here +is the equivalent usage for `@param.query.object()` decorator. It takes in the +name of the parameter and an optional schema or reference object for it. + +```ts +@param.query.object('location', { + type: 'object', + properties: {lat: {type: 'number', format: 'float'}, long: {type: 'number', format: 'float'}}, +}) +``` + +The parameters are retrieved as the result of `parseParams` Sequence action. +Please note that deeply nested properties are not officially supported by OAS +yet and is tracked by +[OAI/OpenAPI-Specification#1706](https://github.com/OAI/OpenAPI-Specification/issues/1706). +Therefore, our REST API Explorer does not allow users to provide values for such +parameters and unfortunately has no visible indication of that. This problem is +tracked and discussed in +[swagger-api/swagger-js#1385](https://github.com/swagger-api/swagger-js/issues/1385). + +### Parsing Requests + +Parsing and validating arguments from the request url, headers, and body. See +page [Parsing requests](Parsing-requests.md). + +### Invoking controller methods + +The `invoke` sequence action simply takes the parsed request parameters from the +`parseParams` action along with non-decorated arguments, calls the corresponding +controller method or route handler method, and returns the value from it. The +default implementation of +[invoke](https://github.com/strongloop/loopback-next/blob/6bafa0774662991199090219913c3dc77ad5b149/packages/rest/src/providers/invoke-method.provider.ts) +action calls the handler function for the route with the request specific +context and the arguments for the function. It is important to note that +controller methods use `invokeMethod` from `@loopback/core` and can be used with +global and custom interceptors. See +[Interceptor docs](Interceptors.md#use-invokemethod-to-apply-interceptors) for +more details. The request flow for two route flavours is explained below. + +For controller methods: + +- A controller instance is instantiated from the context. As part of the + instantiation, constructor and property dependencies are injected. The + appropriate controller method is invoked via the chain of interceptors. +- Arguments decorated with `@param` are resolved using data parsed from the + request. Arguments decorated with `@inject` are resolved from the context. + Arguments with no decorators are set to undefined, which is replaced by the + argument default value if it's provided. + +For route handlers, the handler function is invoked via the chain of +interceptors. The array of method arguments is constructed using OpenAPI spec +provided at handler registration time (either via `.api()` for full schema or +`.route()` for individual route registration). + +### Writing the response + +The +[send](https://github.com/strongloop/loopback-next/blob/6bafa0774662991199090219913c3dc77ad5b149/packages/rest/src/providers/send.provider.ts) +sequence action is responsible for writing the result of the `invoke` action to +the HTTP response object. The default sequence calls send with (transformed) +data. Under the hood, send performs all steps required to send back the +response, from content-negotiation to serialization of the response body. In +Express, the handler is responsible for setting response status code, headers +and writing the response body. In LoopBack, controller methods and route +handlers return data describing the response and it's the responsibility of the +Sequence to send that data back to the client. This design makes it easier to +transform the response before it is sent. + +LoopBack 4 does not yet provide first-class support for streaming responses, see +[Issue#2230](https://github.com/strongloop/loopback-next/issues/2230). As a +short-term workaround, controller methods are allowed to send the response +directly, effectively bypassing send action. The default implementation of send +is prepared to handle this case +[here](https://github.com/strongloop/loopback-next/blob/bf07ff959a1f90577849b61221b292d3127696d6/packages/rest/src/writer.ts#L22-L26). + +### Handling errors + +There are many reasons why the application may not be able to handle an incoming +request: + +- The requested endpoint (method + URL path) was not found. +- Parameters provided by the client were not valid. +- A backend database or a service cannot be reached. +- The response object cannot be converted to JSON because of cyclic + dependencies. +- A programmer made a mistake and a `TypeError` is thrown by the runtime. +- And so on. + +In the Sequence implementation described above, all errors are handled by a +single `catch` block at the end of the sequence, using the Sequence Action +called `reject`. + +The default implementation of `reject` does the following steps: + +- Call + [strong-error-handler](https://github.com/strongloop/strong-error-handler) to + send back an HTTP response describing the error. +- Log the error to `stderr` if the status code was 5xx (an internal server + error, not a bad request). + +To prevent the application from leaking sensitive information like filesystem +paths and server addresses, the error handler is configured to hide error +details. + +- For 5xx errors, the output contains only the status code and the status name + from the HTTP specification. For example: + + ```json + { + "error": { + "statusCode": 500, + "message": "Internal Server Error" + } + } + ``` + +- For 4xx errors, the output contains the full error message (`error.message`) + and the contents of the `details` property (`error.details`) that + `ValidationError` typically uses to provide machine-readable details about + validation problems. It also includes `error.code` to allow a machine-readable + error code to be passed through which could be used, for example, for + translation. + + ```json + { + "error": { + "statusCode": 422, + "name": "Unprocessable Entity", + "message": "Missing required fields", + "code": "MISSING_REQUIRED_FIELDS" + } + } + ``` + +During development and testing, it may be useful to see all error details in the +HTTP response returned by the server. This behavior can be enabled by enabling +the `debug` flag in error-handler configuration as shown in the code example +below. See strong-error-handler +[docs](https://github.com/strongloop/strong-error-handler#options) for a list of +all available options. + +```ts +app.bind(RestBindings.ERROR_WRITER_OPTIONS).to({debug: true}); +``` + +An example error message when the debug mode is enabled: + +```json +{ + "error": { + "statusCode": 500, + "name": "Error", + "message": "ENOENT: no such file or directory, open '/etc/passwords'", + "errno": -2, + "syscall": "open", + "code": "ENOENT", + "path": "/etc/passwords", + "stack": "Error: a test error message\n at Object.openSync (fs.js:434:3)\n at Object.readFileSync (fs.js:339:35)" + } +} +``` + +### Keeping your Sequences + +For most use cases, the +[default](https://github.com/strongloop/loopback-next/blob/6bafa0774662991199090219913c3dc77ad5b149/packages/rest/src/sequence.ts) +sequence supplied with LoopBack 4 applications is good enough for +request-response handling pipeline. Check out +[Custom Sequences](#custom-sequences) on how to extend it and implement custom +actions. + +## Working with Express middleware + +{% include warning.html content="First-class support for Express middleware has +been added to LoopBack since v4.0.0 of `@loopback/rest`. Please refer to +[Using Express Middleware](Express-middleware.md). The following information +only applies to earlier versions of `@loopback/rest`." %} + +Under the hood, LoopBack leverages [Express](https://expressjs.com) framework +and its concept of middleware. To avoid common pitfalls, it is not possible to +mount Express middleware directly on a LoopBack application. Instead, LoopBack +provides and enforces a higher-level structure. + +In a typical Express application, there are four kinds of middleware invoked in +the following order: + +1. Request-preprocessing middleware like + [cors](https://www.npmjs.com/package/cors) or + [body-parser](https://www.npmjs.com/package/body-parser). +2. Route handlers handling requests and producing responses. +3. Middleware serving static assets (files). +4. Error handling middleware. + +In LoopBack, we handle the request in the following steps: + +1. The built-in request-preprocessing middleware is invoked. +2. The registered Sequence is started. The default implementation of `findRoute` + and `invoke` actions will try to match the incoming request against the + following resources: + 1. Native LoopBack routes (controller methods, route handlers). + 2. External Express routes (registered via `mountExpressRouter` API) + 3. Static assets +3. Errors are handled by the Sequence using `reject` action. + +Let's see how different kinds of Express middleware can be mapped to LoopBack +concepts: + +### Request-preprocessing middleware + +At the moment, LoopBack does not provide API for mounting arbitrary middleware, +we are discussing this feature in issues +[#1293](https://github.com/strongloop/loopback-next/issues/1293) and +[#2035](https://github.com/strongloop/loopback-next/issues/2035). Please up-vote +them if you are interested in using Express middleware in LoopBack applications. + +All applications come with [cors](https://www.npmjs.com/package/cors) enabled, +this middleware can be configured via RestServer options - see +[Customize CORS](Server.md#customize-cors). + +While it is not possible to add additional middleware to a LoopBack application, +it is possible to mount the entire LoopBack application as component of a parent +top-level Express application where you can add arbitrary middleware as needed. +You can find more details about this approach in +[Creating an Express Application with LoopBack REST API](express-with-lb4-rest-tutorial.md) + +### Route handlers + +In Express, a route handler is a middleware function that serves the response +and does not call `next()`. Handlers can be registered using APIs like +`app.get()`, `app.post()`, but also a more generic `app.use()`. + +In LoopBack, we typically use [Controllers](Controllers.md) and +[Route handlers](Routes.md) to implement request handling logic. + +To support interoperability with Express, it is also possible to take an Express +Router instance and add it to a LoopBack application as an external router - see +[Mounting an Express Router](Routes.md#mounting-an-express-router). This way it +is possible to implement server endpoints using Express APIs. + +### Static files + +LoopBack provides native API for registering static assets as described in +[Serve static files](Application.md#serve-static-files). Under the hood, static +assets are served by [serve-static](https://www.npmjs.com/package/serve-static) +middleware from Express. + +The main difference between LoopBack and vanilla Express applications: LoopBack +ensures that static-asset middleware is always invoked as the last one, only +when no other route handled the request. This is important for performance +reasons to avoid costly filesystem calls. + +### Error handling middleware + +In Express, errors are handled by a special form of middleware, one that's +accepting four arguments: `err`, `request`, `response`, `next`. It's up to the +application developer to ensure that error handler is registered as the last +middleware in the chain, otherwise not all errors may be routed to it. + +In LoopBack, we use async functions instead of callbacks and thus can use simple +`try`/`catch` flow to receive both sync and async errors from individual +sequence actions. A typical Sequence implementation then passes these errors to +the Sequence action `reject`. + +You can learn more about error handling in +[Handling errors](Sequence.md#handling-errors). + +``` + +``` diff --git a/docs/site/Sequence.md b/docs/site/Sequence.md index 9cbf3a1ab74e..d2caa49eac1d 100644 --- a/docs/site/Sequence.md +++ b/docs/site/Sequence.md @@ -8,582 +8,32 @@ permalink: /doc/en/lb4/Sequence.html ## What is a Sequence? -A `Sequence` is a stateless grouping of [Actions](#actions) that control how a -`Server` responds to requests. +A `Sequence` is a series of steps to control how a specific type of `Server` +responds to incoming requests. Each types of servers, such as RestServer, +GraphQLServer, GRPCServer, and WebSocketServer, will have its own flavor of +sequence. The sequence represents the pipeline for inbound connections. -The contract of a `Sequence` is simple: it must produce a response to a request. -Creating your own `Sequence` gives you full control over how your `Server` -instances handle requests and responses. The `DefaultSequence` looks like this: +The contract of a `Sequence` is simple: it must produce a response for a +request. The signature will vary by server types. -```ts -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; +Each server type has a default sequence. It's also possible to create your own +`Sequence` to have full control over how your `Server` instances handle requests +and responses. - /** - * Constructor: Injects findRoute, invokeMethod & logError - * methods as promises. - * - * @param findRoute - Finds the appropriate controller method, - * spec and args for invocation (injected via SequenceActions.FIND_ROUTE). - * @param parseParams - The parameter parsing function (injected - * via SequenceActions.PARSE_PARAMS). - * @param invoke - Invokes the method specified by the route - * (injected via SequenceActions.INVOKE_METHOD). - * @param send - The action to merge the invoke result with the response - * (injected via SequenceActions.SEND) - * @param reject - The action to take if the invoke returns a rejected - * promise result (injected via SequenceActions.REJECT). - */ - 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, - ) {} +For now, we focus on `Sequence` for REST Servers, which has two flavors. - /** - * Runs the default sequence. Given a handler context (request and response), - * running the sequence will produce a response or an error. - * - * Default sequence executes these steps - * - Executes middleware for CORS, OpenAPI spec endpoints - * - Finds the appropriate controller method, swagger spec - * and args for invocation - * - Parses HTTP request to get API argument list - * - Invokes the API which is defined in the Application Controller - * - Writes the result from API into the HTTP response - * - Error is caught and logged using 'logError' if any of the above steps - * in the sequence fails with an error. - * - * @param context - The request context: HTTP request and response objects, - * per-request IoC container and more. - */ - async handle(context: RequestContext): Promise { - try { - const {request, response} = context; - // Invoke registered Express middleware - const finished = await this.invokeMiddleware(context); - if (finished) { - // The response been produced by the middleware chain - return; - } - const route = this.findRoute(request); - const args = await this.parseParams(request, route); - const result = await this.invoke(route, args); +### Middleware-based sequence for REST Server - debug('%s result -', route.describe(), result); - this.send(response, result); - } catch (error) { - this.reject(context, error); - } - } -} -``` +The [middleware-based sequence](REST-middleware-sequence.md) is introduced by +@loopback/rest v6.0.0. It consists of groups of cascading middleware that allow +better extensibility and composability. Newly generated LoopBack applications +use this approach by default. -## Elements +### Action-based sequence for REST Server -In the example above, `route`, `params`, and `result` are all Elements. When -building sequences, you use LoopBack Elements to respond to a request: - -- [`InvokeMiddleware`](https://loopback.io/doc/en/lb4/apidocs.express.invokemiddleware.html) -- [`FindRoute`](https://loopback.io/doc/en/lb4/apidocs.rest.findroute.html) -- [`Request`](http://apidocs.strongloop.com/loopback-next/) - (TBD) missing API - docs link -- [`Response`](http://apidocs.strongloop.com/loopback-next/) - (TBD) missing API - docs link -- [`OperationRetVal`](https://loopback.io/doc/en/lb4/apidocs.rest.operationretval.html) -- [`ParseParams`](https://loopback.io/doc/en/lb4/apidocs.rest.parseparams.html) -- [`OpenAPISpec`](https://loopback.io/doc/en/lb4/apidocs.openapi-v3.openapispec.html) - -## Actions - -Actions are JavaScript functions that only accept or return `Elements`. Since -the input of one action (an Element) is the output of another action (Element) -you can easily compose them. Below is an example that uses several built-in -Actions: - -```ts -class MySequence extends DefaultSequence { - async handle(context: RequestContext) { - try { - // Invoke registered Express middleware - const finished = await this.invokeMiddleware(context); - if (finished) { - // The response been produced by the middleware chain - return; - } - // findRoute() produces an element - const route = this.findRoute(context.request); - // parseParams() uses the route element and produces the params element - const params = await this.parseParams(context.request, route); - // invoke() uses both the route and params elements to produce the result (OperationRetVal) element - const result = await this.invoke(route, params); - // send() uses the result element - this.send(context.response, result); - } catch (error) { - this.reject(context, error); - } - } -} -``` - -{% include warning.html content="Starting from v4.0.0 of `@loopback/rest`. The -sequence adds an `InvokeMiddleware` action for CORS and OpenAPI spec endpoints -as well as other middleware. See [Middleware](Middleware.md) and -[Express Middleware](Express-middleware.md) for more details. For applications -generated using old version of `lb4`, the `src/sequence.ts` needs be to manually -updated with the code above." %} - -## Custom Sequences - -Most use cases can be accomplished with `DefaultSequence` or by slightly -customizing it. When an app is generated by the command `lb4 app`, a sequence -file extending `DefaultSequence` at `src/sequence.ts` is already generated and -bound for you so that you can easily customize it. - -Here is an example where the application logs out a message before and after a -request is handled: - -```ts -import {DefaultSequence, Request, Response} from '@loopback/rest'; - -class MySequence extends DefaultSequence { - log(msg: string) { - console.log(msg); - } - async handle(context: RequestContext) { - this.log('before request'); - await super.handle(context); - this.log('after request'); - } -} -``` - -In order for LoopBack to use your custom sequence, you must register it before -starting your `Application`: - -```js -import {RestApplication} from '@loopback/rest'; - -const app = new RestApplication(); -app.sequence(MySequencce); - -app.start(); -``` - -## Advanced topics - -### Customizing Sequence Actions - -There might be scenarios where the default sequence _ordering_ is not something -you want to change, but rather the individual actions that the sequence will -execute. - -To do this, you'll need to override one or more of the sequence action bindings -used by the `RestServer`, under the `RestBindings.SequenceActions` constants. - -As an example, we'll implement a custom sequence action to replace the default -"send" action. This action is responsible for returning the response from a -controller to the client making the request. - -To do this, we'll register a custom send action by binding a -[Provider](https://loopback.io/doc/en/lb4/apidocs.context.provider.html) to the -`RestBindings.SequenceActions.SEND` key. - -First, let's create our `CustomSendProvider` class, which will provide the send -function upon injection. - -{% include code-caption.html content="/src/providers/custom-send.provider.ts" %} -**custom-send.provider.ts** - -```ts -import {Send, Response} from '@loopback/rest'; -import {Provider, BoundValue, inject} from '@loopback/core'; -import {writeResultToResponse, RestBindings, Request} from '@loopback/rest'; - -// Note: This is an example class; we do not provide this for you. -import {Formatter} from '../utils'; - -export class CustomSendProvider implements Provider { - // In this example, the injection key for formatter is simple - constructor( - @inject('utils.formatter') public formatter: Formatter, - @inject(RestBindings.Http.REQUEST) public request: Request, - ) {} - - value() { - // Use the lambda syntax to preserve the "this" scope for future calls! - return (response: Response, result: OperationRetval) => { - this.action(response, result); - }; - } - /** - * Use the mimeType given in the request's Accept header to convert - * the response object! - * @param response - The response object used to reply to the client. - * @param result - The result of the operation carried out by the controller's - * handling function. - */ - action(response: Response, result: OperationRetval) { - if (result) { - // Currently, the headers interface doesn't allow arbitrary string keys! - const headers = (this.request.headers as any) || {}; - const header = headers.accept || 'application/json'; - const formattedResult = this.formatter.convertToMimeType(result, header); - response.setHeader('Content-Type', header); - response.end(formattedResult); - } else { - response.end(); - } - } -} -``` - -Our custom provider will automatically read the `Accept` header from the request -context, and then transform the result object so that it matches the specified -MIME type. - -Next, in our application class, we'll inject this provider on the -`RestBindings.SequenceActions.SEND` key. - -{% include code-caption.html content="/src/application.ts" %} - -```ts -import {RestApplication, RestBindings} from '@loopback/rest'; -import { - RepositoryMixin, - Class, - Repository, - juggler, -} from '@loopback/repository'; -import {CustomSendProvider} from './providers/custom-send.provider'; -import {Formatter} from './utils'; -import {BindingScope} from '@loopback/core'; - -export class YourApp extends RepositoryMixin(RestApplication) { - constructor() { - super(); - // Assume your controller setup and other items are in here as well. - this.bind('utils.formatter') - .toClass(Formatter) - .inScope(BindingScope.SINGLETON); - this.bind(RestBindings.SequenceActions.SEND).toProvider(CustomSendProvider); - } -} -``` - -As a result, whenever the send action of the -[`DefaultSequence`](https://loopback.io/doc/en/lb4/apidocs.rest.defaultsequence.html) -is called, it will make use of your function instead! You can use this approach -to override any of the actions listed under the `RestBindings.SequenceActions` -namespace. - -### Query string parameters and path parameters - -OAI 3.0.x describes the data from a request’s header, query and path in an -operation specification’s parameters property. In a Controller method, such an -argument is typically decorated by @param(). We've made multiple shortcuts -available to the `@param()` decorator in the form of -`@param..`. Using this notation, path -parameters can be described as `@param.path.string`. Here is an example of a -controller method which retrieves a Note model instance by obtaining the `id` -from the path object. - -```ts -@get('/notes/{id}', { - responses: { - '200': { - description: 'Note model instance', - content: { - 'application/json': { - schema: getModelSchemaRef(Note, {includeRelations: true}), - }, - }, - }, - }, -}) -async findById( - @param.path.string('id') id: string, - @param.filter(Note, {exclude: 'where'}) filter?: FilterExcludingWhere -): Promise { - return this.noteRepository.findById(id, filter); -} -``` - -(Notice: the filter for `findById()` method only supports the `include` clause -for now.) - -You can also specify a parameter which is an object value encoded as a JSON -string or in multiple nested keys. For a JSON string, a sample value would be -`location={"lang": 23.414, "lat": -98.1515}`. For the same `location` object, it -can also be represented as `location[lang]=23.414&location[lat]=-98.1515`. Here -is the equivalent usage for `@param.query.object()` decorator. It takes in the -name of the parameter and an optional schema or reference object for it. - -```ts -@param.query.object('location', { - type: 'object', - properties: {lat: {type: 'number', format: 'float'}, long: {type: 'number', format: 'float'}}, -}) -``` - -The parameters are retrieved as the result of `parseParams` Sequence action. -Please note that deeply nested properties are not officially supported by OAS -yet and is tracked by -[OAI/OpenAPI-Specification#1706](https://github.com/OAI/OpenAPI-Specification/issues/1706). -Therefore, our REST API Explorer does not allow users to provide values for such -parameters and unfortunately has no visible indication of that. This problem is -tracked and discussed in -[swagger-api/swagger-js#1385](https://github.com/swagger-api/swagger-js/issues/1385). - -### Parsing Requests - -Parsing and validating arguments from the request url, headers, and body. See -page [Parsing requests](Parsing-requests.md). - -### Invoking controller methods - -The `invoke` sequence action simply takes the parsed request parameters from the -`parseParams` action along with non-decorated arguments, calls the corresponding -controller method or route handler method, and returns the value from it. The -default implementation of -[invoke](https://github.com/strongloop/loopback-next/blob/6bafa0774662991199090219913c3dc77ad5b149/packages/rest/src/providers/invoke-method.provider.ts) -action calls the handler function for the route with the request specific -context and the arguments for the function. It is important to note that -controller methods use `invokeMethod` from `@loopback/core` and can be used with -global and custom interceptors. See -[Interceptor docs](Interceptors.md#use-invokemethod-to-apply-interceptors) for -more details. The request flow for two route flavours is explained below. - -For controller methods: - -- A controller instance is instantiated from the context. As part of the - instantiation, constructor and property dependencies are injected. The - appropriate controller method is invoked via the chain of interceptors. -- Arguments decorated with `@param` are resolved using data parsed from the - request. Arguments decorated with `@inject` are resolved from the context. - Arguments with no decorators are set to undefined, which is replaced by the - argument default value if it's provided. - -For route handlers, the handler function is invoked via the chain of -interceptors. The array of method arguments is constructed using OpenAPI spec -provided at handler registration time (either via `.api()` for full schema or -`.route()` for individual route registration). - -### Writing the response - -The -[send](https://github.com/strongloop/loopback-next/blob/6bafa0774662991199090219913c3dc77ad5b149/packages/rest/src/providers/send.provider.ts) -sequence action is responsible for writing the result of the `invoke` action to -the HTTP response object. The default sequence calls send with (transformed) -data. Under the hood, send performs all steps required to send back the -response, from content-negotiation to serialization of the response body. In -Express, the handler is responsible for setting response status code, headers -and writing the response body. In LoopBack, controller methods and route -handlers return data describing the response and it's the responsibility of the -Sequence to send that data back to the client. This design makes it easier to -transform the response before it is sent. - -LoopBack 4 does not yet provide first-class support for streaming responses, see -[Issue#2230](https://github.com/strongloop/loopback-next/issues/2230). As a -short-term workaround, controller methods are allowed to send the response -directly, effectively bypassing send action. The default implementation of send -is prepared to handle this case -[here](https://github.com/strongloop/loopback-next/blob/bf07ff959a1f90577849b61221b292d3127696d6/packages/rest/src/writer.ts#L22-L26). - -### Handling errors - -There are many reasons why the application may not be able to handle an incoming -request: - -- The requested endpoint (method + URL path) was not found. -- Parameters provided by the client were not valid. -- A backend database or a service cannot be reached. -- The response object cannot be converted to JSON because of cyclic - dependencies. -- A programmer made a mistake and a `TypeError` is thrown by the runtime. -- And so on. - -In the Sequence implementation described above, all errors are handled by a -single `catch` block at the end of the sequence, using the Sequence Action -called `reject`. - -The default implementation of `reject` does the following steps: - -- Call - [strong-error-handler](https://github.com/strongloop/strong-error-handler) to - send back an HTTP response describing the error. -- Log the error to `stderr` if the status code was 5xx (an internal server - error, not a bad request). - -To prevent the application from leaking sensitive information like filesystem -paths and server addresses, the error handler is configured to hide error -details. - -- For 5xx errors, the output contains only the status code and the status name - from the HTTP specification. For example: - - ```json - { - "error": { - "statusCode": 500, - "message": "Internal Server Error" - } - } - ``` - -- For 4xx errors, the output contains the full error message (`error.message`) - and the contents of the `details` property (`error.details`) that - `ValidationError` typically uses to provide machine-readable details about - validation problems. It also includes `error.code` to allow a machine-readable - error code to be passed through which could be used, for example, for - translation. - - ```json - { - "error": { - "statusCode": 422, - "name": "Unprocessable Entity", - "message": "Missing required fields", - "code": "MISSING_REQUIRED_FIELDS" - } - } - ``` - -During development and testing, it may be useful to see all error details in the -HTTP response returned by the server. This behavior can be enabled by enabling -the `debug` flag in error-handler configuration as shown in the code example -below. See strong-error-handler -[docs](https://github.com/strongloop/strong-error-handler#options) for a list of -all available options. - -```ts -app.bind(RestBindings.ERROR_WRITER_OPTIONS).to({debug: true}); -``` - -An example error message when the debug mode is enabled: - -```json -{ - "error": { - "statusCode": 500, - "name": "Error", - "message": "ENOENT: no such file or directory, open '/etc/passwords'", - "errno": -2, - "syscall": "open", - "code": "ENOENT", - "path": "/etc/passwords", - "stack": "Error: a test error message\n at Object.openSync (fs.js:434:3)\n at Object.readFileSync (fs.js:339:35)" - } -} -``` - -### Keeping your Sequences - -For most use cases, the -[default](https://github.com/strongloop/loopback-next/blob/6bafa0774662991199090219913c3dc77ad5b149/packages/rest/src/sequence.ts) -sequence supplied with LoopBack 4 applications is good enough for -request-response handling pipeline. Check out -[Custom Sequences](#custom-sequences) on how to extend it and implement custom -actions. - -## Working with Express middleware - -{% include warning.html content="First-class support for Express middleware has -been added to LoopBack since v4.0.0 of `@loopback/rest`. Please refer to -[Using Express Middleware](Express-middleware.md). The following information -only applies to earlier versions of `@loopback/rest`." %} - -Under the hood, LoopBack leverages [Express](https://expressjs.com) framework -and its concept of middleware. To avoid common pitfalls, it is not possible to -mount Express middleware directly on a LoopBack application. Instead, LoopBack -provides and enforces a higher-level structure. - -In a typical Express application, there are four kinds of middleware invoked in -the following order: - -1. Request-preprocessing middleware like - [cors](https://www.npmjs.com/package/cors) or - [body-parser](https://www.npmjs.com/package/body-parser). -2. Route handlers handling requests and producing responses. -3. Middleware serving static assets (files). -4. Error handling middleware. - -In LoopBack, we handle the request in the following steps: - -1. The built-in request-preprocessing middleware is invoked. -2. The registered Sequence is started. The default implementation of `findRoute` - and `invoke` actions will try to match the incoming request against the - following resources: - 1. Native LoopBack routes (controller methods, route handlers). - 2. External Express routes (registered via `mountExpressRouter` API) - 3. Static assets -3. Errors are handled by the Sequence using `reject` action. - -Let's see how different kinds of Express middleware can be mapped to LoopBack -concepts: - -### Request-preprocessing middleware - -At the moment, LoopBack does not provide API for mounting arbitrary middleware, -we are discussing this feature in issues -[#1293](https://github.com/strongloop/loopback-next/issues/1293) and -[#2035](https://github.com/strongloop/loopback-next/issues/2035). Please up-vote -them if you are interested in using Express middleware in LoopBack applications. - -All applications come with [cors](https://www.npmjs.com/package/cors) enabled, -this middleware can be configured via RestServer options - see -[Customize CORS](Server.md#customize-cors). - -While it is not possible to add additional middleware to a LoopBack application, -it is possible to mount the entire LoopBack application as component of a parent -top-level Express application where you can add arbitrary middleware as needed. -You can find more details about this approach in -[Creating an Express Application with LoopBack REST API](express-with-lb4-rest-tutorial.md) - -### Route handlers - -In Express, a route handler is a middleware function that serves the response -and does not call `next()`. Handlers can be registered using APIs like -`app.get()`, `app.post()`, but also a more generic `app.use()`. - -In LoopBack, we typically use [Controllers](Controllers.md) and -[Route handlers](Routes.md) to implement request handling logic. - -To support interoperability with Express, it is also possible to take an Express -Router instance and add it to a LoopBack application as an external router - see -[Mounting an Express Router](Routes.md#mounting-an-express-router). This way it -is possible to implement server endpoints using Express APIs. - -### Static files - -LoopBack provides native API for registering static assets as described in -[Serve static files](Application.md#serve-static-files). Under the hood, static -assets are served by [serve-static](https://www.npmjs.com/package/serve-static) -middleware from Express. - -The main difference between LoopBack and vanilla Express applications: LoopBack -ensures that static-asset middleware is always invoked as the last one, only -when no other route handled the request. This is important for performance -reasons to avoid costly filesystem calls. - -### Error handling middleware - -In Express, errors are handled by a special form of middleware, one that's -accepting four arguments: `err`, `request`, `response`, `next`. It's up to the -application developer to ensure that error handler is registered as the last -middleware in the chain, otherwise not all errors may be routed to it. - -In LoopBack, we use async functions instead of callbacks and thus can use simple -`try`/`catch` flow to receive both sync and async errors from individual -sequence actions. A typical Sequence implementation then passes these errors to -the Sequence action `reject`. - -You can learn more about error handling in -[Handling errors](Sequence.md#handling-errors). +The [action-based sequence](REST-action-sequence.md) is the default +implementation for @loopback/rest version 5.x or below. The sequence is a +generated class that contains hard-coded actions in the `handle` method and can +be modified by application developers to extend or customize the steps. It is +supported for backward compatibility and will be deprecated and removed in +future releases. diff --git a/docs/site/imgs/cascading-middleware.png b/docs/site/imgs/cascading-middleware.png new file mode 100644 index 000000000000..3b2b34a1674e Binary files /dev/null and b/docs/site/imgs/cascading-middleware.png differ diff --git a/docs/site/imgs/middleware-sequence.png b/docs/site/imgs/middleware-sequence.png new file mode 100644 index 000000000000..6868ac31bf23 Binary files /dev/null and b/docs/site/imgs/middleware-sequence.png differ diff --git a/docs/site/sidebars/lb4_sidebar.yml b/docs/site/sidebars/lb4_sidebar.yml index 167e32f19735..6b54748f5ee9 100644 --- a/docs/site/sidebars/lb4_sidebar.yml +++ b/docs/site/sidebars/lb4_sidebar.yml @@ -466,6 +466,14 @@ children: output: 'web, pdf' children: + - title: 'Middleware based sequence' + url: REST-middleware-sequence.html + output: 'web, pdf' + + - title: 'Action based sequence' + url: REST-action-sequence.html + output: 'web, pdf' + - title: 'Routing requests' url: Routing-requests.html output: 'web, pdf' diff --git a/examples/access-control-migration/src/sequence.ts b/examples/access-control-migration/src/sequence.ts index 31d2a5a552d3..d17f5e9fd00b 100644 --- a/examples/access-control-migration/src/sequence.ts +++ b/examples/access-control-migration/src/sequence.ts @@ -3,68 +3,6 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import { - AuthenticateFn, - AuthenticationBindings, - AUTHENTICATION_STRATEGY_NOT_FOUND, - USER_PROFILE_NOT_FOUND, -} from '@loopback/authentication'; -import {Context, inject} from '@loopback/core'; -import { - FindRoute, - InvokeMethod, - InvokeMiddleware, - ParseParams, - Reject, - RequestContext, - RestBindings, - Send, - SequenceHandler, -} from '@loopback/rest'; +import {MiddlewareSequence} from '@loopback/rest'; -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, - @inject(SequenceActions.SEND) public send: Send, - @inject(SequenceActions.REJECT) public reject: Reject, - @inject(AuthenticationBindings.AUTH_ACTION) - protected authenticateRequest: AuthenticateFn, - ) {} - - async handle(context: RequestContext) { - try { - const {request, response} = context; - const finished = await this.invokeMiddleware(context); - if (finished) return; - - const route = this.findRoute(request); - - //call authentication action - await this.authenticateRequest(request); - - const args = await this.parseParams(request, route); - const result = await this.invoke(route, args); - this.send(response, result); - } catch (error) { - if ( - error.code === AUTHENTICATION_STRATEGY_NOT_FOUND || - error.code === USER_PROFILE_NOT_FOUND - ) { - Object.assign(error, {statusCode: 401 /* Unauthorized */}); - } - this.reject(context, error); - } - } -} +export class MySequence extends MiddlewareSequence {} diff --git a/examples/express-composition/src/sequence.ts b/examples/express-composition/src/sequence.ts index ef3adaf25a6e..bae08eee74b8 100644 --- a/examples/express-composition/src/sequence.ts +++ b/examples/express-composition/src/sequence.ts @@ -3,48 +3,6 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {inject} from '@loopback/core'; -import { - FindRoute, - InvokeMethod, - InvokeMiddleware, - ParseParams, - Reject, - RequestContext, - RestBindings, - Send, - SequenceHandler, -} from '@loopback/rest'; +import {MiddlewareSequence} from '@loopback/rest'; -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, - @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, - @inject(SequenceActions.SEND) public send: Send, - @inject(SequenceActions.REJECT) public reject: Reject, - ) {} - - 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); - this.send(response, result); - } catch (err) { - this.reject(context, err); - } - } -} +export class MySequence extends MiddlewareSequence {} diff --git a/examples/file-transfer/src/sequence.ts b/examples/file-transfer/src/sequence.ts index ccb39e3f8490..06d2c3eb2c8b 100644 --- a/examples/file-transfer/src/sequence.ts +++ b/examples/file-transfer/src/sequence.ts @@ -3,48 +3,6 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {inject} from '@loopback/core'; -import { - FindRoute, - InvokeMethod, - InvokeMiddleware, - ParseParams, - Reject, - RequestContext, - RestBindings, - Send, - SequenceHandler, -} from '@loopback/rest'; +import {MiddlewareSequence} from '@loopback/rest'; -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, - @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, - @inject(SequenceActions.SEND) public send: Send, - @inject(SequenceActions.REJECT) public reject: Reject, - ) {} - - 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); - this.send(response, result); - } catch (err) { - this.reject(context, err); - } - } -} +export class MySequence extends MiddlewareSequence {} diff --git a/examples/lb3-application/src/sequence.ts b/examples/lb3-application/src/sequence.ts index 2b16903ac3f3..a00a84247d30 100644 --- a/examples/lb3-application/src/sequence.ts +++ b/examples/lb3-application/src/sequence.ts @@ -3,48 +3,6 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {inject} from '@loopback/core'; -import { - FindRoute, - InvokeMethod, - InvokeMiddleware, - ParseParams, - Reject, - RequestContext, - RestBindings, - Send, - SequenceHandler, -} from '@loopback/rest'; +import {MiddlewareSequence} from '@loopback/rest'; -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, - @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, - @inject(SequenceActions.SEND) public send: Send, - @inject(SequenceActions.REJECT) public reject: Reject, - ) {} - - 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); - this.send(response, result); - } catch (err) { - this.reject(context, err); - } - } -} +export class MySequence extends MiddlewareSequence {} diff --git a/examples/multi-tenancy/src/__tests__/acceptance/user.controller.header.acceptance.ts b/examples/multi-tenancy/src/__tests__/acceptance/user.controller.header.acceptance.ts index 9c96e403ce99..b15d00130f6b 100644 --- a/examples/multi-tenancy/src/__tests__/acceptance/user.controller.header.acceptance.ts +++ b/examples/multi-tenancy/src/__tests__/acceptance/user.controller.header.acceptance.ts @@ -4,11 +4,11 @@ // License text available at https://opensource.org/licenses/MIT import {Client, expect} from '@loopback/testlab'; -import {ExampleMultiTenancyApplication} from '../..'; import { - MultiTenancyActionOptions, + ExampleMultiTenancyApplication, MultiTenancyBindings, -} from '../../multi-tenancy'; + MultiTenancyMiddlewareOptions, +} from '../..'; import {setupApplication} from './test-helper'; describe('UserController with header-based multi-tenancy', () => { @@ -18,7 +18,7 @@ describe('UserController with header-based multi-tenancy', () => { before('setupApplication', async () => { ({app, client} = await setupApplication()); app - .configure(MultiTenancyBindings.ACTION) + .configure(MultiTenancyBindings.MIDDLEWARE) .to({strategyNames: ['jwt', 'header', 'query']}); }); diff --git a/examples/multi-tenancy/src/__tests__/acceptance/user.controller.jwt.acceptance.ts b/examples/multi-tenancy/src/__tests__/acceptance/user.controller.jwt.acceptance.ts index cf676c4b800e..37e348c8b472 100644 --- a/examples/multi-tenancy/src/__tests__/acceptance/user.controller.jwt.acceptance.ts +++ b/examples/multi-tenancy/src/__tests__/acceptance/user.controller.jwt.acceptance.ts @@ -7,8 +7,8 @@ import {Client, expect, supertest} from '@loopback/testlab'; import {sign} from 'jsonwebtoken'; import {ExampleMultiTenancyApplication} from '../..'; import { - MultiTenancyActionOptions, MultiTenancyBindings, + MultiTenancyMiddlewareOptions, } from '../../multi-tenancy'; import {setupApplication} from './test-helper'; @@ -19,7 +19,7 @@ describe('UserController with jwt-based multi-tenancy', () => { before('setupApplication', async () => { ({app, client} = await setupApplication()); app - .configure(MultiTenancyBindings.ACTION) + .configure(MultiTenancyBindings.MIDDLEWARE) .to({strategyNames: ['jwt', 'header', 'query']}); }); diff --git a/examples/multi-tenancy/src/application.ts b/examples/multi-tenancy/src/application.ts index eb07ef816058..f465f95612a8 100644 --- a/examples/multi-tenancy/src/application.ts +++ b/examples/multi-tenancy/src/application.ts @@ -37,7 +37,7 @@ export class ExampleMultiTenancyApplication extends BootMixin( this.component(RestExplorerComponent); /* - * app.configure(MultiTenancyBindings.ACTION) + * app.configure(MultiTenancyBindings.MIDDLEWARE) * .to({strategyName: ['jwt', 'header', 'query']}); */ this.component(MultiTenancyComponent); diff --git a/examples/multi-tenancy/src/index.ts b/examples/multi-tenancy/src/index.ts index ac4e2894e528..a8abcd7b808f 100644 --- a/examples/multi-tenancy/src/index.ts +++ b/examples/multi-tenancy/src/index.ts @@ -6,6 +6,7 @@ import {ApplicationConfig, ExampleMultiTenancyApplication} from './application'; export * from './application'; +export * from './multi-tenancy'; export async function main(options: ApplicationConfig = {}) { const app = new ExampleMultiTenancyApplication(options); diff --git a/examples/multi-tenancy/src/multi-tenancy/component.ts b/examples/multi-tenancy/src/multi-tenancy/component.ts index 9f5571cd2e28..6add45c52d94 100644 --- a/examples/multi-tenancy/src/multi-tenancy/component.ts +++ b/examples/multi-tenancy/src/multi-tenancy/component.ts @@ -9,8 +9,8 @@ import { createBindingFromClass, extensionFor, } from '@loopback/core'; -import {MultiTenancyActionProvider} from './actions/multi-tenancy-action.provider'; import {MultiTenancyBindings, MULTI_TENANCY_STRATEGIES} from './keys'; +import {MultiTenancyMiddlewareProvider} from './middleware/multi-tenancy-middleware.provider'; import { HeaderStrategy, HostStrategy, @@ -20,8 +20,8 @@ import { export class MultiTenancyComponent implements Component { bindings: Binding[] = [ - createBindingFromClass(MultiTenancyActionProvider, { - key: MultiTenancyBindings.ACTION, + createBindingFromClass(MultiTenancyMiddlewareProvider, { + key: MultiTenancyBindings.MIDDLEWARE, }), createBindingFromClass(JWTStrategy).apply( extensionFor(MULTI_TENANCY_STRATEGIES), diff --git a/examples/multi-tenancy/src/multi-tenancy/index.ts b/examples/multi-tenancy/src/multi-tenancy/index.ts index 479d98bca678..83b47a78f547 100644 --- a/examples/multi-tenancy/src/multi-tenancy/index.ts +++ b/examples/multi-tenancy/src/multi-tenancy/index.ts @@ -3,6 +3,6 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -export * from './actions/multi-tenancy-action.provider'; export * from './keys'; +export * from './middleware/multi-tenancy-middleware.provider'; export * from './types'; diff --git a/examples/multi-tenancy/src/multi-tenancy/keys.ts b/examples/multi-tenancy/src/multi-tenancy/keys.ts index 5ff357e43363..27a56f743880 100644 --- a/examples/multi-tenancy/src/multi-tenancy/keys.ts +++ b/examples/multi-tenancy/src/multi-tenancy/keys.ts @@ -4,11 +4,12 @@ // License text available at https://opensource.org/licenses/MIT import {BindingKey} from '@loopback/core'; -import {MultiTenancyAction, Tenant} from './types'; +import {Middleware} from '@loopback/rest'; +import {Tenant} from './types'; export namespace MultiTenancyBindings { - export const ACTION = BindingKey.create( - 'sequence.actions.multi-tenancy', + export const MIDDLEWARE = BindingKey.create( + 'middleware.multi-tenancy', ); export const CURRENT_TENANT = BindingKey.create( diff --git a/examples/multi-tenancy/src/multi-tenancy/actions/multi-tenancy-action.provider.ts b/examples/multi-tenancy/src/multi-tenancy/middleware/multi-tenancy-middleware.provider.ts similarity index 75% rename from examples/multi-tenancy/src/multi-tenancy/actions/multi-tenancy-action.provider.ts rename to examples/multi-tenancy/src/multi-tenancy/middleware/multi-tenancy-middleware.provider.ts index 75ab0b80da8f..e807502ca033 100644 --- a/examples/multi-tenancy/src/multi-tenancy/actions/multi-tenancy-action.provider.ts +++ b/examples/multi-tenancy/src/multi-tenancy/middleware/multi-tenancy-middleware.provider.ts @@ -6,42 +6,46 @@ import { config, ContextTags, - Getter, - Provider, extensionPoint, extensions, + Getter, + Provider, } from '@loopback/core'; -import {RequestContext} from '@loopback/rest'; +import {asMiddleware, Middleware, RequestContext} from '@loopback/rest'; import debugFactory from 'debug'; import {MultiTenancyBindings, MULTI_TENANCY_STRATEGIES} from '../keys'; -import { - MultiTenancyAction, - MultiTenancyActionOptions, - MultiTenancyStrategy, -} from '../types'; -const debug = debugFactory('loopback:multi-tenancy:action'); +import {MultiTenancyMiddlewareOptions, MultiTenancyStrategy} from '../types'; +const debug = debugFactory('loopback:multi-tenancy'); /** * Provides the multi-tenancy action for a sequence */ -@extensionPoint(MULTI_TENANCY_STRATEGIES, { - tags: { - [ContextTags.KEY]: MultiTenancyBindings.ACTION, +@extensionPoint( + MULTI_TENANCY_STRATEGIES, + { + tags: { + [ContextTags.KEY]: MultiTenancyBindings.MIDDLEWARE, + }, }, -}) -export class MultiTenancyActionProvider - implements Provider { + asMiddleware({ + group: 'tenancy', + downstreamGroups: 'findRoute', + }), +) +export class MultiTenancyMiddlewareProvider implements Provider { constructor( @extensions() private readonly getMultiTenancyStrategies: Getter, @config() - private options: MultiTenancyActionOptions = {strategyNames: ['header']}, + private options: MultiTenancyMiddlewareOptions = { + strategyNames: ['header'], + }, ) {} - /** - * @returns MultiTenancyStrategyFactory - */ - value(): MultiTenancyAction { - return this.action.bind(this); + value(): Middleware { + return async (ctx, next) => { + await this.action(ctx as RequestContext); + return next(); + }; } /** diff --git a/examples/multi-tenancy/src/multi-tenancy/types.ts b/examples/multi-tenancy/src/multi-tenancy/types.ts index 14892f2172d5..cf906f544c30 100644 --- a/examples/multi-tenancy/src/multi-tenancy/types.ts +++ b/examples/multi-tenancy/src/multi-tenancy/types.ts @@ -14,14 +14,7 @@ export interface Tenant { [attribute: string]: unknown; } -/** - * Resolve a tenant for the given request - */ -export interface MultiTenancyAction { - (requestContext: RequestContext): ValueOrPromise; -} - -export interface MultiTenancyActionOptions { +export interface MultiTenancyMiddlewareOptions { strategyNames: string[]; } diff --git a/examples/multi-tenancy/src/sequence.ts b/examples/multi-tenancy/src/sequence.ts index 2097c7cc7e50..35c1bbfaa202 100644 --- a/examples/multi-tenancy/src/sequence.ts +++ b/examples/multi-tenancy/src/sequence.ts @@ -3,53 +3,6 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {inject} from '@loopback/core'; -import { - FindRoute, - InvokeMethod, - InvokeMiddleware, - ParseParams, - Reject, - RequestContext, - RestBindings, - Send, - SequenceHandler, -} from '@loopback/rest'; -import {MultiTenancyAction, MultiTenancyBindings} from './multi-tenancy'; +import {MiddlewareSequence} from '@loopback/rest'; -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, - @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, - @inject(SequenceActions.SEND) public send: Send, - @inject(SequenceActions.REJECT) public reject: Reject, - @inject(MultiTenancyBindings.ACTION) - public multiTenancy: MultiTenancyAction, - ) {} - - async handle(context: RequestContext) { - try { - const {request, response} = context; - const finished = await this.invokeMiddleware(context); - if (finished) return; - - await this.multiTenancy(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 (err) { - this.reject(context, err); - } - } -} +export class MySequence extends MiddlewareSequence {} diff --git a/examples/rest-crud/src/sequence.ts b/examples/rest-crud/src/sequence.ts index 6da07a6c1511..1bd5171aa378 100644 --- a/examples/rest-crud/src/sequence.ts +++ b/examples/rest-crud/src/sequence.ts @@ -3,48 +3,6 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {inject} from '@loopback/core'; -import { - FindRoute, - InvokeMethod, - InvokeMiddleware, - ParseParams, - Reject, - RequestContext, - RestBindings, - Send, - SequenceHandler, -} from '@loopback/rest'; +import {MiddlewareSequence} from '@loopback/rest'; -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, - @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, - @inject(SequenceActions.SEND) public send: Send, - @inject(SequenceActions.REJECT) public reject: Reject, - ) {} - - 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); - this.send(response, result); - } catch (err) { - this.reject(context, err); - } - } -} +export class MySequence extends MiddlewareSequence {} diff --git a/examples/soap-calculator/src/sequence.ts b/examples/soap-calculator/src/sequence.ts index b21da36501c3..1011bc88bf98 100644 --- a/examples/soap-calculator/src/sequence.ts +++ b/examples/soap-calculator/src/sequence.ts @@ -3,48 +3,6 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {inject} from '@loopback/core'; -import { - FindRoute, - InvokeMethod, - InvokeMiddleware, - ParseParams, - Reject, - RequestContext, - RestBindings, - Send, - SequenceHandler, -} from '@loopback/rest'; +import {MiddlewareSequence} from '@loopback/rest'; -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, - @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, - @inject(SequenceActions.SEND) public send: Send, - @inject(SequenceActions.REJECT) public reject: Reject, - ) {} - - 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); - this.send(response, result); - } catch (err) { - this.reject(context, err); - } - } -} +export class MySequence extends MiddlewareSequence {} diff --git a/examples/todo-jwt/src/sequence.ts b/examples/todo-jwt/src/sequence.ts index a19f201cef99..30158dce771c 100644 --- a/examples/todo-jwt/src/sequence.ts +++ b/examples/todo-jwt/src/sequence.ts @@ -3,68 +3,6 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import { - AuthenticateFn, - AuthenticationBindings, - AUTHENTICATION_STRATEGY_NOT_FOUND, - USER_PROFILE_NOT_FOUND, -} from '@loopback/authentication'; -import {Context, inject} from '@loopback/core'; -import { - FindRoute, - InvokeMethod, - InvokeMiddleware, - ParseParams, - Reject, - RequestContext, - RestBindings, - Send, - SequenceHandler, -} from '@loopback/rest'; +import {MiddlewareSequence} from '@loopback/rest'; -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, - @inject(SequenceActions.SEND) public send: Send, - @inject(SequenceActions.REJECT) public reject: Reject, - @inject(AuthenticationBindings.AUTH_ACTION) - protected authenticateRequest: AuthenticateFn, - ) {} - - async handle(context: RequestContext) { - try { - const {request, response} = context; - const finished = await this.invokeMiddleware(context); - if (finished) return; - - const route = this.findRoute(request); - - await this.authenticateRequest(request); - const args = await this.parseParams(request, route); - const result = await this.invoke(route, args); - this.send(response, result); - } catch (error) { - // if error is coming from the JWT authentication extension - // make the statusCode 401 - if ( - error.code === AUTHENTICATION_STRATEGY_NOT_FOUND || - error.code === USER_PROFILE_NOT_FOUND - ) { - Object.assign(error, {statusCode: 401 /* Unauthorized */}); - } - this.reject(context, error); - } - } -} +export class MySequence extends MiddlewareSequence {} diff --git a/examples/todo-list/src/sequence.ts b/examples/todo-list/src/sequence.ts index 7de21d79a76d..0a7ff1d0bd79 100644 --- a/examples/todo-list/src/sequence.ts +++ b/examples/todo-list/src/sequence.ts @@ -3,48 +3,6 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {inject} from '@loopback/core'; -import { - FindRoute, - InvokeMethod, - InvokeMiddleware, - ParseParams, - Reject, - RequestContext, - RestBindings, - Send, - SequenceHandler, -} from '@loopback/rest'; +import {MiddlewareSequence} from '@loopback/rest'; -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, - @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, - @inject(SequenceActions.SEND) public send: Send, - @inject(SequenceActions.REJECT) public reject: Reject, - ) {} - - 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); - this.send(response, result); - } catch (err) { - this.reject(context, err); - } - } -} +export class MySequence extends MiddlewareSequence {} diff --git a/examples/todo/src/sequence.ts b/examples/todo/src/sequence.ts index f1905a154542..f11a7062d49f 100644 --- a/examples/todo/src/sequence.ts +++ b/examples/todo/src/sequence.ts @@ -3,48 +3,6 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {inject} from '@loopback/core'; -import { - FindRoute, - InvokeMethod, - InvokeMiddleware, - ParseParams, - Reject, - RequestContext, - RestBindings, - Send, - SequenceHandler, -} from '@loopback/rest'; +import {MiddlewareSequence} from '@loopback/rest'; -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, - @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, - @inject(SequenceActions.SEND) public send: Send, - @inject(SequenceActions.REJECT) public reject: Reject, - ) {} - - 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); - this.send(response, result); - } catch (err) { - this.reject(context, err); - } - } -} +export class MySequence extends MiddlewareSequence {} diff --git a/examples/validation-app/src/application.ts b/examples/validation-app/src/application.ts index bcf0ead152a6..cf7e27e59297 100644 --- a/examples/validation-app/src/application.ts +++ b/examples/validation-app/src/application.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {BootMixin} from '@loopback/boot'; -import {ApplicationConfig} from '@loopback/core'; +import {ApplicationConfig, createBindingFromClass} from '@loopback/core'; import {RepositoryMixin} from '@loopback/repository'; import {RestApplication} from '@loopback/rest'; import { @@ -13,6 +13,7 @@ import { } from '@loopback/rest-explorer'; import {ServiceMixin} from '@loopback/service-proxy'; import path from 'path'; +import {ValidationErrorMiddlewareProvider} from './middleware/validation-error.middleware'; import {MySequence} from './sequence'; export {ApplicationConfig}; @@ -23,6 +24,8 @@ export class ValidationApplication extends BootMixin( constructor(options: ApplicationConfig = {}) { super(options); + this.add(createBindingFromClass(ValidationErrorMiddlewareProvider)); + // Set up the custom sequence this.sequence(MySequence); diff --git a/examples/validation-app/src/middleware/validation-error.middleware.ts b/examples/validation-app/src/middleware/validation-error.middleware.ts new file mode 100644 index 000000000000..d17760728b1a --- /dev/null +++ b/examples/validation-app/src/middleware/validation-error.middleware.ts @@ -0,0 +1,89 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/example-validation-app +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {bind, inject, Provider} from '@loopback/core'; +import { + asMiddleware, + ErrorWriterOptions, + HttpErrors, + LogError, + Middleware, + MiddlewareContext, + Response, + RestBindings, + RestMiddlewareGroups, +} from '@loopback/rest'; + +@bind( + asMiddleware({ + group: 'validationError', + upstreamGroups: RestMiddlewareGroups.SEND_RESPONSE, + downstreamGroups: RestMiddlewareGroups.CORS, + }), +) +export class ValidationErrorMiddlewareProvider implements Provider { + constructor( + @inject(RestBindings.SequenceActions.LOG_ERROR) + protected logError: LogError, + @inject(RestBindings.ERROR_WRITER_OPTIONS, {optional: true}) + protected errorWriterOptions?: ErrorWriterOptions, + ) {} + + async value() { + const middleware: Middleware = async (ctx, next) => { + try { + return await next(); + } catch (err) { + return this.handleError(ctx, err); + } + }; + return middleware; + } + + /** + * Handle errors + * If the request url is `/coffee-shops`, customize the error message. + * @param context + * @param err + */ + handleError( + context: MiddlewareContext, + err: HttpErrors.HttpError, + ): Response | undefined { + // 2. customize error for particular endpoint + if (context.request.url === '/coffee-shops') { + // if this is a validation error from the PATCH method, customize it + // for other validation errors, the default AJV error object will be sent + if (err.statusCode === 422 && context.request.method === 'PATCH') { + const customizedMessage = 'My customized validation error message'; + + let customizedProps = {}; + if (this.errorWriterOptions?.debug) { + customizedProps = {stack: err.stack}; + } + + // 3. Create a new error with customized properties + // you can change the status code here too + const errorData = { + statusCode: 422, + message: customizedMessage, + resolution: 'Contact your admin for troubleshooting.', + code: 'VALIDATION_FAILED', + ...customizedProps, + }; + + context.response.status(422).send(errorData); + + // 4. log the error using RestBindings.SequenceActions.LOG_ERROR + this.logError(err, err.statusCode, context.request); + + // The error was handled + return context.response; + } else { + throw err; + } + } + } +} diff --git a/examples/validation-app/src/sequence.ts b/examples/validation-app/src/sequence.ts index 76d22904de9b..8faa85602c21 100644 --- a/examples/validation-app/src/sequence.ts +++ b/examples/validation-app/src/sequence.ts @@ -3,107 +3,6 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {inject} from '@loopback/core'; -import { - FindRoute, - HttpErrors, - InvokeMethod, - InvokeMiddleware, - LogError, - ParseParams, - Reject, - RequestContext, - RestBindings, - Send, - SequenceHandler, -} from '@loopback/rest'; -import {ErrorWriterOptions} from 'strong-error-handler'; +import {MiddlewareSequence} from '@loopback/rest'; -const SequenceActions = RestBindings.SequenceActions; - -/** - * A few things to note on top of the generated sequence - * 1. inject RestBindings.SequenceActions.LOG_ERROR for logging error - * 2. customize error for particular endpoints - * 3. create a new error with customized properties - * 4. log the error using RestBindings.SequenceActions.LOG_ERROR - */ -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; - - // 1. inject RestBindings.SequenceActions.LOG_ERROR for logging error - // and RestBindings.ERROR_WRITER_OPTIONS for options - 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, - @inject(RestBindings.SequenceActions.LOG_ERROR) - protected logError: LogError, - @inject(RestBindings.ERROR_WRITER_OPTIONS, {optional: true}) - protected errorWriterOptions?: ErrorWriterOptions, - ) {} - - 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); - this.send(response, result); - } catch (err) { - this.handleError(context, err); - } - } - - /** - * Handle errors - * If the request url is `/coffee-shops`, customize the error message. - * @param context - * @param err - */ - handleError(context: RequestContext, err: HttpErrors.HttpError) { - // 2. customize error for particular endpoint - if (context.request.url === '/coffee-shops') { - // if this is a validation error from the PATCH method, customize it - // for other validation errors, the default AJV error object will be sent - if (err.statusCode === 422 && context.request.method === 'PATCH') { - const customizedMessage = 'My customized validation error message'; - - let customizedProps = {}; - if (this.errorWriterOptions?.debug) { - customizedProps = {stack: err.stack}; - } - - // 3. Create a new error with customized properties - // you can change the status code here too - const errorData = { - statusCode: 422, - message: customizedMessage, - resolution: 'Contact your admin for troubleshooting.', - code: 'VALIDATION_FAILED', - ...customizedProps, - }; - - context.response.status(422).send(errorData); - - // 4. log the error using RestBindings.SequenceActions.LOG_ERROR - this.logError(err, err.statusCode, context.request); - - // The error was handled - return; - } - } - - // Otherwise fall back to the default error handler - this.reject(context, err); - } -} +export class MySequence extends MiddlewareSequence {} diff --git a/packages/authentication/src/__tests__/acceptance/basic-auth-extension.middleware.acceptance.ts b/packages/authentication/src/__tests__/acceptance/basic-auth-extension.middleware.acceptance.ts new file mode 100644 index 000000000000..22fe7deacc33 --- /dev/null +++ b/packages/authentication/src/__tests__/acceptance/basic-auth-extension.middleware.acceptance.ts @@ -0,0 +1,271 @@ +// Copyright IBM Corp. 2019,2020. All Rights Reserved. +// Node module: @loopback/authentication +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Application, inject} from '@loopback/core'; +import {anOpenApiSpec} from '@loopback/openapi-spec-builder'; +import {api, get, Request, RestServer} from '@loopback/rest'; +import {SecurityBindings, securityId, UserProfile} from '@loopback/security'; +import {Client, createClientForHandler, expect} from '@loopback/testlab'; +import { + authenticate, + AuthenticationBindings, + registerAuthenticationStrategy, +} from '../..'; +import {AuthenticationStrategy} from '../../types'; +import { + createBasicAuthorizationHeaderValue, + getApp, + getUserRepository, + myUserProfileFactory, +} from '../fixtures/helper'; +import {BasicAuthenticationStrategyBindings, USER_REPO} from '../fixtures/keys'; +import {AuthenticationMiddlewareSequence} from '../fixtures/sequences/authentication.middleware.sequence'; +import {BasicAuthenticationUserService} from '../fixtures/services/basic-auth-user-service'; +import {BasicAuthenticationStrategy} from '../fixtures/strategies/basic-strategy'; +import {User} from '../fixtures/users/user'; +import {UserRepository} from '../fixtures/users/user.repository'; + +describe('Basic Authentication', () => { + let app: Application; + let server: RestServer; + let users: UserRepository; + let joeUser: User; + beforeEach(givenAServer); + beforeEach(givenControllerInApp); + beforeEach(givenAuthenticatedSequence); + beforeEach(givenProviders); + + it(`authenticates successfully for correct credentials of user 'jack'`, async () => { + const client = whenIMakeRequestTo(server); + await client + .get('/whoAmI') + .set('Authorization', createBasicAuthorizationHeaderValue(joeUser)) + .expect(joeUser.id); + }); + + it('returns error for missing Authorization header', async () => { + const client = whenIMakeRequestTo(server); + + await client.get('/whoAmI').expect({ + error: { + message: 'Authorization header not found.', + name: 'UnauthorizedError', + statusCode: 401, + }, + }); + }); + + it(`returns error for missing 'Basic ' portion of Authorization header value`, async () => { + const client = whenIMakeRequestTo(server); + await client + .get('/whoAmI') + .set( + 'Authorization', + createBasicAuthorizationHeaderValue(joeUser, {prefix: 'NotB@sic '}), + ) + .expect({ + error: { + message: `Authorization header is not of type 'Basic'.`, + name: 'UnauthorizedError', + statusCode: 401, + }, + }); + }); + + it(`returns error for too many parts in Authorization header value`, async () => { + const client = whenIMakeRequestTo(server); + await client + .get('/whoAmI') + .set( + 'Authorization', + createBasicAuthorizationHeaderValue(joeUser) + ' someOtherValue', + ) + .expect({ + error: { + message: `Authorization header value has too many parts. It must follow the pattern: 'Basic xxyyzz' where xxyyzz is a base64 string.`, + name: 'UnauthorizedError', + statusCode: 401, + }, + }); + }); + + it(`returns error for missing ':' in decrypted Authorization header credentials value`, async () => { + const client = whenIMakeRequestTo(server); + await client + .get('/whoAmI') + .set( + 'Authorization', + createBasicAuthorizationHeaderValue(joeUser, {separator: '|'}), + ) + .expect({ + error: { + message: `Authorization header 'Basic' value does not contain two parts separated by ':'.`, + name: 'UnauthorizedError', + statusCode: 401, + }, + }); + }); + + it(`returns error for too many parts in decrypted Authorization header credentials value`, async () => { + const client = whenIMakeRequestTo(server); + await client + .get('/whoAmI') + .set( + 'Authorization', + createBasicAuthorizationHeaderValue(joeUser, { + extraSegment: 'extraPart', + }), + ) + .expect({ + error: { + message: `Authorization header 'Basic' value does not contain two parts separated by ':'.`, + name: 'UnauthorizedError', + statusCode: 401, + }, + }); + }); + + it('allows anonymous requests to methods with no decorator', async () => { + class InfoController { + @get('/status') + status() { + return {running: true}; + } + } + + app.controller(InfoController); + await whenIMakeRequestTo(server) + .get('/status') + .expect(200, {running: true}); + }); + + it('returns error for unknown authentication strategy', async () => { + class InfoController { + @get('/status') + @authenticate('doesnotexist') + status() { + return {running: true}; + } + } + + app.controller(InfoController); + await whenIMakeRequestTo(server) + .get('/status') + .expect({ + error: { + message: `The strategy 'doesnotexist' is not available.`, + name: 'Error', + statusCode: 401, + code: 'AUTHENTICATION_STRATEGY_NOT_FOUND', + }, + }); + }); + + it('returns error when undefined user profile returned from authentication strategy', async () => { + class BadBasicStrategy implements AuthenticationStrategy { + name = 'badbasic'; + async authenticate(request: Request): Promise { + return undefined; + } + } + registerAuthenticationStrategy(server, BadBasicStrategy); + + class InfoController { + @get('/status') + @authenticate('badbasic') + status() { + return {running: true}; + } + } + + app.controller(InfoController); + await whenIMakeRequestTo(server) + .get('/status') + .expect({ + error: { + message: `User profile not returned from strategy's authenticate function`, + name: 'Error', + statusCode: 401, + code: 'USER_PROFILE_NOT_FOUND', + }, + }); + }); + + it('adds security scheme component to apiSpec', async () => { + const EXPECTED_SPEC = { + components: { + securitySchemes: { + basic: { + type: 'http', + scheme: 'basic', + }, + }, + }, + }; + const spec = await server.getApiSpec(); + expect(spec).to.containDeep(EXPECTED_SPEC); + }); + + async function givenAServer() { + app = getApp(); + server = await app.getServer(RestServer); + } + + function givenControllerInApp() { + const apispec = anOpenApiSpec() + .withOperation('get', '/whoAmI', { + 'x-operation-name': 'whoAmI', + responses: { + '200': { + description: '', + schema: { + type: 'string', + }, + }, + }, + }) + .build(); + + @api(apispec) + class MyController { + constructor() {} + + @authenticate('basic') + async whoAmI( + @inject(SecurityBindings.USER) userProfile: UserProfile, + ): Promise { + if (!userProfile) return 'userProfile is undefined'; + if (!userProfile[securityId]) return 'userProfile id is undefined'; + return userProfile[securityId]; + } + } + app.controller(MyController); + } + + function givenAuthenticatedSequence() { + // bind user defined sequence + server.sequence(AuthenticationMiddlewareSequence); + } + + function givenProviders() { + registerAuthenticationStrategy(server, BasicAuthenticationStrategy); + + server + .bind(BasicAuthenticationStrategyBindings.USER_SERVICE) + .toClass(BasicAuthenticationUserService); + + users = getUserRepository(); + joeUser = users.list['joe888']; + server.bind(USER_REPO).to(users); + + server + .bind(AuthenticationBindings.USER_PROFILE_FACTORY) + .to(myUserProfileFactory); + } + + function whenIMakeRequestTo(restServer: RestServer): Client { + return createClientForHandler(restServer.requestHandler); + } +}); diff --git a/packages/authentication/src/__tests__/acceptance/jwt-auth-extension.middleware.acceptance.ts b/packages/authentication/src/__tests__/acceptance/jwt-auth-extension.middleware.acceptance.ts new file mode 100644 index 000000000000..e3066da598f6 --- /dev/null +++ b/packages/authentication/src/__tests__/acceptance/jwt-auth-extension.middleware.acceptance.ts @@ -0,0 +1,503 @@ +// Copyright IBM Corp. 2019,2020. All Rights Reserved. +// Node module: @loopback/authentication +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Application, inject} from '@loopback/core'; +import {get, post, Request, RestServer} from '@loopback/rest'; +import {SecurityBindings, securityId, UserProfile} from '@loopback/security'; +import {Client, createClientForHandler, expect} from '@loopback/testlab'; +import { + authenticate, + AuthenticationBindings, + AuthenticationStrategy, + registerAuthenticationStrategy, +} from '../..'; +import {UserProfileFactory} from '../../types'; +import { + createBearerAuthorizationHeaderValue, + getApp, + getUserRepository, + myUserProfileFactory, +} from '../fixtures/helper'; +import {JWTAuthenticationStrategyBindings, USER_REPO} from '../fixtures/keys'; +import {AuthenticationMiddlewareSequence} from '../fixtures/sequences/authentication.middleware.sequence'; +import {JWTService} from '../fixtures/services/jwt-service'; +import {JWTAuthenticationStrategy} from '../fixtures/strategies/jwt-strategy'; +import {User} from '../fixtures/users/user'; +import {UserRepository} from '../fixtures/users/user.repository'; + +describe('JWT Authentication', () => { + let app: Application; + let server: RestServer; + let testUsers: UserRepository; + let joeUser: User; + let token: string; + const TOKEN_SECRET_VALUE = 'myjwts3cr3t'; + const TOKEN_EXPIRES_IN_VALUE = '600'; + + beforeEach(givenAServer); + beforeEach(givenAuthenticatedSequence); + beforeEach(givenProviders); + + it('authenticates successfully with valid token', async () => { + class InfoController { + constructor( + @inject(JWTAuthenticationStrategyBindings.TOKEN_SERVICE) + public tokenService: JWTService, + @inject(USER_REPO) + public users: UserRepository, + @inject(AuthenticationBindings.USER_PROFILE_FACTORY) + public userProfileFactory: UserProfileFactory, + ) {} + + @post('/login') + async logIn() { + // + // ...Other code for verifying a valid user (e.g. basic or local strategy)... + // + + // Now with a valid userProfile, let's create a JSON web token + return this.tokenService.generateToken( + this.userProfileFactory(joeUser), + ); + } + + @get('/whoAmI') + @authenticate('jwt') + whoAmI(@inject(SecurityBindings.USER) userProfile: UserProfile) { + if (!userProfile) return 'userProfile is undefined'; + if (!userProfile[securityId]) + return 'userProfile[securityId] is undefined'; + return userProfile[securityId]; + } + } + + app.controller(InfoController); + + token = (await whenIMakeRequestTo(server).post('/login').expect(200)).text; + + expect(token).to.be.not.null(); + expect(token).to.be.String(); + + const id = ( + await whenIMakeRequestTo(server) + .get('/whoAmI') + .set('Authorization', createBearerAuthorizationHeaderValue(token)) + .expect(200) + ).text; + + expect(id).to.equal(joeUser.id); + }); + + it('returns error for missing Authorization header', async () => { + class InfoController { + constructor( + @inject(JWTAuthenticationStrategyBindings.TOKEN_SERVICE) + public tokenService: JWTService, + @inject(USER_REPO) + public users: UserRepository, + @inject(AuthenticationBindings.USER_PROFILE_FACTORY) + public userProfileFactory: UserProfileFactory, + ) {} + + @post('/login') + async logIn() { + // + // ...Other code for verifying a valid user (e.g. basic or local strategy)... + // + + // Now with a valid userProfile, let's create a JSON web token + return this.tokenService.generateToken( + this.userProfileFactory(joeUser), + ); + } + + @get('/whoAmI') + @authenticate('jwt') + whoAmI(@inject(SecurityBindings.USER) userProfile: UserProfile) { + if (!userProfile) return 'userProfile is undefined'; + if (!userProfile[securityId]) + return 'userProfile[securityId] is undefined'; + return userProfile[securityId]; + } + } + + app.controller(InfoController); + + token = (await whenIMakeRequestTo(server).post('/login').expect(200)).text; + + expect(token).to.be.not.null(); + expect(token).to.be.String(); + + await whenIMakeRequestTo(server) + .get('/whoAmI') + .expect({ + error: { + message: 'Authorization header not found.', + name: 'UnauthorizedError', + statusCode: 401, + }, + }); + }); + + it(`returns error for invalid 'Bearer ' portion of Authorization header value`, async () => { + class InfoController { + constructor( + @inject(JWTAuthenticationStrategyBindings.TOKEN_SERVICE) + public tokenService: JWTService, + @inject(USER_REPO) + public users: UserRepository, + @inject(AuthenticationBindings.USER_PROFILE_FACTORY) + public userProfileFactory: UserProfileFactory, + ) {} + + @post('/login') + async logIn() { + // + // ...Other code for verifying a valid user (e.g. basic or local strategy)... + // + + // Now with a valid userProfile, let's create a JSON web token + return this.tokenService.generateToken( + this.userProfileFactory(joeUser), + ); + } + + @get('/whoAmI') + @authenticate('jwt') + whoAmI(@inject(SecurityBindings.USER) userProfile: UserProfile) { + if (!userProfile) return 'userProfile is undefined'; + if (!userProfile[securityId]) + return 'userProfile[securityId] is undefined'; + return userProfile[securityId]; + } + } + + app.controller(InfoController); + + token = (await whenIMakeRequestTo(server).post('/login').expect(200)).text; + + expect(token).to.be.not.null(); + expect(token).to.be.String(); + + await whenIMakeRequestTo(server) + .get('/whoAmI') + .set( + 'Authorization', + createBearerAuthorizationHeaderValue(token, 'NotB3ar3r '), + ) + .expect({ + error: { + message: `Authorization header is not of type 'Bearer'.`, + name: 'UnauthorizedError', + statusCode: 401, + }, + }); + }); + + it(`returns error for too many parts in Authorization header value`, async () => { + class InfoController { + constructor( + @inject(JWTAuthenticationStrategyBindings.TOKEN_SERVICE) + public tokenService: JWTService, + @inject(USER_REPO) + public users: UserRepository, + @inject(AuthenticationBindings.USER_PROFILE_FACTORY) + public userProfileFactory: UserProfileFactory, + ) {} + + @post('/login') + async logIn() { + // + // ...Other code for verifying a valid user (e.g. basic or local strategy)... + // + + return this.tokenService.generateToken( + this.userProfileFactory(joeUser), + ); + } + + @get('/whoAmI') + @authenticate('jwt') + whoAmI(@inject(SecurityBindings.USER) userProfile: UserProfile) { + if (!userProfile) return 'userProfile is undefined'; + if (!userProfile[securityId]) + return 'userProfile[securityId] is undefined'; + return userProfile[securityId]; + } + } + + app.controller(InfoController); + + token = (await whenIMakeRequestTo(server).post('/login').expect(200)).text; + + expect(token).to.be.not.null(); + expect(token).to.be.String(); + + await whenIMakeRequestTo(server) + .get('/whoAmI') + .set( + 'Authorization', + createBearerAuthorizationHeaderValue(token) + ' someOtherValue', + ) + .expect({ + error: { + message: `Authorization header value has too many parts. It must follow the pattern: 'Bearer xx.yy.zz' where xx.yy.zz is a valid JWT token.`, + name: 'UnauthorizedError', + statusCode: 401, + }, + }); + }); + + it('returns error due to expired token', async () => { + class InfoController { + constructor() {} + + @get('/whoAmI') + @authenticate('jwt') + whoAmI(@inject(SecurityBindings.USER) userProfile: UserProfile) { + if (!userProfile) return 'userProfile is undefined'; + if (!userProfile[securityId]) + return 'userProfile[securityId] is undefined'; + return userProfile[securityId]; + } + } + + app.controller(InfoController); + + const expiredToken = await getExpiredToken(); + + await whenIMakeRequestTo(server) + .get('/whoAmI') + .set('Authorization', createBearerAuthorizationHeaderValue(expiredToken)) + .expect({ + error: { + message: `Error verifying token : jwt expired`, + name: 'UnauthorizedError', + statusCode: 401, + }, + }); + }); + + it('returns error due to invalid token #1', async () => { + class InfoController { + constructor() {} + + @get('/whoAmI') + @authenticate('jwt') + whoAmI(@inject(SecurityBindings.USER) userProfile: UserProfile) { + if (!userProfile) return 'userProfile is undefined'; + if (!userProfile[securityId]) + return 'userProfile[securityId] is undefined'; + return userProfile[securityId]; + } + } + + app.controller(InfoController); + + const invalidToken = 'aaa.bbb.ccc'; + + await whenIMakeRequestTo(server) + .get('/whoAmI') + .set('Authorization', createBearerAuthorizationHeaderValue(invalidToken)) + .expect({ + error: { + message: 'Error verifying token : invalid token', + name: 'UnauthorizedError', + statusCode: 401, + }, + }); + }); + + it('returns error due to invalid token #2', async () => { + class InfoController { + constructor() {} + + @get('/whoAmI') + @authenticate('jwt') + whoAmI(@inject(SecurityBindings.USER) userProfile: UserProfile) { + if (!userProfile) return 'userProfile is undefined'; + if (!userProfile[securityId]) + return 'userProfile[securityId] is undefined'; + return userProfile[securityId]; + } + } + + app.controller(InfoController); + + const invalidToken = 'aaa.bbb.ccc.ddd'; + + await whenIMakeRequestTo(server) + .get('/whoAmI') + .set('Authorization', createBearerAuthorizationHeaderValue(invalidToken)) + .expect({ + error: { + message: 'Error verifying token : jwt malformed', + name: 'UnauthorizedError', + statusCode: 401, + }, + }); + }); + + it('creates a json web token and throws error for userProfile that is undefined', async () => { + class InfoController { + constructor( + @inject(JWTAuthenticationStrategyBindings.TOKEN_SERVICE) + public tokenService: JWTService, + @inject(USER_REPO) + public users: UserRepository, + @inject(AuthenticationBindings.USER_PROFILE_FACTORY) + public userProfileFactory: UserProfileFactory, + ) {} + + @get('/createtoken') + async createToken() { + return this.tokenService.generateToken(undefined); + } + } + + app.controller(InfoController); + + await whenIMakeRequestTo(server) + .get('/createtoken') + .expect({ + error: { + message: `Error generating token : userProfile is null`, + name: 'UnauthorizedError', + statusCode: 401, + }, + }); + }); + + it('allows anonymous requests to methods with no decorator', async () => { + class InfoController { + @get('/status') + status() { + return {running: true}; + } + } + + app.controller(InfoController); + await whenIMakeRequestTo(server) + .get('/status') + .expect(200, {running: true}); + }); + + it('returns error for unknown authentication strategy', async () => { + class InfoController { + @get('/status') + @authenticate('doesnotexist') + status() { + return {running: true}; + } + } + + app.controller(InfoController); + await whenIMakeRequestTo(server) + .get('/status') + .expect({ + error: { + message: `The strategy 'doesnotexist' is not available.`, + name: 'Error', + statusCode: 401, + code: 'AUTHENTICATION_STRATEGY_NOT_FOUND', + }, + }); + }); + + it('returns error when undefined user profile returned from authentication strategy', async () => { + class BadJWTStrategy implements AuthenticationStrategy { + name = 'badjwt'; + async authenticate(request: Request): Promise { + return undefined; + } + } + registerAuthenticationStrategy(server, BadJWTStrategy); + + class InfoController { + @get('/status') + @authenticate('badjwt') + status() { + return {running: true}; + } + } + + app.controller(InfoController); + await whenIMakeRequestTo(server) + .get('/status') + .expect({ + error: { + message: `User profile not returned from strategy's authenticate function`, + name: 'Error', + statusCode: 401, + code: 'USER_PROFILE_NOT_FOUND', + }, + }); + }); + + it('adds security scheme component to apiSpec', async () => { + const EXPECTED_SPEC = { + components: { + securitySchemes: { + jwt: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + }, + }; + const spec = await server.getApiSpec(); + expect(spec).to.containDeep(EXPECTED_SPEC); + }); + + async function givenAServer() { + app = getApp(); + server = await app.getServer(RestServer); + } + + /** + * Creates an expired token + * + * Specifying a negative value for 'expiresIn' so the + * token is automatically expired + */ + async function getExpiredToken() { + const userProfile = myUserProfileFactory(joeUser); + const tokenService = new JWTService(TOKEN_SECRET_VALUE, '-10'); + return tokenService.generateToken(userProfile); + } + + function givenAuthenticatedSequence() { + // bind user defined sequence + server.sequence(AuthenticationMiddlewareSequence); + } + + function givenProviders() { + registerAuthenticationStrategy(server, JWTAuthenticationStrategy); + + server + .bind(JWTAuthenticationStrategyBindings.TOKEN_SECRET) + .to(TOKEN_SECRET_VALUE); + + server + .bind(JWTAuthenticationStrategyBindings.TOKEN_EXPIRES_IN) + .to(TOKEN_EXPIRES_IN_VALUE); + + server + .bind(JWTAuthenticationStrategyBindings.TOKEN_SERVICE) + .toClass(JWTService); + + testUsers = getUserRepository(); + joeUser = testUsers.list['joe888']; + server.bind(USER_REPO).to(testUsers); + server + .bind(AuthenticationBindings.USER_PROFILE_FACTORY) + .to(myUserProfileFactory); + } + + function whenIMakeRequestTo(restServer: RestServer): Client { + return createClientForHandler(restServer.requestHandler); + } +}); diff --git a/packages/authentication/src/__tests__/fixtures/sequences/authentication.middleware.sequence.ts b/packages/authentication/src/__tests__/fixtures/sequences/authentication.middleware.sequence.ts new file mode 100644 index 000000000000..ba7b9b99d21e --- /dev/null +++ b/packages/authentication/src/__tests__/fixtures/sequences/authentication.middleware.sequence.ts @@ -0,0 +1,8 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/authentication +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {MiddlewareSequence} from '@loopback/rest'; + +export class AuthenticationMiddlewareSequence extends MiddlewareSequence {} diff --git a/packages/authentication/src/authentication.component.ts b/packages/authentication/src/authentication.component.ts index df158aaf35bd..f3638c17e987 100644 --- a/packages/authentication/src/authentication.component.ts +++ b/packages/authentication/src/authentication.component.ts @@ -3,23 +3,22 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {bind, Component, ContextTags, ProviderMap} from '@loopback/core'; +import {bind, Component, ContextTags} from '@loopback/core'; import {AuthenticationBindings} from './keys'; import { AuthenticateActionProvider, + AuthenticationMiddlewareProvider, AuthenticationStrategyProvider, AuthMetadataProvider, } from './providers'; @bind({tags: {[ContextTags.KEY]: AuthenticationBindings.COMPONENT}}) export class AuthenticationComponent implements Component { - providers?: ProviderMap; - - constructor() { - this.providers = { - [AuthenticationBindings.AUTH_ACTION.key]: AuthenticateActionProvider, - [AuthenticationBindings.STRATEGY.key]: AuthenticationStrategyProvider, - [AuthenticationBindings.METADATA.key]: AuthMetadataProvider, - }; - } + providers = { + [AuthenticationBindings.AUTH_ACTION.key]: AuthenticateActionProvider, + [AuthenticationBindings.STRATEGY.key]: AuthenticationStrategyProvider, + [AuthenticationBindings.METADATA.key]: AuthMetadataProvider, + [AuthenticationBindings.AUTHENTICATION_MIDDLEWARE + .key]: AuthenticationMiddlewareProvider, + }; } diff --git a/packages/authentication/src/keys.ts b/packages/authentication/src/keys.ts index 28f1bb7e55fa..615e0dfdb608 100644 --- a/packages/authentication/src/keys.ts +++ b/packages/authentication/src/keys.ts @@ -5,6 +5,7 @@ import {BindingKey, MetadataAccessor} from '@loopback/core'; import {SecurityBindings, UserProfile} from '@loopback/security'; +import {Middleware} from '@loopback/rest'; import {AuthenticationComponent} from './authentication.component'; import { AuthenticateFn, @@ -88,6 +89,13 @@ export namespace AuthenticationBindings { 'authentication.actions.authenticate', ); + /** + * Binding key for AUTHENTICATION_MIDDLEWARE + */ + export const AUTHENTICATION_MIDDLEWARE = BindingKey.create( + 'middleware.authentication', + ); + /** * Key used to inject authentication metadata, which is used to determine * whether a request requires authentication or not. diff --git a/packages/authentication/src/providers/auth-action.provider.ts b/packages/authentication/src/providers/auth-action.provider.ts index 40e54f0fbbfe..601c1c789fbf 100644 --- a/packages/authentication/src/providers/auth-action.provider.ts +++ b/packages/authentication/src/providers/auth-action.provider.ts @@ -3,13 +3,20 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Getter, inject, Provider, Setter} from '@loopback/core'; -import {Request, RedirectRoute} from '@loopback/rest'; +import {bind, Getter, inject, Provider, Setter} from '@loopback/core'; +import { + asMiddleware, + Middleware, + RedirectRoute, + Request, + RestMiddlewareGroups, +} from '@loopback/rest'; import {SecurityBindings, UserProfile} from '@loopback/security'; import {AuthenticationBindings} from '../keys'; import { AuthenticateFn, AuthenticationStrategy, + AUTHENTICATION_STRATEGY_NOT_FOUND, USER_PROFILE_NOT_FOUND, } from '../types'; /** @@ -80,3 +87,33 @@ export class AuthenticateActionProvider implements Provider { } } } + +@bind( + asMiddleware({ + group: RestMiddlewareGroups.AUTHENTICATION, + upstreamGroups: [RestMiddlewareGroups.FIND_ROUTE], + }), +) +export class AuthenticationMiddlewareProvider implements Provider { + constructor( + @inject(AuthenticationBindings.AUTH_ACTION) + private authenticate: AuthenticateFn, + ) {} + + value(): Middleware { + return async (ctx, next) => { + try { + await this.authenticate(ctx.request); + } catch (error) { + if ( + error.code === AUTHENTICATION_STRATEGY_NOT_FOUND || + error.code === USER_PROFILE_NOT_FOUND + ) { + error.statusCode = 401; + } + throw error; + } + return next(); + }; + } +} diff --git a/packages/cli/generators/app/templates/src/sequence.ts.ejs b/packages/cli/generators/app/templates/src/sequence.ts.ejs index f564ebcfe84d..2fe7751cc1a5 100644 --- a/packages/cli/generators/app/templates/src/sequence.ts.ejs +++ b/packages/cli/generators/app/templates/src/sequence.ts.ejs @@ -1,45 +1,3 @@ -import {inject} from '@loopback/core'; -import { - FindRoute, - InvokeMethod, - InvokeMiddleware, - ParseParams, - Reject, - RequestContext, - RestBindings, - Send, - SequenceHandler, -} from '@loopback/rest'; +import {MiddlewareSequence} from '@loopback/rest'; -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, - @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, - @inject(SequenceActions.SEND) public send: Send, - @inject(SequenceActions.REJECT) public reject: Reject, - ) {} - - 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); - this.send(response, result); - } catch (err) { - this.reject(context, err); - } - } -} +export class MySequence extends MiddlewareSequence {} diff --git a/packages/cli/snapshots/integration/generators/app.integration.snapshots.js b/packages/cli/snapshots/integration/generators/app.integration.snapshots.js index d166eb2920da..b80dc76995de 100644 --- a/packages/cli/snapshots/integration/generators/app.integration.snapshots.js +++ b/packages/cli/snapshots/integration/generators/app.integration.snapshots.js @@ -57,6 +57,14 @@ export class MyAppApplication extends BootMixin( exports[`app-generator specific files generates all the proper files 2`] = ` +import {MiddlewareSequence} from '@loopback/rest'; + +export class MySequence extends MiddlewareSequence {} + +`; + + +exports[`app-generator specific files generates all the proper files 3`] = ` import {ApplicationConfig, MyAppApplication} from './application'; export * from './application'; @@ -100,7 +108,7 @@ if (require.main === module) { `; -exports[`app-generator specific files generates all the proper files 3`] = ` +exports[`app-generator specific files generates all the proper files 4`] = ` import {Request, RestBindings, get, ResponseObject} from '@loopback/rest'; import {inject} from '@loopback/core'; @@ -157,7 +165,7 @@ export class PingController { `; -exports[`app-generator specific files generates all the proper files 4`] = ` +exports[`app-generator specific files generates all the proper files 5`] = ` import {Client, expect} from '@loopback/testlab'; import {MyAppApplication} from '../..'; import {setupApplication} from './test-helper'; @@ -183,7 +191,7 @@ describe('PingController', () => { `; -exports[`app-generator specific files generates all the proper files 5`] = ` +exports[`app-generator specific files generates all the proper files 6`] = ` import {Client} from '@loopback/testlab'; import {MyAppApplication} from '../..'; import {setupApplication} from './test-helper'; @@ -219,7 +227,7 @@ describe('HomePage', () => { `; -exports[`app-generator specific files generates all the proper files 6`] = ` +exports[`app-generator specific files generates all the proper files 7`] = ` import {MyAppApplication} from '../..'; import { createRestAppClient, diff --git a/packages/cli/test/integration/generators/app.integration.js b/packages/cli/test/integration/generators/app.integration.js index 66fadf6db456..34c7f240d599 100644 --- a/packages/cli/test/integration/generators/app.integration.js +++ b/packages/cli/test/integration/generators/app.integration.js @@ -42,6 +42,7 @@ describe('app-generator specific files', () => { assertFilesToMatchSnapshot( {}, 'src/application.ts', + 'src/sequence.ts', 'src/index.ts', 'src/controllers/ping.controller.ts', 'src/__tests__/acceptance/ping.controller.acceptance.ts', diff --git a/packages/express/package-lock.json b/packages/express/package-lock.json index f7bd8d90e15c..fd92d0d19801 100644 --- a/packages/express/package-lock.json +++ b/packages/express/package-lock.json @@ -113,6 +113,12 @@ "@types/mime": "*" } }, + "@types/toposort": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/toposort/-/toposort-2.0.3.tgz", + "integrity": "sha512-jRtyvEu0Na/sy0oIxBW0f6wPQjidgVqlmCTJVHEGTNEUdL1f0YSvdPzHY7nX7MUWAZS6zcAa0KkqofHjy/xDZQ==", + "dev": true + }, "accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", @@ -584,6 +590,11 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" }, + "toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha1-riF2gXXRVZ1IvvNUILL0li8JwzA=" + }, "tslib": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.0.tgz", diff --git a/packages/express/package.json b/packages/express/package.json index 71dbcceac07c..88ac98fdb3d1 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -48,6 +48,7 @@ "express": "^4.17.1", "http-errors": "^1.8.0", "on-finished": "^2.3.0", + "toposort": "^2.0.2", "tslib": "^2.0.0" }, "devDependencies": { @@ -57,6 +58,7 @@ "@types/node": "^10.17.28", "@types/on-finished": "^2.3.1", "http-errors": "^1.8.0", + "@types/toposort": "^2.0.3", "source-map-support": "^0.5.19", "typescript": "~3.9.7" } diff --git a/packages/express/src/__tests__/acceptance/middleware-registeration.acceptance.ts b/packages/express/src/__tests__/acceptance/middleware-registeration.acceptance.ts index c1484d9d8bf3..5b5679ea94a2 100644 --- a/packages/express/src/__tests__/acceptance/middleware-registeration.acceptance.ts +++ b/packages/express/src/__tests__/acceptance/middleware-registeration.acceptance.ts @@ -83,6 +83,20 @@ describe('Express middleware registry', () => { await testSpyLog(); }); + it('reports error for circular dependencies', async () => { + server.middleware(spyMiddleware, { + key: 'middleware.spy', + downstreamGroups: ['x'], + upstreamGroups: ['x'], + }); + const res = await client + .post('/hello') + .send('"World"') + .set('content-type', 'application/json') + .expect(500); + expect(res.text).to.match(/Error\: Cyclic dependency/); + }); + it('registers a LoopBack middleware provider', async () => { class SpyMiddlewareProvider implements Provider { value() { diff --git a/packages/express/src/__tests__/unit/group-order.unit.ts b/packages/express/src/__tests__/unit/group-order.unit.ts new file mode 100644 index 000000000000..37c4edc45e52 --- /dev/null +++ b/packages/express/src/__tests__/unit/group-order.unit.ts @@ -0,0 +1,55 @@ +// 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 {sortListOfGroups} from '../../'; + +describe('sortGroups', () => { + it('sorts groups across lists', () => { + const result = sortListOfGroups(['first', 'end'], ['start', 'end', 'last']); + expect(result).to.eql(['first', 'start', 'end', 'last']); + }); + + it('add new groups after existing groups', () => { + const result = sortListOfGroups( + ['initial', 'session', 'auth'], + ['initial', 'added', 'auth'], + ); + expect(result).to.eql(['initial', 'session', 'added', 'auth']); + }); + + it('merges arrays preserving the order', () => { + const target = ['initial', 'session', 'auth', 'routes', 'files', 'final']; + const result = sortListOfGroups(target, [ + 'initial', + 'postinit', + 'preauth', // add + 'auth', + 'routes', + 'subapps', // add + 'final', + 'last', // add + ]); + + expect(result).to.eql([ + 'initial', + 'session', + 'postinit', + 'preauth', + 'auth', + 'routes', + 'files', + 'subapps', + 'final', + 'last', + ]); + }); + + it('throws on conflicting order', () => { + expect(() => { + sortListOfGroups(['one', 'two'], ['two', 'one']); + }).to.throw(/Cyclic dependency/); + }); +}); diff --git a/packages/express/src/group-sorter.ts b/packages/express/src/group-sorter.ts new file mode 100644 index 000000000000..7ae5d0a62765 --- /dev/null +++ b/packages/express/src/group-sorter.ts @@ -0,0 +1,35 @@ +// 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 toposort from 'toposort'; +const debug = debugFactory('loopback:middleware'); +/** + * Sort the groups by their relative order + * @param orderedGroups - A list of arrays - each of which represents a partial + * order of groups. + */ +export function sortListOfGroups(...orderedGroups: string[][]) { + if (debug.enabled) { + debug( + 'Dependency graph: %s', + orderedGroups.map(edge => edge.join('->')).join(', '), + ); + } + const graph: [string, string][] = []; + for (const groups of orderedGroups) { + if (groups.length >= 2) { + groups.reduce((prev: string | undefined, group) => { + if (typeof prev === 'string') { + graph.push([prev, group]); + } + return group; + }, undefined); + } + } + const sorted = toposort(graph); + debug('Sorted groups: %s', sorted.join('->')); + return sorted; +} diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts index a61c0332f074..ccf10bb7d03f 100644 --- a/packages/express/src/index.ts +++ b/packages/express/src/index.ts @@ -22,6 +22,7 @@ */ export * from './express.application'; export * from './express.server'; +export * from './group-sorter'; export * from './keys'; export * from './middleware'; export * from './middleware-interceptor'; diff --git a/packages/express/src/keys.ts b/packages/express/src/keys.ts index 139b94140841..9f9895f896ee 100644 --- a/packages/express/src/keys.ts +++ b/packages/express/src/keys.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {BindingKey} from '@loopback/core'; -import {MiddlewareContext} from './types'; +import {MiddlewareContext, MiddlewareGroups} from './types'; export namespace MiddlewareBindings { /** @@ -34,4 +34,4 @@ export const MIDDLEWARE_INTERCEPTOR_NAMESPACE = 'globalInterceptors.middleware'; /** * Default order group name for Express middleware based global interceptors */ -export const DEFAULT_MIDDLEWARE_GROUP = 'middleware'; +export const DEFAULT_MIDDLEWARE_GROUP = MiddlewareGroups.DEFAULT; diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts index 2865b8707e27..baa4bf62602c 100644 --- a/packages/express/src/middleware.ts +++ b/packages/express/src/middleware.ts @@ -21,6 +21,7 @@ import { ValueOrPromise, } from '@loopback/core'; import debugFactory from 'debug'; +import {sortListOfGroups} from './group-sorter'; import {DEFAULT_MIDDLEWARE_GROUP, MIDDLEWARE_NAMESPACE} from './keys'; import { createInterceptor, @@ -133,6 +134,20 @@ export function asMiddleware( binding .apply(extensionFor(options.chain ?? DEFAULT_MIDDLEWARE_CHAIN)) .tag({group: options.group ?? DEFAULT_MIDDLEWARE_GROUP}); + const groupsBefore = options.upstreamGroups; + if (groupsBefore != null) { + binding.tag({ + upstreamGroups: + typeof groupsBefore === 'string' ? [groupsBefore] : groupsBefore, + }); + } + const groupsAfter = options.downstreamGroups; + if (groupsAfter != null) { + binding.tag({ + downstreamGroups: + typeof groupsAfter === 'string' ? [groupsAfter] : groupsAfter, + }); + } }; } @@ -199,9 +214,19 @@ export function invokeMiddleware( middlewareCtx.request.originalUrl, options, ); - const {chain = DEFAULT_MIDDLEWARE_CHAIN, orderedGroups} = options ?? {}; + const {chain = DEFAULT_MIDDLEWARE_CHAIN, orderedGroups = []} = options ?? {}; // Find extensions for the given extension point binding const filter = extensionFilter(chain); + + // Calculate orders from middleware dependencies + const ordersFromDependencies: string[][] = []; + middlewareCtx.find(filter).forEach(b => { + const group: string = b.tagMap.group ?? DEFAULT_MIDDLEWARE_GROUP; + const groupsBefore: string[] = b.tagMap.upstreamGroups ?? []; + groupsBefore.forEach(d => ordersFromDependencies.push([d, group])); + const groupsAfter: string[] = b.tagMap.downstreamGroups ?? []; + groupsAfter.forEach(d => ordersFromDependencies.push([group, d])); + }); if (debug.enabled) { debug( 'Middleware for extension point "%s":', @@ -209,10 +234,11 @@ export function invokeMiddleware( middlewareCtx.find(filter).map(b => b.key), ); } + const order = sortListOfGroups(orderedGroups, ...ordersFromDependencies); const mwChain = new MiddlewareChain( middlewareCtx, filter, - compareBindingsByTag('group', orderedGroups), + compareBindingsByTag('group', order), ); return mwChain.invokeInterceptors(options?.next); } diff --git a/packages/express/src/types.ts b/packages/express/src/types.ts index f12242999bbe..5546853e9458 100644 --- a/packages/express/src/types.ts +++ b/packages/express/src/types.ts @@ -37,6 +37,11 @@ export type ExpressRequestHandler = RequestHandler; * context (request, response, etc.). */ export class MiddlewareContext extends Context implements HandlerContext { + /** + * A flag to tell if the response is finished. + */ + responseFinished = false; + /** * Constructor for `MiddlewareContext` * @param request - Express request object @@ -53,6 +58,7 @@ export class MiddlewareContext extends Context implements HandlerContext { super(parent, name); this.setupBindings(); onFinished(this.response, () => { + this.responseFinished = true; // Close the request context when the http response is finished so that // it can be recycled by GC this.emit('close'); @@ -68,7 +74,28 @@ export class MiddlewareContext extends Context implements HandlerContext { /** * Interface LoopBack 4 middleware to be executed within sequence of actions. * A middleware for LoopBack is basically a generic interceptor that uses - * `RequestContext`. + * `MiddlewareContext`. + * + * @remarks + * + * The middleware function is responsible for processing HTTP requests and + * responses. It typically includes the following logic. + * + * 1. Process the request with one of the following outcome + * - Reject the request by throwing an error if request is invalid, such as + * validation or authentication failures + * - Produce a response by itself, such as from the cache + * - Proceed by calling `await next()` to invoke downstream middleware. When + * `await next()` returns, it goes to step 2. If an error thrown from + * `await next()`, step 3 handles the error. + * + * 2. Process the response with one the following outcome + * - Reject the response by throwing an error + * - Replace the response with its own value + * - Return the response to upstream middleware + * + * 3. Catch the error thrown from `await next()`. If the `catch` block does not + * exist, the error will be bubbled up to upstream middleware * * The signature of a middleware function is described at * {@link https://loopback.io/doc/en/lb4/apidocs.express.middleware.html | Middleware}. @@ -116,7 +143,8 @@ export interface InvokeMiddlewareOptions { */ chain?: string; /** - * An array of group names to denote the order of execution + * An array of group names to denote the order of execution, such as + * `['cors', 'caching', 'rate-limiting']`. */ orderedGroups?: string[]; @@ -227,6 +255,24 @@ export interface MiddlewareBindingOptions * Name of the middleware extension point. Default to `DEFAULT_MIDDLEWARE_CHAIN`. */ chain?: string; + + /** + * An array of group names for upstream middleware in the cascading order. + * + * For example, the `invokeMethod` depends on `parseParams` for request + * processing. The `upstreamGroups` for `invokeMethod` should be + * `['parseParams']`. The order of groups in the array does not matter. + */ + upstreamGroups?: string | string[]; + + /** + * An array of group names for downstream middleware in the cascading order. + * + * For example, the `sendResponse` depends on `invokeMethod` for response + * processing. The `downstreamGroups` for `sendResponse` should be + * `['invokeMethod']`. The order of groups in the array does not matter. + */ + downstreamGroups?: string | string[]; } /** @@ -241,3 +287,24 @@ export interface ExpressMiddlewareFactory { * A symbol to store `MiddlewareContext` on the request object */ export const MIDDLEWARE_CONTEXT = Symbol('loopback.middleware.context'); + +/** + * Constants for middleware groups + */ +export namespace MiddlewareGroups { + /** + * Enforce CORS + */ + export const CORS = 'cors'; + + /** + * Server OpenAPI specs + */ + export const API_SPEC = 'apiSpec'; + + /** + * Default middleware group + */ + export const MIDDLEWARE = 'middleware'; + export const DEFAULT = MIDDLEWARE; +} diff --git a/packages/rest/src/__tests__/acceptance/middleware/middleware-registeration.acceptance.ts b/packages/rest/src/__tests__/acceptance/middleware/middleware-registeration.acceptance.ts index c51cfd557f7e..2c06c593dc32 100644 --- a/packages/rest/src/__tests__/acceptance/middleware/middleware-registeration.acceptance.ts +++ b/packages/rest/src/__tests__/acceptance/middleware/middleware-registeration.acceptance.ts @@ -59,8 +59,9 @@ describe('Express middleware registry', () => { const spyMiddleware: Middleware = async (middlewareCtx, next) => { const {request, response} = middlewareCtx; response.set('x-spy-log-req', `${request.method} ${request.path}`); - await next(); + const result = await next(); response.set('x-spy-log-res', `${request.method} ${request.path}`); + return result; }; it('registers a LoopBack middleware handler', async () => { @@ -93,11 +94,12 @@ describe('Express middleware registry', () => { `${this.options.headerName}-req`, `${request.method} ${request.path}`, ); - await next(); + const result = await next(); response.set( `${this.options.headerName}-res`, `${request.method} ${request.path}`, ); + return result; }; } } diff --git a/packages/rest/src/__tests__/acceptance/middleware/middleware-sequence.acceptance.ts b/packages/rest/src/__tests__/acceptance/middleware/middleware-sequence.acceptance.ts index 34631d6ca224..71fad312cf6a 100644 --- a/packages/rest/src/__tests__/acceptance/middleware/middleware-sequence.acceptance.ts +++ b/packages/rest/src/__tests__/acceptance/middleware/middleware-sequence.acceptance.ts @@ -5,6 +5,7 @@ import {BindingScope, Constructor, CoreTags, inject} from '@loopback/core'; import {InvokeMiddleware, InvokeMiddlewareProvider} from '@loopback/express'; +import {RestTags} from '../../../keys'; import {RequestContext} from '../../../request-context'; import {DefaultSequence} from '../../../sequence'; import {SpyAction} from '../../fixtures/middleware/spy-config'; @@ -18,7 +19,9 @@ describe('Middleware in sequence', () => { afterEach(() => helper?.stop()); it('registers a middleware in default slot', () => { - const binding = helper.app.expressMiddleware(spy, undefined); + const binding = helper.app.expressMiddleware(spy, undefined, { + chain: RestTags.ACTION_MIDDLEWARE_CHAIN, + }); return helper.testSpyLog(binding); }); @@ -46,7 +49,9 @@ describe('Middleware in sequence', () => { it('registers a middleware in default slot with sequence 2', () => { helper.app.sequence(SequenceWithTwoInvokeMiddleware); - const binding = helper.app.expressMiddleware(spy, undefined); + const binding = helper.app.expressMiddleware(spy, undefined, { + chain: RestTags.ACTION_MIDDLEWARE_CHAIN, + }); return helper.testSpyLog(binding); }); diff --git a/packages/rest/src/__tests__/integration/rest.server.integration.ts b/packages/rest/src/__tests__/integration/rest.server.integration.ts index d83de4a552ab..18c7b5525592 100644 --- a/packages/rest/src/__tests__/integration/rest.server.integration.ts +++ b/packages/rest/src/__tests__/integration/rest.server.integration.ts @@ -1406,7 +1406,9 @@ paths: async function dummyRequestHandler(requestContext: RequestContext) { const {response} = requestContext; - const result = await invokeMiddleware(requestContext); + const result = await invokeMiddleware(requestContext, { + chain: RestTags.ACTION_MIDDLEWARE_CHAIN, + }); if (result === response) return; response.write('Hello'); response.end(); diff --git a/packages/rest/src/keys.ts b/packages/rest/src/keys.ts index a1fdc8755342..3970f130363c 100644 --- a/packages/rest/src/keys.ts +++ b/packages/rest/src/keys.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {BindingKey, Context, CoreBindings} from '@loopback/core'; -import {InvokeMiddleware} from '@loopback/express'; +import {DEFAULT_MIDDLEWARE_CHAIN, InvokeMiddleware} from '@loopback/express'; import {HttpProtocol} from '@loopback/http-server'; import {OpenApiSpec, OperationObject} from '@loopback/openapi-v3'; import https from 'https'; @@ -12,13 +12,15 @@ import {ErrorWriterOptions} from 'strong-error-handler'; import {BodyParser, RequestBodyParser} from './body-parsers'; import {HttpHandler} from './http-handler'; import {RestServer, RestServerConfig} from './rest.server'; -import {RestRouter, RestRouterOptions} from './router'; +import {ResolvedRoute, RestRouter, RestRouterOptions} from './router'; import {SequenceHandler} from './sequence'; import { AjvFactory, FindRoute, InvokeMethod, LogError, + OperationArgs, + OperationRetval, ParseParams, Reject, Request, @@ -179,12 +181,20 @@ export namespace RestBindings { */ export const SEQUENCE = BindingKey.create('rest.sequence'); + /** + * Binding key for setting and injecting a `invokeMiddleware` function for + * middleware based sequence + */ + export const INVOKE_MIDDLEWARE_SERVICE = BindingKey.create( + 'rest.invokeMiddleware', + ); + /** * Bindings for potential actions that could be used in a sequence */ export namespace SequenceActions { /** - * Binding key for setting and injecting a route finding function + * Binding key for setting and injecting `invokeMiddleware` function */ export const INVOKE_MIDDLEWARE = BindingKey.create( 'rest.sequence.actions.invokeMiddleware', @@ -225,6 +235,20 @@ export namespace RestBindings { ); } + export namespace Operation { + export const ROUTE = BindingKey.create( + 'rest.operation.route', + ); + + export const PARAMS = BindingKey.create( + 'rest.operation.params', + ); + + export const RETURN_VALUE = BindingKey.create( + 'rest.operation.returnValue', + ); + } + /** * Request-specific bindings */ @@ -285,4 +309,11 @@ export namespace RestTags { export const AJV_KEYWORD = 'ajvKeyword'; export const AJV_FORMAT = 'ajvFormat'; + + export const REST_MIDDLEWARE_CHAIN = DEFAULT_MIDDLEWARE_CHAIN; + + /** + * Legacy middleware chain for action-based REST sequence + */ + export const ACTION_MIDDLEWARE_CHAIN = 'middlewareChain.rest.actions'; } diff --git a/packages/rest/src/providers/find-route.provider.ts b/packages/rest/src/providers/find-route.provider.ts index bc02702db279..7428a79924ce 100644 --- a/packages/rest/src/providers/find-route.provider.ts +++ b/packages/rest/src/providers/find-route.provider.ts @@ -3,11 +3,13 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Context, inject, Provider} from '@loopback/core'; -import {FindRoute, Request} from '../types'; +import {bind, Context, inject, Provider} from '@loopback/core'; +import {asMiddleware, Middleware} from '@loopback/express'; import {HttpHandler} from '../http-handler'; -import {RestBindings} from '../keys'; +import {RestBindings, RestTags} from '../keys'; import {ResolvedRoute} from '../router'; +import {RestMiddlewareGroups} from '../sequence'; +import {FindRoute, Request} from '../types'; export class FindRouteProvider implements Provider { constructor( @@ -25,3 +27,24 @@ export class FindRouteProvider implements Provider { return found; } } + +@bind( + asMiddleware({ + group: RestMiddlewareGroups.FIND_ROUTE, + chain: RestTags.REST_MIDDLEWARE_CHAIN, + }), +) +export class FindRouteMiddlewareProvider implements Provider { + constructor( + @inject(RestBindings.SequenceActions.FIND_ROUTE) + protected findRoute: FindRoute, + ) {} + + value(): Middleware { + return async (ctx, next) => { + const route = this.findRoute(ctx.request); + ctx.bind(RestBindings.Operation.ROUTE).to(route); + return next(); + }; + } +} diff --git a/packages/rest/src/providers/invoke-method.provider.ts b/packages/rest/src/providers/invoke-method.provider.ts index 3bf4a1b74865..b12deb317868 100644 --- a/packages/rest/src/providers/invoke-method.provider.ts +++ b/packages/rest/src/providers/invoke-method.provider.ts @@ -3,10 +3,12 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Context, inject, Provider} from '@loopback/core'; -import {InvokeMethod, OperationArgs, OperationRetval} from '../types'; -import {RestBindings} from '../keys'; +import {bind, Context, inject, Provider} from '@loopback/core'; +import {asMiddleware, Middleware} from '@loopback/express'; +import {RestBindings, RestTags} from '../keys'; import {RouteEntry} from '../router'; +import {RestMiddlewareGroups} from '../sequence'; +import {InvokeMethod, OperationArgs, OperationRetval} from '../types'; export class InvokeMethodProvider implements Provider { constructor(@inject(RestBindings.Http.CONTEXT) protected context: Context) {} @@ -19,3 +21,29 @@ export class InvokeMethodProvider implements Provider { return route.invokeHandler(this.context, args); } } + +@bind( + asMiddleware({ + group: RestMiddlewareGroups.INVOKE_METHOD, + upstreamGroups: RestMiddlewareGroups.PARSE_PARAMS, + chain: RestTags.REST_MIDDLEWARE_CHAIN, + }), +) +export class InvokeMethodMiddlewareProvider implements Provider { + constructor( + @inject(RestBindings.SequenceActions.INVOKE_METHOD) + protected invokeMethod: InvokeMethod, + ) {} + + value(): Middleware { + return async (ctx, next) => { + const route: RouteEntry = await ctx.get(RestBindings.Operation.ROUTE); + const params: OperationArgs = await ctx.get( + RestBindings.Operation.PARAMS, + ); + const retVal = await this.invokeMethod(route, params); + ctx.bind(RestBindings.Operation.RETURN_VALUE).to(retVal); + return retVal; + }; + } +} diff --git a/packages/rest/src/providers/parse-params.provider.ts b/packages/rest/src/providers/parse-params.provider.ts index f7668976d6e6..70b3b6d35fb7 100644 --- a/packages/rest/src/providers/parse-params.provider.ts +++ b/packages/rest/src/providers/parse-params.provider.ts @@ -3,11 +3,13 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {inject, Provider} from '@loopback/core'; +import {bind, inject, Provider} from '@loopback/core'; +import {asMiddleware, Middleware} from '@loopback/express'; import {RequestBodyParser} from '../body-parsers'; -import {RestBindings} from '../keys'; +import {RestBindings, RestTags} from '../keys'; import {parseOperationArgs} from '../parser'; import {ResolvedRoute} from '../router'; +import {RestMiddlewareGroups} from '../sequence'; import {AjvFactory, ParseParams, Request, ValidationOptions} from '../types'; import {DEFAULT_AJV_VALIDATION_OPTIONS} from '../validation/ajv-factory.provider'; /** @@ -36,3 +38,26 @@ export class ParseParamsProvider implements Provider { }); } } + +@bind( + asMiddleware({ + group: RestMiddlewareGroups.PARSE_PARAMS, + upstreamGroups: RestMiddlewareGroups.FIND_ROUTE, + chain: RestTags.REST_MIDDLEWARE_CHAIN, + }), +) +export class ParseParamsMiddlewareProvider implements Provider { + constructor( + @inject(RestBindings.SequenceActions.PARSE_PARAMS) + protected parseParams: ParseParams, + ) {} + + value(): Middleware { + return async (ctx, next) => { + const route: ResolvedRoute = await ctx.get(RestBindings.Operation.ROUTE); + const params = await this.parseParams(ctx.request, route); + ctx.bind(RestBindings.Operation.PARAMS).to(params); + return next(); + }; + } +} diff --git a/packages/rest/src/providers/send.provider.ts b/packages/rest/src/providers/send.provider.ts index 2d3f549b7dce..9ca2c239e529 100644 --- a/packages/rest/src/providers/send.provider.ts +++ b/packages/rest/src/providers/send.provider.ts @@ -3,7 +3,11 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Provider, BoundValue} from '@loopback/core'; +import {bind, inject, Provider} from '@loopback/core'; +import {asMiddleware, Middleware} from '@loopback/express'; +import {RestBindings, RestTags} from '../keys'; +import {RestMiddlewareGroups} from '../sequence'; +import {Reject, Send} from '../types'; import {writeResultToResponse} from '../writer'; /** * Provides the function that populates the response object with @@ -12,8 +16,47 @@ import {writeResultToResponse} from '../writer'; * @returns The handler function that will populate the * response with operation results. */ -export class SendProvider implements Provider { +export class SendProvider implements Provider { value() { return writeResultToResponse; } } + +@bind( + asMiddleware({ + group: RestMiddlewareGroups.SEND_RESPONSE, + downstreamGroups: [ + RestMiddlewareGroups.CORS, + RestMiddlewareGroups.INVOKE_METHOD, + ], + chain: RestTags.REST_MIDDLEWARE_CHAIN, + }), +) +export class SendResponseMiddlewareProvider implements Provider { + constructor( + @inject(RestBindings.SequenceActions.SEND) + protected send: Send, + @inject(RestBindings.SequenceActions.REJECT) + protected reject: Reject, + ) {} + + value(): Middleware { + return async (ctx, next) => { + try { + /** + * Invoke downstream middleware to produce the result + */ + const result = await next(); + /** + * Write the result to HTTP response + */ + this.send(ctx.response, result); + } catch (err) { + /** + * Write the error to HTTP response + */ + this.reject(ctx, err); + } + }; + } +} diff --git a/packages/rest/src/rest.component.ts b/packages/rest/src/rest.component.ts index 91f3806ae029..3e2eeaa8958e 100644 --- a/packages/rest/src/rest.component.ts +++ b/packages/rest/src/rest.component.ts @@ -4,15 +4,14 @@ // License text available at https://opensource.org/licenses/MIT import { + Application, Binding, + Component, Constructor, + CoreBindings, + CoreTags, createBindingFromClass, inject, -} from '@loopback/core'; -import { - Application, - Component, - CoreBindings, ProviderMap, Server, } from '@loopback/core'; @@ -26,21 +25,25 @@ import { UrlEncodedBodyParser, } from './body-parsers'; import {RawBodyParser} from './body-parsers/body-parser.raw'; -import {RestBindings} from './keys'; +import {RestBindings, RestTags} from './keys'; import { + FindRouteMiddlewareProvider, FindRouteProvider, + InvokeMethodMiddlewareProvider, InvokeMethodProvider, LogErrorProvider, + ParseParamsMiddlewareProvider, ParseParamsProvider, RejectProvider, SendProvider, + SendResponseMiddlewareProvider, } from './providers'; import { createBodyParserBinding, RestServer, RestServerConfig, } from './rest.server'; -import {DefaultSequence} from './sequence'; +import {MiddlewareSequence} from './sequence'; import {ConsolidationEnhancer} from './spec-enhancers/consolidate.spec-enhancer'; import {InfoSpecEnhancer} from './spec-enhancers/info.spec-enhancer'; import {AjvFactoryProvider} from './validation/ajv-factory.provider'; @@ -48,8 +51,6 @@ 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, @@ -86,6 +87,8 @@ export class RestComponent implements Component { ), createBindingFromClass(InfoSpecEnhancer), createBindingFromClass(ConsolidationEnhancer), + + ...getRestMiddlewareBindings(), ]; servers: { [name: string]: Constructor; @@ -97,7 +100,27 @@ export class RestComponent implements Component { @inject(CoreBindings.APPLICATION_INSTANCE) app: Application, @inject(RestBindings.CONFIG) config?: RestComponentConfig, ) { - app.bind(RestBindings.SEQUENCE).toClass(DefaultSequence); + // Register the `InvokeMiddleware` with default to `ACTION_MIDDLEWARE_CHAIN` + // to keep backward compatibility with action based sequence + const invokeMiddlewareActionBinding = createBindingFromClass( + InvokeMiddlewareProvider, + { + key: RestBindings.SequenceActions.INVOKE_MIDDLEWARE, + }, + ).tag({[CoreTags.EXTENSION_POINT]: RestTags.ACTION_MIDDLEWARE_CHAIN}); + app.add(invokeMiddlewareActionBinding); + + // Register the `InvokeMiddleware` with default to `DEFAULT_MIDDLEWARE_CHAIN` + // for the middleware based sequence + const invokeMiddlewareServiceBinding = createBindingFromClass( + InvokeMiddlewareProvider, + { + key: RestBindings.INVOKE_MIDDLEWARE_SERVICE, + }, + ).tag({[CoreTags.EXTENSION_POINT]: RestTags.REST_MIDDLEWARE_CHAIN}); + app.add(invokeMiddlewareServiceBinding); + + app.bind(RestBindings.SEQUENCE).toClass(MiddlewareSequence); const apiSpec = createEmptyApiSpec(); // Merge the OpenAPI `servers` spec from the config into the empty one if (config?.openApiSpec?.servers) { @@ -107,5 +130,14 @@ export class RestComponent implements Component { } } +function getRestMiddlewareBindings() { + return [ + SendResponseMiddlewareProvider, + FindRouteMiddlewareProvider, + ParseParamsMiddlewareProvider, + InvokeMethodMiddlewareProvider, + ].map(cls => createBindingFromClass(cls)); +} + // TODO(kevin): Extend this interface def to include multiple servers? export type RestComponentConfig = RestServerConfig; diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index 3899a61d513c..e5b38585abef 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -12,6 +12,7 @@ import { ContextObserver, CoreBindings, createBindingFromClass, + extensionFor, filterByKey, filterByTag, inject, @@ -61,7 +62,12 @@ import { RoutingTable, } from './router'; import {assignRouterSpec} from './router/router-spec'; -import {DefaultSequence, SequenceFunction, SequenceHandler} from './sequence'; +import { + DefaultSequence, + RestMiddlewareGroups, + SequenceFunction, + SequenceHandler, +} from './sequence'; import {Request, RequestBodyParserOptions, Response} from './types'; const debug = debugFactory('loopback:rest:server'); @@ -249,8 +255,13 @@ export class RestServer extends BaseMiddlewareRegistry this.expressMiddleware(cors, this.config.cors, { injectConfiguration: false, key: 'middleware.cors', - group: 'cors', - }); + group: RestMiddlewareGroups.CORS, + }).apply( + extensionFor( + RestTags.REST_MIDDLEWARE_CHAIN, + RestTags.ACTION_MIDDLEWARE_CHAIN, + ), + ); // Set up endpoints for OpenAPI spec/ui this._setupOpenApiSpecEndpoints(); @@ -323,8 +334,14 @@ export class RestServer extends BaseMiddlewareRegistry this._redirectToSwaggerUI(req, res, next), ); this.expressMiddleware('middleware.apiSpec.defaults', router, { - group: 'apiSpec', - }); + group: RestMiddlewareGroups.API_SPEC, + upstreamGroups: RestMiddlewareGroups.CORS, + }).apply( + extensionFor( + RestTags.REST_MIDDLEWARE_CHAIN, + RestTags.ACTION_MIDDLEWARE_CHAIN, + ), + ); } /** diff --git a/packages/rest/src/sequence.ts b/packages/rest/src/sequence.ts index abaef7ffe673..f25bb2f01a31 100644 --- a/packages/rest/src/sequence.ts +++ b/packages/rest/src/sequence.ts @@ -3,12 +3,17 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -const debug = require('debug')('loopback:rest:sequence'); -import {inject, ValueOrPromise} from '@loopback/core'; -import {InvokeMiddleware} from '@loopback/express'; -import {RestBindings} from './keys'; +import {config, inject, ValueOrPromise} from '@loopback/core'; +import { + InvokeMiddleware, + InvokeMiddlewareOptions, + MiddlewareGroups, +} from '@loopback/express'; +import debugFactory from 'debug'; +import {RestBindings, RestTags} from './keys'; import {RequestContext} from './request-context'; import {FindRoute, InvokeMethod, ParseParams, Reject, Send} from './types'; +const debug = debugFactory('loopback:rest:sequence'); const SequenceActions = RestBindings.SequenceActions; @@ -121,3 +126,126 @@ export class DefaultSequence implements SequenceHandler { } } } + +/** + * Built-in middleware groups for the REST sequence + */ +export namespace RestMiddlewareGroups { + /** + * Invoke downstream middleware to get the result or catch errors so that it + * can produce the http response + */ + export const SEND_RESPONSE = 'sendResponse'; + + /** + * Enforce CORS + */ + export const CORS = MiddlewareGroups.CORS; + + /** + * Server OpenAPI specs + */ + export const API_SPEC = MiddlewareGroups.API_SPEC; + + /** + * Default middleware group + */ + export const MIDDLEWARE = MiddlewareGroups.MIDDLEWARE; + export const DEFAULT = MIDDLEWARE; + + /** + * Find the route that can serve the request + */ + export const FIND_ROUTE = 'findRoute'; + + /** + * Perform authentication + */ + export const AUTHENTICATION = 'authentication'; + + /** + * Parse the http request to extract parameter values for the operation + */ + export const PARSE_PARAMS = 'parseParams'; + + /** + * Invoke the target controller method or handler function + */ + export const INVOKE_METHOD = 'invokeMethod'; +} + +/** + * A sequence implementation using middleware chains + */ +export class MiddlewareSequence implements SequenceHandler { + static defaultOptions: InvokeMiddlewareOptions = { + chain: RestTags.REST_MIDDLEWARE_CHAIN, + orderedGroups: [ + // Please note that middleware is cascading. The `sendResponse` is + // added first to invoke downstream middleware to get the result or + // catch errors so that it can produce the http response. + RestMiddlewareGroups.SEND_RESPONSE, + + RestMiddlewareGroups.CORS, + RestMiddlewareGroups.API_SPEC, + RestMiddlewareGroups.MIDDLEWARE, + + RestMiddlewareGroups.FIND_ROUTE, + + // authentication depends on the route + RestMiddlewareGroups.AUTHENTICATION, + + RestMiddlewareGroups.PARSE_PARAMS, + + RestMiddlewareGroups.INVOKE_METHOD, + ], + }; + + /** + * Constructor: Injects `InvokeMiddleware` and `InvokeMiddlewareOptions` + * + * @param invokeMiddleware - invoker for registered middleware in a chain. + * To be injected via RestBindings.INVOKE_MIDDLEWARE_SERVICE. + */ + constructor( + @inject(RestBindings.INVOKE_MIDDLEWARE_SERVICE) + readonly invokeMiddleware: InvokeMiddleware, + @config() + readonly options: InvokeMiddlewareOptions = MiddlewareSequence.defaultOptions, + ) {} + + /** + * Runs the default sequence. Given a handler context (request and response), + * running the sequence will produce a response or an error. + * + * Default sequence executes these groups of middleware: + * + * - `cors`: Enforces `CORS` + * - `openApiSpec`: Serves OpenAPI specs + * - `findRoute`: Finds the appropriate controller method, swagger spec and + * args for invocation + * - `parseParams`: Parses HTTP request to get API argument list + * - `invokeMethod`: Invokes the API which is defined in the Application + * controller method + * + * In front of the groups above, we have a special middleware called + * `sendResponse`, which first invokes downstream middleware to get a result + * and handles the result or error respectively. + * + * - Writes the result from API into the HTTP response (if the HTTP response + * has not been produced yet by the middleware chain. + * - Catches error logs it using 'logError' if any of the above steps + * in the sequence fails with an error. + * + * @param context - The request context: HTTP request and response objects, + * per-request IoC container and more. + */ + async handle(context: RequestContext): Promise { + debug( + 'Invoking middleware chain %s with groups %s', + this.options.chain, + this.options.orderedGroups, + ); + await this.invokeMiddleware(context, this.options); + } +} diff --git a/packages/rest/src/types.ts b/packages/rest/src/types.ts index 74b59a092ce5..253f386cf4cd 100644 --- a/packages/rest/src/types.ts +++ b/packages/rest/src/types.ts @@ -26,7 +26,7 @@ export * from '@loopback/express'; export type FindRoute = (request: Request) => ResolvedRoute; /** - * + * A function to parse OpenAPI operation parameters for a given route */ export type ParseParams = ( request: Request,