-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(rest): add an extension point for express middleware
- Loading branch information
1 parent
b71eedb
commit 8dc57b3
Showing
3 changed files
with
308 additions
and
0 deletions.
There are no files selected for viewing
92 changes: 92 additions & 0 deletions
92
packages/rest/src/__tests__/unit/middleware-registry.unit.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); | ||
} | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
} |