Skip to content

Commit

Permalink
feat: Finalize functionality for initial release of @feathersjs/hooks…
Browse files Browse the repository at this point in the history
… package (#1)
  • Loading branch information
daffl committed Jan 5, 2020
1 parent 2e63153 commit edab7a1
Show file tree
Hide file tree
Showing 8 changed files with 272 additions and 171 deletions.
72 changes: 72 additions & 0 deletions packages/hooks/src/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Middleware } from './compose';

export const HOOKS: string = Symbol('@feathersjs/hooks') as any;

/**
*
* @param target The target object or function
* @param middleware
*/
export function registerMiddleware<T> (target: T, middleware: Middleware[]) {
const current: Middleware[] = (target as any)[HOOKS] || [];

(target as any)[HOOKS] = current.concat(middleware);

return target;
}

export function getMiddleware (target: any): Middleware[] {
return (target && target[HOOKS]) || [];
}

/**
* The base hook context.
*/
export class HookContext<T = any> {
result?: T;
arguments: any[];
[key: string]: any;

constructor (data: { [key: string]: any } = {}) {
Object.assign(this, data);
}
}

/**
* A function that updates the hook context with the `this` reference and
* arguments of the function call.
*/
export type ContextUpdater<T = any> = (self: any, args: any[], context: HookContext<T>) => HookContext<T>;

/**
* Returns a ContextUpdater function that turns function arguments like
* `function (data, name)` into named properties (`context.data`, `context.name`)
* on the hook context
*
* @param params The list of parameter names
*/
export function withParams<T = any> (...params: string[]) {
return (self: any, args: any[], context: HookContext<T>) => {
params.forEach((name, index) => {
context[name] = args[index];
});

if (params.length > 0) {
Object.defineProperty(context, 'arguments', {
get (this: HookContext<T>) {
const result = params.map(name => this[name]);

return Object.freeze(result);
}
});
} else {
context.arguments = args;
}

if (self) {
context.self = self;
}

return context;
};
}
24 changes: 18 additions & 6 deletions packages/hooks/src/decorator.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,35 @@
import { Middleware } from './compose';
import { functionHooks, ContextCreator, initContext } from './function';
import { functionHooks } from './function';
import { ContextUpdater, withParams, HookContext, registerMiddleware } from './base';

