lib/core/params.js
'use strict';
const upath = require('upath');
const symbols = require('./symbols');
/**
* A lightweight configuration system for Ravel, allowing
* clients, as well as other parts of the application, to
* define expected configuration parameters and get/set
* their values.
*
* @param {Class} Ravel - The Ravel prototype.
* @private
*/
module.exports = function (Ravel) {
/**
* Stores default configuration values, specified at parameter creation-time.
*
* @private
*/
const defaults = Object.create(null);
const ENVVAR_PATTERN = /\$([a-zA-Z_][0-9a-zA-Z_]*)/g;
/**
* Interpolates config values with the values of the environment variables of the process.
*
* @private
* @param {any} value - The value specified for a parameter; possibly an environment variable.
*/
const interpolateEnvironmentVariables = function (value) {
const IllegalValue = this.$err.IllegalValue;
if (typeof value !== 'string') {
return value;
}
const result = value.replace(ENVVAR_PATTERN, function () {
const varname = arguments[1];
if (process.env[varname] === undefined) {
throw new IllegalValue(`Environment variable ${varname} was referenced but not set`);
}
return process.env[varname];
});
return result;
};
/**
* Load parameters from a `.ravelrc.json` file during `init()`.
* This file must be located beside `app.js` or in any parent directory of `app.js`.
*
* @private
*/
Ravel.prototype[symbols.loadParameters] = function () {
let fromFile = Object.create(null);
let nextDir = this.cwd;
let currentDir;
do {
currentDir = nextDir;
// load parameters from config file into an empty object
// search upwards until we run out of path components or find something
// don't require extension; node will search for .js/.json/.node
const search = upath.toUnix(upath.posix.join(currentDir, '.ravelrc'));
try {
fromFile = require(search);
if (typeof fromFile === 'string') {
fromFile = JSON.parse(fromFile);
}
break;
} catch (err) {
if (err.constructor.name === 'SyntaxError') {
throw err;
} else {
this.once('post init', () => {
this.$log.trace(`Could not locate .ravelrc.json at ${search}`, err.message);
});
nextDir = upath.join(currentDir, '..');
}
}
} while (nextDir !== currentDir);
// verify all parameters in fromFile are known
for (const key of Object.keys(fromFile)) {
if (!this[symbols.knownParameters][key]) {
throw new this.$err.IllegalValue(
`Attempted to set unknown parameter: ${key.toString()}`);
}
}
for (const key of Object.keys(fromFile)) {
fromFile[key] = interpolateEnvironmentVariables.bind(this)(fromFile[key]);
}
// merge params from file into defaults, so file params take precendence over defaults
Object.assign(defaults, fromFile);
// now merge with this[symbols.params], allowing programmatically set params to take precedence
Object.assign(defaults, this[symbols.params]);
// now defaults contains what we want, so make it this[symbols.params]
this[symbols.params] = defaults;
// done!
this[symbols.parametersLoaded] = true;
};
/**
* Validate that all defined required parameters have been set.
*
* @throws {NotFoundError} If any defined required parameters have not been set.
* @private
*/
Ravel.prototype[symbols.validateParameters] = function () {
const unknowns = Object.keys(this[symbols.knownParameters])
.filter(p => this[symbols.knownParameters][p].required)
.filter(p => {
try {
this.get(p);
return false;
} catch (err) {
if (!(err instanceof this.$err.NotFound)) throw err;
return true;
}
});
if (unknowns.length > 0) {
throw new this.$err.NotFound(
`Required parameters have not been defined yet:\n${unknowns.join('\n')}`);
}
};
/* eslint-disable jsdoc/check-param-names */
/**
* Register an application parameter.
*
* @param {string} key - The key for the parameter.
* @param {boolean} required - True, iff the parameter is required. False otherwise.
* @param {(any | undefined)} defaultValue - The default value for the parameter.
*/
Ravel.prototype.registerParameter = function (key, required) {
this[symbols.knownParameters][key] = {
required: required
};
if (arguments.length === 3 && arguments[2] !== undefined) {
defaults[key] = JSON.parse(JSON.stringify(arguments[2]));
} else if (arguments.length === 3 && arguments[2] === undefined) {
throw new this.$err.IllegalValue(`Undefined default value supplied for parameter "${key}"`);
}
};
/* eslint-enable jsdoc/check-param-names */
/**
* Set the value of an application parameter.
*
* @param {string} key - The key for the parameter.
* @param {Any} value - The value for the parameter.
* @throws {IllegalValueError} If key refers to an unregistered parameter.
* @returns {any} The parameter value.
*/
Ravel.prototype.set = function (key, value) {
if (this[symbols.knownParameters][key]) {
this[symbols.params][key] = value;
} else {
throw new this.$err.IllegalValue(`Attempted to set unknown parameter: ${key}.`);
}
};
/**
* Get the value of an application parameter.
*
* @param {string} key - The key for the parameter.
* @throws {NotFoundError} If the parameter is required and not set.
* @returns {any} The parameter value, or undefined if it is not required and not set.
*/
Ravel.prototype.get = function (key) {
if (!this[symbols.parametersLoaded]) {
throw new this.$err.General('Cannot get() parameters until after app.init()');
} else if (!this[symbols.knownParameters][key]) {
throw new this.$err.NotFound(`Parameter ${key} was requested, but is unknown.`);
} else if (this[symbols.knownParameters][key].required && this[symbols.params][key] === undefined) {
throw new this.$err.NotFound(
`Known required parameter ${key} was requested, but hasn't been defined yet.`);
} else if (this[symbols.params][key] === undefined) {
this.$log.trace(`Optional parameter ${key} was requested, but is not defined.`);
return undefined;
} else {
return JSON.parse(JSON.stringify(this[symbols.params][key]));
}
};
/**
* Getter for the Ravel app configuration object. This is only available after `app.init()`.
*
* @memberof Ravel
* @name config
* @returns {object} The Ravel app configuration object (read-only).
*/
Object.defineProperty(Ravel.prototype, 'config', {
get: function () { return JSON.parse(JSON.stringify(this[symbols.params])); }
});
};