Repository: Yoctol/bottender
Pull request #465 (2019-10-05 08:04:48)

feat(chain): implement chain of responsibility pattern
We are now introducing a new `chain` function for implementing "chain of responsibility" pattern in Bottender `v1`. It's a better alternative to the old `middleware` that we used in `v0.x`.

```js
const { chain } = require('bottender');
```

If we don't return `next` in `First`, it will not execute the `Second`:

```js
async function SayHi(context) {
  await context.sendText('hi');
}

chain([
  function First(context, { next }) {
    if (context.event.text === 'hi') {
      return SayHi;
    }

    // discontinue
  },
  function Second(context, { next }) {},
])
```

And if it enters the branch and returns `next`, it will execute the `Second`:


```js
async function SayHi(context) {
  await context.sendText('hi');
}

chain([
  function First(context, { next }) {
    if (context.event.text === 'hi') {
      return SayHi;
    }

    // continue
    return next;
  },
  function Second(context, { next }) {
     // ...
  },
])
```

This pattern can be used with the `compose` and `router` mechanism we provided.


Patch:
diff --git a/packages/bottender/src/browser.ts b/packages/bottender/src/browser.ts
index 228cbe8c..d1801d63 100644
--- a/packages/bottender/src/browser.ts
+++ b/packages/bottender/src/browser.ts
@@ -1,3 +1,5 @@
+export { default as chain } from './chain';
+
 /* Bot */
 export { default as Bot } from './bot/Bot';
 
diff --git a/packages/bottender/src/chain.ts b/packages/bottender/src/chain.ts
new file mode 100644
index 00000000..1ab1178d
--- /dev/null
+++ b/packages/bottender/src/chain.ts
@@ -0,0 +1,40 @@
+import Context from './context/Context';
+import { Action, Props } from './types';
+
+function chain(actions: Action[]) {
+  if (!Array.isArray(actions))
+    throw new TypeError('Chain stack must be an array!');
+  for (const action of actions) {
+    if (typeof action !== 'function')
+      throw new TypeError('Chain must be composed of actions!');
+  }
+
+  return function Chain(context: Context, props: Props = {}): Action {
+    // do immutable reverse
+    const reversedAction = actions.slice().reverse();
+
+    const boundActions = reversedAction.reduce(
+      (acc: Action[], curr: Action) => {
+        if (acc.length === 0) {
+          return [
+            curr.bind(null, context, {
+              ...props,
+            }),
+          ];
+        }
+        return [
+          curr.bind(null, context, {
+            ...props,
+            next: acc[0],
+          }),
+          ...acc,
+        ];
+      },
+      []
+    );
+
+    return boundActions[0];
+  };
+}
+
+export default chain;
diff --git a/packages/bottender/src/index.ts b/packages/bottender/src/index.ts
index 2ca8747f..15d10273 100644
--- a/packages/bottender/src/index.ts
+++ b/packages/bottender/src/index.ts
@@ -2,6 +2,8 @@ import * as utils from './utils';
 
 export { createServer } from '@bottender/express';
 
+export { default as chain } from './chain';
+
 /* Bot */
 export { default as Bot } from './bot/Bot';
 export { default as ConsoleBot } from './bot/ConsoleBot';
diff --git a/packages/bottender/src/router/index.ts b/packages/bottender/src/router/index.ts
index 31164362..ed84d0cb 100644
--- a/packages/bottender/src/router/index.ts
+++ b/packages/bottender/src/router/index.ts
@@ -1,4 +1,5 @@
 import Context from '../context/Context';
+import { Action, Props } from '../types';
 
 type MatchPattern = string | Array<string> | RegExp;
 
@@ -6,21 +7,11 @@ type RoutePattern = '*' | RoutePredicate;
 
 type RoutePredicate = (context: Context) => boolean | Promise<boolean>;
 
-type Action = (
-  context: Context,
-  props?: Props
-) => void | Action | Promise<Action>;
-
 type Route = {
   predicate: RoutePredicate;
   action: Action;
 };
 