export const hookDecorator = <T> (hooks: Array<Middleware<T>>, _updateContext?: ContextUpdater<T>) => {
return (_target: any, method: string, descriptor: TypedPropertyDescriptor<any>): TypedPropertyDescriptor<any> => {
if (!descriptor) {
if (_updateContext) {
throw new Error('Context can not be updated at the class decorator level. Remove updateContext parameter.');
}

registerMiddleware(_target.prototype, hooks);

return _target;
}

export const hookDecorator = <T> (hooks: Array<Middleware<T>>, createContext: ContextCreator<T> = initContext()) => {
return (_target: object, method: string, descriptor: TypedPropertyDescriptor<any>): TypedPropertyDescriptor<any> => {
const fn = descriptor.value;

if (typeof fn !== 'function') {
throw new Error(`Can not apply hooks. '${method}' is not a function`);
}

const methodContext = (...args: any[]) => {
const ctx = createContext(...args);
const originalUpdateContext = _updateContext || withParams();
const updateContext = (self: any, args: any[], context: HookContext<any>) => {
const ctx = originalUpdateContext(self, args, context);

ctx.method = method;

return ctx;
};

descriptor.value = functionHooks(fn, hooks, methodContext);
descriptor.value = functionHooks(fn, hooks, updateContext);

return descriptor;
};
Expand Down
110 changes: 35 additions & 75 deletions packages/hooks/src/function.ts
Original file line number Diff line number Diff line change
@@ -1,80 +1,48 @@
import { compose, Middleware } from './compose';

// Typing hacks converting symbols as strings so they can be used as keys
export const HOOKS: string = Symbol('@feathersjs/hooks') as any;
export const ORIGINAL: string = Symbol('@feathersjs/hooks/original') as any;
export const CONTEXT: string = Symbol('@feathersjs/hooks/context') as any;

export class HookContext<T = any> {
result?: T;
arguments: any[];
[key: string]: any;

toJSON () {
return Object.keys(this).reduce((result, key) => {
return {
...result,
[key]: this[key]
};
}, {} as { [key: string]: any });
}
}

export type ContextCreator<T = any> = (...args: any[]) => HookContext<T>;

export const initContext = <T = any>(...params: string[]): ContextCreator<T> => () => {
const ctx = new HookContext<T>();

if (params.length > 0) {
Object.defineProperty(ctx, 'arguments', {
get (this: HookContext<T>) {
const result = params.map(name => this[name]);

return typeof Object.freeze === 'function' ? Object.freeze(result) : result;
},

set (this: HookContext<T>, value: any[]) {
params.forEach((name, index) => {
this[name] = value[index];
});
}
});
}

return ctx;
};

export const createContext = <T = any>(fn: any, data: { [key: string]: any } = {}): HookContext<T> => {
const getContext: ContextCreator<T> = fn[CONTEXT];

if (typeof getContext !== 'function') {
throw new Error('Can not get context, function is not hook enabled');
}

const context = getContext();

return Object.assign(context, data);
};

export const functionHooks = <T = any>(method: any, _hooks: Array<Middleware<T>>, defaultContext: ContextCreator<T> = initContext()) => {
if (typeof method !== 'function') {
import {
HookContext, ContextUpdater, withParams,
registerMiddleware, getMiddleware
} from './base';

/**
* Returns a new function that is wrapped in the given hooks.
* Allows to pass a context updater function, usually used
* with `withParams` to initialize named parameters. If not passed
* 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
*/
export const functionHooks = <T = any>(
fn: any,
hooks: Array<Middleware<T>>,
updateContext: ContextUpdater = withParams()
) => {
if (typeof fn !== 'function') {
throw new Error('Can not apply hooks to non-function');
}

const hooks = (method[HOOKS] || []).concat(_hooks);
const original = method[ORIGINAL] || method;
const fn = function (this: any, ...args: any[]) {
const result = registerMiddleware(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;
const context: HookContext = returnContext ? args.pop() : defaultContext.call(this, args);
// Initialize the context. Either the default context or the one that was passed
const baseContext: HookContext = returnContext ? args.pop() : new HookContext();
// Initialize the context with the self reference and arguments
const context = updateContext(this, args, baseContext);
// Assemble the hook chain
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
...(fn as any)[HOOKS],
...getMiddleware(result),
// Runs the actual original method if `ctx.result` is not set
(ctx, next) => {
if (ctx.result === undefined) {
return Promise.resolve(original.apply(this, ctx.arguments)).then(result => {
return Promise.resolve(fn.apply(this, ctx.arguments)).then(result => {
ctx.result = result;

return next();
Expand All @@ -85,16 +53,8 @@ export const functionHooks = <T = any>(method: any, _hooks: Array<Middleware<T>>
}
];

context.arguments = args;

return compose(hookChain).call(this, context);
};

Object.assign(fn, {
[CONTEXT]: defaultContext,
[HOOKS]: hooks,
[ORIGINAL]: original
});
}, hooks);

return fn;
return Object.assign(result, { original: fn });
};
28 changes: 23 additions & 5 deletions packages/hooks/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,37 @@
import { functionHooks, ContextCreator } from './function';
import { objectHooks, MiddlewareMap, ContextCreatorMap } from './object';
import { functionHooks } from './function';
import { Middleware } from './compose';
import { ContextUpdater } from './base';
import { objectHooks, MiddlewareMap, ContextUpdaterMap } from './object';
import { hookDecorator } from './decorator';

export * from './function';
export * from './compose';
export * from './object';
export * from './base';

export function hooks<F, T = any> (method: F, _hooks: Array<Middleware<T>>, defaultContext?: ContextCreator<T>): F;
export function hooks<O> (obj: O, hookMap: MiddlewareMap, contextMap?: ContextCreatorMap): O;
// hooks(fn, hooks, updateContext?)
export function hooks<F, T = any> (
fn: F,
hooks: Array<Middleware<T>>,
updateContext?: ContextUpdater<T>
): F&((...rest: any[]) => Promise<T>);
// hooks(object, methodHookMap, methodUpdateContextMap?)
export function hooks<T> (obj: T, hookMap: MiddlewareMap, contextMap?: ContextUpdaterMap): T;
// @hooks(hooks)
export function hooks<F, T = any> (
hooks: Array<Middleware<T>>,
updateContext?: ContextUpdater<T>
): any;
// Fallthrough to actual implementation
export function hooks (...args: any[]) {
const [ target, _hooks, ...rest ] = args;

if (Array.isArray(_hooks)) {
return functionHooks(target, _hooks, ...rest);
}

if (Array.isArray(target)) {
return hookDecorator(target, _hooks);
}

return objectHooks(target, _hooks, ...rest);
}
17 changes: 9 additions & 8 deletions packages/hooks/src/object.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
import { Middleware } from './compose';
import { ContextCreator, functionHooks, initContext } from './function';
import { functionHooks } from './function';
import { ContextUpdater, HookContext, withParams } from './base';

export interface MiddlewareMap {
[key: string]: Middleware[];
}

export interface ContextCreatorMap {
[key: string]: ContextCreator;
export interface ContextUpdaterMap {
[key: string]: ContextUpdater;
}

export const objectHooks = (_obj: any, hookMap: MiddlewareMap, contextMap?: ContextCreatorMap) => {
export const objectHooks = (_obj: any, hookMap: MiddlewareMap, contextMap?: ContextUpdaterMap) => {
const obj = typeof _obj === 'function' ? _obj.prototype : _obj;

return Object.keys(hookMap).reduce((result, method) => {
const value = obj[method];
const hooks = hookMap[method];
const createContext = (contextMap && contextMap[method]) || initContext();
const methodContext = (...args: any[]) => {
const ctx = createContext(...args);
const originalUpdateContext = (contextMap && contextMap[method]) || withParams();
const updateContext = (self: any, args: any[], context: HookContext<any>) => {
const ctx = originalUpdateContext(self, args, context);

ctx.method = method;

Expand All @@ -28,7 +29,7 @@ export const objectHooks = (_obj: any, hookMap: MiddlewareMap, contextMap?: Cont
throw new Error(`Can not apply hooks. '${method}' is not a function`);
}

const fn = functionHooks(value, hooks, methodContext);
const fn = functionHooks(value, hooks, updateContext);

result[method] = fn;

Expand Down
Loading

0 comments on commit edab7a1

Please sign in to comment.