diff --git a/package-lock.json b/package-lock.json index 0ea2898..3cccede 100644 --- a/package-lock.json +++ b/package-lock.json @@ -938,9 +938,9 @@ } }, "@lerna/publish": { - "version": "3.20.1", - "resolved": "https://registry.npmjs.org/@lerna/publish/-/publish-3.20.1.tgz", - "integrity": "sha512-F0Eb2lyIkwMiucGVzOq91RyhJSkJsuh/9xKnKgm6VK8tuVbLDD8cNE7dLRIIDtBYWPtEmujTW+nBg/aDdJBjWg==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@lerna/publish/-/publish-3.20.2.tgz", + "integrity": "sha512-N7Y6PdhJ+tYQPdI1tZum8W25cDlTp4D6brvRacKZusweWexxaopbV8RprBaKexkEX/KIbncuADq7qjDBdQHzaA==", "dev": true, "requires": { "@evocateur/libnpmaccess": "^3.1.2", @@ -964,7 +964,7 @@ "@lerna/run-lifecycle": "3.16.2", "@lerna/run-topologically": "3.18.5", "@lerna/validation-error": "3.13.0", - "@lerna/version": "3.20.1", + "@lerna/version": "3.20.2", "figgy-pudding": "^3.5.1", "fs-extra": "^8.1.0", "npm-package-arg": "^6.1.0", @@ -1100,9 +1100,9 @@ } }, "@lerna/version": { - "version": "3.20.1", - "resolved": "https://registry.npmjs.org/@lerna/version/-/version-3.20.1.tgz", - "integrity": "sha512-MY80FV4uqdjxHNl6WaZ5k9hr0IGeQXo1+bKTKzMUudpr3HlTS0c0PoAwSGB2KHafGFpoyb9WHBhb1uDyhL+OfQ==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@lerna/version/-/version-3.20.2.tgz", + "integrity": "sha512-ckBJMaBWc+xJen0cMyCE7W67QXLLrc0ELvigPIn8p609qkfNM0L0CF803MKxjVOldJAjw84b8ucNWZLvJagP/Q==", "dev": true, "requires": { "@lerna/check-working-tree": "3.16.5", @@ -1296,9 +1296,9 @@ "dev": true }, "@types/node": { - "version": "13.1.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-13.1.2.tgz", - "integrity": "sha512-B8emQA1qeKerqd1dmIsQYnXi+mmAzTB7flExjmy5X1aVAKFNNNDubkavwR13kR6JnpeLp3aLoJhwn9trWPAyFQ==", + "version": "13.1.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.1.6.tgz", + "integrity": "sha512-Jg1F+bmxcpENHP23sVKkNuU3uaxPnsBMW0cLjleiikFKomJQbsn0Cqk2yDvQArqzZN6ABfBkZ0To7pQ8sLdWDg==", "dev": true }, "@zkochan/cmd-shim": { @@ -3667,9 +3667,9 @@ "dev": true }, "handlebars": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.5.3.tgz", - "integrity": "sha512-3yPecJoJHK/4c6aZhSvxOyG4vJKDshV36VHp0iVCDVh7o9w2vwi3NSnL2MMPj3YdduqaBcu7cGbggJQM0br9xA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.0.tgz", + "integrity": "sha512-PaZ6G6nYzfJ0Hd1WIhOpsnUPWh1R0Pg//r4wEYOtzG65c2V8RJQ/++yYlVmuoQ7EMXcb4eri5+FB2XH1Lwed9g==", "dev": true, "requires": { "neo-async": "^2.6.0", @@ -4605,9 +4605,9 @@ "dev": true }, "lerna": { - "version": "3.20.1", - "resolved": "https://registry.npmjs.org/lerna/-/lerna-3.20.1.tgz", - "integrity": "sha512-TUS6aSyVdOoXLF1CMwUsT0zCGwgO1LvRUP9zUqWRYdvZP8NofSEzd4ChkRXZWGwXyQ8ozA9bIfwWxMck2QpfIA==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/lerna/-/lerna-3.20.2.tgz", + "integrity": "sha512-bjdL7hPLpU3Y8CBnw/1ys3ynQMUjiK6l9iDWnEGwFtDy48Xh5JboR9ZJwmKGCz9A/sarVVIGwf1tlRNKUG9etA==", "dev": true, "requires": { "@lerna/add": "3.20.0", @@ -4623,9 +4623,9 @@ "@lerna/init": "3.18.5", "@lerna/link": "3.18.5", "@lerna/list": "3.20.0", - "@lerna/publish": "3.20.1", + "@lerna/publish": "3.20.2", "@lerna/run": "3.20.0", - "@lerna/version": "3.20.1", + "@lerna/version": "3.20.2", "import-local": "^2.0.0", "npmlog": "^4.1.2" } @@ -4870,18 +4870,18 @@ } }, "mime-db": { - "version": "1.42.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.42.0.tgz", - "integrity": "sha512-UbfJCR4UAVRNgMpfImz05smAXK7+c+ZntjaA26ANtkXLlOe947Aag5zdIcKQULAiF9Cq4WxBi9jUs5zkA84bYQ==", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz", + "integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==", "dev": true }, "mime-types": { - "version": "2.1.25", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.25.tgz", - "integrity": "sha512-5KhStqB5xpTAeGqKBAMgwaYMnQik7teQN4IAzC7npDv6kzeU6prfkR67bc87J1kWMPGkoaZSq1npmexMgkmEVg==", + "version": "2.1.26", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz", + "integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==", "dev": true, "requires": { - "mime-db": "1.42.0" + "mime-db": "1.43.0" } }, "mimic-fn": { @@ -5316,9 +5316,9 @@ "dev": true }, "ansi-styles": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.0.tgz", - "integrity": "sha512-7kFQgnEaMdRtwf6uSfUnVr9gSGC7faurn+J/Mv90/W+iTtN0405/nLdopfMWwchyxhbGYl6TC4Sccn9TUkGAgg==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", "dev": true, "requires": { "@types/color-name": "^1.1.1", @@ -5462,9 +5462,9 @@ } }, "yargs": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.0.2.tgz", - "integrity": "sha512-GH/X/hYt+x5hOat4LMnCqMd8r5Cv78heOMIJn1hr7QPPBqfeC6p89Y78+WB9yGDvfpCvgasfmWLzNzEioOUD9Q==", + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.1.0.tgz", + "integrity": "sha512-T39FNN1b6hCW4SOIk1XyTOWxtXdcen0t+XYrysQmChzSipvhBO8Bj0nK1ozAasdk24dNWuMZvr4k24nz+8HHLg==", "dev": true, "requires": { "cliui": "^6.0.0", @@ -5660,9 +5660,9 @@ "dev": true }, "p-limit": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.1.tgz", - "integrity": "sha512-85Tk+90UCVWvbDavCLKPOLC9vvY8OwEX/RtKF+/1OADJMVlFfEHOiMTPVyxg7mk/dKa+ipdHm0OUkTvCpMTuwg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.2.tgz", + "integrity": "sha512-WGR+xHecKTr7EbUEhyLSh5Dube9JtdiG78ufaeLxTgpudf/20KqyMioIUZJAezlTIi6evxuoUs9YXc11cU+yzQ==", "dev": true, "requires": { "p-try": "^2.0.0" @@ -6137,9 +6137,9 @@ } }, "readable-stream": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", - "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", "dev": true, "requires": { "core-util-is": "~1.0.0", @@ -6262,9 +6262,9 @@ "dev": true }, "resolve": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.14.1.tgz", - "integrity": "sha512-fn5Wobh4cxbLzuHaE+nphztHy43/b++4M6SsGFC2gB8uYwf0C8LcarfCz1un7UTW8OFQg9iNjZ4xpcFVGebDPg==", + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.14.2.tgz", + "integrity": "sha512-EjlOBLBO1kxsUxsKjLt7TAECyKW6fOh1VRkykQkKGzcBbjjPIxBqGh0jf7GJ3k/f5mxMqW3htMD3WdTUVtW8HQ==", "dev": true, "requires": { "path-parse": "^1.0.6" @@ -7193,9 +7193,9 @@ } }, "uglify-js": { - "version": "3.7.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.7.3.tgz", - "integrity": "sha512-7tINm46/3puUA4hCkKYo4Xdts+JDaVC9ZPRcG8Xw9R4nhO/gZgUM3TENq8IF4Vatk8qCig4MzP/c8G4u2BkVQg==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.7.4.tgz", + "integrity": "sha512-tinYWE8X1QfCHxS1lBS8yiDekyhSXOO6R66yNOCdUJeojxxw+PX2BHAz/BWyW7PQ7pkiWVxJfIEbiDxyLWvUGg==", "dev": true, "optional": true, "requires": { diff --git a/packages/hooks/src/base.ts b/packages/hooks/src/base.ts index f5b6ee9..6499296 100644 --- a/packages/hooks/src/base.ts +++ b/packages/hooks/src/base.ts @@ -15,7 +15,7 @@ export function registerMiddleware (target: T, middleware: Middleware[]) { return target; } -export function getMiddleware (target: any): Middleware[] { +export function getMiddleware (target: any): Array> { return (target && target[HOOKS]) || []; } @@ -39,6 +39,39 @@ export class HookContext { * arguments of the function call. */ export type ContextUpdater = (self: any, args: any[], context: HookContext) => HookContext; +/** + * A function that for a given function, calling context and arguments returns the list of hooks + */ +export type MiddlewareCollector = (self: any, fn: any, args: any[]) => Array>; + +/** + * Available options when initializing hooks with more than just an array of middleware + */ +export interface FunctionHookOptions { + middleware: Array>; + context: ContextUpdater; + collect: MiddlewareCollector; +} + +export type HookSettings = Array>|Partial; + +export function defaultCollectMiddleware (self: any, fn: any, _args: any[]) { + return [ + ...getMiddleware(self), + ...getMiddleware(fn) + ]; +} + +export function normalizeOptions (opts: HookSettings): FunctionHookOptions { + const options: Partial = Array.isArray(opts) ? { middleware: opts } : opts; + const { + middleware = [], + context = withParams(), + collect = defaultCollectMiddleware + } = options; + + return { middleware, context, collect }; +} /** * Returns a ContextUpdater function that turns function arguments like diff --git a/packages/hooks/src/decorator.ts b/packages/hooks/src/decorator.ts index 2d2b710..7ad233a 100644 --- a/packages/hooks/src/decorator.ts +++ b/packages/hooks/src/decorator.ts @@ -1,15 +1,12 @@ -import { Middleware } from './compose'; import { functionHooks } from './function'; -import { ContextUpdater, withParams, HookContext, registerMiddleware } from './base'; +import { HookContext, registerMiddleware, normalizeOptions, HookSettings } from './base'; -export const hookDecorator = (hooks: Array>, _updateContext?: ContextUpdater) => { +export const hookDecorator = (hooks: HookSettings = []) => { return (_target: any, method: string, descriptor: TypedPropertyDescriptor): TypedPropertyDescriptor => { - if (!descriptor) { - if (_updateContext) { - throw new Error('Context can not be updated at the class decorator level. Remove updateContext parameter.'); - } + const options = normalizeOptions(hooks); - registerMiddleware(_target.prototype, hooks); + if (!descriptor) { + registerMiddleware(_target.prototype, options.middleware); return _target; } @@ -20,16 +17,19 @@ export const hookDecorator = (hooks: Array>, _updateContext?: throw new Error(`Can not apply hooks. '${method}' is not a function`); } - const originalUpdateContext = _updateContext || withParams(); - const updateContext = (self: any, args: any[], context: HookContext) => { - const ctx = originalUpdateContext(self, args, context); + const originalContext = options.context; + const context = (self: any, args: any[], context: HookContext) => { + const ctx = originalContext(self, args, context); ctx.method = method; return ctx; }; - descriptor.value = functionHooks(fn, hooks, updateContext); + descriptor.value = functionHooks(fn, { + ...options, + context + }); return descriptor; }; diff --git a/packages/hooks/src/function.ts b/packages/hooks/src/function.ts index 1eacbde..6c3f3cb 100644 --- a/packages/hooks/src/function.ts +++ b/packages/hooks/src/function.ts @@ -1,8 +1,5 @@ import { compose, Middleware } from './compose'; -import { - HookContext, ContextUpdater, withParams, - registerMiddleware, getMiddleware -} from './base'; +import { HookContext, registerMiddleware, normalizeOptions, HookSettings } from './base'; /** * Returns a new function that is wrapped in the given hooks. @@ -11,20 +8,16 @@ import { * just set `context.arguments` to the function call arguments * and `context.self` to the function call `this` reference. * - * @param fn The function to wrap - * @param hooks The list of hooks (middleware) - * @param updateContext A ContextUpdate method + * @param original The function to wrap + * @param options A list of hooks (middleware) or options for more detailed hook processing */ -export const functionHooks = ( - fn: any, - hooks: Array>, - updateContext: ContextUpdater = withParams() -) => { - if (typeof fn !== 'function') { +export const functionHooks = (original: F, opts: HookSettings) => { + if (typeof original !== 'function') { throw new Error('Can not apply hooks to non-function'); } - const result = registerMiddleware(function (this: any, ...args: any[]) { + const { context: updateContext, collect, middleware } = normalizeOptions(opts); + const wrapper = 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 @@ -35,14 +28,12 @@ export const functionHooks = ( const hookChain: Middleware[] = [ // Return `ctx.result` or the context (ctx, next) => next().then(() => returnContext ? ctx : ctx.result), - // The hooks attached to the `this` object - ...getMiddleware(this), - // The hook chain attached to this function - ...getMiddleware(result), + // Create the hook chain by calling the `collectMiddleware function + ...collect(this, wrapper, args), // Runs the actual original method if `ctx.result` is not already set (ctx, next) => { if (ctx.result === undefined) { - return Promise.resolve(fn.apply(this, ctx.arguments)).then(result => { + return Promise.resolve(original.apply(this, ctx.arguments)).then(result => { ctx.result = result; return next(); @@ -54,7 +45,9 @@ export const functionHooks = ( ]; return compose(hookChain).call(this, context); - }, hooks); + }; - return Object.assign(result, { original: fn }); + registerMiddleware(wrapper, middleware); + + return Object.assign(wrapper, { original }); }; diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 1e2137c..6854f5a 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -1,7 +1,6 @@ import { functionHooks } from './function'; -import { Middleware } from './compose'; -import { ContextUpdater } from './base'; -import { objectHooks, MiddlewareMap, ContextUpdaterMap } from './object'; +import { HookSettings } from './base'; +import { objectHooks, HookMap } from './object'; import { hookDecorator } from './decorator'; export * from './function'; @@ -12,30 +11,27 @@ export interface OriginalAddon { original: F; } -// hooks(fn, hooks, updateContext?) +// hooks(fn, hookSettings) export function hooks ( - fn: F, - hooks: Array>, - updateContext?: ContextUpdater + fn: F, hooks: HookSettings ): F&((...rest: any[]) => Promise)&OriginalAddon; -// hooks(object, methodHookMap, methodUpdateContextMap?) -export function hooks (obj: T, hookMap: MiddlewareMap, contextMap?: ContextUpdaterMap): T; -// @hooks(hooks) -export function hooks ( - hooks: Array>, - updateContext?: ContextUpdater +// hooks(object, hookMap) +export function hooks (obj: O, hookMap: HookMap): O; +// @hooks(hookSettings) +export function hooks ( + hooks?: HookSettings ): any; // Fallthrough to actual implementation export function hooks (...args: any[]) { - const [ target, _hooks, ...rest ] = args; + const [ target, _hooks ] = args; - if (Array.isArray(_hooks) && typeof target === 'function') { - return functionHooks(target, _hooks, ...rest); + if (typeof target === 'function' && Array.isArray(_hooks.middleware || _hooks)) { + return functionHooks(target, _hooks); } - if (Array.isArray(target)) { - return hookDecorator(target, _hooks); + if (args.length === 2) { + return objectHooks(target, _hooks); } - return objectHooks(target, _hooks, ...rest); + return hookDecorator(target); } diff --git a/packages/hooks/src/object.ts b/packages/hooks/src/object.ts index 2b6b44e..a2ea362 100644 --- a/packages/hooks/src/object.ts +++ b/packages/hooks/src/object.ts @@ -1,30 +1,24 @@ import { Middleware } from './compose'; import { functionHooks } from './function'; -import { ContextUpdater, HookContext, withParams, registerMiddleware } from './base'; +import { HookContext, registerMiddleware, normalizeOptions, HookSettings } from './base'; -export interface MiddlewareMap { - [key: string]: Middleware[]; +export interface HookMap { + [key: string]: HookSettings; } -export interface ContextUpdaterMap { - [key: string]: ContextUpdater; -} - -export const objectHooks = (_obj: any, hooks: MiddlewareMap|Middleware[], contextMap?: ContextUpdaterMap) => { +export const objectHooks = (_obj: any, hooks: HookMap|Middleware[]) => { const obj = typeof _obj === 'function' ? _obj.prototype : _obj; if (Array.isArray(hooks)) { return registerMiddleware(obj, hooks); } - const hookMap = hooks as MiddlewareMap; - - return Object.keys(hookMap).reduce((result, method) => { + return Object.keys(hooks).reduce((result, method) => { const value = obj[method]; - const hooks = hookMap[method]; - const originalUpdateContext = (contextMap && contextMap[method]) || withParams(); - const updateContext = (self: any, args: any[], context: HookContext) => { - const ctx = originalUpdateContext(self, args, context); + 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; @@ -35,7 +29,10 @@ export const objectHooks = (_obj: any, hooks: MiddlewareMap|Middleware[], contex throw new Error(`Can not apply hooks. '${method}' is not a function`); } - const fn = functionHooks(value, hooks, updateContext); + const fn = functionHooks(value, { + ...options, + context + }); result[method] = fn; diff --git a/packages/hooks/test/decorator.test.ts b/packages/hooks/test/decorator.test.ts index 9a27a59..9464d71 100644 --- a/packages/hooks/test/decorator.test.ts +++ b/packages/hooks/test/decorator.test.ts @@ -22,21 +22,29 @@ describe('hookDecorator', () => { ctx.result += ' ResultFromDummyClass'; }]) class DummyClass extends TopLevel { - @hooks([async (ctx: HookContext, next: NextFunction) => { - assert.deepStrictEqual(ctx, new HookContext({ - method: 'sayHi', - self: instance, - name: expectedName - })); + @hooks({ + middleware: [async (ctx: HookContext, next: NextFunction) => { + assert.deepStrictEqual(ctx, new HookContext({ + method: 'sayHi', + self: instance, + name: expectedName + })); - await next(); + await next(); - ctx.result += ' ResultFromMethodDecorator'; - }], withParams('name')) + ctx.result += ' ResultFromMethodDecorator'; + }], + context: withParams('name') + }) async sayHi (name: string) { return `Hi ${name}`; } + @hooks() + async hookedFn () { + return 'Hooks with nothing'; + } + @hooks([async (_ctx: HookContext, next: NextFunction) => next()]) async sayWorld () { return 'World'; @@ -51,10 +59,7 @@ describe('hookDecorator', () => { }); it('error cases', () => { - assert.throws(() => hooks([], withParams('jkfds'))({}), { - message: 'Context can not be updated at the class decorator level. Remove updateContext parameter.' - }); - assert.throws(() => hooks([], withParams('jkfds'))({}, 'test', { + assert.throws(() => hooks([])({}, 'test', { value: 'not a function' }), { message: `Can not apply hooks. 'test' is not a function` diff --git a/packages/hooks/test/function.test.ts b/packages/hooks/test/function.test.ts index 44e754e..8423cdf 100644 --- a/packages/hooks/test/function.test.ts +++ b/packages/hooks/test/function.test.ts @@ -152,15 +152,18 @@ describe('functionHooks', () => { }); it('creates context with params and converts to arguments', async () => { - const fn = hooks(hello, [ - async (ctx, next) => { - assert.equal(ctx.name, 'Dave'); + const fn = hooks(hello, { + middleware: [ + async (ctx, next) => { + assert.equal(ctx.name, 'Dave'); - ctx.name = 'Changed'; + ctx.name = 'Changed'; - await next(); - } - ], withParams('name')); + await next(); + } + ], + context: withParams('name') + }); assert.equal(await fn('Dave'), 'Hello Changed'); }); @@ -172,7 +175,10 @@ describe('functionHooks', () => { await next(); }; - const fn = hooks(hello, [ modifyArgs ], withParams('name')); + const fn = hooks(hello, { + middleware: [ modifyArgs ], + context: withParams('name') + }); await assert.rejects(() => fn('There'), { message: `Cannot assign to read only property '0' of object '[object Array]'` @@ -181,15 +187,18 @@ describe('functionHooks', () => { it('can take and return an existing HookContext', async () => { const message = 'Custom message'; - const fn = hooks(hello, [ - async (ctx, next) => { - assert.equal(ctx.name, 'Dave'); - assert.equal(ctx.message, message); - - ctx.name = 'Changed'; - await next(); - } - ], withParams('name')); + const fn = hooks(hello, { + middleware: [ + async (ctx, next) => { + assert.equal(ctx.name, 'Dave'); + assert.equal(ctx.message, message); + + ctx.name = 'Changed'; + await next(); + } + ], + context: withParams('name') + }); const customContext = new HookContext({ message }); const resultContext: HookContext = await fn('Dave', customContext); diff --git a/packages/hooks/test/object.test.ts b/packages/hooks/test/object.test.ts index 90360b9..bbe908e 100644 --- a/packages/hooks/test/object.test.ts +++ b/packages/hooks/test/object.test.ts @@ -56,26 +56,27 @@ describe('objectHooks', () => { it('hooks object and allows to customize context for method', async () => { const hookedObj = hooks(obj, { - sayHi: [async (ctx: HookContext, next: NextFunction) => { - assert.deepStrictEqual(ctx, new HookContext({ - method: 'sayHi', - name: 'David', - self: obj - })); + sayHi: { + middleware: [async (ctx: HookContext, next: NextFunction) => { + assert.deepStrictEqual(ctx, new HookContext({ + method: 'sayHi', + name: 'David', + self: obj + })); - ctx.name = 'Dave'; + ctx.name = 'Dave'; - await next(); + await next(); - ctx.result += '?'; - }], + ctx.result += '?'; + }], + context: withParams('name') + }, addOne: [async (ctx: HookContext, next: NextFunction) => { ctx.arguments[0] += 1; await next(); }] - }, { - sayHi: withParams('name') }); assert.strictEqual(obj, hookedObj); @@ -118,17 +119,20 @@ describe('objectHooks', () => { it('hooking object on class adds to the prototype', async () => { hooks(DummyClass, { - sayHi: [async (ctx: HookContext, next: NextFunction) => { - assert.deepStrictEqual(ctx, new HookContext({ - self: instance, - method: 'sayHi', - arguments: [ 'David' ] - })); + sayHi: { + middleware: [async (ctx: HookContext, next: NextFunction) => { + assert.deepStrictEqual(ctx, new HookContext({ + self: instance, + method: 'sayHi', + name: 'David' + })); - await next(); + await next(); - ctx.result += '?'; - }], + ctx.result += '?'; + }], + context: withParams('name') + }, addOne: [async (ctx: HookContext, next: NextFunction) => { ctx.arguments[0] += 1; diff --git a/readme.md b/readme.md index 2e342ac..d7d7aec 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -# @feathersjs/hooks +

@feathersjs/hooks

[![CI GitHub action](https://github.com/feathersjs/hooks/workflows/Node%20CI/badge.svg)](https://github.com/feathersjs/hooks/actions?query=workflow%3A%22Node+CI%22) [![Greenkeeper badge](https://badges.greenkeeper.io/feathersjs/hooks.svg)](https://greenkeeper.io/) @@ -16,59 +16,61 @@ To a function or class without having to change its original code while also kee -- [@feathersjs/hooks](#feathersjshooks) - - [Installation](#installation) - - [Node](#node) - - [Deno](#deno) - - [Quick Example](#quick-example) - - [JavaScript](#javascript) - - [TypeScript](#typescript) - - [Documentation](#documentation) - - [Middleware](#middleware) - - [Function hooks](#function-hooks) - - [Object hooks](#object-hooks) - - [Class hooks](#class-hooks) - - [JavaScript](#javascript-1) - - [TypeScript](#typescript-1) +- [Installation](#installation) + - [Node](#node) + - [Deno](#deno) +- [Quick Example](#quick-example) + - [JavaScript](#javascript) + - [TypeScript](#typescript) +- [Documentation](#documentation) + - [Middleware](#middleware) + - [Options](#options) - [Hook Context](#hook-context) - [Context properties](#context-properties) - - [Modifying the result](#modifying-the-result) + - [Arguments](#arguments) - [Using named parameters](#using-named-parameters) + - [Modifying the result](#modifying-the-result) - [Calling the original](#calling-the-original) - [Customizing and returning the context](#customizing-and-returning-the-context) - - [Best practises](#best-practises) - - [More Examples](#more-examples) - - [Cache](#cache) - - [Permissions](#permissions) - - [License](#license) + - [Function hooks](#function-hooks) + - [Object hooks](#object-hooks) + - [Class hooks](#class-hooks) + - [JavaScript](#javascript-1) + - [TypeScript](#typescript-1) +- [Best practises](#best-practises) +- [More Examples](#more-examples) + - [Cache](#cache) + - [Permissions](#permissions) +- [License](#license) -## Installation +# Installation -### Node +## Node ``` npm install @feathersjs/hooks --save yarn add @feathersjs/hooks ``` -### Deno +## Deno ``` import { hooks } from 'https://unpkg.com/@feathersjs/hooks@latest/deno/index.ts'; ``` -> __Note:__ You might want to replace `latest` with the actual version you want to use (e.g. `https://unpkg.com/@feathersjs/hooks@0.2.0/deno/index.ts`) +> __Note:__ You might want to replace `latest` with the actual version you want to use (e.g. `https://unpkg.com/@feathersjs/hooks@^0.2.0/deno/index.ts`) -## Quick Example +# Quick Example -### JavaScript +## JavaScript -The following example logs information about a function call: +The following example makes sure that the `name` is valid and logs information about a function call: ```js const { hooks } = require('@feathersjs/hooks'); + const logRuntime = async (context, next) => { const start = new Date().getTime(); @@ -79,16 +81,24 @@ const logRuntime = async (context, next) => { console.log(`Function '${context.method || '[no name]'}' returned '${context.result}' after ${end - start}ms`); } -const sayHello = async name => { - return `Hello ${name}!`; +const validateName = async (context, next) => { + const [ name ] = context.arguments; + + if (!name || name.trim() === '') { + throw new Error('Name is not valid'); + } + + // Always has to be called + await next(); } // Hooks can be used with a function like this: -const hookSayHello = hooks(sayhello, [ logRuntime ]); - -(async () => { - console.log(await hookSayHello('David')); -})(); +const sayHello = hooks(async name => { + return `Hello ${name}!`; +}, [ + logRuntime, + validateName +]); // And on an object or class like this class Hello { @@ -98,25 +108,33 @@ class Hello { } hooks(Hello, { - sayHi: [ logRuntime ] + sayHi: [ + logRuntime, + validateName + ] }); (async () => { + console.log(await sayHello('David')); + + // The following would throw an error + // await sayHello(' '); + const hi = new Hello(); - hi.sayHi(); + console.log(await hi.sayHi('Dave')); })(); ``` -### TypeScript +## TypeScript -In addition to the normal JavaScript use, with the `experimentalDecorators` option in the `tsconfig.json` enabled +In addition to the normal JavaScript use, with the `experimentalDecorators` option in `tsconfig.json` enabled ```json "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ ``` -Hooks can also be registered using a decorator: +Hooks can be registered using a decorator: ```ts import { hooks, HookContext, NextFunction } from '@feathersjs/hooks'; @@ -131,9 +149,21 @@ const logRuntime = async (context: HookContext, next: NextFunction) => { console.log(`Function '${context.method || '[no name]'}' returned '${context.result}' after ${end - start}ms`); } +const validateName = async (context: HookContext, next: NextFunction) => { + const [ name ] = context.arguments; + + if (!name || name.trim() === '') { + throw new Error('Name is not valid'); + } + + // Always has to be called + await next(); +} + class Hello { @hooks([ - logRuntime + logRuntime, + validateName ]) async sayHi (name: string) { return `Hi ${name}`; @@ -144,14 +174,16 @@ class Hello { const hi = new Hello(); console.log(await hi.sayHi('David')); + // The following would throw an error + // console.log(await hi.sayHi(' ')); })(); ``` -## Documentation +# Documentation -### Middleware +## Middleware -Middleware functions (or hook functions) take a `context` and an asynchronous `next` function as their parameters. The `context` contains information about the function call (like the arguments, the result or `this` context) and the `next` function can be called to continue to the next hook or actual function. +Middleware functions (or hook functions) take a `context` and an asynchronous `next` function as their parameters. The `context` contains information about the function call (like the arguments, the result or `this` context) and the `next` function can be called to continue to the next hook or the original function. A middleware function can do things before calling `await next()` and after all following middleware functions and the function call itself return. It can also `try/catch` the `await next()` call to handle and modify errors. This is the same control flow that the web framework [KoaJS](https://koajs.com/) uses for handling HTTP requests and response. @@ -209,28 +241,187 @@ hook2 after hook1 after ``` -### Function hooks +## Options + +Instead an array of middleware, an object with the following options can be passed: -`hooks(fn, middleware[], updateContext?)` returns a new function that wraps `fn` with `middleware`. `updateContext` is an optional function with `(self, arguments, context)` that takes an initial hook context and updates it with information about this method call. This is usually used for [named parameters](#using-named-parameters). +- `middleware` - The array of middleware functions +- `context` (*optional*) - A function `(self: any, args: any[], context: HookContext) => HookContext` that updates the existing `context` with information about the function call like the `this` reference (`self`) and the function call arguments (`args`). Usually used for [named paramters](#using-named-parameters). +- `collect` (*optional*) - A function `(self: any, fn: any, args: any[]) => Middleware[]` that returns all middleware functions for a function call. Usually does not need to be customized. + +```js +const sayHelloWithHooks = hooks(sayHello, { + middleware: [ + hook1, + hook2, + hook3 + ], + context: withParams('name') +}); + +(async () => { + await sayHelloWithHooks('David'); +})(); +``` + +## Hook Context + +The hook `context` in a [middleware function](#middleware) is an object that contains information about the function call. + +### Context properties + +The default properties available are: + +- `context.arguments` - The arguments of the function as an array +- `context.method` - The name of the function (if it belongs to an object or class) +- `context.self` - The `this` context of the function being called (may not always be available e.g. for top level arrow functions) +- `context.result` - The result of the method call +- `context[name]` - Value of a named parameter when [using named arguments](#using-named-parameters) + +### Arguments + +By default, the function call arguments will be available as an array in `context.arguments`. The values can be modified to change what is passed to the original function call: ```js const { hooks } = require('@feathersjs/hooks'); -const sayHello = async name => { - return `Hello ${name}!`; +const sayHello = async (firstName, lastName) => { + return `Hello ${firstName} ${lastName}!`; }; const wrappedSayHello = hooks(sayHello, [ async (context, next) => { - console.log(context.someProperty); + // Replace the `lastName` + context.arguments[1] = 'X'; await next(); } -], (self, args, context) => { - context.self = self; - context.arguments = args; - context.someProperty = 'Set from updateContext'; +]); + +(async () => { + console.log(await wrappedSayHello('David', 'L')); // Hello David L! +})(); +``` + +### Using named parameters + +It is also possible to turn the arguments into named parameters. In the above example we probably want to have `context.firstName` and `context.lastName` available. To do this, the [`context` option](#options) can be used with `params` like this: + +```js +const { hooks, params } = require('@feathersjs/hooks'); + +const sayHello = async (firstName, lastName) => { + return `Hello ${firstName} ${lastName}!`; +}; + +const wrappedSayHello = hooks(sayHello, { + context: withParams('firstName', 'lastName') + middleware: [ + async (context, next) => { + // Now we can modify `context.lastName` instead + context.lastName = 'X'; + await next(); + } + ] +}); + +(async () => { + console.log(await wrappedSayHello('David', 'L')); // Hello David X +})(); +``` + +> __Note:__ When using named parameters, `context.arguments` is read only. + +### Modifying the result + +In a hook function, `context.result` can be + +- Set _before_ calling `await next()` to skip the original function call. Other hooks will still run. +- Modified _after_ calling `await next()` to modify what is being returned by the function. + +See the [cache example](#cache) for how this can be used. + +### Calling the original + +The original function without any hooks is available as `fn.original`: + +```js +const { hooks } = require('@feathersjs/hooks'); +const emphasize = async (context, next) => { + await next(); + + context.result += '!!!'; +}; +const sayHello = hooks(async name => `Hello ${name}`, [ emphasize ]); + +const o = hooks({ + async sayHi(name) { + return `Hi ${name}`; + } +}, { + sayHi: [ emphasize ] +}); + +(async () => { + console.log(await sayHello.original('Dave')); // Hello Dave + // Originals on object need to be called with an explicit `this` context + console.log(await o.sayHi.original.call(o, 'David')) +})(); +``` + +### Customizing and returning the context + +To add additional data to the context an instance of `HookContext` can be passed as the last argument of a hook-enabled function call. In that case, the up to date context object with all the information (like `context.result`) will be returned: + +```js +const { hooks, HookContext } = require('@feathersjs/hooks'); +const customContextData = async (context, next) => { + console.log('Custom context message is', context.message); + + context.customProperty = 'Hi'; + + await next(); +} + +const sayHello = hooks(async message => { + return `Hello ${message}!`; +}, [ customContextData ]); + +const customContext = new HookContext({ + message: 'Hi from context' +}); + +(async () => { + const finalContext = await sayHello('Dave', customContext); + + console.log(finalContext); +})(); +``` + +## Function hooks + +`hooks(fn, middleware[]|settings)` returns a new function that wraps `fn` with `middleware`. The following example shows how a custom [`context` option](#options) could be used: + +```js +const { hooks } = require('@feathersjs/hooks'); + +const sayHello = async name => { + return `Hello ${name}!`; +}; + +const wrappedSayHello = hooks(sayHello, { + middleware: [ + async (context, next) => { + console.log(context.someProperty); + await next(); + } + ], + context (self, args, context) { + context.self = self; + context.arguments = args; + context.someProperty = 'Set from updateContext'; - return context; + return context; + } }); (async () => { @@ -240,9 +431,9 @@ const wrappedSayHello = hooks(sayHello, [ > __Important:__ A wrapped function will _always_ return a Promise even it was not originally `async`. -### Object hooks +## Object hooks -`hooks(obj, middlewareMap, updateContextMap?)` takes an object and wraps the functions in `middlewareMap` with the middleware. It will modify the existing Object `obj`: +`hooks(obj, middlewareMap)` takes an object and wraps the functions indicated in `middlewareMap`. It will modify the existing Object `obj`: ```js const { hooks, withParams } = require('@feathersjs/hooks'); @@ -262,13 +453,16 @@ hooks(o, { sayHi: [ logRuntime ] }); -// With `updateContext` and named parameters +// With `context` and named parameters hooks(o, { - sayHello: [ logRuntime ], - sayHi: [ logRuntime ] -}, { - sayHello: withParams('name'), - sayHi: withParams('name') + sayHello: { + middleware: [ logRuntime ], + context: withParams('name') + }, + sayHi: { + middleware: [ logRuntime ], + context: withParams('name') + } }); ``` @@ -300,13 +494,13 @@ hooks(o, { }); ``` -### Class hooks +## Class hooks Similar to object hooks, class hooks modify the class (or class prototype). Just like for objects it is possible to register hooks that are global to the class or object. Registering hooks also works with inheritance. > __Note:__ Object or class level global hooks will only run if the method itself has been enabled for hooks. This can be done by registering hooks with an empty array. -#### JavaScript +### JavaScript ```js const { hooks } = require('@feathersjs/hooks'); @@ -319,7 +513,8 @@ class HelloSayer { class HappyHelloSayer extends HelloSayer { async sayHello (name) { - return super.sayHello(name) + '!!!!! :)'; + const baseHello = await super.sayHello(name); + return baseHello + '!!!!! :)'; } } @@ -339,7 +534,7 @@ hooks(HappyHelloSayer.prototype, [ ]); // Methods can also be wrapped directly on the class -hooks(HelloSayer, { +hooks(HelloSayer.prototype, { sayHello: [async (context, next) => { console.log('Hook on HelloSayer.sayHello'); await next(); @@ -349,16 +544,16 @@ hooks(HelloSayer, { (async () => { const happy = new HappyHelloSayer(); - await happy.sayHello('David'); + console.log(await happy.sayHello('David')); })(); ``` -#### TypeScript +### TypeScript With decorators and inheritance ```js -import { hooks, HookContext, NextFunction } from '@feathersjs/hooks'; +import { hooks, params, HookContext, NextFunction } from '@feathersjs/hooks'; @hooks([ async (context: HookContext, next: NextFunction) => { @@ -367,12 +562,15 @@ import { hooks, HookContext, NextFunction } from '@feathersjs/hooks'; } ]) class HelloSayer { - @hooks([ - async (context: HookContext, next: NextFunction) => { - console.log('Hook on HelloSayer.sayHello'); - await next(); - } - ]) + @hooks({ + context: withParams('name'), + middleware: [ + async (context: HookContext, next: NextFunction) => { + console.log('Hook on HelloSayer.sayHello'); + await next(); + } + ] + }) async sayHello (name: string) { return `Hello ${name}`; } @@ -404,112 +602,7 @@ class HappyHelloSayer extends HelloSayer { > __Note:__ Decorators only work on classes and class methods, not on functions. Standalone (arrow) functions require the [JavaScript function style](#function-hooks) hook registration. -## Hook Context - -The hook context is an object that contains information about the function call. - -### Context properties - -The default properties available are: - -- `context.arguments` - The arguments of the function as an array -- `context.method` - The name of the function (if it belongs to an object or class) -- `context.self` - The `this` context of the function being called (may not always be available e.g. for top level arrow functions) -- `context.result` - The result of the method call -- `context[name]` - Value of a named parameter when [using named arguments](#using-named-arguments) - -### Modifying the result - -In a hook function, `context.result` can be - -- Set _before_ calling `await next()` to skip the actual function call. Other hooks will still run. -- Modified _after_ calling `await next()` to modify what is being returned by the function call - -### Using named parameters - -By default, `context.arguments` contains the array of the function call arguments and can be modified before calling `await next()`. To turn the arguments into named parameters, `withParams` can be used like this: - -```js -const { hooks, withParams } = require('@feathersjs/hooks'); - -const logMessage = async (context, next) => { - console.log('Context', context.message, context.punctuationMark); - // Can also be modified for the following hooks and the function call - context.punctuationMark = '??'; - - await next(); -}; - -const sayHello = hooks(async (message, punctuationMark) => { - return `Hello ${message}${punctuationMark}`; -}, [ logMessage ], withParams('message', 'punctuationMark')); - -(async () => { - console.log(await sayHello('Steve', '!!')); // Hello Steve?? -})(); -``` - -> __Note:__ When using named parameters, `context.arguments` is read only. - -### Calling the original - -The original function without any hooks is available as `fn.original`: - -```js -const { hooks } = require('@feathersjs/hooks'); -const emphasize = async (context, next) => { - await next(); - - context.result += '!!!'; -}; -const sayHello = hooks(async name => `Hello ${name}`, [ emphasize ]); - -const o = hooks({ - async sayHi(name) { - return `Hi ${name}`; - } -}, { - sayHi: [ emphasize ] -}); - -(async () => { - console.log(await sayHello.original('Dave')); // Hello Dave - // Originals on object need to be called with an explicit `this` context - console.log(await o.sayHi.original.call(o, 'David')) -})(); -``` - -### Customizing and returning the context - -To add additional data to the context an instance of `HookContext` can be passed as the last argument of a hook-enabled function call. In that case, the up to date context object with all the information (like `context.result`) will be returned: - -```js -const { hooks, HookContext } = require('@feathersjs/hooks'); -const customContextData = async (context, next) => { - console.log('Custom context message is', context.message); - - context.customProperty = 'Hi'; - - await next(); -} - -const sayHello = hooks(async message => { - return `Hello ${message}!`; -}, [ customContextData ]); - -const customContext = new HookContext({ - message: 'Hi from context' -}); - -(async () => { - const finalContext = await sayHello('Dave', customContext); - - console.log(finalContext.customProperty); // Hi - console.log(finalContext.result); // Hello Dave -})(); -``` - -## Best practises +# Best practises - Hooks can be registered at any time by calling `hooks` again but registration should be kept in one place for better visibility. - Decorators make the flow even more visible by putting it right next to the code the hooks are affecting. @@ -531,17 +624,20 @@ const customContext = new HookContext({ const findUser = hooks(async query => { return collection.find(query); - }, [ updateQuery ], withParams('query')); + }, { + context: withParams('query'), + middleware: [ updateQuery ] + }); ``` -## More Examples +# More Examples -### Cache +## Cache The following example is a simple hook that caches the results of a function call and uses the cached value. It will clear the cache every 5 seconds. This is useful for any kind of expensive method call like an external HTTP request: ```js -const hooks = require('@feathersjs/hooks'); +const { hooks } = require('@feathersjs/hooks'); const cache = () => { let cacheData = {}; @@ -551,7 +647,7 @@ const cache = () => { }, 5000); return async (context, next) => { - const key = JSON.stringify(ctx); + const key = JSON.stringify(context); if (cacheData[key]) { // Setting context.result before `await next()` @@ -574,7 +670,7 @@ const getData = hooks(async url => { await getData('http://url-that-takes-long-to-respond'); ``` -### Permissions +## Permissions When passing e.g. a `user` object to a function call, hooks allow for a better separation of concerns by handling permissions in a hook: @@ -592,7 +688,7 @@ const deleteInvoice = hooks(async (id, user) => { }, [ checkPermission('admin') ], withParams('id', 'user')); ``` -## License +# License Copyright (c) 2020