From a556272f535c7d2a25bcbc12d8473cdaefaf8c56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Berthommier?= Date: Wed, 29 Jan 2020 04:48:40 +0100 Subject: [PATCH] feat: Allow multiple context initializers (#12) --- packages/hooks/src/base.ts | 98 ++++++++++++++++++++++------ packages/hooks/src/decorator.ts | 10 +-- packages/hooks/src/function.ts | 31 +++++++-- packages/hooks/src/object.ts | 19 ++---- packages/hooks/test/function.test.ts | 66 ++++++++++++++++++- packages/hooks/test/object.test.ts | 57 +++++++++++++++- 6 files changed, 231 insertions(+), 50 deletions(-) diff --git a/packages/hooks/src/base.ts b/packages/hooks/src/base.ts index 6499296..2660f4b 100644 --- a/packages/hooks/src/base.ts +++ b/packages/hooks/src/base.ts @@ -1,9 +1,15 @@ import { Middleware } from './compose'; export const HOOKS: string = Symbol('@feathersjs/hooks') as any; +export const CONTEXT: string = Symbol('@feathersjs/hooks/context') as any; + +function walkOriginal (fn: any, method: any, res: any[] = []): any { + return typeof fn.original === 'function' + ? walkOriginal(fn.original, method, [...res, ...method(fn)]) + : [...res, ...method(fn)]; +} /** - * * @param target The target object or function * @param middleware */ @@ -19,6 +25,22 @@ export function getMiddleware (target: any): Array> { return (target && target[HOOKS]) || []; } +/** + * @param target The target object or function + * @param updaters + */ +export function registerContextUpdater (target: T, updaters: ContextUpdater[]) { + const current: ContextUpdater[] = (target as any)[CONTEXT] || []; + + (target as any)[CONTEXT] = current.concat(updaters); + + return target; +} + +export function getContextUpdater (target: any): Array> { + return (target && target[CONTEXT]) || []; +} + /** * The base hook context. */ @@ -38,7 +60,7 @@ export class HookContext { * A function that updates the hook context with the `this` reference and * arguments of the function call. */ -export type ContextUpdater = (self: any, args: any[], context: HookContext) => HookContext; +export type ContextUpdater = (self: any, fn: any, args: any[], context: HookContext) => HookContext; /** * A function that for a given function, calling context and arguments returns the list of hooks */ @@ -49,20 +71,22 @@ export type MiddlewareCollector = (self: any, fn: any, args: any[]) => */ export interface FunctionHookOptions { middleware: Array>; - context: ContextUpdater; + context: Array>; collect: MiddlewareCollector; } -export type HookSettings = Array>|Partial; +export type HookSettings = Array>|Partial & { + context: ContextUpdater|Array>; +}>; export function defaultCollectMiddleware (self: any, fn: any, _args: any[]) { return [ ...getMiddleware(self), - ...getMiddleware(fn) + ...walkOriginal(fn, getMiddleware) ]; } -export function normalizeOptions (opts: HookSettings): FunctionHookOptions { +export function normalizeOptions (opts: any): FunctionHookOptions { const options: Partial = Array.isArray(opts) ? { middleware: opts } : opts; const { middleware = [], @@ -70,7 +94,16 @@ export function normalizeOptions (opts: HookSettings): FunctionHookOpti collect = defaultCollectMiddleware } = options; - return { middleware, context, collect }; + const contextUpdaters = Array.isArray(context) ? context : [context]; + + return { middleware, context: contextUpdaters, collect }; +} + +export function collectContextUpdaters (self: any, fn: any, _args: any[]) { + return [ + ...getContextUpdater(self), + ...walkOriginal(fn, getContextUpdater) + ]; } /** @@ -80,22 +113,32 @@ export function normalizeOptions (opts: HookSettings): FunctionHookOpti * * @param params The list of parameter names */ -export function withParams (...params: string[]) { - return (self: any, args: any[], context: HookContext) => { - params.forEach((name, index) => { - context[name] = args[index]; +export function withParams (...params: Array) { + return (self: any, _fn: any, args: any[], context: HookContext) => { + params.forEach((param: string | [string, any], index: number) => { + if (typeof param === 'string') { + context[param] = args[index]; + return; + } + const [name, defaultValue] = param; + context[name] = args[index] === undefined ? defaultValue : args[index]; }); - if (params.length > 0) { - Object.defineProperty(context, 'arguments', { - get (this: HookContext) { - const result = params.map(name => this[name]); - - return Object.freeze(result); - } - }); - } else { - context.arguments = args; + if (!context.arguments) { + if (params.length > 0) { + Object.defineProperty(context, 'arguments', { + get (this: HookContext) { + const result = params.map(param => { + const name = typeof param === 'string' ? param : param[0]; + return this[name]; + }); + + return Object.freeze(result); + } + }); + } else { + context.arguments = args; + } } if (self) { @@ -105,3 +148,16 @@ export function withParams (...params: string[]) { return context; }; } + +/** + * Returns a ContextUpdater function that adds props on the hook context + * + * @param props The props object to assign + */ +export function withProps (props: any) { + return (_self: any, _fn: any, _args: any[], context: HookContext) => { + Object.assign(context, props); + + return context; + }; +} diff --git a/packages/hooks/src/decorator.ts b/packages/hooks/src/decorator.ts index 7ad233a..fd63d84 100644 --- a/packages/hooks/src/decorator.ts +++ b/packages/hooks/src/decorator.ts @@ -3,7 +3,7 @@ import { HookContext, registerMiddleware, normalizeOptions, HookSettings } from export const hookDecorator = (hooks: HookSettings = []) => { return (_target: any, method: string, descriptor: TypedPropertyDescriptor): TypedPropertyDescriptor => { - const options = normalizeOptions(hooks); + const { context, ...options } = normalizeOptions(hooks); if (!descriptor) { registerMiddleware(_target.prototype, options.middleware); @@ -17,14 +17,10 @@ export const hookDecorator = (hooks: HookSettings = []) => { throw new Error(`Can not apply hooks. '${method}' is not a function`); } - const originalContext = options.context; - const context = (self: any, args: any[], context: HookContext) => { - const ctx = originalContext(self, args, context); - + context.push((_self: any, _fn: any, _args: any[], ctx: HookContext) => { ctx.method = method; - return ctx; - }; + }); descriptor.value = functionHooks(fn, { ...options, diff --git a/packages/hooks/src/function.ts b/packages/hooks/src/function.ts index 6c3f3cb..c0f435c 100644 --- a/packages/hooks/src/function.ts +++ b/packages/hooks/src/function.ts @@ -1,5 +1,16 @@ import { compose, Middleware } from './compose'; -import { HookContext, registerMiddleware, normalizeOptions, HookSettings } from './base'; +import { + HookContext, + registerMiddleware, + registerContextUpdater, + normalizeOptions, + collectContextUpdaters, + HookSettings +} from './base'; + +function getOriginal (fn: any): any { + return typeof fn.original === 'function' ? getOriginal(fn.original) : fn; +} /** * Returns a new function that is wrapped in the given hooks. @@ -9,7 +20,7 @@ import { HookContext, registerMiddleware, normalizeOptions, HookSettings } from * and `context.self` to the function call `this` reference. * * @param original The function to wrap - * @param options A list of hooks (middleware) or options for more detailed hook processing + * @param opts A list of hooks (middleware) or options for more detailed hook processing */ export const functionHooks = (original: F, opts: HookSettings) => { if (typeof original !== 'function') { @@ -17,13 +28,20 @@ export const functionHooks = (original: F, opts: HookSettings) => } const { context: updateContext, collect, middleware } = normalizeOptions(opts); - const wrapper = function (this: any, ...args: any[]) { + + const wrapper: any = function (this: any, ...args: any[]) { // If we got passed an existing HookContext instance, we want to return it as well const returnContext = args[args.length - 1] instanceof HookContext; // Initialize the context. Either the default context or the one that was passed - const baseContext: HookContext = returnContext ? args.pop() : new HookContext(); + let context: HookContext = returnContext ? args.pop() : new HookContext(); + + const contextUpdaters = collectContextUpdaters(this, wrapper, args); // Initialize the context with the self reference and arguments - const context = updateContext(this, args, baseContext); + + for (const contextUpdater of contextUpdaters) { + context = contextUpdater(this, wrapper, args, context); + } + // Assemble the hook chain const hookChain: Middleware[] = [ // Return `ctx.result` or the context @@ -33,7 +51,7 @@ export const functionHooks = (original: F, opts: HookSettings) => // Runs the actual original method if `ctx.result` is not already set (ctx, next) => { if (ctx.result === undefined) { - return Promise.resolve(original.apply(this, ctx.arguments)).then(result => { + return Promise.resolve(getOriginal(original).apply(this, ctx.arguments)).then(result => { ctx.result = result; return next(); @@ -47,6 +65,7 @@ export const functionHooks = (original: F, opts: HookSettings) => return compose(hookChain).call(this, context); }; + registerContextUpdater(wrapper, updateContext); registerMiddleware(wrapper, middleware); return Object.assign(wrapper, { original }); diff --git a/packages/hooks/src/object.ts b/packages/hooks/src/object.ts index a2ea362..514bdc0 100644 --- a/packages/hooks/src/object.ts +++ b/packages/hooks/src/object.ts @@ -15,27 +15,22 @@ export const objectHooks = (_obj: any, hooks: HookMap|Middleware[]) => { return Object.keys(hooks).reduce((result, method) => { const value = obj[method]; - const options = normalizeOptions(hooks[method]); - const originalContext = options.context; - const context = (self: any, args: any[], context: HookContext) => { - const ctx = originalContext(self, args, context); - - ctx.method = method; - - return ctx; - }; + const { context, ...options } = normalizeOptions(hooks[method]); if (typeof value !== 'function') { throw new Error(`Can not apply hooks. '${method}' is not a function`); } - const fn = functionHooks(value, { + context.push((_self: any, _fn: any, _args: any[], ctx: HookContext) => { + ctx.method = method; + return ctx; + }); + + result[method] = functionHooks(value, { ...options, context }); - result[method] = fn; - return result; }, obj); }; diff --git a/packages/hooks/test/function.test.ts b/packages/hooks/test/function.test.ts index 8423cdf..fdaab76 100644 --- a/packages/hooks/test/function.test.ts +++ b/packages/hooks/test/function.test.ts @@ -1,11 +1,12 @@ import { strict as assert } from 'assert'; import { hooks, HookContext, functionHooks, - NextFunction, getMiddleware, withParams, registerMiddleware + NextFunction, getMiddleware, registerMiddleware, + withParams, withProps } from '../src/'; describe('functionHooks', () => { - const hello = async (name: string) => { + const hello = async (name: string, _params: any = {}) => { return `Hello ${name}`; }; @@ -168,6 +169,42 @@ describe('functionHooks', () => { assert.equal(await fn('Dave'), 'Hello Changed'); }); + it('creates context with default params', async () => { + const fn = hooks(hello, { + middleware: [ + async (ctx, next) => { + assert.equal(ctx.name, 'Dave'); + assert.deepEqual(ctx.params, {}); + + ctx.name = 'Changed'; + + await next(); + } + ], + context: withParams('name', ['params', {}]) + }); + + assert.equal(await fn('Dave'), 'Hello Changed'); + }); + + it('assigns props to context', async () => { + const fn = hooks(hello, { + middleware: [ + async (ctx, next) => { + assert.equal(ctx.name, 'Dave'); + assert.equal(ctx.dev, true); + + ctx.name = 'Changed'; + + await next(); + } + ], + context: [withParams('name'), withProps({ dev: true })] + }); + + assert.equal(await fn('Dave'), 'Hello Changed'); + }); + it('with named context ctx.arguments is frozen', async () => { const modifyArgs = async (ctx: HookContext, next: NextFunction) => { ctx.arguments[0] = 'Test'; @@ -201,7 +238,7 @@ describe('functionHooks', () => { }); const customContext = new HookContext({ message }); - const resultContext: HookContext = await fn('Dave', customContext); + const resultContext: HookContext = await fn('Dave', {}, customContext); assert.equal(resultContext, customContext); assert.deepEqual(resultContext, new HookContext({ @@ -210,4 +247,27 @@ describe('functionHooks', () => { result: 'Hello Changed' })); }); + + it('calls middleware one time', async () => { + let called = 0; + + const sayHi = hooks((name: any) => `Hi ${name}`, [ + async (_context, next) => { + called++; + await next(); + } + ]); + + const exclamation = hooks(sayHi, [ + async (context, next) => { + await next(); + context.result += '!'; + } + ]); + + const result = await exclamation('Bertho'); + + assert.equal(result, 'Hi Bertho!'); + assert.equal(called, 1); + }); }); diff --git a/packages/hooks/test/object.test.ts b/packages/hooks/test/object.test.ts index bbe908e..a0222f3 100644 --- a/packages/hooks/test/object.test.ts +++ b/packages/hooks/test/object.test.ts @@ -1,5 +1,5 @@ import { strict as assert } from 'assert'; -import { hooks, HookContext, NextFunction, withParams } from '../src'; +import {hooks, HookContext, NextFunction, withParams, withProps} from '../src'; describe('objectHooks', () => { let obj: any; @@ -195,4 +195,59 @@ describe('objectHooks', () => { assert.equal(await obj.sayHi('Dave'), 'Hi Dave?!'); }); + + it('works with multiple context updaters', async () => { + hooks(DummyClass, { + sayHi: { + middleware: [ + async (ctx, next) => { + assert.equal(ctx.name, 'Dave'); + assert.equal(ctx.gna, 42); + assert.equal(ctx.app, 'ok'); + + ctx.name = 'Changed'; + + await next(); + } + ], + context: withParams('name') + } + }); + + class OtherDummy extends DummyClass {} + + hooks(OtherDummy, { + sayHi: { + middleware: [ + async (ctx, next) => { + assert.equal(ctx.name, 'Dave'); + assert.equal(ctx.gna, 42); + assert.equal(ctx.app, 'ok'); + + await next(); + } + ], + context: withProps({ gna: 42 }) + } + }); + + const instance = new OtherDummy(); + + hooks(instance, { + sayHi: { + middleware: [ + async (ctx, next) => { + assert.equal(ctx.name, 'Dave'); + assert.equal(ctx.gna, 42); + assert.equal(ctx.app, 'ok'); + + await next(); + } + ], + context: withProps({ app: 'ok' }) + } + }); + + assert.equal(await instance.sayHi('Dave'), 'Hi Changed'); + }); });