From 01d27a18eb3cdc31662634dd9ea60b5f983e26b4 Mon Sep 17 00:00:00 2001 From: David Luecke Date: Wed, 27 May 2020 12:22:33 -0700 Subject: [PATCH] feat(hooks): Finalize default initializer functionality --- packages/hooks/src/base.ts | 26 +++++- packages/hooks/src/index.ts | 2 +- packages/hooks/test/class.test.ts | 128 +++++++++++++++++++++++++++ packages/hooks/test/function.test.ts | 77 ++++++---------- packages/hooks/test/object.test.ts | 120 ------------------------- readme.md | 22 ++++- 6 files changed, 197 insertions(+), 178 deletions(-) create mode 100644 packages/hooks/test/class.test.ts diff --git a/packages/hooks/src/base.ts b/packages/hooks/src/base.ts index 6c9e70f..820510d 100644 --- a/packages/hooks/src/base.ts +++ b/packages/hooks/src/base.ts @@ -22,12 +22,14 @@ export class HookContext { export type HookContextConstructor = new (data?: { [key: string]: any }) => HookContext; +export type HookDefaultsInitializer = (self?: any, args?: any[], context?: HookContext) => HookContextData; + export class HookManager { _parent?: this|null = null; _params: string[] = []; _middleware: Middleware[] = []; _props: HookContextData = {}; - _defaults: HookContextData|(() => HookContextData) = {}; + _defaults: HookDefaultsInitializer; parent (parent: this) { this._parent = parent; @@ -77,12 +79,19 @@ export class HookManager { return previous.concat(this._params); } - defaults (defaults: HookContextData|(() => HookContextData)) { + defaults (defaults: HookDefaultsInitializer) { this._defaults = defaults; return this; } + getDefaults (self: any, args: any[], context: HookContext): HookContextData { + const previous = this._parent ? this._parent.getDefaults(self, args, context) : {}; + const defaults = typeof this._defaults === 'function' ? this._defaults(self, args, context) : {}; + + return Object.assign({}, previous, defaults); + } + getContextClass (Base: HookContextConstructor = HookContext): HookContextConstructor { const ContextClass = class ContextClass extends Base { constructor (data: any) { @@ -95,10 +104,14 @@ export class HookManager { const props = this.getProps(); params.forEach((name, index) => { + if (props[name]) { + throw new Error(`Hooks can not have a property and param named '${name}'. Use .defaults instead.`); + } + Object.defineProperty(ContextClass.prototype, name, { enumerable: true, get () { - return this.arguments[index]; + return this?.arguments[index]; }, set (value: any) { this.arguments[index] = value; @@ -113,6 +126,7 @@ export class HookManager { initializeContext (self: any, args: any[], context: HookContext): HookContext { const ctx = this._parent ? this._parent.initializeContext(self, args, context) : context; + const defaults = this.getDefaults(self, args, ctx); if (self) { ctx.self = self; @@ -120,6 +134,12 @@ export class HookManager { ctx.arguments = args; + for (const name of Object.keys(defaults)) { + if (ctx[name] === undefined) { + ctx[name] = defaults[name]; + } + } + return ctx; } } diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index b53f083..b43b373 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -29,7 +29,7 @@ export function middleware (mw: Middleware[] = []) { /** * Returns a new function that wraps an existing async function * with hooks. - * + * * @param fn The async function to add hooks to. * @param manager An array of middleware or hook settings * (`middleware([]).params()` etc.) diff --git a/packages/hooks/test/class.test.ts b/packages/hooks/test/class.test.ts new file mode 100644 index 0000000..aff897e --- /dev/null +++ b/packages/hooks/test/class.test.ts @@ -0,0 +1,128 @@ +import { strict as assert } from 'assert'; +import { hooks, middleware, HookContext, NextFunction } from '../src'; + +interface Dummy { + sayHi (name: string): Promise; + addOne (number: number): Promise; +} + +describe('class objectHooks', () => { + let DummyClass: new () => Dummy; + + beforeEach(() => { + DummyClass = class DummyClass implements Dummy { + async sayHi (name: string) { + return `Hi ${name}`; + } + + async addOne (number: number) { + return number + 1; + } + }; + }); + + + it('hooking object on class adds to the prototype', async () => { + hooks(DummyClass, { + sayHi: middleware([async (ctx: HookContext, next: NextFunction) => { + assert.deepStrictEqual(ctx, new DummyClass.prototype.sayHi.Context({ + arguments: ['David'], + method: 'sayHi', + name: 'David', + self: instance + })); + + await next(); + + ctx.result += '?'; + }]).params('name'), + + addOne: middleware([async (ctx: HookContext, next: NextFunction) => { + ctx.arguments[0] += 1; + + await next(); + }]) + }); + + const instance = new DummyClass(); + + assert.strictEqual(await instance.sayHi('David'), 'Hi David?'); + assert.strictEqual(await instance.addOne(1), 3); + }); + + it('works with inheritance', async () => { + hooks(DummyClass, { + sayHi: middleware([async (ctx: HookContext, next: NextFunction) => { + assert.deepStrictEqual(ctx, new (OtherDummy.prototype.sayHi as any).Context({ + arguments: [ 'David' ], + method: 'sayHi', + self: instance + })); + + await next(); + + ctx.result += '?'; + }]) + }); + + class OtherDummy extends DummyClass {} + + hooks(OtherDummy, { + sayHi: middleware([async (ctx: HookContext, next: NextFunction) => { + await next(); + + ctx.result += '!'; + }]) + }); + + const instance = new OtherDummy(); + + assert.strictEqual(await instance.sayHi('David'), 'Hi David?!'); + }); + + 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(); + } + ]).params('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(); + } + ]).props({ 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(); + } + ]).props({ app: 'ok' }) + }); + + assert.equal(await instance.sayHi('Dave'), 'Hi Changed'); + }); +}); diff --git a/packages/hooks/test/function.test.ts b/packages/hooks/test/function.test.ts index 00caa58..653b474 100644 --- a/packages/hooks/test/function.test.ts +++ b/packages/hooks/test/function.test.ts @@ -10,7 +10,7 @@ import { } from '../src'; describe('functionHooks', () => { - const hello = async (name: string, _params: any = {}) => { + const hello = async (name?: string, _params: any = {}) => { return `Hello ${name}`; }; @@ -309,53 +309,30 @@ describe('functionHooks', () => { assert.deepEqual(Object.keys(resultContext), ['message', 'name', 'arguments', 'result']); }); - // 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('is chainable with .params on function', async () => { - // const hook = async function (this: any, context: HookContext, next: NextFunction) { - // await next(); - // context.result += '!'; - // }; - // const exclamation = hooks(hello, middleware([hook]).params(['name', 'Dave'])); - - // const result = await exclamation(); - - // assert.equal(result, 'Hello Dave!'); - // }); - - // it('is chainable with .params on object', async () => { - // const hook = async function (this: any, context: HookContext, next: NextFunction) { - // await next(); - // context.result += '!'; - // }; - // const obj = { - // sayHi (name: any) { - // return `Hi ${name}`; - // } - // }; - - // hooks(obj, { - // sayHi: hooks([hook]).params('name') - // }); - - // const result = await obj.sayHi('Dave'); - - // assert.equal(result, 'Hi Dave!'); - // }); + it('same params and props throw an error', async () => { + const hello = async (name?: string) => { + return `Hello ${name}`; + }; + assert.throws(() => hooks(hello, middleware([]).params('name').props({ name: 'David' })), { + message: `Hooks can not have a property and param named 'name'. Use .defaults instead.` + }); + }); + + it('creates context with default params', async () => { + const fn = hooks(hello, middleware([ + async (ctx, next) => { + assert.deepEqual(ctx.params, {}); + + await next(); + }]).params('name', 'params').defaults(() => { + return { + name: 'Bertho', + params: {} + } + }) + ); + + assert.equal(await fn('Dave'), 'Hello Dave'); + assert.equal(await fn(), 'Hello Bertho'); + }); }); diff --git a/packages/hooks/test/object.test.ts b/packages/hooks/test/object.test.ts index 4c98351..80f27bf 100644 --- a/packages/hooks/test/object.test.ts +++ b/packages/hooks/test/object.test.ts @@ -7,14 +7,8 @@ interface HookableObject { addOne (number: number): Promise; } -interface Dummy { - sayHi (name: string): Promise; - addOne (number: number): Promise; -} - describe('objectHooks', () => { let obj: HookableObject; - let DummyClass: new () => Dummy; beforeEach(() => { obj = { @@ -28,16 +22,6 @@ describe('objectHooks', () => { return number + 1; } }; - - DummyClass = class DummyClass implements Dummy { - async sayHi (name: string) { - return `Hi ${name}`; - } - - async addOne (number: number) { - return number + 1; - } - }; }); it('hooks object with hook methods, sets method name', async () => { @@ -128,64 +112,6 @@ describe('objectHooks', () => { } }); - it('hooking object on class adds to the prototype', async () => { - hooks(DummyClass, { - sayHi: middleware([async (ctx: HookContext, next: NextFunction) => { - assert.deepStrictEqual(ctx, new DummyClass.prototype.sayHi.Context({ - arguments: ['David'], - method: 'sayHi', - name: 'David', - self: instance - })); - - await next(); - - ctx.result += '?'; - }]).params('name'), - - addOne: middleware([async (ctx: HookContext, next: NextFunction) => { - ctx.arguments[0] += 1; - - await next(); - }]) - }); - - const instance = new DummyClass(); - - assert.strictEqual(await instance.sayHi('David'), 'Hi David?'); - assert.strictEqual(await instance.addOne(1), 3); - }); - - it('works with inheritance', async () => { - hooks(DummyClass, { - sayHi: middleware([async (ctx: HookContext, next: NextFunction) => { - assert.deepStrictEqual(ctx, new (OtherDummy.prototype.sayHi as any).Context({ - arguments: [ 'David' ], - method: 'sayHi', - self: instance - })); - - await next(); - - ctx.result += '?'; - }]) - }); - - class OtherDummy extends DummyClass {} - - hooks(OtherDummy, { - sayHi: middleware([async (ctx: HookContext, next: NextFunction) => { - await next(); - - ctx.result += '!'; - }]) - }); - - const instance = new OtherDummy(); - - assert.strictEqual(await instance.sayHi('David'), 'Hi David?!'); - }); - it('works with object level hooks', async () => { hooks(obj, [ async (ctx: HookContext, next: NextFunction) => { @@ -205,50 +131,4 @@ 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(); - } - ]).params('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(); - } - ]).props({ 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(); - } - ]).props({ app: 'ok' }) - }); - - assert.equal(await instance.sayHi('Dave'), 'Hi Changed'); - }); }); diff --git a/readme.md b/readme.md index 9f4e3e1..47bcd00 100644 --- a/readme.md +++ b/readme.md @@ -35,8 +35,8 @@ To a function or class without having to change its original code while also kee - [Customizing and returning the context](#customizing-and-returning-the-context) - [Options](#options) - [params(...names)](#paramsnames) - - [props](#props) - - [defaults](#defaults) + - [props(properties)](#propsproperties) + - [defaults(callback)](#defaultscallback) - [Function hooks](#function-hooks) - [Object hooks](#object-hooks) - [Class hooks](#class-hooks) @@ -416,7 +416,7 @@ const sayHelloWithHooks = hooks(sayHello, middleware([ ]).params('name')); ``` -### props +### props(properties) Initializes properties on the `context` @@ -431,7 +431,21 @@ const sayHelloWithHooks = hooks(sayHello, middleware([ })); ``` -### defaults +> __Note:__ `.props` can not contain any of the field names defined in `.params`. + +### defaults(callback) + +Calls a `callback(self, arguments, context)` that returns default values which will be set if the property on the hook context is `undefined`. Applies to both, `params` and other properties. + +```js +const sayHello = async (name?: string) => `Hello ${name}`; + +const sayHelloWithHooks = hooks(sayHello, middleware([]).params('name').defaults(() => { + return { + name: 'Unknown human' + } +}); +``` ## Function hooks