-type Props = {
-  next?: Action;
-  [key: string]: any;
-};
-
 function router(routes: Route[]) {
   return async function Router(context: Context, { next }: Props = {}) {
     for (const r of routes) {
diff --git a/packages/bottender/src/types.ts b/packages/bottender/src/types.ts
new file mode 100644
index 00000000..cecae4de
--- /dev/null
+++ b/packages/bottender/src/types.ts
@@ -0,0 +1,11 @@
+import Context from './context/Context';
+
+export type Action = (
+  context: Context,
+  props?: Props
+) => void | Action | Promise<Action>;
+
+export type Props = {
+  next?: Action;
+  [key: string]: any;
+};


Test patch:
diff --git a/packages/bottender/src/__tests__/browser.spec.ts b/packages/bottender/src/__tests__/browser.spec.ts
index 23c2e925..03fd7ca8 100644
--- a/packages/bottender/src/__tests__/browser.spec.ts
+++ b/packages/bottender/src/__tests__/browser.spec.ts
@@ -21,4 +21,8 @@ describe('browser', () => {
   it('export extensions', () => {
     expect(core.withTyping).toBeDefined();
   });
+
+  it('export chain', () => {
+    expect(core.chain).toBeDefined();
+  });
 });
diff --git a/packages/bottender/src/__tests__/chain.spec.ts b/packages/bottender/src/__tests__/chain.spec.ts
new file mode 100644
index 00000000..1a1b969a
--- /dev/null
+++ b/packages/bottender/src/__tests__/chain.spec.ts
@@ -0,0 +1,88 @@
+import chain from '../chain';
+
+function setup() {
+  const context = {
+    sendText: jest.fn(),
+  };
+
+  return {
+    context,
+  };
+}
+
+it('should resolve undefined if First action return undefined', async () => {
+  const { context } = setup();
+
+  function First(ctx, { next }) {} // eslint-disable-line @typescript-eslint/no-empty-function
+  function Second(ctx, { next }) {} // eslint-disable-line @typescript-eslint/no-empty-function
+
+  const Chain = chain([First, Second]);
+
+  const Action = await Chain(context);
+  const ActionReturnedByFirst = await Action(context);
+
+  expect(ActionReturnedByFirst).toBeUndefined();
+});
+
+it('should resolve SayHi if first action return SayHi', async () => {
+  const { context } = setup();
+
+  async function SayHi(ctx) {
+    await ctx.sendText('hi');
+  }
+  function First(ctx, { next }) {
+    return SayHi;
+  }
+  function Second(ctx, { next }) {} // eslint-disable-line @typescript-eslint/no-empty-function
+
+  const Chain = chain([First, Second]);
+
+  const Action = await Chain(context);
+  const ActionReturnedByFirst = await Action(context);
+
+  expect(ActionReturnedByFirst).toEqual(SayHi);
+});
+
+it('should resolve SayHi if second action return SayHi', async () => {
+  const { context } = setup();
+
+  async function SayHi(ctx) {
+    await ctx.sendText('hi');
+  }
+  function First(ctx, { next }) {
+    return next;
+  }
+  function Second(ctx, { next }) {
+    return SayHi;
+  }
+
+  const Chain = chain([First, Second]);
+
+  const Action = await Chain(context);
+  const ActionReturnedByFirst = await Action(context);
+  const ActionReturnedBySecond = await ActionReturnedByFirst(context);
+
+  expect(ActionReturnedBySecond).toEqual(SayHi);
+});
+
+it('should resolve SayHi if second action return next', async () => {
+  const { context } = setup();
+
+  async function SayHi(ctx) {
+    await ctx.sendText('hi');
+  }
+  function First(ctx, { next }) {
+    return next;
+  }
+  function Second(ctx, { next }) {
+    return next;
+  }
+
+  const Chain = chain([First, Second]);
+
+  const Action = await Chain(context, { next: SayHi });
+  const ActionReturnedByFirst = await Action(context);
+  const ActionReturnedBySecond = await ActionReturnedByFirst(context);
+
+  expect(ActionReturnedBySecond).toEqual(SayHi);
+});
diff --git a/packages/bottender/src/__tests__/index.spec.ts b/packages/bottender/src/__tests__/index.spec.ts
index 456bad3e..60fc7209 100644
--- a/packages/bottender/src/__tests__/index.spec.ts
+++ b/packages/bottender/src/__tests__/index.spec.ts
@@ -50,4 +50,8 @@ describe('core', () => {
   it('export createServer', () => {
     expect(core.createServer).toBeDefined();
   });
+
+  it('export chain', () => {
+    expect(core.chain).toBeDefined();
+  });
 });