Permalink
Browse files

Merge pull request #30 from webcomputing/feature/filter-params

Feature/filter params
  • Loading branch information...
antoniusostermann committed Jan 7, 2019
2 parents 7342b4d + 0e544cf commit 475d9b6ce454d44715a5fc41342b972a4c88a225

Large diffs are not rendered by default.

Oops, something went wrong.
@@ -44,7 +44,7 @@
"inversify": "4.1.1",
"inversify-components": "^0.4.0",
"js-combinatorics": "^0.5.3",
"redis": "^2.8.0",
"redis": "2.8.0",
"reflect-metadata": "^0.1.12",
"resolve": "^1.8.1",
"rxjs": "^5.5.11"
@@ -59,9 +59,21 @@ describe("ExecuteFiltersHook", function() {
expect(this.callSpyResults[0][1].constructor.name).toEqual("FilterAState");
expect(this.callSpyResults[0][2]).toEqual("FilterAState");
expect(this.callSpyResults[0][3]).toEqual("filterTestAIntent");
expect(this.callSpyResults[0][4]).toEqual("arg1");
expect(this.callSpyResults[0][5]).toEqual("arg2");
expect(typeof this.callSpyResults[0][6]).toEqual("undefined");
expect(this.callSpyResults[0][5]).toEqual("arg1");
expect(this.callSpyResults[0][6]).toEqual("arg2");
expect(typeof this.callSpyResults[0][7]).toEqual("undefined");
});

describe("with filter annotation using object format", function() {
it("executes filter", async function(this: CurrentThisContext) {
await this.stateMachine.handleIntent("filterTestEIntent");
expect(this.specHelper.getResponseResults().voiceMessage!.text).toBe(await this.translateHelper.t("filter.stateA.intentB"));
});

it("passes params from annotation to execute method of filter", async function(this: CurrentThisContext) {
await this.stateMachine.handleIntent("filterTestEIntent");
expect(this.callSpyResults[0][4]).toEqual({ exampleParam: "example" });
});
});

