src/Action.js
const assert = require('assert');
const TypeCheck = require('js-typecheck');
const Utils = require('./Utils');
const Settings = require('./Settings');
const Session = require('./Session');
const Input = require('./Input');
const Metadata = require('./Metadata');
// symbols used for private members to avoid any potential clashing
// caused by re-implementations
const _inputs = Symbol('inputs');
const _session = Symbol('session');
const _metadata = Symbol('metadata');
const _registeredActions = Symbol('registeredActions');
class InvalidActionError extends Error{
}
/**
* An action is used to perform an evaluation.
*
* By implementing an evaluation through an action, the evaluation is wrapped by an
* agnostic interface which can be triggered through different domains ({@link Handler}).
*
* The data used to perform an evaluation is held by inputs ({@link Action.createInput}).
* These inputs can be widely configured to enforce quality control via properties.
* The available properties can be found under the documentation for each input type.
*
* ```
* class HelloWorld extends Mebo.Action{
* constructor(){
* super();
* this.createInput('repeat: numeric', {max: 100}); // <- input
* }
*
* async _perform(data){
* return 'HelloWorld '.repeat(data.repeat);
* }
* }
*
* const action = new HelloWorld();
* action.input('repeat').setValue(3);
* action.run().then(...) // HelloWorld HelloWorld HelloWorld
* ```
*
* An action is triggered through {@link Action.run} which internally calls
* {@link Action._perform}. Use `_perform` to implement the evaluation of your action.
* Also, you can implement {@link Action._after} to execute secondary routines.
*
* Make sure actions are always created through the factory function create
* ({@link Action.create}). For that you need to register the action
* that can be done in two ways:
*
* Decorator support:
* ```
* @Mebo.register('helloWorld') // <- registering action
* class HelloWorld extends Mebo.Action{
* constructor(){
* super();
* this.createInput('repeat: numeric', {max: 100});
* }
*
* async _perform(data){
* return 'HelloWorld '.repeat(data.repeat);
* }
* }
*
* const action = Mebo.Action.create('helloWorld');
* action.input('repeat').setValue(3);
* action.run().then(...) // HelloWorld HelloWorld HelloWorld
* ```
*
* Registration api ({@link Action.register}):
* ```
* class HelloWorld extends Mebo.Action{
* constructor(){
* super();
* this.createInput('repeat: numeric', {max: 100});
* }
*
* async _perform(data){
* return 'HelloWorld '.repeat(data.repeat);
* }
* }
*
* Mebo.Action.register(HelloWorld, 'helloWorld'); // <- registering action
*
* const action = Mebo.Action.create('helloWorld');
* action.input('repeat').setValue(3);
* action.run().then(...) // HelloWorld HelloWorld HelloWorld
* ```
*
* In case you have an action that may need to call another actions during
* `_perform`, it can be done through:
*
* - {@link Action.createAction} - allows actions to be created from inside of another action.
* By doing that it creates an action that shares the same {@link Session}.
*
* ```
* class HelloWorld extends Mebo.Action{
* // ...
* async _perform(data){
* const fooAction = this.createAction('foo');
* const fooResult = await fooAction.run();
* // ...
* }
* }
* // ...
* ```
*
* - {@link Action.create} - factory an action with using a specific session when supplied
* otherwise, creates an action with a new session.
*
* ```
* class HelloWorld extends Mebo.Action{
* // ...
* async _perform(data){
* // in case you are planning to share the same session please
* // use the "sugary" `this.createAction` instead.
* const fooAction = Mebo.Action.create('foo');
* const fooResult = await fooAction.run();
* // ...
* }
* }
* // ...
* ```
*
* Also, actions can take advantage of the caching mechanism designed to improve the performance
* by avoiding re-evaluations in actions that might be executed multiple times. This can enabled
* through {@link Action.isCacheable}.
*/
class Action{
/**
* creates an action
*/
constructor(){
this[_inputs] = new Map();
this[_metadata] = new Metadata();
}
/**
* Creates a new input through {@link Input.create} then adds it
* to the action inputs {@link Action.addInput}
*
* @param {string} inputInterface - string followed by either the pattern `name: type`
* or `name?: type` in case of optional {@link Input}
* @param {...*} args - arguments passed to the input's constructor
* @return {Input} Returns the created input instance
*/
createInput(inputInterface, ...args){
const inputInstance = Input.create(inputInterface, ...args);
this.addInput(inputInstance);
return inputInstance;
}
/**
* Adds an {@link Input} instance to the action
*
* @param {Input} inputInstance - input that should be added to the action
*/
addInput(inputInstance){
// making sure the input is derived from Inputs
assert(inputInstance instanceof Input, 'Invalid Input Type!');
// making sure the new input name is not in use
const inputName = inputInstance.name();
if (this[_inputs].has(inputName)){
throw new Error('Input name is already in use!');
}
this[_inputs].set(inputName, inputInstance);
}
/**
* Returns the action input names
*
* @return {Array<string>}
*/
inputNames(){
return [...this[_inputs].keys()];
}
/**
* Returns the input instance based on the given name
*
* @param {string} inputName - name of the input
* @param {*} [defaultValue] - default value that is returned in case the
* input does not exist
* @return {Input}
*/
input(inputName, defaultValue=null){
assert(TypeCheck.isString(inputName), 'inputName needs to be defined as string!');
if (this[_inputs].has(inputName)){
return this[_inputs].get(inputName);
}
return defaultValue;
}
/**
* Runs the validations of all inputs
*
* @return {Promise}
*/
validate(){
return Promise.all([...this[_inputs].values()].map((input) => input.validate()));
}
/**
* Returns a boolean telling if the action is cacheable (`false` by default).
*
* This method should be overridden by derived classes to tell if the action
* is cacheable. This information is used by {@link Action.run}.
*
* The configuration about the LRU cache can be found under the {@link Session}.
*
* @return {boolean}
*/
isCacheable(){
return false;
}
/**
* Executes the action and returns the result through a promise
*
* @param {boolean} [useCache=true] - tells if the action should try to use the LRU
* cache to avoid the execution. This option is only used when the action is {@link Action.isCacheable}
* @return {Promise<*>}
*/
async run(useCache=true){
// in case the result cache does not exist yet, creating it as an arbitrary
// data under the session, therefore when the session is cloned by nested
// actions this cache will be shared across them
let resultCache = this.session().get('_actionResultCache');
if (!resultCache){
resultCache = new Utils.LruCache(
Settings.get('action/lruCacheSize'),
Settings.get('action/lruCacheLifespan') * 1000,
);
this.session().set('_actionResultCache', resultCache);
}
// pulling out result from the cache (if applicable)
let actionSignature = null;
if (useCache && this.isCacheable()){
actionSignature = await this.id();
// checking if the input hash is under the cache
if (resultCache.has(actionSignature)){
return resultCache.get(actionSignature);
}
}
// the action is performed inside of a try/catch block to call the _after
// no matter what, since that can be used to perform clean-up operations...
let result = null;
let err = null;
try{
// calling super class validations & executing action
result = await this._execute();
}
catch(errr){
err = this._processError(errr);
throw err;
}
// running the finalize
finally{
await this._after(err, result);
}
// adding the result to the cache
if (actionSignature){
resultCache.set(actionSignature, result);
}
return result;
}
/**
* Serializes the current interface of the action into json format. Serialized
* actions can be recreated later through {@link Action.createFromJSON}
* or in case of non-registered actions the baked information can be loaded
* directly to an instance through {@link Action.fromJSON}.
*
* @param {boolean} [autofill=true] - tells if the {@link Session.autofill} will be
* included in the serialization
* @param {boolean} [avoidHidden=true] - tells if inputs with the 'hidden' property
* should be ignored
* @return {Promise<string>} serialized json version of the action
*/
async bakeToJSON(autofill=true, avoidHidden=true){
const actionInputs = await this._serializeInputs(avoidHidden);
const session = this.session();
// collecting autofill values
const autofillData = {};
if (autofill && session){
for (const key of session.autofillKeys()){
autofillData[key] = session.autofill(key);
}
}
const result = {
id: this.id(),
inputs: actionInputs,
metadata: {
action: this.meta('action', {}),
},
session: {
autofill: autofillData,
},
};
return JSON.stringify(result, null, '\t');
}
/**
* Loads the interface of the action from json (serialized through {@link Action.bakeToJSON}).
*
* @param {string} serializedAction - serialized json information generated by {@link Action.bakeToJSON}
* @param {boolean} [autofill=true] - tells if the {@link Session.autofill} should
* be loaded
*/
fromJSON(serializedAction, autofill=true){
const actionContents = JSON.parse(serializedAction);
this._loadContents(actionContents, autofill);
}
/**
* Returns an unique signature based on the action's current state. It's based
* on the input types, input values and meta data information about the action.
*
* For a more reliable signature make sure that the action has been created through
* the factory method ({@link Action.create}).
*
* @return {Promise<string>}
*/
async id(){
let actionSignature = '';
const separator = ';\n';
// header
const actionRegisteredName = this.meta('action.name');
if (actionRegisteredName){
actionSignature = actionRegisteredName;
}
// using the class name can be very flawed, make sure to always creating actions
// via their registration name
else{
actionSignature = `!${this.constructor.name}`;
}
actionSignature += separator;
actionSignature += this.inputNames().length;
actionSignature += separator;
// contents
const actionInputs = await this._serializeInputs(false);
for (const inputName in actionInputs){
actionSignature += `${inputName}: ${actionInputs[inputName]}${separator}`;
}
return Utils.hash(Buffer.from(actionSignature));
}
/**
* Allows the creation of an action based on the current action. By doing this it passes
* the current {@link Action.session} to the static create method ({@link Action.create}).
* Therefore creating an action that shares the same session.
*
* @param {string} actionName - registered action name (case-insensitive)
* @return {Action}
*/
createAction(actionName){
const action = Action.create(actionName, this.session());
// overriding the metadata information about the origin of the action, by telling
// it has been created from inside of another action
action.setMeta('action.origin', 'nested');
return action;
}
/**
* Creates an action based on the registered action name, in case the action does
* not exist `null` is returned instead
*
* @param {string} actionName - registered action name (case-insensitive)
* @param {Session} [session] - optional custom session object
* @return {Action}
*/
static create(actionName, session=null){
assert(TypeCheck.isString(actionName), 'Action name needs to be defined as string');
const RegisteredAction = this.registeredAction(actionName);
// creating action
const action = new RegisteredAction();
// setting session
if (session){
action.setSession(session);
}
// adding the action name used to create the action under the metadata
action.setMeta('action.name', actionName);
// adding a metadata information telling the action is a top level one
// it has not being created inside of another action through the
// Action.createAction
action.setMeta('action.origin', 'topLevel');
return action;
}
/**
* Creates an action based on the serialized input which is generated by
* {@link Action.bakeToJSON}
*
* @param {string} serializedAction - json encoded action
* @param {boolean} [autofill=true] - tells if the autofill information should be
* loaded
* @return {Action}
*/
static createFromJSON(serializedAction, autofill=true){
assert(TypeCheck.isString(serializedAction), 'serializedAction needs to be defined as string!');
const actionContents = JSON.parse(serializedAction);
const name = actionContents.metadata.action.name;
assert(TypeCheck.isString(name), 'Could not find the action information');
const action = this.create(name);
assert(action, `Action not found: ${name}`);
action._loadContents(actionContents, autofill);
return action;
}
/**
* Associates a {@link Session} with the action. By doing this all inputs that
* are flagged with 'autofill' property will be initialized with the
* session value. The session assigned to the action is cloned during the assignment
* ({@link Session.clone}). A session is always assigned to an action,
* during the factoring ({@link Action.create}).
*
* @param {Session} session - session object
*/
setSession(session){
assert(session instanceof Session, 'Invalid session!');
this[_session] = session.clone();
// setting the session inputs
const autofillKeys = this[_session].autofillKeys();
for (const inputName of this.inputNames()){
const input = this.input(inputName);
// setting the autofill inputs
const autofillName = input.property('autofill');
if (autofillName && autofillKeys.includes(autofillName)){
input.setValue(this[_session].autofill(autofillName));
}
}
}
/**
* Returns the session object
*
* @return {Session}
*/
session(){
// creating session on demanding
if (!this[_session]){
this[_session] = new Session();
}
return this[_session];
}
/**
* Returns a value under the action's metadata.
*
* @param {string} path - path about where the value is localized (the levels
* must be separated by '.'). In case of an empty string it returns the
* entire metadata. The path can be defined using `option vars`
* ({@link Metadata.optionVar}).
* @param {*} [defaultValue] - default value returned in case a value was
* not found for the path
* @return {*}
*/
meta(path, defaultValue=undefined){
assert(TypeCheck.isString(path), 'path needs to be defined as string');
return this[_metadata].value(path, defaultValue);
}
/**
* Sets a value to the action's metadata.
*
* Detailed information about the metadata support can be found at
* {@link Metadata}.
*
* @param {string} path - path about where the value should be stored under the metadata
* (the levels must be separated by '.'). The path can be defined using `option vars`
* ({@link Metadata.optionVar}).
* @param {*} value - value that is going to be stored under the collection
* @param {boolean} [merge=true] - this option is used to decide in case of the
* last level is already existing under the collection, if the value should be
* either merged (default) or overridden.
*/
setMeta(path, value, merge=true){
assert(TypeCheck.isString(path), 'path needs to be defined as string');
this[_metadata].setValue(path, value, merge);
}
/**
* Registers an {@link Action} to the available actions
*
* In case you want to use a compound name with a prefix common across some group
* of actions, you can use '.' as separator.
*
* @param {Action} actionClass - action implementation that will be registered
* @param {string} name - string containing the registration name for the
* action, this name is used later to create the action ({@link Action.create}).
* In case of an empty string, the registration is done by using the name
* of the type.
*/
static register(actionClass, name){
assert(TypeCheck.isSubClassOf(actionClass, Action), 'Invalid action type');
assert(TypeCheck.isString(name), 'name needs to defined as string');
assert(name.length, 'name cannot be empty');
// validating name
assert(name.length, 'action name cannot be empty');
assert((/^([\w_\.\-])+$/gi).test(name), `Illegal action name: ${name}`); // eslint-disable-line no-useless-escape
this[_registeredActions].set(name, actionClass);
}
/**
* Returns the action based on the registration name
*
* @param {string} name - name of the registered action
* @return {Action}
*/
static registeredAction(name){
assert(TypeCheck.isString(name), 'Invalid name!');
if (this[_registeredActions].has(name)){
return this[_registeredActions].get(name);
}
throw new Error(`Action ${name} is not registered!`);
}
/**
* Returns the registered action name based on the action class
*
* @param {Action} actionClass - action that should be used to query the
* registered name
* @return {string}
*/
static registeredActionName(actionClass){
assert(TypeCheck.isSubClassOf(actionClass, Action), 'Invalid action!');
for (const [registeredName, registeredActionClass] of this[_registeredActions].entries()){
if (registeredActionClass === actionClass){
return registeredName;
}
}
throw new InvalidActionError(`There is no action registered for the class ${actionClass.name}!`);
}
/**
* Returns a list containing the names of the registered actions
*
* @return {Array<string>}
*/
static registeredActionNames(){
return [...this[_registeredActions].keys()];
}
/**
* This method is called before the execution of the action.
*
* In case you need to check data against multiple inputs a recommended strategy
* to tackle this scenario is to implement a special input type where compound data
* can be represented (like json input type). However, for cases where the data cannot
* be coupled you can use this method to provide a way to check them before the
* execution of the action by raising an error in case the check fails. Therefore,
* avoiding to have any special verification inside of `_perform`.
*
* ```
* class MyAction extends Mebo.Action{
* // ...
* async _before(data){
* // if (...){
* throw new Mebo.Error.NotFound('my error message');
* // }
* }
* // ...
* }
* ```
*
* @param {Object} data - plain object containing the value of the inputs, this is just to
* provide a more convenient way to query the value of the inputs
* ```data.myInput``` instead of ```this.input('myInput').value()```.
* @return {Promise} resolved promise (any result passed to the promise is ignored)
*
* @protected
*/
async _before(data){
return null;
}
/**
* This method should be used to implement the evaluation for the action. It's called
* by {@link Action.run} after all inputs have been validated ({@link Action.validate}
* and {@link Action._before}). It's expected to return a Promise containing
* the result for the evaluation.
*
* During the execution of the action all inputs are assigned as read-only ({@link Input.readOnly}),
* this is done to prevent any modification in the input while the execution is happening,
* by the end of the execution the inputs are assigned back with the read-only state
* that was assigned before of the execution.
*
* *Result through a {@link Handler}:*
*
* The {@link Handler.output} is used for the serialization of a result. Therefore,
* actions should not serialize the result by themselves; instead it should be
* done by a handler. The handlers shipped with Mebo have support for streams
* where in case of any readable stream or buffer value they are piped to the
* output, otherwise the result is serialized using JSON.
*
* @param {Object} data - plain object containing the value of the inputs, this is just to
* provide a more convenient way to query the value of the inputs inside of the
* execution for instance: ```data.myInput``` instead of ```this.input('myInput').value()```.
* @return {Promise<*>} value that should be returned by the action
*
* @abstract
* @protected
*/
async _perform(data){
throw new Error('Not implemented error!');
}
/**
* This method is called after the execution of the action.
*
* You could re-implement this method to:
* - Add custom metadata information that can be used by a {@link Writer}
* - Add arbitrary information to a log
* - In case of errors to purge temporary files
*
* @param {Error|null} err - Error exception or null in case the action has
* been successfully executed
* @param {*} value - value returned by the action
* @return {Promise} resolved promise (any result passed to the promise is ignored)
*
* @protected
*/
async _after(err, value){
return null;
}
/**
* Executes the action and returns the result through a promise
*
* @return {Promise<*>}
* @private
*/
async _execute(){
let result = null;
const data = {};
const readOnlyOriginalValues = new Map();
// making inputs read-only during the execution, otherwise it would be very dangerous
// since a modified input would not get validated until the next execution.
// The original read-only value is restored in the end of the execution. Also,
// this process collects the input values that are stored under 'data' which
// is later passed as argument of _perform method, it's used as a more convenient
// way to query the value of the inputs
for (const name of this.inputNames()){
const input = this.input(name);
readOnlyOriginalValues.set(input, input.readOnly());
// making input as readOnly
input.setReadOnly(true);
// input value
data[name] = input.value();
}
// checking if the inputs are valid (it throws an exception in case an input fails)
try{
await this.validate();
await this._before(data);
}
finally{
// restoring the read-only
for (const [input, originalReadOnly] of readOnlyOriginalValues){
input.setReadOnly(originalReadOnly);
}
}
// the action is performed inside of a try/catch block to call the _after
// no matter what, since that can be used to perform clean-up operations...
try{
// performing the action
result = await this._perform(data);
}
finally{
// restoring the read-only
for (const [input, originalReadOnly] of readOnlyOriginalValues){
input.setReadOnly(originalReadOnly);
}
}
return result;
}
/**
* Auxiliary method used to the contents of the action
*
* @param {Object} actionContents - object created when a serialized action
* is parsed
* @param {boolean} autofill - tells if the {@link Session.autofill} should
* be loaded
* @private
*/
_loadContents(actionContents, autofill){
const session = this.session();
if (autofill && session){
for (const autofillKey in actionContents.session.autofill){
session.setAutofill(autofillKey, actionContents.session.autofill[autofillKey]);
}
}
for (const inputName in actionContents.inputs){
const input = this.input(inputName);
assert(input, `Invalid input ${inputName}`);
input.parseValue(actionContents.inputs[inputName]);
}
}
/**
* Returns the value of the action inputs serialized
*
* @param {boolean} avoidHidden - tells if inputs with the 'hidden' property
* should be ignored
* @return {Promise<Object>}
* @private
*/
async _serializeInputs(avoidHidden){
let inputNames = this.inputNames();
// skipping hidden inputs
if (avoidHidden){
inputNames = inputNames.filter((x) => !this.input(x).property('hidden', false));
}
const serializeValuePromises = inputNames.map((x) => this.input(x).serializeValue());
const serializedResult = await Promise.all(serializeValuePromises);
const actionInputs = {};
for (let i=0, len=inputNames.length; i < len; ++i){
actionInputs[inputNames[i]] = serializedResult[i];
}
return actionInputs;
}
/**
* Auxiliary method used to include additional information
* to the exception raised during execution of the action
*
* @param {Error} err - exception that should be processed
* @return {Error}
* @private
*/
_processError(err){
// adding a member that tells the origin of the error
let topLevel = false;
if (!err.origin){
err.origin = this.meta('action.origin');
topLevel = true;
// disabling output
if (err.disableOutputInNested && err.origin === 'nested'){
err.output = false;
}
}
// adding the action class name and the registered name as a hint
// to the stack (for debugging purposes)
if (Object.getOwnPropertyDescriptor(err, 'stack').writable){
let actionName = this.constructor.name;
const registeredName = this.meta('action.name');
if (registeredName){
actionName += ` (${registeredName})`;
}
// including the action name information in a way that includes all action levels
// aka: `/TopLevelAction (...)/NestedActionA (...)/NestedActionB (...):'
if (topLevel){
actionName += ':\n';
}
// including hint to the stack
err.stack = `Oops, error on action /${actionName}${err.stack}`;
}
return err;
}
}
Action[_registeredActions] = new Map();
// Setting the default settings:
// lruCacheSize
// Sets in bytes the size of the LRU cache available for the execution of actions.
// (default: `20 mb`)
Settings.set('action/lruCacheSize', 20 * 1012 * 1024);
// lruCacheLifespan
// Sets in seconds the amount of time that an item under LRU cache should
// be kept alive. This cache is defined by {@link Session.resultCache}
// (default: `10 seconds`)
Settings.set('action/lruCacheLifespan', 10);
module.exports = Action;