customize
Version:
A simple framework to create customizable engines
395 lines (359 loc) • 13 kB
JavaScript
/*!
* customize <https://github.com/nknapp/ride-over>
*
* Copyright (c) 2015 Nils Knappmeier.
* Released under the MIT license.
*/
/*
* Use the `debug`-module to provide debug output if needed
*/
const debug = require('debug')('customize:base')
const debugState = require('debug')('customize:state')
const debugVersions = require('debug')('customize:versions')
const deep = require('deep-aplus')(Promise)
const mergeWith = require('lodash.mergewith')
const util = require('./lib/util')
const mapValues = util.mapValues
const isString = util.isString
const constant = util.constant
/**
* The configuration file is defined (and validated) by a JSON-schema
* (see [the config-schema file](./config-schema.js)) for details.
* We use the `jsonschema` module for validation, and add the
* function-validator of `jsonschema-extra` because configurations
* usually include functions
* from https://gitlab.com/jksdua/jsonschema-extra/blob/master/types.js#L16
* @private
*/
const jsonschema = require('jsonschema')
const validator = new jsonschema.Validator()
validator.types = Object.create(validator.types)
validator.types.function = function testFunction(fn) {
return typeof fn === 'function'
}
/**
* Create a new Customize object with an empty configuration
*
* @module customize
*/
module.exports = customize
/**
* For coverage testing: Expose the debugState object so it can be enabled an disabled in testcases
*/
module.exports.debugState = debugState
/**
* For coverage testing: Expose the debug object so it can be enabled an disabled in testcases
*/
module.exports.debug = debug
/**
* Exposes the constructor of the `customize` object
* @type {customize}
*/
module.exports.Customize = Customize
/**
* Custom overrider-function (that is used as `customizer` in (lodash#merge)[https://lodash.com/docs#merge]
* @type {customOverrider}
*/
module.exports.overrider = customOverrider
/**
* @returns {Customize}
* @api public
*/
function customize() {
return new Customize({}, {}, {})
}
/**
* This class does the actual work. When calling
* `require('customize')()` a new instance of this
* class is returned with an empty configuration, so
* `new Customize(...)` should never be called outside
* this module
* `config` and `parentConfig` are of the form
*
* ```js
* { engine: { config: ..., watched: [ ... ] } }
* ```
*
* @constructor
*/
function Customize(config, parentConfig, engines) {
const _config = mergeWith({}, parentConfig, config, customOverrider)
// Debug logging
if (debugState.enabled) {
deep(_config).then(
function(config) {
debugState('New configuration', config)
} /* istanbul ignore next */,
function(e) {
console.error('Error while debug-logging the built configuration ' + e.stack) // eslint-disable-line no-console
}
)
}
/**
* Register an engine
* @param {string} id the identifier of the engine. This identifier is also used
* within the config as key within the configuration object to identify the
* sub-configuration stored for this engine.
* @param {object} engine a customize engine that is registered
* @param {object=} engine.defaultConfig the default configuration of the engine
* @param {function(object):object=} engine.preprocessConfig a preprocessor to convert a merge-configuration to the internal format of the engine
* @param {function(object):object} engine.run the execution function of the engine (the merged config is passed as parameter
* @param {function(object):object} engine.run the execution function of the engine (the merged config is passed as parameter)
* @param {object=} engine.schema a JSON-schema to validate the merge-configurations against.
*
* @public
*/
this.registerEngine = function(id, engine) {
debug("Registering engine '" + id + "'")
if (typeof id !== 'string') {
throw new Error('Engine-id must be a string, but is ' + id)
}
if (id.substr(0, 1) === '_') {
throw new Error('Engine-id may not start with an underscore ("_"), but is ' + id)
}
if (engine.run == null) {
throw new Error('Engine ' + id + ' needs a run method')
}
// This is only allowed if no engine with the same id exists.
if (engines[id] != null || _config[id] != null) {
const error = new Error("Engine '" + id + "' already registered.", 'ERR_ENGINE_EXISTS')
error.engine = engines[id]
error.config = _config[id]
throw error
}
const _engines = mapValues(engines) // clone
_engines[id] = engine
const _defaultConfig = {}
_defaultConfig[id] = {
config: engine.defaultConfig || {},
watched: engine.defaultWatched || []
}
return new Customize(_defaultConfig, _config, _engines)
}
/**
* Returns the JSON-schema that configuration objects must match for this
* configuration. The schema does not contain main description property
*/
this.configSchema = function() {
return {
id: 'http://json-schema.org/draft-04/schema#',
$schema: 'http://json-schema.org/draft-04/schema#',
type: 'object',
properties: mapValues(engines, function(engine) {
return (
engine.schema || {
type: 'object',
description: 'No expicit schema has been provided for this engine'
}
)
})
}
}
/**
* Creates a new instance of Customize. The configuration values of the current Customize
* are used as default values and are overridden by the configuration provided as parameter.
* @param {object} config configuration overriding the current configuration
* @return {Customize} the new Customize instance
* @api public
*/
this.merge = function(config) {
if (config == null) {
throw new Error("Cannot merge undefined 'config'")
}
debug('Calling merge', config)
// Assert that for each key in the other configuration, there is an engine present
// Apply engine preprocessor to each config
const preprocessedConfig = mapValues(config, function(engineConf, engineName) {
const engine = engines[engineName]
if (engine == null) {
throw new Error("Engine '" + engineName + "' not found. Refusing to store configuration")
}
// Load preprocessor with identity as default
const preprocessor =
engine.preprocessConfig ||
function(a) {
return a
}
// Watch no files by default (constant [])
const watched = engine.watched || constant([])
return Promise.resolve(engineConf)
.then(function(engineConf) {
if (engine.schema) {
debug('Validating schema for ', engineName)
/**
* The overriding configuration must validate against the [JSON-schema for configurations](./config-schema.html)
* Otherwise we refuse to proceed.
*/
const validationErrors = validator.validate(engineConf, engine.schema).errors
if (validationErrors.length > 0) {
debug("Error while validating config for engine '" + engineName + "': ", engineConf)
debug('Errors: ', validationErrors.map(String).join('\n'))
const error = new Error('Error while validating Customize configuration')
error.validationErrors = validationErrors
throw error
}
}
return {
config: preprocessor(engineConf),
watched: watched(engineConf).filter(isString)
}
})
.then(function(config) {
debug('Merging preprocessed config', config)
return config
})
})
return new Customize(preprocessedConfig, _config, engines)
}
/**
* Inherit configuration config from another module.
* a Customizer-module usually exports a `function(Customize):Customize`
* which in tern calls `Customize.merge` to create a new Customize instance.
* This function needs to be passed in here.
*
* A new Customize will be returned that overrides the current configuration
* with the configuration of the module.
* @param {function(Customize):Customize} customizeModule that receives a Customize as paramater
* and returns a Customize with changed configuration.
* @return {Customize} the Customize instance returned by the module
* @public
*/
this.load = function(customizeModule) {
// Container for configuration metadata (e.g. versions of loaded modules)
const _metadata = {
config: {
modules: []
}
}
if (customizeModule.package) {
debugVersions('Loading', customizeModule.package.name, customizeModule.package.version)
_metadata.config.modules.push(customizeModule.package)
}
return customizeModule(new Customize({ _metadata: _metadata }, _config, engines))
}
/**
* Return a promise for the merged configuration.
* This functions is only needed to inspect intermediate configuration results
* (i.e. for testing and documentation purposes)
* @return {Promise<object>} a promise for the whole configuration
* @public
*/
this.buildConfig = function() {
return deep(_config)
.then(function(config) {
return mapValues(config, 'config')
})
.then(function(config) {
debug('Building', config)
return config
})
}
/**
* Return a promise for the files needing to be watched in watch-mode,
* indexed by engine.
* @return {Promise<object<string[]>>} a promise for the files to be watched.
*
* @public
*/
this.watched = function() {
return deep(_config)
.then(function(config) {
return mapValues(config, 'watched')
})
.then(function(watchedFiles) {
debug('Watched files', watchedFiles)
return watchedFiles
})
}
/**
* Run each engine with its part of the config.
*
* @param {object=} options optional paramters
* @param {string=} options.onlyEngine the name of an engine if only a single engine should
* be executed
* @return {Promise<object>} an object containing on property per registered engine
* (the key is the engine-id) containing the result of each engine
* @public
*/
this.run = function(options) {
const onlyEngine = options && options.onlyEngine
return this.buildConfig().then(function(resolvedConfig) {
const result = mapValues(engines, function(engine, key) {
// if "onlyEngine" is set to a value, execute on the engine with the same name
if (!onlyEngine || onlyEngine === key) {
return engine.run(resolvedConfig[key])
}
})
return deep(result)
})
}
}
/**
* Wrap a function so that if it overrides another function, that function will
* be available as `this.parent`
* @param fn
* @api public
* @readonly
*/
module.exports.withParent = require('./lib/withParent')
/**
* Create a promise that is regarded as leaf in the configuration tree.
* That means, that the overrider is not resolving this promise when overriding values.
* Promised object values will not be merged but replaced.
* @param {*} promiseOrValue a promise or a valude that represents the leaf
* @returns {Promise}
* @public
* @readonly
*/
module.exports.leaf = require('./lib/leaf')
/**
* Customize has predefined override rules for merging configs.
*
* * If the overriding object has a `_customize_custom_overrider` function-property,
* it isk called to perform the merger.
* * Arrays are concatenated
* * Promises are resolved and the results are merged
*
*
* @param a the overridden value
* @param b the overriding value
* @param propertyName the property name
* @returns {*} the merged value
* @private
* @readonly
*/
function customOverrider(a, b, propertyName) {
if (b == null) {
return a
}
if (a == null) {
// Invoke default overrider
return undefined
}
// Some objects have custom overriders
if (b._customize_custom_overrider && b._customize_custom_overrider instanceof Function) {
return b._customize_custom_overrider(a, b, propertyName)
}
// Arrays should be concatenated
if (Array.isArray(a)) {
return a.concat(b)
}
// Merge values resolving promises, if they are not leaf-promises
if (isPromiseAlike(a) || isPromiseAlike(b)) {
return Promise.all([a, b]).then(function([_a, _b]) {
// Merge the promise results
return mergeWith({}, { x: _a }, { x: _b }, customOverrider).x
})
}
// None of these options apply. Implicit "undefined" return value to invoke default overrider.
}
/**
* Check if this is something like a promise (taken from the Q-module)
* @param {*} obj the object to check for being a promise
* @returns {boolean} true, if the object is a promise
* @private
*/
function isPromiseAlike(obj) {
return obj === Object(obj) && typeof obj.then === 'function'
}