src/Metadata.js
const assert = require('assert');
const TypeCheck = require('js-typecheck');
const Utils = require('./Utils');
// symbols used for private members to avoid any potential clashing
// caused by re-implementations
const _collection = Symbol('collection');
const _optionVariables = Symbol('optionVariables');
const _cachedOptionVariables = Symbol('cachedOptionVariables');
const _maxDepth = Symbol('maxDepth');
/**
* Metadata provides a way for actions ({@link Action}) to control about how a
* handler should perform the reading ({@link Reader}) and writing
* ({@link Writer}) operations. Therefore, making possible handlers to perform
* differently per action basis.
*
* This is done by defining {@link Reader} & {@link Writer} options through
* the metadata. By doing that the options are passed from the action to the
* handler during the handler's execution ({@link Handler.runAction}).
*
* You can define options by either using option variables or the full option location:
*
* **Option var (recommended):**
* Eliminates the need of using convoluted long names to define the options by
* simply using a variable that represents a full option location:
*
* Example:
* ```
* class MyAction extends Mebo.Action{
*
* constructor(){
* super();
* // defining a custom uploadDirectory by using the `$webUploadDirectory` variable,
* // rather than the full option location (`handler.web.readOptions.uploadDirectory`)
* this.setMeta('$webUploadDirectory', '/tmp/customUploadDir');
* }
*
* async _perform(data){
* // ...
* }
*
* async _after(err, value){
* // defining a custom header that only affects the web handler
* // this call could be done inside of the _perform method. However, we
* // are defining it inside of the _after to keep _perform as
* // abstract as possible. Since, _after is always called (even during
* // an error) after the execution of the action, it provides a way to
* // hook and define custom metadata related with the result.
* if (!err){
* // defining a custom header by using the `$webHeaders` variable, rather
* // than the full option location (`handler.web.writeOptions.headers`)
* this.setMeta('$webHeaders', {
* someOption: 'foo',
* });
* }
* }
* }
* ```
*
* **Full option location:**
* Uses a convention interpreted by the {@link Handler.metadata} to describe
* where the option is localized:
* ```
* handler.<HANDLER_NAME>.<OPERATION>Options.<OPTION_NAME>
* ```
*
* Example:
* ```
* class MyAction extends Mebo.Action{
* constructor(){
* super();
* this.setMeta('handler.web.readOptions.uploadDirectory', '/tmp/customUploadDir');
* }
*
* async _perform(data){
* // ...
* }
*
* async _after(err, value){
*
* if (!err){
* // location (not recommended, see option var)
* this.setMeta('handler.web.writeOptions.headers', {
* someOption: 'foo',
* });
* }
* }
* }
* ```
*
* The complete list of the available option variables can be found bellow, it's
* separated by handler type. Also, new variables can be assigned through
* {@link Metadata.registerOptionVar}.
*
* <h2>Web Variables</h2>
*
* Variable name | Value | Value used by
* --- | --- | ---
* $web | `handler.web` |
* $webUploadDirectory | `$web.readOptions.uploadDirectory` | {@link WebRequest}
* $webUploadPreserveName | `$web.readOptions.uploadPreserveName` | {@link WebRequest}
* $webDefaultUploadMaxFileSize | `$web.readOptions.uploadDefaultMaxFileSize` | {@link WebRequest}
* $webMaxFields | `$web.readOptions.maxFields` | {@link WebRequest}
* $webMaxFieldsSize | `$web.readOptions.maxFieldsSize` | {@link WebRequest}
* $webHeaders | `$web.writeOptions.headers` | {@link WebResponse}
* $webHeadersOnly | `$web.writeOptions.headersOnly`| {@link WebResponse}
* $webResult | `$web.writeOptions.result`| {@link WebResponse}
* $webRoot | `$web.writeOptions.root`| {@link WebResponse}
* $webStatus | `$web.writeOptions.status`| {@link WebResponse}
* $webResultLabel | `$web.writeOptions.resultLabel`| {@link WebResponse}
*
* <h2>Cli Variables</h2>
*
* Variable name | Value | Value used by
* --- | --- | ---
* $cli | `handler.cli` |
* $cliResult | `$cli.writeOptions.result` | {@link CliOutput}
*/
class Metadata{
/**
* Creates a metadata
*/
constructor(){
this[_collection] = new Utils.HierarchicalCollection();
}
/**
* Returns a value under the metadata.
*
* @param {string} path - path about where the value is localized (the levels
* must be separated by '.'). In case of empty string the entire metadata
* is returned.
* @param {*} [defaultValue] - default value returned in case a value was
* not found for the path
* @return {*}
*/
value(path, defaultValue=undefined){
assert(TypeCheck.isString(path), 'path needs to be defined as string');
return this[_collection].query(Metadata._resolvePath(path), defaultValue);
}
/**
* Sets a value to the metadata.
*
* @param {string} path - path about where the value should be stored under the metadata
* (the levels must be separated by '.')
* @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.
*/
setValue(path, value, merge=true){
assert(TypeCheck.isString(path), 'path needs to be defined as string');
this[_collection].insert(Metadata._resolvePath(path), value, merge);
}
/**
* Returns a list of the root levels under the metadata
*
* @return {Array<string>}
*/
root(){
return this[_collection].root();
}
/**
* register an option variable under metadata
*
* The value of the variable can contain other pre-defined option variables,
* for instance:
* ```
* Mebo.Metadata.registerOptionVar('$myVar', '$otherVar.data')
* ```
*
* @param {string} name - name of the variable
* @param {string} value - value for the variable
*/
static registerOptionVar(name, value){
assert(TypeCheck.isString(name), 'name needs to be defined as string');
assert(TypeCheck.isString(value), 'value needs to be defined as string');
assert((/^([\w_\$\.\-])+$/gi).test(value), `Illegal characters found variable (${name}) value: ${value}`); // eslint-disable-line no-useless-escape
this._validateOptionVarName(name);
// flushing cache
this[_cachedOptionVariables] = {};
// assigning variable
this[_optionVariables][name] = value;
}
/**
* Returns the value for an option variable
*
* for instance:
* ```
* const myVariableValue = Mebo.Metadata.optionVar('$myVariable');
* console.log(myVariableValue)
* ```
*
* @param {string} name - name of the variable
* @param {boolean} [processValue=true] - process any variables that may be
* defined as part of the value
* @return {string}
*
* @throws {Error} throws an error if the var name is undefined
* under the metadata.
*/
static optionVar(name, processValue=true){
this._validateOptionVarName(name);
if (!(name in this[_optionVariables])){
throw new Error(`Option variable ${name} is undefined`);
}
if (processValue){
return this._resolveOptionVar(name);
}
return this[_optionVariables][name];
}
/**
* Returns a boolean telling if the variable name is defined
*
* @param {string} name - variable name
* @return {boolean}
*/
static hasOptionVar(name){
this._validateOptionVarName(name);
return (name in this[_optionVariables]);
}
/**
* Returns a list of the registered variable names under the metadata
*
* @return {Array<string>}
*/
static registeredOptionVars(){
return Object.keys(this[_optionVariables]);
}
/**
* Returns the path resolved by processing any variables that may
* be defined as part of the path
*
* @param {string} path - path to be resolved
* @return {string}
*/
static _resolvePath(path){
// if the path contains variables lets process it
if (path.indexOf('$') !== -1){
const processedPath = [];
for (const part of path.split('.')){
if (part.startsWith('$')){
processedPath.push(Metadata.optionVar(part));
}
else{
processedPath.push(part);
}
}
// processed path
return processedPath.join('.');
}
return path;
}
/**
* Returns the value of the variable by processing any variables
* that may be defined as part of the value
*
* @param {string} name - variable name
* @param {string} [rootName] - root variable name used to report the origin
* of the circular reference error
* @param {number} [depth=0] - deep used to identify max recursion caused
* by circular references
* @return {string}
* @private
*/
static _resolveOptionVar(name, rootName, depth=0){
// detecting circular references
if (depth >= this[_maxDepth]){
const error = new Error(`Circular reference detected while processing the value for $${rootName}`);
// processing the stack of the error to get rid of the duplicated entries
// caused by the recursion (we don't need to show 1000+ lines of the same
// thing, the error explanation should be good enough)
const stackContents = error.stack.split('\n');
error.stack = stackContents.slice(0, 2).concat(stackContents.slice(this[_maxDepth] + 1)).join('\n');
throw error;
}
// processing value
const rawValue = this[_optionVariables][name];
// in case the value is not under the cache, lets process it
if (!(name in this[_cachedOptionVariables])){
// checking if the value contains any variable
let processedValue = rawValue;
if (rawValue.indexOf('$') !== -1){
// splitting the levels of the value that are separated by '.'
const processedValueParts = [];
for (const part of rawValue.split('.')){
let processedPartValue;
// in case of a variable
if (part.startsWith('$')){
this._validateOptionVarName(part);
// processing variable value
processedPartValue = this._resolveOptionVar(part, rootName || name, depth + 1);
}
// otherwise just use the part without any processing
else{
processedPartValue = part;
}
processedValueParts.push(processedPartValue);
}
// building back the structure of the value
processedValue = processedValueParts.join('.');
}
// adding processed value to the cache
this[_cachedOptionVariables][name] = processedValue;
}
// returning value from cache
return this[_cachedOptionVariables][name];
}
/**
* Check if the variable is defined using the proper syntax, otherwise in case
* of any issues an exception is raised
*
* @param {string} name - variable name
* @private
*/
static _validateOptionVarName(name){
assert(TypeCheck.isString(name), 'name needs to be a string');
// check if variable name starts with $
if (!name.startsWith('$')){
throw new Error(`Option variable (${name}) needs to start with: $`);
}
// checking if variable is empty
const cleanVarName = name.slice(1);
if (!cleanVarName.length){
throw new Error('Option variable cannot be empty');
}
// checking for invalid syntax
else if (!(/^([\w_])+$/gi).test(cleanVarName)){
throw new Error(`Option variable (${name}) contains invalid characters`);
}
}
}
Metadata[_optionVariables] = {};
Metadata[_cachedOptionVariables] = {};
Metadata[_maxDepth] = 1000;
module.exports = Metadata;