diff --git a/lib/convict.js b/lib/convict.js index 6c391f54..cbb08b7d 100644 --- a/lib/convict.js +++ b/lib/convict.js @@ -226,97 +226,92 @@ const BUILT_INS = BUILT_IN_NAMES.map(function(name) { return BUILT_INS_BY_NAME[name]; }); -function normalizeSchema(name, node, props, fullName, env, argv, sensitive) { - // If the current schema node is not a config property (has no "default"), recursively normalize it. - if (typeof node === 'object' && node !== null && !Array.isArray(node) && - Object.keys(node).length > 0 && !('default' in node)) { +function normalizeSchema(name, rawSchema, props, fullName, env, argv, sensitive) { + // If the current schema rawSchema is not a config property (has no "default"), recursively normalize it. + if (typeof rawSchema === 'object' && rawSchema !== null && !Array.isArray(rawSchema) && + Object.keys(rawSchema).length > 0 && !('default' in rawSchema)) { props[name] = { properties: {} }; - Object.keys(node).forEach(function(k) { - normalizeSchema(k, node[k], props[name].properties, fullName + '.' + - k, env, argv, sensitive); + Object.keys(rawSchema).forEach(function(key) { + const path = fullName + '.' + key; + normalizeSchema(key, rawSchema[key], props[name].properties, path, env, argv, sensitive); }); return; - } else if (typeof node !== 'object' || Array.isArray(node) || - node === null || Object.keys(node).length == 0) { + } else if (typeof rawSchema !== 'object' || Array.isArray(rawSchema) || + rawSchema === null || Object.keys(rawSchema).length == 0) { // Normalize shorthand "value" config properties - node = { default: node }; + rawSchema = { default: rawSchema }; } - let o = cloneDeep(node); - props[name] = o; + const schema = cloneDeep(rawSchema); + props[name] = schema; // associate this property with an environmental variable - if (o.env) { - if (!env[o.env]) { - env[o.env] = []; + if (schema.env) { + if (!env[schema.env]) { + env[schema.env] = []; } - env[o.env].push(fullName); + env[schema.env].push(fullName); } // associate this property with a command-line argument - if (o.arg) { - if (argv[o.arg]) { - throw new Error("'" + fullName + "' reuses a command-line argument: " + - o.arg); + if (schema.arg) { + if (argv[schema.arg]) { + throw new Error("'" + fullName + "' reuses a command-line argument: " + schema.arg); } - argv[o.arg] = fullName; + argv[schema.arg] = fullName; } // mark this property as sensitive - if (o.sensitive === true) { + if (schema.sensitive === true) { sensitive.add(fullName); } // store original format function - let format = o.format; - let newFormat; - - if (BUILT_INS.indexOf(format) >= 0 || BUILT_IN_NAMES.indexOf(format) >= 0) { - // if the format property is a built-in JavaScript constructor, - // assert that the value is of that type - let Format = typeof format === 'string' ? BUILT_INS_BY_NAME[format] : format; - newFormat = function(x) { - assert(Object.prototype.toString.call(x) == - Object.prototype.toString.call(new Format()), - 'must be of type ' + Format.name); - }; - o.format = Format.name.toLowerCase(); - - } else if (typeof format === 'string') { - // store declared type - if (!types[format]) { - throw new Error("'" + fullName + "' uses an unknown format type: " + - format); + const format = schema.format; + let newFormat = (() => { + if (BUILT_INS.indexOf(format) >= 0 || BUILT_IN_NAMES.indexOf(format) >= 0) { + // if the format property is a built-in JavaScript constructor, + // assert that the value is of that type + const Format = typeof format === 'string' ? BUILT_INS_BY_NAME[format] : format; + const formatFormat = Object.prototype.toString.call(new Format()); + const myFormat = Format.name; + schema.format = myFormat.toLowerCase(); + return function(value) { + const valueFormat = Object.prototype.toString.call(value); + assert(valueFormat === formatFormat, 'must be of type ' + myFormat); + }; + } else if (typeof format === 'string') { + // store declared type + if (!types[format]) { + throw new Error("'" + fullName + "' uses an unknown format type: " + format); + } + // use a predefined type + return types[format]; + } else if (Array.isArray(format)) { + // assert that the value is a valid option + return contains.bind(null, format); + } else if (typeof format === 'function') { + return format; + } else if (format && typeof format !== 'function') { + throw new Error("'" + fullName + + "': `format` must be a function or a known format type."); + } else { // !format + // default format is the typeof the default value + const defaultFormat = Object.prototype.toString.call(schema.default); + const myFormat = defaultFormat.replace(/\[.* |]/g, ''); + // magic coerceing + schema.format = myFormat.toLowerCase(); + return function(value) { + const valueFormat = Object.prototype.toString.call(value); + assert(valueFormat === defaultFormat, 'must be of type ' + myFormat); + }; } + })(); - // use a predefined type - newFormat = types[format]; - - } else if (Array.isArray(format)) { - // assert that the value is a valid option - newFormat = contains.bind(null, format); - - } else if (typeof format === 'function') { - newFormat = format; - - } else if (format && typeof format !== 'function') { - throw new Error("'" + fullName + - "': `format` must be a function or a known format type."); - } - - if (!newFormat && !format) { - // default format is the typeof the default value - let type = Object.prototype.toString.call(o.default); - newFormat = function(x) { - assert(Object.prototype.toString.call(x) == type, - ' should be of type ' + type.replace(/\[.* |]/g, '')); - }; - } - - o._format = function(x) { + schema._format = function(x) { try { - newFormat(x, this); + newFormat(x, schema); // schema = this } catch (e) { // attach the value and the property's fullName to the error e.fullName = fullName; @@ -352,15 +347,14 @@ function importArguments(o) { }); } -function addDefaultValues(schema, c, instance) { +function addDefaultValues(schema, node) { Object.keys(schema.properties).forEach(function(name) { - let p = schema.properties[name]; - if (p.properties) { - let kids = c[name] || {}; - addDefaultValues(p, kids, instance); - c[name] = kids; + const mySchema = schema.properties[name]; + if (mySchema.properties) { + node[name] = {}; // node[name] is always undefined because addDefaultValues is the first to run. + addDefaultValues(mySchema, node[name]); } else { - c[name] = coerce(name, cloneDeep(p.default), schema, instance); + node[name] = coerce(mySchema, cloneDeep(mySchema.default)); } }); } @@ -368,13 +362,14 @@ function addDefaultValues(schema, c, instance) { function isObj(o) { return (typeof o === 'object' && o !== null); } function overlay(from, to, schema) { - Object.keys(from).forEach(function(k) { + Object.keys(from).forEach(function(name) { + const mySchema = (schema && schema.properties) ? schema.properties[name] : null; // leaf - if (Array.isArray(from[k]) || !isObj(from[k]) || !schema || schema.format === 'object') { - to[k] = coerce(k, from[k], schema); + if (Array.isArray(from[name]) || !isObj(from[name]) || !schema || schema.format === 'object') { + to[name] = coerce(mySchema, from[name]); } else { - if (!isObj(to[k])) to[k] = {}; - overlay(from[k], to[k], schema.properties[k]); + if (!isObj(to[name])) to[name] = {}; + overlay(from[name], to[name], mySchema); } }); } @@ -395,49 +390,41 @@ function traverseSchema(schema, path) { return o; } -function getFormat(schema, path) { - let o = traverseSchema(schema, path); - if (o == null) return null; - if (typeof o.format === 'string') return o.format; - if (o.default != null) return typeof o.default; - return null; -} - -function coerce(k, v, schema, instance) { - // magic coerceing - let format = getFormat(schema, k); +function coerce(schema, v) { + const format = (schema && typeof schema.format === 'string') ? schema.format : null; if (typeof v === 'string') { if (converters.has(format)) { - return converters.get(format)(v, instance, k); + return converters.get(format)(v); } switch (format) { case 'port': case 'nat': case 'integer': - case 'int': v = parseInt(v, 10); break; - case 'port_or_windows_named_pipe': v = isWindowsNamedPipe(v) ? v : parseInt(v, 10); break; - case 'number': v = parseFloat(v); break; - case 'boolean': v = (String(v).toLowerCase() !== 'false'); break; - case 'array': v = v.split(','); break; - case 'object': v = JSON.parse(v); break; - case 'regexp': v = new RegExp(v); break; - case 'timestamp': v = moment(v).valueOf(); break; + case 'int': return parseInt(v, 10); + case 'port_or_windows_named_pipe': return isWindowsNamedPipe(v) ? v : parseInt(v, 10); + case 'number': return parseFloat(v); + case 'boolean': return (String(v).toLowerCase() !== 'false'); + case 'array': return v.split(','); + case 'object': return JSON.parse(v); + case 'regexp': return new RegExp(v); + case 'timestamp': return moment(v).valueOf(); case 'duration': { let split = v.split(' '); if (split.length == 1) { // It must be an integer in string form. - v = parseInt(v, 10); + return parseInt(v, 10); } else { // Add an "s" as the unit of measurement used in Moment if (!split[1].match(/s$/)) split[1] += 's'; - v = moment.duration(parseInt(split[0], 10), split[1]).valueOf(); + return moment.duration(parseInt(split[0], 10), split[1]).valueOf(); } - break; } default: - // TODO: Should we throw an exception here? + // ? } + + // TODO: Should we throw an exception here? } return v; @@ -581,7 +568,7 @@ let convict = function convict(def, opts) { * exist, they will be initialized to empty objects */ set: function(k, v) { - v = coerce(k, v, this._schema, this); + v = coerce(traverseSchema(this._schema, k), v); let path = k.split('.'); let childKey = path.pop(); let parentKey = path.join('.'); @@ -712,7 +699,7 @@ let convict = function convict(def, opts) { }); rv._instance = {}; - addDefaultValues(rv._schema, rv._instance, rv); + addDefaultValues(rv._schema, rv._instance); importEnvironment(rv); importArguments(rv); @@ -742,8 +729,8 @@ convict.addFormat = function(name, validate, coerce) { * Adds new custom formats */ convict.addFormats = function(formats) { - Object.keys(formats).forEach(function(type) { - convict.addFormat(type, formats[type].validate, formats[type].coerce); + Object.keys(formats).forEach(function(name) { + convict.addFormat(name, formats[name].validate, formats[name].coerce); }); }; diff --git a/test/cases/coerce_aggregate.js b/test/cases/coerce_aggregate.js deleted file mode 100644 index 2da9b3b9..00000000 --- a/test/cases/coerce_aggregate.js +++ /dev/null @@ -1,31 +0,0 @@ -'use strict'; - -exports.conf = { - nested: { - data: { - format: 'map', - default: {}, - arg: 'data' - } - } -} - -exports.argv = '--data key=val --data foo=bar' - -exports.formats = { - map: { - validate: function(value) { - if (typeof value !== 'object') { - throw new Error('must be a map of key/value pairs'); - } - }, - coerce: function(value, config, path) { - const accum = config.get(path); - value.split(',').forEach(function(pair) { - const [k, v] = pair.split('='); - accum[k] = v; - }) - return accum; - } - } -} diff --git a/test/cases/coerce_aggregate.json b/test/cases/coerce_aggregate.json deleted file mode 100644 index 3d51d7cf..00000000 --- a/test/cases/coerce_aggregate.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "nested": { - "data": { - "foo": "baz" - } - } -} diff --git a/test/cases/coerce_aggregate.out b/test/cases/coerce_aggregate.out deleted file mode 100644 index bd2f90a3..00000000 --- a/test/cases/coerce_aggregate.out +++ /dev/null @@ -1,8 +0,0 @@ -{ - "nested": { - "data": { - "foo": "bar", - "key": "val" - } - } -} diff --git a/test/cases/coerce_placeholder.js b/test/cases/coerce_placeholder.js deleted file mode 100644 index 7bd348b6..00000000 --- a/test/cases/coerce_placeholder.js +++ /dev/null @@ -1,45 +0,0 @@ -'use strict'; - -exports.conf = { - env: { - format: ['production', 'local'], - default: 'local', - env: 'NODE_ENV', - doc: 'The environment that we\'re running in.' - }, - nested: { - value: { - format: String, - default: 'root', - doc: 'Nested value.' - } - }, - root: { - format: 'placeholder', - default: '/path/to/${nested.value}', - doc: 'The path to the root directory.' - }, - configPath: { - format: 'placeholder', - default: '${root}/config', - doc: 'Path to configuration files. Defaults to ${root}/config/' - }, - config: { - format: 'placeholder', - default: '${configPath}/${env}.json', - doc: 'Path to configuration file. Defaults to ${configPath}/${env}.json' - } -}; - -exports.env = { - env: 'local' -}; - -exports.formats = { - placeholder: { - validate: function() { }, - coerce: function(value, config) { - return value.replace(/\$\{([\w.]+)}/g, function(v,m) { return config.get(m); }); - } - } -}; diff --git a/test/cases/coerce_placeholder.json b/test/cases/coerce_placeholder.json deleted file mode 100644 index 0967ef42..00000000 --- a/test/cases/coerce_placeholder.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/test/cases/coerce_placeholder.out b/test/cases/coerce_placeholder.out deleted file mode 100644 index 99d1177a..00000000 --- a/test/cases/coerce_placeholder.out +++ /dev/null @@ -1,9 +0,0 @@ -{ - "env": "local", - "nested": { - "value": "root" - }, - "root": "/path/to/root", - "configPath": "/path/to/root/config", - "config": "/path/to/root/config/local.json" -} diff --git a/test/cases/magic_coerceing.js b/test/cases/magic_coerceing.js new file mode 100644 index 00000000..a12cf635 --- /dev/null +++ b/test/cases/magic_coerceing.js @@ -0,0 +1,17 @@ +'use strict'; + +exports.conf = { + int: { + default: 8, + env: 'INT' + }, + str: { + format: 'String', + default: '8', + env: '' + } +}; + +exports.env = { + INT: '7' +}; diff --git a/test/cases/magic_coerceing.out b/test/cases/magic_coerceing.out new file mode 100644 index 00000000..99e7e863 --- /dev/null +++ b/test/cases/magic_coerceing.out @@ -0,0 +1,4 @@ +{ + "int": 7, + "str": "8" +} \ No newline at end of file diff --git a/test/cases/schema-object.schema b/test/cases/schema-object.schema index 19237648..e6c40efe 100644 --- a/test/cases/schema-object.schema +++ b/test/cases/schema-object.schema @@ -1,13 +1,16 @@ { "properties": { "shorthand": { - "default": "value" + "default": "value", + "format": "string" }, "basic": { - "default": true + "default": true, + "format": "boolean" }, "doc": { "default": null, + "format": "null", "doc": "A value with a docstring" }, "format": { @@ -20,14 +23,17 @@ }, "env": { "default": "", + "format": "string", "env": "ENV" }, "arg": { "default": false, + "format": "boolean", "arg": "arg" }, "sensitive": { "default": "password", + "format": "string", "sensitive": true }, "combined": { @@ -41,7 +47,8 @@ "nested": { "properties": { "child": { - "default": "ababa" + "default": "ababa", + "format": "string" } } } diff --git a/test/schema-tests.js b/test/schema-tests.js index eff1a0f1..de05ee2c 100644 --- a/test/schema-tests.js +++ b/test/schema-tests.js @@ -102,17 +102,20 @@ describe('convict schema', function() { 'foo': { 'properties': { 'bar': { - 'default': 7 + 'default': 7, + 'format': 'number' }, 'baz': { 'properties': { 'bing': { - 'default': 'foo' + 'default': 'foo', + 'format': 'string' }, 'name with spaces': { 'properties': { 'name_with_underscores': { - 'default': true + 'default': true, + 'format': 'boolean' } } } @@ -131,17 +134,20 @@ describe('convict schema', function() { 'foo': { 'properties': { 'bar': { - 'default': 7 + 'default': 7, + 'format': 'number' }, 'baz': { 'properties': { 'bing': { - 'default': 'foo' + 'default': 'foo', + 'format': 'string' }, 'name with spaces': { 'properties': { 'name_with_underscores': { - 'default': true + 'default': true, + 'format': 'boolean' } } }