describe("with a filter returning arguments", function() {
@@ -88,7 +88,8 @@
"intentA": "FilterAState - filterTestAIntent",
"intentB": "FilterAState - filterTestBIntent",
"intentC": "FilterAState - filterTestCIntent",
"intentD": "FilterAState - filterTestDIntent"
"intentD": "FilterAState - filterTestDIntent",
"intentE": "FilterAState - filterTestEIntent"
},
"stateB": {
"intentA": "FilterBState - filterTestAIntent",
@@ -43,4 +43,9 @@ export class FilterAState extends BaseState<MockHandlerASpecificTypes, MockHandl
public async filterTestDIntent() {
await this.responseHandler.endSessionWith(this.t("filter.stateA.intentD")).send();
}

@filter({ filter: TestFilterA, params: { exampleParam: "example" } })
public async filterTestEIntent() {
await this.responseHandler.endSessionWith(this.t("filter.stateA.intentE")).send();
}
}
@@ -22,44 +22,80 @@ export class ExecuteFiltersHook {
this.filters = typeof filters !== "undefined" ? filters : [];
}

/** Hook method, the only method which will be called */
public execute: Hooks.BeforeIntentHook = async (mode, state, stateName, intent, machine, ...args) => {
this.logger.debug({ intent, state: stateName }, "Executing filter hook");

/** Prioritize filters by retrieving state filters first, followed by intent filters */
const prioritizedFilters = [...this.retrieveStateFiltersFromMetadata(state), ...this.retrieveIntentFiltersFromMetadata(state, intent)];

/** Check for each retrieved filter if there is a registered filter matching it */
for (const prioritizedFilter of prioritizedFilters) {
const fittingFilter = this.filters.find(filter => filter.constructor === prioritizedFilter);
const hasParams = typeof prioritizedFilter === "object" && prioritizedFilter !== null;

let prioritizedFilterConstructor: Constructor<Filter>;
let params: { [key: string]: any };

if (hasParams) {
/** If extended format --> extract filter class and params */
prioritizedFilterConstructor = (prioritizedFilter as { filter: Constructor<Filter>; params: { [key: string]: any } }).filter;
params = (prioritizedFilter as { filter: Constructor<Filter>; params: { [key: string]: any } }).params;
} else {
/** If plain format --> use given class as filter class and an empty object for params */
prioritizedFilterConstructor = prioritizedFilter as Constructor<Filter>;
params = {};
}

/** Find the first matching registered filter */

const fittingFilter = this.filters.find(filter => filter.constructor === prioritizedFilterConstructor);

/** If there is a matching filter registered, execute it */
if (fittingFilter) {
this.logger.debug(`Executing filter ${fittingFilter.constructor.name}...`);
const filterResult = await Promise.resolve(fittingFilter.execute(state, stateName, intent, ...args));
const filterResult = await Promise.resolve(fittingFilter.execute(state, stateName, intent, params, ...args));

/** If filter returns redirecting object => redirect */
if (typeof filterResult === "object") {
const filterArgs = filterResult.args ? filterResult.args : args;
this.logger.info(`${fittingFilter.constructor.name} initialized redirect to ${filterResult.state}#${filterResult.intent}`);
await machine.redirectTo(filterResult.state, filterResult.intent, ...filterArgs);
return false;
}

/** If filter returns false => use hook failure to stop planned intent execution (which means that filter handles a response itself) */
if (filterResult === false) {
this.logger.info(`${fittingFilter.constructor.name} returned false, now halting state machine execution`);
return false;
}
} else {
this.logger.warn(`No matching filter class found for ${prioritizedFilter.name}`);
this.logger.warn(`No matching filter class found for ${prioritizedFilterConstructor.name}`);
}
}

this.logger.debug("All filters returned true, will now continue with state machine execution");
return true;
};

private retrieveStateFiltersFromMetadata(state: State.Required): Array<Constructor<Filter>> {
/**
* Returns 'filters'-property of metadata-object of state or [] if not set
* @param state State to which metadata will be checked
*/
private retrieveStateFiltersFromMetadata(
state: State.Required
): Array<Constructor<Filter> | { filter: Constructor<Filter>; params: { [key: string]: any } }> {
const metadata = Reflect.getMetadata(filterMetadataKey, state.constructor);
return metadata ? metadata.filters : [];
}

private retrieveIntentFiltersFromMetadata(state: State.Required, intent: string): Array<Constructor<Filter>> {
/**
* Returns 'filters'-property of metadata-object of intent or [] if not set
* @param state State to which metadata will be checked
* @param intent Intent to which metadata will be checked
*/
private retrieveIntentFiltersFromMetadata(
state: State.Required,
intent: string
): Array<Constructor<Filter> | { filter: Constructor<Filter>; params: { [key: string]: any } }> {
if (typeof state[intent] !== "undefined") {
const metadata = Reflect.getMetadata(filterMetadataKey, state[intent]);
return metadata ? metadata.filters : [];
@@ -3,7 +3,7 @@ import { Filter } from "./public-interfaces";

export const filterMetadataKey = Symbol("metadata-key: filter");

export function filter(...args: Array<Constructor<Filter>>) {
export function filter(...args: Array<Constructor<Filter> | { filter: Constructor<Filter>; params: { [key: string]: any } }>) {
const metadata = { filters: args };

return function(targetClass: any, methodName?: string) {
@@ -115,25 +115,27 @@ export interface Transitionable {

export interface Filter {
/**
* Method of filter that is executed when a filter decorator is given.
* Method of filter that is executed if the referenced filter is used as a decorator
* @param {State.Required} state Instance of state which occured the execution of this filter
* @param {string} stateName Name of state which occured the execution of this filter
* @param {string} intentMethod Name of intent method which state machine wanted to call originally
* @param args all additional arguments passed to the intent method
* @returns an object containing a state/intent to be used instead of the intially called intent or a boolean (both as promises, if filter does some async operations). If it returns true the filter gets ignored. If it's false the filter handles an intent execution by itself.
* @param {[key: string]: any} params Parameters you use while annotating a state/intent with a certain filter (eg. @filter({filter: ExampleFilter, params: {a: "a", b: "b"}})) will be passed here
* @param args All additional arguments passed to the intent method
* @returns An object containing a state/intent to be used instead of the intially called intent or a boolean (both as promises, if filter does some async operations); If it returns true the filter gets ignored; If it's false the filter handles an intent execution by itself.
*/
execute(
state: State.Required,
stateName: string,
intentMethod: string,
params: {},
...args: any[]
): Promise<{ state: string; intent: string; args?: any[] } | boolean> | { state: string; intent: string; args?: any[] } | boolean;
}
/**
* This interface represents extensions which are used after the context is set. e.g the StateMachine
*/
export interface AfterContextExtension extends ExecutableExtension {
execute(): any | Promise<any>;
execute(...args: any[]): any | Promise<any>;
}

/**
@@ -148,6 +150,6 @@ export interface BeforeStateMachine {

/**
* Extensions of this type are executed after the statemachine is executed.
* has same type like interface BeforeStateMachine
* Has same type like interface BeforeStateMachine
*/
export type AfterStateMachine = BeforeStateMachine;
@@ -19,14 +19,14 @@ export class Runner implements AfterContextExtension {
private afterStatemachineExtensions: AfterStateMachine[]
) {}

public async execute() {
public async execute(...args: any[]) {
// Only start state machine if there is an extraction result
if (typeof this.extraction !== "undefined") {
// call all before state machine extensions
await Promise.all(this.beforeStatemachineExtensions.map(ex => ex.execute()));

// call state machine
await this.machine.handleIntent(this.extraction.intent);
await this.machine.handleIntent(this.extraction.intent, ...args);

// call all after state machine extensions
await Promise.all(this.afterStatemachineExtensions.map(ex => ex.execute()));
@@ -29,7 +29,7 @@ export class StateMachine implements Transitionable {
this.intentHistory.push({ stateName: currentState.name, intentMethodName: intentMethod });
this.logger.info("Handling intent '" + intentMethod + "' on state " + currentState.name);

/* execute clearContext callback if decorator is present */
/* Execute clearContext callback if decorator is present */
const clearContextCallbackFn = this.retrieveClearContextCallbackFromMetadata(currentState.instance.constructor as State.Constructor);
if (clearContextCallbackFn && clearContextCallbackFn(currentState.name, contextStates.map(cState => cState.name), this.intentHistory)) {
this.currentSessionFactory().set("__context_states", JSON.stringify([]));
@@ -47,7 +47,7 @@ export class StateMachine implements Transitionable {
return;
}

// Check if there is a "beforeIntent_" method available
/* Check if there is a "beforeIntent_" method available */
if (this.isStateWithBeforeIntent(currentState.instance)) {
const callbackResult = await Promise.resolve(currentState.instance.beforeIntent_(intentMethod, this, ...args));

@@ -68,7 +68,7 @@ export class StateMachine implements Transitionable {
/* Call given intent */
await Promise.resolve(currentState.instance[intentMethod](this, ...args));

// Call afterIntent_ method if present
/* Call afterIntent_ method if present */
if (this.isStateWithAfterIntent(currentState.instance)) {
currentState.instance.afterIntent_(intentMethod, this, ...args);
}
@@ -102,13 +102,13 @@ export class StateMachine implements Transitionable {

const stayInContextCallbackFn = this.retrieveStayInContextCallbackFromMetadata(currentState.instance.constructor as State.Constructor);

/* add current state to context if context meta data is present and remove previous context entry of current state */
/* Add current state to context if context meta data is present and remove previous context entry of current state */
if (stayInContextCallbackFn) {
contextStates = contextStates.filter(contextState => contextState.name !== currentState.name);
contextStates.push(currentState);
}

/* execute callbacks of context states and filter by result */
/* Execute callbacks of context states and filter by result */
contextStates = contextStates.filter(contextState =>
(this.retrieveStayInContextCallbackFromMetadata(contextState.instance.constructor as State.Constructor) as ((...args: any[]) => boolean))(
currentState.name,
@@ -117,7 +117,7 @@ export class StateMachine implements Transitionable {
state
)
);
/* set remaining context states as new context */
/* Set remaining context states as new context */
await this.currentSessionFactory().set("__context_states", JSON.stringify(contextStates.map(contextState => contextState.name)));

return this.currentSessionFactory().set("__current_state", state);
@@ -138,9 +138,9 @@ export class StateMachine implements Transitionable {
private async handleOrReject(error: Error, state: State.Required, stateName: string, intentMethod: string, ...args): Promise<void> {
if (this.isStateWithErrorFallback(state)) {
return Promise.resolve(state.errorFallback(error, state, stateName, intentMethod, this, ...args));
} else {
throw error;
}

throw error;
}

/** If you change this: Have a look at registering of states / automatic intent recognition, too! */
@@ -158,7 +158,7 @@ export class SpecHelper {
* Runs state machine. Needs created request scope!
* @param stateName Name of state to run.
*/
public async runMachine(stateName: string): Promise<void> {
public async runMachine(stateName: string, ...args): Promise<void> {
this.throwIfNoRequestScope();

// Transition to given state
@@ -174,7 +174,7 @@ export class SpecHelper {
);
const runner = afterContextExtensions.filter(extensionClass => extensionClass.constructor.name === "Runner")[0];

return runner.execute();
return runner.execute(...args);
}

/**

0 comments on commit 475d9b6

Please sign in to comment.