const fs = require('fs')
const cloneDeep = require('lodash.clonedeep')
const parsePath = require('objectpath').parse
const stringifyPath = require('objectpath').stringify
const SchemaNode = require('./schemanode.js')
const Apply = require('./../performer/apply.js')
const parsingSchema = require('./../performer/utils/parsingschema.js')
const validator = require('./../performer/utils/validator.js')
const utils = require('./../performer/utils/utils.js')
const walk = require('./../performer/utils/walk.js')
const unroot = utils.unroot
const ALLOWED_OPTION_STRICT = 'strict'
const ALLOWED_OPTION_WARN = 'warn'
const cvtError = require('./../error.js')
const BLUECONFIG_ERROR = cvtError.BLUECONFIG_ERROR
// 2
const CUSTOMISE_FAILED = cvtError.CUSTOMISE_FAILED
const INCORRECT_USAGE = cvtError.INCORRECT_USAGE
const PATH_INVALID = cvtError.PATH_INVALID
// 2
const VALIDATE_FAILED = cvtError.VALIDATE_FAILED
/**
* Class for configNode, created with blueconfig class. This class is declared by `const config = blueconfig(schema)`.
*
* The global getter config will be cloned to local config. You must refresh getters configs if you apply global change to local
* (configuration instance).
*
* @example
* const config = blueconfig({
* env: {
* doc: 'The applicaton environment.',
* format: ['production', 'development', 'test'],
* default: 'development',
* env: 'NODE_ENV'
* },
* log_file_path: {
* 'doc': 'Log file path',
* 'format': String,
* 'default': '/tmp/app.log'
* }
* });
* // or
* config = blueconfig('/some/path/to/a/config-schema.json');
*
* @param {string|object} rawSchema Schema object or a path to a schema JSON file
*
* @param {object} [options] Options:
*
* @param {object} [options.env] Override `process.env` if specified using an object `{'NODE_ENV': 'production'}`.
* @param {string[]} [options.args] Override `process.argv` if specified using an array `['--argname', 'value']` or a string `--argname value`.
* @param {string} [options.defaultSubstitute] Override `'$~default'`, this value will be replaced by `'default'` during the schema parsing.
* @param {string} [options.strictParsing] Throw an error if `default` or `format` properties are omitted.
*
* @param {object} scope workers
* @param {Getter} scope.Getter Getter worker
* @param {Parser} scope.Parser Parser worker
* @param {Ruler} scope.Ruler Ruler worker
*
* @class
*/
function ConfigObjectModel(rawSchema, options, scope) {
this.options = options
this.Getter = scope.Getter
this.Parser = scope.Parser
this.Ruler = scope.Ruler
this._strictParsing = !!(options && options.strictParsing)
// The key `$~default` will be replaced by `default` during the schema parsing that allow
// to use default key for config properties.
const optsDefSub = (options) ? options.defaultSubstitute : false
this._defaultSubstitute = (typeof optsDefSub !== 'string') ? '$~default' : optsDefSub
// If the definition is a string treat it as an external schema file
if (typeof rawSchema === 'string') {
rawSchema = parseFile.call(this, rawSchema)
}
rawSchema = {
root: rawSchema
}
// build up current config from definition
this._schema = {
_cvtProperties: {
// root key lets apply format on the config root tree
// root: { _cvtProperties: {} }
}
}
this._getterAlreadyUsed = {}
this._sensitive = new Set()
// inheritance (own getter)
this._getters = this.Getter.cloneStorage()
Object.keys(rawSchema).forEach((key) => {
parsingSchema.call(this, key, rawSchema[key], this._schema._cvtProperties, key)
})
this._schemaRoot = this._schema._cvtProperties.root
// config instance
this._instance = {}
Apply.getters.call(this, this._schema, this._instance)
}
module.exports = ConfigObjectModel
/**
* Parse constructor arguments.
* Returns the list of process arguments (not including the launcher and application file arguments).
* Defaults to process.argv unless an override is specified using the args key of the second (options)
* argument of the blueconfig function.
*
* @return {string[]} Returns custom args {options.args} or `process.argv.slice(2)`
*/
ConfigObjectModel.prototype.getArgs = function() {
return (this.options && this.options.args) || process.argv.slice(2)
}
/**
* Gets the environment variable map, using the override passed to the
* blueconfig function or process.env if no override was passed.
*
* Returns the list of environment variables. Defaults to process.env unless an override is specified using the env key of
* the second argument (options) argument of the blueconfig function.
*
* @return {object} Returns custom args {options.env} or `process.env`
*/
ConfigObjectModel.prototype.getEnv = function() {
return (this.options && this.options.env) || process.env
}
/**
* Exports all the properties (that is the keys and their current values)
*
* @return {object} Returns properties
*/
ConfigObjectModel.prototype.getProperties = function() {
return cloneDeep(this._instance.root)
}
/**
* Exports all the properties (that is the keys and their current values) as
* a JSON string, with sensitive values masked. Sensitive values are masked
* even if they aren't set, to avoid revealing any information.
*
* @return {string} Returns properties as a JSON string
*/
ConfigObjectModel.prototype.toString = function() {
const clone = cloneDeep(this._instance.root)
this._sensitive.forEach(function(fullpath) {
const path = parsePath(unroot(fullpath))
const childKey = path.pop()
const parentKey = stringifyPath(path)
const parent = walk(clone, parentKey)
parent[childKey] = '[Sensitive]'
})
return JSON.stringify(clone, null, 2)
}
/**
* Exports the schema (as blueconfig understands your schema, may be more strict).
*
* @param {boolean} {debug=false} When debug is true, returns Schema Node Model (as stored in blueconfig database).
*
* @return {object} Returns schema object
*/
ConfigObjectModel.prototype.getSchema = function(debug) {
const schema = cloneDeep(this._schemaRoot)
return (debug) ? schema : convertSchema.call(this, schema)
}
function convertSchema(schemaObjectModel) {
if (!schemaObjectModel || typeof schemaObjectModel !== 'object' || Array.isArray(schemaObjectModel)) {
return schemaObjectModel
} else if (schemaObjectModel._cvtProperties) {
return convertSchema.call(this, schemaObjectModel._cvtProperties)
} else {
let isSchemaNode = false
if (schemaObjectModel instanceof SchemaNode) {
schemaObjectModel = schemaObjectModel.attributes
isSchemaNode = true
}
const schema = {}
Object.keys(schemaObjectModel).forEach((name) => {
let keyname = name
if (name === 'default' && !isSchemaNode) {
keyname = this._defaultSubstitute
}
schema[keyname] = convertSchema.call(this, schemaObjectModel[name])
})
return schema
}
}
/**
* Exports the schema as a JSON string.
*
* @param {boolean} {debug=false} When debug is true, returns Schema Node Model (as stored in blueconfig database).
*
* @return {string} Returns schema as a JSON string
*/
ConfigObjectModel.prototype.getSchemaString = function(debug) {
return JSON.stringify(this.getSchema(debug), null, 2)
}
/**
* Returns the current getter name of the `name` value origin. `name` can use dot notation to reference nested values.
*
* @example
* config.get('db.host')
* // or
* config.get('db').host
* // also
* config.get('db[0]')
* // with dot:
* config.get('db["www.airbus.com"]') { 'db': { 'www.airbus.com': 'air company'} }
* // in the first level
* config.get("['site.fr']") // { 'site.fr': 'french site' }
*
* @param {string} name Target property, `name` can use dot notation to reference
*
* @return {*} Returns the current `value` of the name property
*/
ConfigObjectModel.prototype.get = function(path) {
const o = walk(this._instance.root, path)
return cloneDeep(o)
}
/**
* Returns the current getter name of the `name` value origin. `name` can use dot notation to reference nested values.
*
* @example
* config.getOrigin('db.host')
*
* @param {string} name Target property, `name` can use dot notation to reference
*
* @return {string} Returns the getter name with is the current origin of the value.
*/
ConfigObjectModel.prototype.getOrigin = function(path) {
path = pathToSchemaPath(path)
const obj = walk(this._schemaRoot._cvtProperties, path)
return (obj instanceof SchemaNode) ? obj.getOrigin() : undefined
}
function pathToSchemaPath(path) {
const schemaPath = []
path = parsePath(path)
path.forEach((property) => schemaPath.push(property, '_cvtProperties'))
schemaPath.splice(-1)
/* if (addPath) {
parsePath(addPath).forEach((key) => schemaPath.push(key))
} */
return schemaPath
}
/**
* Returns getter order. Local (configuration instance) version of blueconfig.sortGetters().
*
* @see Blueconfig.getGettersOrder
*
* @return {string[]} Returns current getter order
*/
ConfigObjectModel.prototype.getGettersOrder = function(path) {
return [...this._getters.order]
}
/**
* Sorts getter depending of array order, priority uses ascending order.
*
* Local (configuration instance) version of blueconfig.sortGetters().
*
* @see Blueconfig.sortGetters
*
* @example
* config.sortGetters(['default', 'value', 'env', 'arg', 'force'])
*
* @param {string[]} newOrder The new getter order
*
* @return {this}
*/
ConfigObjectModel.prototype.sortGetters = function(newOrder) {
const sortFilter = this.Getter.sortGetters(this._getters.order, newOrder)
this._getters.order.sort(sortFilter)
return this
}
/**
* Reclone global getters config to local getters config and update configuration
* object value depending on new getters' order.
*
* `value` set with `.merge()`/`.set()` will be replaced by schema/getter value depending
* of Origin priority.
*
* @example
* blueconfig.getGettersOrder() // ['default', 'value', 'env', 'arg', 'force']
*
* const config = blueconfig(schema) // will clone: ['default', 'value', 'env', 'arg', 'force']
*
* // ### Two ways to do:
* // 1) Global change
* blueconfig.sortGetters(['value', 'default', 'arg', 'env', 'force'])
*
* config.getGettersOrder() // ['default', 'value', 'env', 'arg', 'force']
* blueconfig.getGettersOrder() // ['value', 'default', 'arg', 'env', 'force']
*
* // apply global change on local
* config.refreshGetters() // refresh and apply global change to local
*
* config.getGettersOrder() // ['value', 'default', 'arg', 'env', 'force']
* // 2) Local change
* config.sortGetters(['default', 'value', 'env', 'arg', 'force'])
* config.getGettersOrder() // ['default', 'value', 'env', 'arg', 'force']
* blueconfig.getGettersOrder() // ['value', 'default', 'arg', 'env', 'force']
*/
ConfigObjectModel.prototype.refreshGetters = function() {
this._getters = this.Getter.cloneStorage()
Apply.getters.call(this, this._schema, this._instance)
}
/**
* Returns the default value of the `name` property (defined in the schema). `name` can use dot notation to reference nested values.
*
* @example
* config.default('db.host')
*
* @param {string} name Target property, `name` can use dot notation to reference
*
* @return {*} Returns the default value.
*/
ConfigObjectModel.prototype.default = function(strPath) {
// The default value for FOO.BAR.BAZ is stored in `_schema._cvtProperties` at:
// FOO._cvtProperties.BAR._cvtProperties.BAZ.default
const path = pathToSchemaPath(strPath)
try {
const prop = walk(this._schemaRoot._cvtProperties, path)
return cloneDeep(prop.attributes.default)
} catch (err) {
if (err instanceof PATH_INVALID) {
throw new PATH_INVALID(err.fullname + '.default', err.path, err.name, err.value)
} else {
throw new INCORRECT_USAGE(unroot(strPath) + ': Cannot read property "default"')
}
}
}
/**
* Resets a property to its default value as defined in the schema
*
* @example
* config.reset('db.host')
*
* @param {string} name Target property, `name` can use dot notation to reference
*
* @return {this}
*/
ConfigObjectModel.prototype.reset = function(name) {
this.set(name, this.default(name), 'default', false)
return this
}
/**
* Checks if the property `name` is set.
*
* @example
* if (config.has('db.host')) {
* // Your code
* }
*
* @param {string} name Target property, `name` can use dot notation to reference
*
* @return {boolean} Returns `true` if the property `name` is defined, or `false` otherwise.
*/
ConfigObjectModel.prototype.has = function(name) {
const isRequired = (() => {
try {
const prop = walk(this._schemaRoot._cvtProperties, pathToSchemaPath(name))
return prop.attributes.required
} catch (err) {
return false
/*
// For debug:
if (err instanceof PATH_INVALID) {
// undeclared property
return false
} else {
// internal error
throw err
}
*/
}
})()
try {
// values that are set and required = false but undefined return false
return isRequired || typeof this.get(name) !== 'undefined'
} catch (err) {
return false
}
}
/**
* Sets the value `name` to value.
*
*
* @example
* config.set('property.that.may.not.exist.yet', 'some value')
* config.get('property.that.may.not.exist.yet')
* config.get('.property.that.may.not.exist.yet') // For path which start with `.` are ignored
* // "some value"
*
* config.set('color', 'green', true) // getter: 'force'
* // .get('color') --> 'green'
*
* config.set('color', 'orange', false, true) // getter: 'value' and respectPriority = true
* // value will be not change because ^^^^ respectPriority = true and value priority < force priority
* // .get('color') --> 'green'
*
* config.set('color', 'pink', false) // getter: 'value'
* // value will change because respectPriority is not active.
* // .get('color') --> 'pink'
*
* config.set('color', 'green', true) // getter: 'force'
* // .get('color') --> 'green'
*
* config.merge({color: 'blue'}) // getter: 'value'
* // value will not change because value priority < force priority
* // .get('color') --> 'green'
*
*
* @param {string} name
* Target property, `name` can use dot notation to reference nested values, e.g. `"db.name"`.
* If objects in the chain don't yet exist, they will be initialized to empty objects.
*
* @param {string} [priority=false]
* Optional, can be a boolean or getter name (a string). You must declare this property in
* the schema to use this option. `set` will change the property getter origin depending on
* `priority` value:
* - `false`: priority set to `value`.
* - `true`: priority set to `force`, can be only changed if you do another `.set(name, value)`.
* Make sure that `.refreshGetters()` will not overwrite your value.
* - `<string>`: must be a getter name (e.g.: `default`, `env`, `arg`).
*
* @param {string} [respectPriority=false]
* Optional, if this argument is `true` this function will change the value only if `priority`
* is higher than or equal to the property getter origin.
*
* @return {this}
*/
ConfigObjectModel.prototype.set = function(name, value, priority, respectPriority) {
name = name.replace(/^\.(.+)$/, '$1') // fix fast `root` & `unroot` issue (devfriendly)
const mySchema = traverseSchema(this._schemaRoot, name)
if (!priority) {
priority = 'value'
} else if (typeof priority !== 'string') {
priority = 'force'
} else if (!this._getters.list[priority] && !['value', 'force'].includes(priority)) {
throw new INCORRECT_USAGE('unknown getter: ' + priority)
} else if (!mySchema) { // no schema and custom priority = impossible
const errorMsg = 'you cannot set priority because "' + name + '" not declared in the schema'
throw new INCORRECT_USAGE(errorMsg)
}
// walk to the value
const path = parsePath(name)
const childKey = path.pop()
const parentKey = stringifyPath(path)
const parent = walk(this._instance.root, parentKey, true)
// respect priority
const canIChangeValue = (() => {
if (!respectPriority) { // -> false or not declared -> always change
return true
}
const gettersOrder = this._getters.order
const lastG = mySchema && mySchema.getOrigin && mySchema.getOrigin()
if (lastG && gettersOrder.indexOf(priority) < gettersOrder.indexOf(lastG)) {
return false
}
return true
})()
// change the value
if (canIChangeValue) {
parent[childKey] = (mySchema && mySchema.coerce) ? mySchema.coerce(value) : value
if (mySchema && mySchema._private) {
mySchema._private.origin = priority
}
}
return this
}
/*
* Get the selected property for COM.set(...)
*/
function traverseSchema(schema, path) {
const ar = parsePath(path)
let o = schema
while (ar.length > 0) {
const k = ar.shift()
if (o && o._cvtProperties && o._cvtProperties[k]) {
o = o._cvtProperties[k]
} else {
o = null
break
}
}
return o
}
/**
* Merges a JavaScript object into config
*
* @deprecated since v6.0.0, use `.merge(obj)` instead or strict way: `.merge(obj, 'data')`
*
* @param {object} obj Load object
*
* @return {this}
*/
ConfigObjectModel.prototype.load = function(obj) {
Apply.values.call(this, {
root: cloneDeep(obj)
}, this._instance, this._schema)
return this
}
/**
* Merges a JavaScript properties files into config
*
* @deprecated since v6.0.0, use `.merge(string|string[])` instead or strict way: `.merge(string|string[], 'filepath')`
*
* @param {string|string[]} paths Config file paths
*
* @return {this}
*/
ConfigObjectModel.prototype.loadFile = function(paths) {
if (!Array.isArray(paths)) paths = [paths]
paths.forEach((path) => {
// Support empty config files #253
const json = parseFile.call(this, path)
if (json) {
this.load(json)
}
})
return this
}
function parseFile(path) {
const segments = path.split('.')
const extension = segments.length > 1 ? segments.pop() : ''
// TODO: Get rid of the sync call
// eslint-disable-next-line no-sync
return this.Parser.parse(extension, fs.readFileSync(path, 'utf-8'))
}
/**
* Merges a JavaScript object/files into config
*
* @example
* // Loads/merges a JavaScript object into `config`.
* config.merge({
* 'env': 'test',
* 'ip': '127.0.0.1',
* 'port': 80
* })
*
* @example
* // If you set contentType to data, blueconfig will parse array like config and not like several config.
* config.merge([
* {'ip': 'test'},
* {'ip': '127.0.0.1'},
* {'ip': 80}
* ], 'data').getProperties() // === [{'ip': 'test'}, {'ip': '127.0.0.1'}, {'ip': 80}]
*
* config.merge([
* {'ip': 'test'},
* {'ip': '127.0.0.1'},
* {'ip': 80}
* ]).getProperties() // === {'ip': 80}
*
* // Merges one or multiple JSON configuration files into `config`.
* config.merge('./config/' + conf.get('env') + '.json')
*
* // Or, merging multiple files at once.
* config.merge(process.env.CONFIG_FILES.split(','))
* // -> where env.CONFIG_FILES=/path/to/production.json,/path/to/secrets.json,/path/to/sitespecific.json
*
* @params {object|string|string[]} sources Configs will be merged
* @params {string} [contentType]
* Accept: `data` or `filepath`. If you set contentType to data, blueconfig will parse array like config and not like several config.
*
* @return {this}
*/
ConfigObjectModel.prototype.merge = function(sources, contentType) {
if (!Array.isArray(sources) || contentType === 'data') sources = [sources]
sources.forEach((config) => {
if (typeof config !== 'string' || contentType === 'data') {
this.load(config)
} else {
const json = parseFile.call(this, config)
if (json) {
this.load(json)
}
}
})
return this
}
/**
* Validates the config considering the schema (iterates each `SchemaNode.validate()` in your config).
* All errors are collected and thrown or displayed at once.
*
* @example
* config.validate({
* allowed: 'strict',
* output: require('debug')('blueconfig:validate:error')
* })
*
*
* @memberof ConfigObjectModel
*
* @param {object} [options] Options, accepts: `options.allow` and `options.output`:
*
* @param {string} [options.allowed=warn]
* Any properties specified in config files that are not declared in the schema will
* print a warning or throw an error depending on this setting:
* - `'warn'`: is the default behavior, will print a warning.
* - `'strict'`: will throw errors. This is to ensure that the schema and the config
* files are sync.
*
* @param {string} [options.output]
* You can replace the default output `console.log` by your own output function.
* You can use [debug module](https://www.npmjs.com/package/debug) like the example.
*
* @return {this}
*/
ConfigObjectModel.prototype.validate = function(options) {
options = options || {}
options.allowed = options.allowed || ALLOWED_OPTION_WARN
if (options.output && typeof options.output !== 'function') {
throw new CUSTOMISE_FAILED('options.output is optionnal and must be a function.')
}
const output_function = options.output || global.console.log
const errors = validator.call(this, options.allowed)
// Write 'Warning:' in bold and in yellow
const BOLD_YELLOW_TEXT = '\x1b[33;1m'
const RESET_TEXT = '\x1b[0m'
if (errors.invalid_type.length + errors.undeclared.length + errors.missing.length) {
const sensitive = this._sensitive
const fillErrorBuffer = function(errors) {
const messages = []
errors.forEach(function(err) {
let err_buf = ' - '
/* if (err.type) {
err_buf += '[' + err.type + '] '
} */
if (err.fullname) {
err_buf += unroot(err.fullname) + ': '
}
if (err.message) {
err_buf += err.message
}
const hidden = !!sensitive.has('root.' + err.fullname)
const value = (hidden) ? '[Sensitive]' : JSON.stringify(err.value)
const getterValue = (hidden) ? '[Sensitive]' : JSON.stringify(err.getter && err.getter.keyname)
if (err.value) {
err_buf += ': value was ' + value
const getter = (err.getter) ? err.getter.name : false
if (getter) {
err_buf += ', getter was `' + getter
err_buf += (getter !== 'value') ? '[' + getterValue + ']`' : '`'
}
}
if (!(err instanceof BLUECONFIG_ERROR)) {
let warning = '[/!\\ this is probably blueconfig internal error]'
console.error(err)
if (process.stdout.isTTY) {
warning = BOLD_YELLOW_TEXT + warning + RESET_TEXT
}
err_buf += ' ' + warning
}
messages.push(err_buf)
})
return messages
}
const types_err_buf = fillErrorBuffer(errors.invalid_type).join('\n')
const params_err_buf = fillErrorBuffer(errors.undeclared).join('\n')
const missing_err_buf = fillErrorBuffer(errors.missing).join('\n')
const output_err_bufs = [types_err_buf, missing_err_buf]
if (options.allowed === ALLOWED_OPTION_WARN && params_err_buf.length) {
let warning = 'Warning:'
if (process.stdout.isTTY) {
warning = BOLD_YELLOW_TEXT + warning + RESET_TEXT
}
output_function(warning + '\n' + params_err_buf)
} else if (options.allowed === ALLOWED_OPTION_STRICT) {
output_err_bufs.push(params_err_buf)
}
const output = output_err_bufs
.filter(function(str) {
return str.length
})
.join('\n')
if (output.length) {
throw new VALIDATE_FAILED(output)
}
}
return this
}