Skip to content

Commit

Permalink
feat(rest): add an extension point for express middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
raymondfeng committed Mar 6, 2019
1 parent c3d8007 commit 2366aa4
Show file tree
Hide file tree
Showing 3 changed files with 308 additions and 0 deletions.
92 changes: 92 additions & 0 deletions packages/rest/src/__tests__/unit/middleware-registry.unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright IBM Corp. 2018. All Rights Reserved.
// Node module: @loopback/rest
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {Context, inject, Provider} from '@loopback/context';
import {
ShotRequestOptions,
stubExpressContext,
expect,
} from '@loopback/testlab';
import {Request, RequestHandler, Response} from 'express';
import {asMiddlewareBinding, MiddlewareSpec} from '../../middleware';
import {MiddlewareRegistry} from '../../middleware-registry';

describe('MiddlewareRegistry', () => {
const DUMMY_RESPONSE = ({
setHeader: () => {},
end: () => {},
} as unknown) as Response;
let ctx: Context;
let registry: MiddlewareRegistry;
let events: string[];

beforeEach(givenContext);
beforeEach(givenMiddlewareRegistry);

it('builds an express router', async () => {
registry.setPhasesByOrder(['cors', 'auth', 'route', 'final']);

givenMiddleware('rest', {phase: 'route', path: '/api'});
givenMiddlewareProvider('cors', {phase: 'cors'});
givenMiddleware('token', {phase: 'auth'});
await testRouter('get', '/api/orders');
expect(events).to.eql(['token: GET /api/orders', 'rest: GET /orders']);
});

function givenContext() {
events = [];
ctx = new Context('app');
}

async function givenMiddlewareRegistry() {
ctx.bind('middleware.registry').toClass(MiddlewareRegistry);
registry = await ctx.get('middleware.registry');
}

function givenMiddleware(name: string, spec?: MiddlewareSpec) {
const middleware: RequestHandler = (req, res, next) => {
events.push(`${name}: ${req.method} ${req.url}`);
next();
};
ctx
.bind(`middleware.${name}`)
.to(middleware)
.apply(asMiddlewareBinding(spec));
}

function givenMiddlewareProvider(moduleName: string, spec?: MiddlewareSpec) {
class MiddlewareProvider implements Provider<MiddlewareProvider> {
private middlewareModule: Function;
constructor(
@inject(`middleware.${moduleName}.options`, {optional: true})
private options: object = {},
) {
this.middlewareModule = require(moduleName);
}

value() {
return this.middlewareModule(this.options);
}
}
ctx
.bind(`middleware.${moduleName}`)
.toProvider(MiddlewareProvider)
.apply(asMiddlewareBinding(spec));
}

function givenRequest(options?: ShotRequestOptions): Request {
return stubExpressContext(options).request;
}

async function testRouter(method: string, url: string) {
const router = await registry.createExpressRouter();
await new Promise<void>((resolve, reject) => {
router(givenRequest({url, method}), DUMMY_RESPONSE, err => {
if (err) reject(err);
else resolve();
});
});
}
});
161 changes: 161 additions & 0 deletions packages/rest/src/middleware-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Copyright IBM Corp. 2018. All Rights Reserved.
// Node module: @loopback/core
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {Binding, ContextView, inject} from '@loopback/context';
import {middlewareFilter, MiddlewareHandler} from './middleware';
import * as express from 'express';
import debugFactory = require('debug');
const debug = debugFactory('loopback:rest:middleware');

/**
* A phase of express middleware
*/
export type MiddlewarePhase = {
/**
* Middleware phase name
*/
phase: string;
/**
* Bindings for middleware within the phase
*/
bindings: Readonly<Binding<MiddlewareHandler>>[];
};

export type MiddlewareOptions = {
phasesByOrder: string[];
parallel?: boolean;
};

