From 5f8f552f9629d90954b08005cf93bd2ffb7edb40 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Thu, 6 Sep 2018 15:57:58 -0700 Subject: [PATCH] feat(rest): use phase to manage express middleware --- packages/rest/package.json | 1 + packages/rest/src/middleware-phase.ts | 132 ++++++++++++++++++ .../middleware-phase.integration.ts | 126 +++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 packages/rest/src/middleware-phase.ts create mode 100644 packages/rest/test/integration/middleware-phase.integration.ts diff --git a/packages/rest/package.json b/packages/rest/package.json index 073d69b83525..4eb84e1edf93 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -26,6 +26,7 @@ "@loopback/openapi-v3": "^1.1.3", "@loopback/openapi-v3-types": "^1.0.1", "@types/body-parser": "^1.17.0", + "@loopback/phase": "^0.1.0", "@types/cors": "^2.8.3", "@types/express": "^4.11.1", "@types/express-serve-static-core": "^4.16.0", diff --git a/packages/rest/src/middleware-phase.ts b/packages/rest/src/middleware-phase.ts new file mode 100644 index 000000000000..18c89627e8f2 --- /dev/null +++ b/packages/rest/src/middleware-phase.ts @@ -0,0 +1,132 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Context} from '@loopback/context'; +import {PhaseList, HandlerChain} from '@loopback/phase'; +import {RequestHandler, ErrorRequestHandler} from 'express'; +import {RequestContext} from './request-context'; +import {PathParams} from 'express-serve-static-core'; +import * as pathToRegExp from 'path-to-regexp'; +const debug = require('debug')('loopback:rest:middleware'); + +/** + * A registry for Express middleware by phase + */ +export class MiddlewareList { + private phaseList: PhaseList; + constructor(phaseNames: string[] = []) { + this.phaseList = new PhaseList(phaseNames); + } + + /** + * Register handlers by phase + * @param phaseName Name of the phase + * @param path Path filter + * @param handlers Middleware handlers + */ + middleware( + phaseName: string, + path: PathParams, + ...handlers: RequestHandler[] + ): this { + const re = pathToRegExp(path, [], {end: false}); + for (const handler of handlers) { + this.phaseList.registerHandler( + phaseName, + async (ctx: RequestContext, chain: HandlerChain) => { + debug('Request: %s pattern: %s', ctx.request.url, re); + if (!re.test(ctx.request.path)) { + debug('Skipping %s', ctx.request.path); + await chain.next(); + return; + } + debug('Invoking middleware %s', handler.name); + let nextPromise: Promise | undefined = undefined; + // tslint:disable-next-line:no-any + handler(ctx.request, ctx.response, (err?: any) => { + // Keep the callback as a sync function as expected by express + // middleware + if (!err) { + // Track the result of chain.next as it can be rejected + nextPromise = chain.next(); + } else { + chain.throw(err); + } + }); + // Catch the rejected promise if necessary + // tslint:disable-next-line:await-promise + if (nextPromise) await nextPromise; + if (!chain.done) chain.stop(); + }, + ); + } + return this; + } + + /** + * + * @param path + * @param handlers + */ + finalMiddleware(path: PathParams, ...handlers: RequestHandler[]) { + this.middleware(this.phaseList.finalPhase.id, path, ...handlers); + } + + /** + * + * @param path + * @param handlers + */ + errorMiddleware(path: PathParams, ...handlers: ErrorRequestHandler[]) { + const re = pathToRegExp(path, [], {end: false}); + for (const handler of handlers) { + this.phaseList.registerHandler( + this.phaseList.errorPhase.id, + // tslint:disable-next-line:no-any + async (ctx: RequestContext & {error?: any}, chain: HandlerChain) => { + debug('Request: %s pattern: %s', ctx.request.url, re); + if (!re.test(ctx.request.path)) { + debug('Skipping %s', ctx.request.path); + await chain.next(); + return; + } + debug('Invoking error middleware %s', handler.name); + let nextPromise: Promise | undefined = undefined; + // tslint:disable-next-line:no-any + handler(ctx.error, ctx.request, ctx.response, (err?: any) => { + // Keep the callback as a sync function as expected by express + // middleware + if (!err) { + // Track the result of chain.next as it can be rejected + nextPromise = chain.next(); + } else { + chain.throw(err); + } + }); + // Catch the rejected promise if necessary + // tslint:disable-next-line:await-promise + if (nextPromise) await nextPromise; + if (!chain.done) chain.stop(); + }, + ); + } + return this; + } + + /** + * Create an express middleware from the registry + */ + asHandler(): RequestHandler { + return async (req, res, next) => { + const reqCtx = new RequestContext(req, res, new Context()); + try { + await this.phaseList.run(reqCtx); + next(); + } catch (e) { + next(e); + } + }; + } +} diff --git a/packages/rest/test/integration/middleware-phase.integration.ts b/packages/rest/test/integration/middleware-phase.integration.ts new file mode 100644 index 000000000000..43449f42ece4 --- /dev/null +++ b/packages/rest/test/integration/middleware-phase.integration.ts @@ -0,0 +1,126 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import * as express from 'express'; +import {MiddlewareList} from '../../src/middleware-phase'; +import {RequestHandler, ErrorRequestHandler} from 'express-serve-static-core'; +import {supertest, expect} from '@loopback/testlab'; + +describe('Express middleware phases', () => { + let app: express.Application; + let middlewareChain: MiddlewareList; + + beforeEach(() => { + app = express(); + middlewareChain = new MiddlewareList(['initial', 'auth', 'route']); + }); + + it('registers middleware by phase', async () => { + const steps: string[] = []; + middlewareChain.middleware( + 'route', + '/', + givenMiddleware('route-1', steps), + givenMiddleware('route-2', steps), + ); + middlewareChain.middleware( + 'initial', + '/', + givenMiddleware('initial-1', steps), + givenMiddleware('initial-2', steps), + ); + app.use(middlewareChain.asHandler()); + app.use((req, res) => { + res.json({steps}); + }); + + await supertest(app) + .get('/') + .expect(200, {steps}); + + expect(steps).to.eql(['initial-1', 'initial-2', 'route-1', 'route-2']); + }); + + it('registers middleware by phase and path', async () => { + const steps: string[] = []; + middlewareChain.middleware( + 'route', + '/foo', + givenMiddleware('route-1', steps), + ); + middlewareChain.middleware( + 'initial', + '/', + givenMiddleware('initial-1', steps), + givenMiddleware('initial-2', steps), + ); + middlewareChain.middleware( + 'route', + '/bar', + givenMiddleware('route-2', steps), + ); + app.use(middlewareChain.asHandler()); + app.use((req, res) => { + res.json({steps}); + }); + + const test = supertest(app); + await test.get('/foo').expect(200, {steps}); + expect(steps).to.eql(['initial-1', 'initial-2', 'route-1']); + + // Reset steps + steps.splice(0, steps.length); + await test.get('/bar').expect(200, {steps}); + expect(steps).to.eql(['initial-1', 'initial-2', 'route-2']); + }); + + it('registers error and final middleware', async () => { + const steps: string[] = []; + middlewareChain.middleware( + 'route', + '/foo', + givenMiddleware('route-1', steps), + ); + middlewareChain.middleware( + 'initial', + '/', + givenMiddleware('initial-1', steps), + ); + middlewareChain.middleware('auth', '/foo', (req, res, next) => { + steps.push('auth'); + next(new Error('not authenticated')); + }); + + middlewareChain.errorMiddleware( + '/', + givenErrorMiddleware('error-1', steps), + ); + middlewareChain.finalMiddleware('/', givenMiddleware('final-1', steps)); + + app.use(middlewareChain.asHandler()); + + const test = supertest(app); + await test.get('/foo').expect(401); + + expect(steps).to.eql(['initial-1', 'auth', 'error-1', 'final-1']); + }); +}); + +function givenMiddleware(name: string, steps: string[]): RequestHandler { + return (req, res, next) => { + steps.push(name); + next(); + }; +} + +function givenErrorMiddleware( + name: string, + steps: string[], +): ErrorRequestHandler { + return (err, req, res, next) => { + steps.push(name); + res.status(401).json({error: err.message}); + }; +}