UNPKG

blueconfig

Version:

Merge and validate your config depending of your schema, forked from node-convict.

783 lines (673 loc) 24.8 kB
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 }