/**
* A context-based registry for express middleware
*/
export class MiddlewareRegistry {
constructor(
@inject.view(middlewareFilter)
protected middlewareView: ContextView<MiddlewareHandler>,
@inject('express.middleware.options', {optional: true})
protected options: MiddlewareOptions = {
parallel: false,
phasesByOrder: [],
},
) {}

setPhasesByOrder(phases: string[]) {
this.options.phasesByOrder = phases || [];
}

/**
* Get middleware phases ordered by the phase
*/
protected getMiddlewarePhasesByOrder(): MiddlewarePhase[] {
const bindings = this.middlewareView.bindings;
const phases = this.sortMiddlewareBindingsByPhase(bindings);
if (debug.enabled) {
debug(
'Middleware phases: %j',
phases.map(phase => ({
phase: phase.phase,
bindings: phase.bindings.map(b => b.key),
})),
);
}
return phases;
}

/**
* Get the phase for a given middleware binding
* @param binding Middleware binding
*/
protected getMiddlewarePhase(
binding: Readonly<Binding<MiddlewareHandler>>,
): string {
const phase = binding.tagMap.phase || '';
debug(
'Binding %s is configured with middleware phase %s',
binding.key,
phase,
);
return phase;
}

/**
* Sort the middleware bindings so that we can start/stop them
* in the right order. By default, we can start other middleware before servers
* and stop them in the reverse order
* @param bindings Middleware bindings
*/
protected sortMiddlewareBindingsByPhase(
bindings: Readonly<Binding<MiddlewareHandler>>[],
) {
// Phase bindings in a map
const phaseMap: Map<
string,
Readonly<Binding<MiddlewareHandler>>[]
> = new Map();
for (const binding of bindings) {
const phase = this.getMiddlewarePhase(binding);
let bindingsInPhase = phaseMap.get(phase);
if (bindingsInPhase == null) {
bindingsInPhase = [];
phaseMap.set(phase, bindingsInPhase);
}
bindingsInPhase.push(binding);
}
// Create an array for phase entries
const phases: MiddlewarePhase[] = [];
for (const [phase, bindingsInPhase] of phaseMap) {
phases.push({phase: phase, bindings: bindingsInPhase});
}
// Sort the phases
return phases.sort((p1, p2) => {
const i1 = this.options.phasesByOrder.indexOf(p1.phase);
const i2 = this.options.phasesByOrder.indexOf(p2.phase);
if (i1 !== -1 || i2 !== -1) {
// Honor the phase order
return i1 - i2;
} else {
// Neither phase is in the pre-defined order
// Use alphabetical order instead so that `1-phase` is invoked before
// `2-phase`
return p1.phase < p2.phase ? -1 : p1.phase > p2.phase ? 1 : 0;
}
});
}

/**
* Create an express router that sets up registered middleware by phase
*/
async createExpressRouter() {
const phases = this.getMiddlewarePhasesByOrder();
const rootRouter = express.Router();
const middleware = await this.middlewareView.values();
const bindings = this.middlewareView.bindings;
for (const phase of phases) {
// Create a child router per phase
const phaseRouter = express.Router();
// Add the phase router to the root
rootRouter.use(phaseRouter);

const bindingsInPhase = phase.bindings;
for (const binding of bindingsInPhase) {
const index = bindings.indexOf(binding);
if (binding.tagMap && binding.tagMap.path) {
// Add the middleware to the given path
debug(
'Adding middleware (binding: %s): %j',
binding.key,
binding.tagMap,
);
phaseRouter.use(binding.tagMap.path, middleware[index]);
} else {
// Add the middleware without a path
debug('Adding middleware (binding: %s)', binding.key);
phaseRouter.use(middleware[index]);
}
}
}
return rootRouter;
}
}
55 changes: 55 additions & 0 deletions packages/rest/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright IBM Corp. 2018. All Rights Reserved.
// Node module: @loopback/core
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {
bind,
Binding,
BindingFilter,
BindingScope,
filterByTag,
BindingTemplate,
} from '@loopback/context';
import {PathParams, RequestHandlerParams} from 'express-serve-static-core';

/**
* Spec for a middleware entry
*/
export interface MiddlewareSpec {
// Path to be mounted
path?: PathParams;
// Optional phase for ordering
phase?: string;
}

export type MiddlewareHandler = RequestHandlerParams;

/**
* Configure the binding as express middleware
* @param binding Binding
*/
export function asMiddlewareBinding(
spec?: MiddlewareSpec,
): BindingTemplate<MiddlewareHandler> {
const tags = Object.assign({}, spec);
return (binding: Binding<MiddlewareHandler>) => {
return binding
.tag('middleware')
.inScope(BindingScope.SINGLETON)
.tag(tags);
};
}

/**
* A filter function to find all middleware bindings
*/
export const middlewareFilter: BindingFilter = filterByTag('middleware');

/**
* A sugar `@middleware` decorator to simplify `@bind` for middleware classes
* @param spec Middleware spec
*/
export function middleware(spec?: MiddlewareSpec) {
return bind({tags: spec}, asMiddlewareBinding(spec));
}

0 comments on commit 2366aa4

Please sign in to comment.