diff --git a/.circleci/config.yml b/.circleci/config.yml index 6d8f9ff..3eab4c7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,7 +2,7 @@ version: 2 jobs: lint: docker: - - image: circleci/node:8.5 + - image: circleci/node:8.9 steps: - checkout - run: @@ -13,7 +13,7 @@ jobs: command: npm run lint test: docker: - - image: circleci/node:8.5 + - image: circleci/node:8.9 steps: - checkout - run: diff --git a/.eslintrc b/.eslintrc index ee2cf07..2199955 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,5 +2,8 @@ "env": { "node": true }, - "extends": "qubyte/ES2015" + "extends": "qubyte/ES2015", + "rules": { + "complexity": ["error", 5] + } } diff --git a/index.js b/index.js index 4bcce55..4c655f2 100644 --- a/index.js +++ b/index.js @@ -2,9 +2,18 @@ const processConfig = require('./lib/processConfig'); const makeParsers = require('./lib/makeParsers'); +const checkTypes = require('./lib/checkTypes'); +const checkRequiredProperties = require('./lib/checkRequiredProperties'); module.exports = function configeur(schema, options = {}) { - return processConfig(schema, process.env, makeParsers(options.parsers), options.mutable); + const parsers = makeParsers(options.parsers); + + checkTypes(parsers, schema); + checkRequiredProperties(process.env, schema); + + const config = processConfig(schema, process.env, parsers); + + return options.mutable ? config : Object.freeze(config); }; module.exports.default = module.exports; diff --git a/lib/checkRequiredProperties.js b/lib/checkRequiredProperties.js new file mode 100644 index 0000000..08ec7cc --- /dev/null +++ b/lib/checkRequiredProperties.js @@ -0,0 +1,17 @@ +'use strict'; + +module.exports = function checkRequiredProperties(env, schema) { + const missingRequiredProperties = []; + + for (const optionName of Object.keys(schema)) { + if (schema[optionName].required && typeof env[optionName] !== 'string') { + missingRequiredProperties.push(optionName); + } + } + + if (missingRequiredProperties.length) { + const error = new Error(`Missing required config for: ${missingRequiredProperties.join(', ')}`); + error.missingRequiredProperties = missingRequiredProperties; + throw error; + } +}; diff --git a/lib/checkTypes.js b/lib/checkTypes.js new file mode 100644 index 0000000..2782ea7 --- /dev/null +++ b/lib/checkTypes.js @@ -0,0 +1,12 @@ +'use strict'; + +module.exports = function checkTypes(parsers, schema) { + for (const optionName of Object.keys(schema)) { + const optionProperties = schema[optionName]; + const type = optionProperties.type || 'string'; + + if (!parsers.get(type)) { + throw new Error(`Unsupported config value type: ${type}`); + } + } +}; diff --git a/lib/defaultParsers.js b/lib/defaultParsers.js deleted file mode 100644 index 4f86d93..0000000 --- a/lib/defaultParsers.js +++ /dev/null @@ -1,7 +0,0 @@ -'use strict'; - -module.exports = [ - ['string', value => value], - ['boolean', value => value === 'true'], - ['number', Number] -]; diff --git a/lib/makeParsers.js b/lib/makeParsers.js index 262a947..89362a8 100644 --- a/lib/makeParsers.js +++ b/lib/makeParsers.js @@ -1,12 +1,16 @@ 'use strict'; -const defaultParsers = require('./defaultParsers'); - function checkValidParserSpec(spec) { return Array.isArray(spec) && spec.length === 2 && typeof spec[0] === 'string' && typeof spec[1] === 'function'; } module.exports = function makeParsers(additional) { + const defaultParsers = [ + ['string', value => value], + ['boolean', value => value === 'true'], + ['number', Number] + ]; + if (additional === undefined) { return new Map(defaultParsers); } diff --git a/lib/processConfig.js b/lib/processConfig.js index a027139..9222e85 100644 --- a/lib/processConfig.js +++ b/lib/processConfig.js @@ -1,39 +1,23 @@ 'use strict'; -function parse(name, value, parser) { +function checkAndParseOption(optionName, schema, env, parsers) { + const { type, defaultValue } = schema[optionName]; + const parser = parsers.get(type || 'string'); + const value = env[optionName] || defaultValue; + if (typeof value !== 'string') { - throw new Error(`Value to cast for ${name} was not a string: ${value}`); + throw new Error(`Value to cast for ${optionName} was not a string: ${value}`); } return parser(value); } -module.exports = function processConfig(schema, env, parsers, mutable) { +module.exports = function processConfig(schema, env, parsers) { const config = Object.create(null); - const envNames = Object.getOwnPropertyNames(env); - const missingRequiredProperties = []; for (const optionName of Object.keys(schema)) { - const optionProperties = schema[optionName]; - const type = Object.prototype.hasOwnProperty.call(optionProperties, 'type') ? optionProperties.type : 'string'; - const parser = parsers.get(type); - - if (!parser) { - throw new Error(`Unsupported config value type: ${type}`); - } - - if (envNames.includes(optionName)) { - config[optionName] = parse(optionName, env[optionName], parser); - } else if (!optionProperties.required) { - config[optionName] = parse(optionName, optionProperties.defaultValue, parser); - } else { - missingRequiredProperties.push(optionName); - } - } - - if (missingRequiredProperties.length) { - throw new Error(`Missing required config for: ${missingRequiredProperties.join(', ')}`); + config[optionName] = checkAndParseOption(optionName, schema, env, parsers); } - return mutable ? config : Object.freeze(config); + return config; }; diff --git a/package.json b/package.json index 2d30e69..b73dfcb 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ }, "files": [ "index.js", - "lib/defaultParsers.js", + "lib/checkRequiredProperties.js", + "lib/checkTypes.js", "lib/makeParsers.js", "lib/processConfig.js", "index.d.ts" @@ -32,10 +33,9 @@ }, "homepage": "https://github.com/qubyte/configeur", "devDependencies": { - "eslint": "^4.8.0", + "eslint": "^4.17.0", "eslint-config-qubyte": "^1.1.0", - "mocha": "^4.0.0", - "sandboxed-module": "^2.0.3", - "sinon": "^4.0.1" + "mocha": "^5.0.0", + "sandboxed-module": "^2.0.3" } } diff --git a/test/index.test.js b/test/index.test.js deleted file mode 100644 index 3484af0..0000000 --- a/test/index.test.js +++ /dev/null @@ -1,55 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const sinon = require('sinon'); -const SandboxedModule = require('sandboxed-module'); - -describe('configeur', () => { - let processConfigStub; - let makeParsersStub; - let configeur; - - beforeEach(() => { - processConfigStub = sinon.stub(); - makeParsersStub = sinon.stub(); - - processConfigStub.returns('fakeProcessedConfig'); - makeParsersStub.returns('fakeParsers'); - - configeur = SandboxedModule.require('../index', { - requires: { - './lib/processConfig': processConfigStub, - './lib/makeParsers': makeParsersStub - }, - globals: { - process: { - env: { AN_ARG: 'argValue' } - } - } - }); - }); - - it('is a function', () => { - assert.strictEqual(typeof configeur, 'function'); - }); - - it('re-exports itself as default', () => { - assert.equal(configeur.default, configeur); - }); - - it('returns a config map', () => { - assert.strictEqual(configeur('fakeSchema'), 'fakeProcessedConfig'); - }); - - it('makes a parsers map with the parsers option', () => { - configeur('fakeSchema', { parsers: 'fake-custom-parsers' }); - - assert.deepEqual(makeParsersStub.args, [['fake-custom-parsers']]); - }); - - it('passes the schema, the environment, and the parsers to processConfig', () => { - configeur('fakeSchema', { parsers: 'fake-custom-parsers', mutable: 'is-mutable' }); - - assert.deepEqual(processConfigStub.args, [['fakeSchema', { AN_ARG: 'argValue' }, 'fakeParsers', 'is-mutable']]); - }); -}); diff --git a/test/integration.test.js b/test/integration.test.js new file mode 100644 index 0000000..623d9be --- /dev/null +++ b/test/integration.test.js @@ -0,0 +1,143 @@ +'use strict'; + +const assert = require('assert'); +const SandboxedModule = require('sandboxed-module'); + +const env = {}; +const configeur = SandboxedModule.require('../index', { + globals: { + process: { + env + } + } +}); + +describe('configeur', () => { + let schema; + + beforeEach(() => { + for (const name of Object.getOwnPropertyNames(env)) { + delete env[name]; + } + + env.AN_ARG = 'Hello, world!'; + + schema = { + AN_ARG: { + type: 'string', + required: true + }, + ANOTHER_ARG: { + type: 'number', + defaultValue: '123' + } + }; + }); + + it('is a function', () => { + assert.strictEqual(typeof configeur, 'function'); + }); + + it('re-exports itself as default', () => { + assert.equal(configeur.default, configeur); + }); + + it('returns a frozen prototypeless object when mutable is falsy', () => { + const config = configeur(schema); + + assert.strictEqual(Object.getPrototypeOf(config), null); + assert.ok(Object.isFrozen(config)); + assert.deepStrictEqual(Object.assign({}, config), { + AN_ARG: 'Hello, world!', + ANOTHER_ARG: 123 + }); + }); + + it('returns a non-frozen prototypeless object when mutable is truthy', () => { + const config = configeur(schema, { mutable: true }); + + assert.strictEqual(Object.getPrototypeOf(config), null); + assert.ok(!Object.isFrozen(config)); + assert.deepStrictEqual(Object.assign({}, config), { + AN_ARG: 'Hello, world!', + ANOTHER_ARG: 123 + }); + }); + + it('provides parsers for strings (default), numbers, and booleans', () => { + const schema = { + A: { defaultValue: 'blah' }, + B: { type: 'string', defaultValue: 'bleh' }, + C: { type: 'number', defaultValue: '123' }, + D: { type: 'boolean', defaultValue: 'true' } + }; + + const fine = configeur(schema); + + assert.deepStrictEqual(Object.assign({}, fine), { A: 'blah', B: 'bleh', C: 123, D: true }); + }); + + it('allows additional parsers to be provided', () => { + const schema = { + A: { defaultValue: 'blah' }, + B: { type: 'string', defaultValue: 'bleh' }, + C: { type: 'number', defaultValue: '123' }, + D: { type: 'boolean', defaultValue: 'true' }, + E: { type: 'custom', defaultValue: 'flop' } + }; + + const parsers = [ + ['custom', () => 'flop'] + ]; + + const fine = configeur(schema, { parsers }); + + assert.deepStrictEqual(Object.assign({}, fine), { A: 'blah', B: 'bleh', C: 123, D: true, E: 'flop' }); + }); + + it('checks for invalid parser types', () => { + const schema = { + A: { defaultValue: 'blah' }, + B: { type: 'string', defaultValue: 'bleh' }, + C: { type: 'number', defaultValue: '123' }, + D: { type: 'boolean', defaultValue: 'true' }, + E: { type: 'custom', defaultValue: 'flop' }, + F: { type: 'non-existent', defaultValue: 'uh oh' } + }; + + const parsers = [ + ['custom', () => 'flop'] + ]; + + assert.throws( + () => configeur(schema, { parsers }), + err => err.message === 'Unsupported config value type: non-existent' + ); + }); + + it('lists missing required options in a thrown error', () => { + const schema = { + A: { required: true }, + AN_ARG: { required: true }, + C: { required: true }, + D: { defaultValue: 'blah' } + }; + + assert.throws( + () => configeur(schema), + err => { + const [x, y, ...z] = err.missingRequiredProperties; + return err instanceof Error && z.length === 0 && x === 'A' && y === 'C'; + } + ); + }); + + it('throws an error when an environment property is not a string', () => { + env.ANOTHER_ARG = 123; + + assert.throws( + () => configeur(schema), + err => err instanceof Error && err.message === 'Value to cast for ANOTHER_ARG was not a string: 123' + ); + }); +}); diff --git a/test/lib/defaultParsers.test.js b/test/lib/defaultParsers.test.js deleted file mode 100644 index 21957d1..0000000 --- a/test/lib/defaultParsers.test.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const defaultParsers = require('../../lib/defaultParsers'); - -describe('defaultParsers', () => { - it('is an array', () => { - assert.ok(Array.isArray(defaultParsers)); - }); - - it('contains parsers for "string", "boolean", "number", and "Array"', () => { - const parserMap = new Map(defaultParsers); - - assert.equal(parserMap.size, 3); - assert.equal(typeof parserMap.get('string'), 'function'); - assert.equal(typeof parserMap.get('boolean'), 'function'); - assert.equal(typeof parserMap.get('number'), 'function'); - }); - - describe('string parser', () => { - const parser = new Map(defaultParsers).get('string'); - - it('returns the value passed to it', () => { - assert.equal(parser('blah'), 'blah'); - }); - }); - - describe('boolean parser', () => { - const parser = new Map(defaultParsers).get('boolean'); - - it('casts "true" to true', () => { - assert.strictEqual(parser('true'), true); - }); - - it('casts "false" to false', () => { - assert.strictEqual(parser('false'), false); - }); - - it('casts other strings to false', () => { - assert.strictEqual(parser('blah'), false); - }); - }); - - describe('number parser', () => { - const parser = new Map(defaultParsers).get('number'); - - it('casts to number', () => { - assert.strictEqual(parser('10'), 10); - assert.strictEqual(parser('10.1'), 10.1); - assert.ok(Number.isNaN(parser('blah'))); - }); - }); -}); diff --git a/test/lib/makeParsers.test.js b/test/lib/makeParsers.test.js deleted file mode 100644 index 4acebf1..0000000 --- a/test/lib/makeParsers.test.js +++ /dev/null @@ -1,77 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const makeParsers = require('../../lib/makeParsers'); -const defaultParsers = require('../../lib/defaultParsers'); - -describe('makeParsers', () => { - it('is a function', () => { - assert.equal(typeof makeParsers, 'function'); - }); - - describe('when no additional parsers are given', () => { - let parsers; - - beforeEach(() => { - parsers = makeParsers(); - }); - - it('returns a Map instance containing default parsers.', () => { - assert.ok(parsers instanceof Map); - assert.deepEqual([...parsers.entries()], defaultParsers); - }); - }); - - describe('when additional parsers are not given as an array', () => { - it('throws an error', () => { - assert.throws( - () => makeParsers('blah'), - /Additional parsers must be undefined or an array./ - ); - }); - }); - - describe('when a parser is not a string-function pair', () => { - it('throws an error', () => { - assert.throws( - () => makeParsers(['no parser']), // Only parser name given. - /Custom parsers must be arrays with name and parser as their two elements./ - ); - - assert.throws( - () => makeParsers([undefined, () => {}]), // Only parser function given. - /Custom parsers must be arrays with name and parser as their two elements./ - ); - }); - }); - - describe('when additional parsers are valid', () => { - const replacementStringParser = () => {}; - const somethingParser = () => {}; - - let parsers; - - beforeEach(() => { - parsers = makeParsers([ - ['string', replacementStringParser], - ['something', somethingParser] - ]); - }); - - it('includes default parsers', () => { - for (const [key, parser] of new Map(defaultParsers)) { - if (key !== 'string') { - assert.equal(parsers.get(key), parser); - } - } - }); - - it('includes additional parsers', () => { - assert.equal(parsers.get('something'), somethingParser); - }); - - it('can be used to override built in parsers', () => { - assert.equal(parsers.get('string'), replacementStringParser); - }); - }); -}); diff --git a/test/lib/processConfig.test.js b/test/lib/processConfig.test.js deleted file mode 100644 index 70ffcb5..0000000 --- a/test/lib/processConfig.test.js +++ /dev/null @@ -1,118 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const parsers = new Map(require('../../lib/defaultParsers')); -const processConfig = require('../../lib/processConfig'); - -function getOwnPropertyDescriptors(obj) { - const descriptors = {}; - - for (const key of Object.getOwnPropertyNames(obj)) { - descriptors[key] = Object.getOwnPropertyDescriptor(obj, key); - } - - return descriptors; -} - -describe('processConfig', () => { - let schema; - let config; - - beforeEach(() => { - schema = { - OPTION_WITH_DEFAULT: { - defaultValue: 'the-default', - type: 'string' - }, - OPTION_IS_REQUIRED: { - required: true, - type: 'number' - }, - ANOTHER_OPTION_IS_REQUIRED: { - required: true, - type: 'boolean' - } - }; - }); - - it('is a function', () => { - assert.equal(typeof processConfig, 'function'); - }); - - describe('when missing optional config', () => { - beforeEach(() => { - config = processConfig(schema, { OPTION_IS_REQUIRED: '10', ANOTHER_OPTION_IS_REQUIRED: 'true' }, parsers); - }); - - it('uses defaults and returns a frozen prototypeless object with parsed values', () => { - assert.deepStrictEqual(getOwnPropertyDescriptors(config), { - OPTION_WITH_DEFAULT: { value: 'the-default', enumerable: true, writable: false, configurable: false }, - OPTION_IS_REQUIRED: { value: 10, enumerable: true, writable: false, configurable: false }, - ANOTHER_OPTION_IS_REQUIRED: { value: true, enumerable: true, writable: false, configurable: false } - }); - }); - }); - - describe('when missing required config', () => { - it('throws an error with a list of missing required options', () => { - assert.throws( - () => processConfig(schema, {}, parsers), - /Missing required config for: OPTION_IS_REQUIRED, ANOTHER_OPTION_IS_REQUIRED/ - ); - }); - }); - - describe('when optional config is available', () => { - beforeEach(() => { - config = processConfig(schema, { - OPTION_WITH_DEFAULT: 'overridden', - OPTION_IS_REQUIRED: '10', - ANOTHER_OPTION_IS_REQUIRED: 'true' - }, parsers); - }); - - it('uses the given optional config and returns a frozen prototypeless object with parsed values', () => { - assert.deepStrictEqual(getOwnPropertyDescriptors(config), { - OPTION_WITH_DEFAULT: { value: 'overridden', enumerable: true, writable: false, configurable: false }, - OPTION_IS_REQUIRED: { value: 10, enumerable: true, writable: false, configurable: false }, - ANOTHER_OPTION_IS_REQUIRED: { value: true, enumerable: true, writable: false, configurable: false } - }); - }); - }); - - describe('when given a value which is not a string', () => { - it('throws an error', () => { - assert.throws( - () => processConfig(schema, { - OPTION_WITH_DEFAULT: true, - OPTION_IS_REQUIRED: '10', - ANOTHER_OPTION_IS_REQUIRED: 'true' - }, parsers), - /Value to cast for OPTION_WITH_DEFAULT was not a string: true/ - ); - }); - }); - - describe('when given a type which does not correspond to a parser', () => { - it('throws an error', () => { - schema.OPTION_WITH_DEFAULT.type = 'blah'; - - assert.throws( - () => processConfig(schema, { OPTION_IS_REQUIRED: '10', ANOTHER_OPTION_IS_REQUIRED: 'true' }, parsers), - /Unsupported config value type: blah/ - ); - }); - }); - - describe('when configured to be mutable', () => { - it('returns a mutable object', () => { - const conf = processConfig(schema, { OPTION_IS_REQUIRED: '10', ANOTHER_OPTION_IS_REQUIRED: 'true' }, parsers, true); - - assert.doesNotThrow(() => { - conf.OPTION_WITH_DEFAULT = 'hello'; - }); - - assert.equal(conf.OPTION_WITH_DEFAULT, 'hello'); - }); - }); -});