diff --git a/README.md b/README.md index 164b4f5..50a7cb5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,9 @@ common-env ========== -[![Build Status](https://img.shields.io/circleci/project/FGRibreau/common-env.svg)](https://circleci.com/gh/FGRibreau/common-env/) -[![Deps](https://img.shields.io/david/FGRibreau/common-env.svg)](https://david-dm.org/FGRibreau/common-env) -[![NPM version](https://img.shields.io/npm/v/common-env.svg)](http://badge.fury.io/js/common-env) [![Downloads](http://img.shields.io/npm/dm/common-env.svg)](https://www.npmjs.com/package/common-env) ![extra](https://img.shields.io/badge/actively%20maintained-yes-ff69b4.svg) [![Twitter Follow](https://img.shields.io/twitter/follow/fgribreau.svg?style=flat)](https://twitter.com/FGRibreau) +[![Build Status](https://img.shields.io/circleci/project/FGRibreau/common-env.svg)](https://circleci.com/gh/FGRibreau/common-env/) [![Coverage Status](https://img.shields.io/coveralls/FGRibreau/common-env/master.svg)](https://coveralls.io/github/FGRibreau/common-env?branch=master) [![Deps]( https://img.shields.io/david/FGRibreau/common-env.svg)](https://david-dm.org/FGRibreau/common-env) [![NPM version](https://img.shields.io/npm/v/common-env.svg)](http://badge.fury.io/js/common-env) [![Downloads](http://img.shields.io/npm/dm/common-env.svg)](https://www.npmjs.com/package/common-env) ![extra](https://img.shields.io/badge/actively%20maintained-yes-ff69b4.svg) -A little helper I use everywhere for configuration. [Environment variables](http://blog.honeybadger.io/ruby-guide-environment-variables/) are a really great way to quickly change a program behavior. +A library I use everywhere for configuration. [Environment variables](http://blog.honeybadger.io/ruby-guide-environment-variables/) are a really great way to quickly change a program behavior. # Philosophy @@ -137,6 +135,48 @@ It's sometimes useful to be able to specify aliases, for instance [Clever-cloud] Common-env adds a [layer of indirection](http://en.wikipedia.org/wiki/Fundamental_theorem_of_software_engineering) enabling you to specify environment aliases that won't impact your codebase. +#### How to handle environment variable arrays + +Since **v6**, common-env is able to read arrays from environment variables. Before going further, please don't forget that **environment variables do not support arrays**, thus `MY_ENV_VAR[0]_A` is not a valid environment variable name, as well as `MY_ENV_VAR$0$_A` and so on. In fact, the only supported characters are `[0-9_]`. But since we wanted **a lot** array support [we had to find a work-around](https://github.com/FGRibreau/common-env/issues/6). + +And here is what we did: + +| Configuration key path | Generated environment key | +|---|---| +| amqp.exchanges[0].name | AMQP_EXCHANGES__0_NAME | +| amqp.exchanges[10].name | AMQP_EXCHANGES__10_NAME | + +As you can see, we a replacing `[0]`, with `__0` and thus common-env is compliant with the limited character support while providing an awesome abstraction for configuration through environment variables. + +Note that **only the first element** of the array will be used as a **description** for every other element of the array. So in the following code: + +```js +const config = env.getOrElseAll({ + mysql: { + hosts: [{ + host: '127.0.0.1', + port: 3306 + }, { + auth: { + $type: env.types.String + $secure: true + } + }] + } +}); +``` + +only the first object + +`{ + host: '127.0.0.1', + port: 3306 +}` + +will be used as a *type* template for every defined elements. + +One last thing, common-env is smart enough to build plain arrays (not sparse), so if you defined `MYSQL_HOSTS__10_PORT=3310`, `config.mysql.hosts` will contains **10 objects** as you thought it would. + #### How to specify environment variable arrays Common-env is able to use arrays as key values for instance: @@ -166,6 +206,8 @@ $ AMQP_HOSTS='88.23.21.21,88.23.21.22,88.23.21.23' node test.js #### How to specify environment variable arrays using $aliases +**Deprecated** aliases breaks common-env philosophy by allowing a developer to specify environment variables that matches outside constraints (like a company convention). Since a software internal configuration should not depends on external factors, this feature is now deprecated. + ```javascript // test.js var env = require('common-env')(); @@ -192,6 +234,9 @@ $ LOCAL_RABBITMQ_HOSTS='88.23.21.21,88.23.21.22,88.23.21.23' node test.js ['88.23.21.21', '88.23.21.22', '88.23.21.23'] ``` +Aliases don't supports arrays in their names and never will. **$aliases is deprecated**, please use common-env classical form. + + ##### fail-fast behaviour If `$default` is not defined and no environment variables (aliases included) resolve to a value then common-env will throw an error. This error should not be caught in order to make the app crash, following the [fail-fast](https://en.wikipedia.org/wiki/Fail-fast) principle. diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000..e3c2322 --- /dev/null +++ b/circle.yml @@ -0,0 +1,10 @@ +machine: + node: + version: 6.3 + environment: + COVERALLS_SERVICE_NAME: circleci + +test: + override: + - npm run test + - npm run send-coverage diff --git a/lib/CommonEnvGetOrDieAliasesException.js b/lib/CommonEnvGetOrDieAliasesException.js deleted file mode 100644 index 7a61d2d..0000000 --- a/lib/CommonEnvGetOrDieAliasesException.js +++ /dev/null @@ -1,13 +0,0 @@ -'use strict'; -var util = require('util'); -var _ = require('lodash'); - -function CommonEnvGetOrDieAliasesException(aliases) { - aliases = _.isArray(aliases) ? aliases : []; - this.message = 'At least one environment variable of [{key}] MUST be defined'.replace('{key}', aliases.join(', ')); - this.name = 'CommonEnvGetOrDieAliasesException'; -} - -util.inherits(CommonEnvGetOrDieAliasesException, Error); - -module.exports = CommonEnvGetOrDieAliasesException; diff --git a/lib/CommonEnvGetOrDieException.js b/lib/CommonEnvGetOrDieException.js deleted file mode 100644 index 67d817f..0000000 --- a/lib/CommonEnvGetOrDieException.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; -var util = require('util'); - -function CommonEnvGetOrDieException(key) { - this.message = '{key} MUST be defined'.replace('{key}', key); - this.name = 'CommonEnvGetOrDieException'; -} - -util.inherits(CommonEnvGetOrDieException, Error); - -module.exports = CommonEnvGetOrDieException; diff --git a/lib/env.js b/lib/env.js deleted file mode 100644 index 83246c5..0000000 --- a/lib/env.js +++ /dev/null @@ -1,186 +0,0 @@ -/*global process, module */ -'use strict'; - -var _ = require('lodash'); -var types = require('./types'); -var EventEmitter = require('events').EventEmitter; -var CommonEnvGetOrDieException = require('./CommonEnvGetOrDieException'); -var CommonEnvGetOrDieAliasesException = require('./CommonEnvGetOrDieAliasesException'); - -var EVENT_FOUND = 'env:found'; -var EVENT_FALLBACK = 'env:fallback'; - -module.exports = function envFactory() { - var em = new EventEmitter(); - - - var getOrDie = getOrDieFactory(function (fullKeyName) { - throw new CommonEnvGetOrDieException(fullKeyName); - }); - - var getOrDieWithAliases = getOrDieFactory(function (fullKeyName, $typeConverter, aliases) { - throw new CommonEnvGetOrDieAliasesException(aliases); - }); - - function getOrElseAll(object, prefix) { - prefix = prefix || ''; - - return _.reduce(object, function (config, value, key) { - var keyName = key.toUpperCase(); - var fullKeyName = prefix + keyName; - - if (isConfigurationObject(value)) { - config[key] = getOrElseConfigurationObject(fullKeyName, value); - } else if (_.isPlainObject(value)) { - config[key] = getOrElseAll(value, fullKeyName + '_'); - } else if (_.isArray(value) && !isArrayOfAtom(value)) { - config[key] = getOrElseArray(fullKeyName, value); - } else { - config[key] = getOrElse(fullKeyName, value); - } - - return config; - }, {}); - } - - // helpers - function getOrElseArray(fullKeyName, value) { - return value.map(function (innerVal, index) { - return getOrElseAll(innerVal, fullKeyName + '[' + index + ']_'); - }); - } - - function getOrElseConfigurationObject(fullKeyName, object) { - if ((object.$type || object.$secure) && _.isUndefined(object.$aliases)) { - object.$aliases = []; - } - if (_.isUndefined(object.$secure)) { - object.$secure = false; - } - if (!Array.isArray(object.$aliases) && typeof(object.$secure) !== 'boolean') { - throw new Error('Common-env: $aliases or $secure must be defined along side $default, key: ' + fullKeyName); - } - - // if `$type` is specified it will be used as a type checker and converter, otherwise infer the type from ``$default` - var $typeConverter = object.$type || getTypeConverter(object.$default); - - return object.$aliases.concat([fullKeyName]).reduce(function (memo, varEnvName, i, aliases) { - var isLast = i === aliases.length - 1; - - if (memo !== null) { - return memo; - } - - // only try to get an env var if memo is undefined - if (isLast) { - return _.isUndefined(object.$default) ? getOrDieWithAliases(varEnvName, $typeConverter, aliases) : getOrElse(varEnvName, object.$default, $typeConverter, object.$secure); - } - - return getOrElse(varEnvName, null, $typeConverter, object.$secure); - }, null); - } - - /** - * [getOrElse description] - * @param {String} fullKeyName env. var. name - * @param {B} $default default fallback value - * @param {Function} $typeConverter f(A) -> B - * @param {B} $secure hide output log value - * @return {B} - */ - function getOrElse(fullKeyName, $default, $typeConverter, $secure) { - $secure = typeof($secure) === 'boolean' ? $secure : false; - $typeConverter = $typeConverter || getTypeConverter($default); - - if (_.has(process.env, fullKeyName)) { - return emitFound(fullKeyName, $typeConverter(process.env[fullKeyName]), $secure); - } - - return emitFallback(fullKeyName, $default, $secure); - } - - function getOrDieFactory(f) { - return function (fullKeyName, $typeConverter /* args */ ) { - var value = getOrElse(fullKeyName, null, $typeConverter); - - if (value === null) { - f.apply(null, arguments); - } - - return value; - }; - } - - function emitFound(key, value, secure) { - em.emit(EVENT_FOUND, key, value, secure); - return value; - } - - function emitFallback(key, value, secure) { - em.emit(EVENT_FALLBACK, key, value, secure); - return value; - } - - return _.extend(em, { - getOrElseAll: getOrElseAll, - getOrElse: getOrElse, - getOrDie: getOrDie, - - EVENT_FOUND: EVENT_FOUND, - EVENT_FALLBACK: EVENT_FALLBACK, - - types: types.convert, - - CommonEnvGetOrDieException: CommonEnvGetOrDieException, - CommonEnvGetOrDieAliasesException: CommonEnvGetOrDieAliasesException - }); -}; - -// Helpers - -function getTypeConverter($default) { - return isArrayOfAtom($default) ? arrayTypeConverter($default) : function (value) { - - if (_.isNumber($default)) { // @todo it's a bug, it should be types.seems.Integer, changing this will be a breaking change - return toInt(value); - } - - if (types.seems.Boolean(value)) { - return String(value).toLowerCase() === 'true'; - } - - return value; - }; -} - -function toInt(str) { - return parseInt(str, 10); -} - -function isArrayOfAtom(array) { - return _.isArray(array) && array.every(isAtom); -} - -function isConfigurationObject(value) { - return _.isPlainObject(value) && (_.has(value, '$default') || _.has(value, '$aliases') || _.has(value, '$type')); -} - -/** - * [arrayTypeConverter description] - * @param {string} value environment value - * @return {[type]} [description] - */ -function arrayTypeConverter($default) { - var typeConverter = getTypeConverter($default[0]); - return function (value) { - return value.split(',').map(typeConverter); - }; -} - -/** - * @param {mixed} value - * @return {Boolean} true if the specified value is either a string, a number or a boolean - */ -function isAtom(value) { - return _.isString(value) || _.isNumber(value) || types.seems.Boolean(value); -} diff --git a/package.json b/package.json index d0f3c02..00b8843 100644 --- a/package.json +++ b/package.json @@ -3,20 +3,28 @@ "version": "5.4.0", "keywords": [ "environment", + "configuration", + "process.env", "env", "var", "getenv", "variables" ], "description": "A nice little helper for retrieving configuration from environment variable", - "main": "./lib", + "main": "./src/lib", "directories": { "doc": "docs", "test": "test" }, + "engines": { + "node": "6" + }, "scripts": { - "test": "mocha test", - "test-watch": "mocha -R min -w test", + "test": "npm run test-unit && npm run test-coverage", + "test-unit": "mocha $(find src -name '*.test.js')", + "test-watch": "mocha -R min -w $(find src -name '*.test.js')", + "test-coverage": "nyc --all --statements=100 --lines=100 --functions=100 --branches=100 --check-coverage --reporter=lcov --reporter=cobertura --report-dir=coverage -- mocha -R spec -t 100000 $(find src -name '*.test.js')", + "send-coverage": "cat ./coverage/lcov.info | coveralls", "check-build": "check-build", "update": "updtr", "changelog": "github-changes --o $(node -p 'process.env.npm_package_repository_url.split(\"/\")[3];') --r $(node -p 'a=process.env.npm_package_repository_url.split(\"/\");a[a.length-1].split(\".\")[0]') --token $CHANGELOG_GITHUB_TOKEN_FG -f CHANGELOG.md" @@ -30,15 +38,26 @@ "bugs": { "url": "https://github.com/FGRibreau/common-env/issues" }, + "nyc": { + "exclude": [ + "node_modules", + "dist", + "coverage", + "webpack.config.js", + "**/**.test.js" + ] + }, "homepage": "https://github.com/FGRibreau/common-env", "devDependencies": { "chai": "^3.5.0", "check-build": "^2.8.2", - "github-changes": "^1.0.2", - "mocha": "^3.0.2" + "coveralls": "^2.11.12", + "github-changes": "^1.0.4", + "mocha": "^3.0.2", + "nyc": "^8.1.0", + "updtr": "^0.2.1" }, "dependencies": { - "lodash": "^4.15.0", - "updtr": "^0.2.1" + "lodash": "^4.15.0" } } diff --git a/test/getOrDie.test.js b/src/lib/env.getOrDie.test.js similarity index 78% rename from test/getOrDie.test.js rename to src/lib/env.getOrDie.test.js index 8fafb7e..10bba26 100644 --- a/test/getOrDie.test.js +++ b/src/lib/env.getOrDie.test.js @@ -1,12 +1,12 @@ 'use strict'; -var t = require('chai').assert; +const t = require('chai').assert; describe('.getOrDie', function () { - var env; + let env; beforeEach(function () { - env = require('..')(); + env = require('../..')(); }); it('should crash the app if the env. variable did not exist', function () { diff --git a/test/getOrElseAll.test.js b/src/lib/env.getOrElseAll.test.js similarity index 64% rename from test/getOrElseAll.test.js rename to src/lib/env.getOrElseAll.test.js index ca0c9bb..d9b370e 100644 --- a/test/getOrElseAll.test.js +++ b/src/lib/env.getOrElseAll.test.js @@ -1,21 +1,21 @@ 'use strict'; -var envFactory = require('..'); +var envFactory = require('../..'); var t = require('chai').assert; var _ = require('lodash'); -describe('.getOrElseAll', function () { +describe('.getOrElseAll', function() { var env; var eventsFound = {}; var eventsFallback = {}; - beforeEach(function () { + beforeEach(function() { env = envFactory(); env - .on(env.EVENT_FOUND, function (fullKeyName, value) { + .on(env.EVENT_FOUND, function(fullKeyName, value) { eventsFound[fullKeyName] = value; }) - .on(env.EVENT_FALLBACK, function (fullKeyName, $default) { + .on(env.EVENT_FALLBACK, function(fullKeyName, $default) { eventsFallback[fullKeyName] = $default; }); process.env = {}; @@ -26,17 +26,16 @@ describe('.getOrElseAll', function () { process.env.AMQP_PASSWORD = ''; process.env.A_B_C_DOVERRIDE = '2,3,4'; process.env.MY_AWESOME_ARRAY_ALIASE_D = '5,7,8'; - process.env['PLOP_API[0]_A'] = 3; }); - it('should emit events', function (done) { + it('should emit events', function(done) { var doneAfterTwoCall = _.after(2, done); env - .on(env.EVENT_FOUND, function (fullKeyName, value) { + .on(env.EVENT_FOUND, function(fullKeyName, value) { console.log('[env] %s was defined, using: %s', fullKeyName, String(value)); doneAfterTwoCall(); }) - .on(env.EVENT_FALLBACK, function (fullKeyName, $default) { + .on(env.EVENT_FALLBACK, function(fullKeyName, $default) { console.log('[env] %s was not defined, using default: %s', fullKeyName, String($default)); doneAfterTwoCall(); }) @@ -50,7 +49,7 @@ describe('.getOrElseAll', function () { }); }); - it('should return an object', function () { + it('should return an object', function() { var config = env.getOrElseAll({ AMQP: { LoGiN: 'guest', // add a bug inside key name (mix lower/upper case) @@ -123,7 +122,7 @@ describe('.getOrElseAll', function () { t.strictEqual(config.c.root, ''); }); - it('should return ask for ENV vars', function () { + it('should return ask for ENV vars', function() { env.getOrElseAll({ plop: { root_token: 'sdfopiqjsdfpoij', @@ -140,24 +139,75 @@ describe('.getOrElseAll', function () { t.ok(_.has(eventsFallback, 'PLOP_API_ENDPOINT_PORT'), 'PLOP_API_ENDPOINT_PORT'); }); + describe('ENV vars as array handling', () =>  { + it('should handle array of plain objects', function() { + process.env['ARRAYWITHOBJECT_API__0_A'] = 3; + process.env['ARRAYWITHOBJECT_API__2_A'] = 1; - it('should handle array as ENV vars', function () { - var config = env.getOrElseAll({ - plop: { - api: [{ - a: 1 - }, { - a: 2 + var config = env.getOrElseAll({ + arrayWithObject: { + api: [{ + a: 1 + }, { + a: 2 + }] + } + }); + t.strictEqual(config.arrayWithObject.api[0].a, 3, 'config.arrayWithObject.api[0].a'); + t.strictEqual(config.arrayWithObject.api[1].a, 2, 'config.arrayWithObject.api[1].a'); + t.strictEqual(config.arrayWithObject.api[2].a, 1, 'config.arrayWithObject.api[2].a'); + + t.ok(_.has(eventsFound, 'ARRAYWITHOBJECT_API__0_A'), 'ARRAYWITHOBJECT_API__0_A should have been found'); + t.ok(_.has(eventsFallback, 'ARRAYWITHOBJECT_API__1_A'), 'ARRAYWITHOBJECT_API__1_A should have NOT been found'); + t.ok(_.has(eventsFound, 'ARRAYWITHOBJECT_API__2_A'), 'ARRAYWITHOBJECT_API__2_A should have been found'); + }); + + it('should handle an array of objects with description objects', function() { + process.env['ARRAYWITHOBJECT_API__0_INT'] = 3; + process.env['ARRAYWITHOBJECT_API__1_A'] = 2; + process.env['ARRAYWITHOBJECT_API__2_INT'] = 1; + + var config = env.getOrElseAll({ + arrayWithObject: { + api: [{ + int: { + $default: 2, + $type: env.types.Integer + }, + a: 1 }] + } + }); + + if (!config.arrayWithObject.api[0].int) { + // for debug only + console.log(config.arrayWithObject.api); } + t.strictEqual(config.arrayWithObject.api[0].int, 3, 'config.arrayWithObject.api[0].int'); + t.strictEqual(config.arrayWithObject.api[1].int, 2, 'config.arrayWithObject.api[1].int'); + t.strictEqual(config.arrayWithObject.api[1].a, 2, 'config.arrayWithObject.api[1].a'); + t.strictEqual(config.arrayWithObject.api[2].int, 1, 'config.arrayWithObject.api[2].int'); + t.ok(_.has(eventsFound, 'ARRAYWITHOBJECT_API__0_INT'), 'ARRAYWITHOBJECT_API__0_INT should have been found'); + t.ok(_.has(eventsFallback, 'ARRAYWITHOBJECT_API__1_INT'), 'ARRAYWITHOBJECT_API__1_INT should have NOT been found'); + t.ok(_.has(eventsFound, 'ARRAYWITHOBJECT_API__2_INT'), 'ARRAYWITHOBJECT_API__2_INT should have been found'); + }); + + it('should throw an error handle array of description objects objects', function() { + t.throws(() => { + env.getOrElseAll({ + arrayWithObject: { + api: [{ + $default: 2, + $type: env.types.Integer + }] + } + }); + }, 'CommonEnvRootConfigurationObjectException: ARRAYWITHOBJECT_API__0_'); }); - t.strictEqual(config.plop.api[0].a, 3); - t.ok(_.has(eventsFound, 'PLOP_API[0]_A'), 'PLOP_ROOT_TOKEN'); - t.ok(_.has(eventsFallback, 'PLOP_API[1]_A'), 'PLOP_ROOT_TOKEN'); }); - describe('$aliases handling', function () { - it('should handle $default object value', function () { + describe('$aliases handling', function() { + it('should handle $default object value', function() { var config = env.getOrElseAll({ a: { b: [{ @@ -184,10 +234,10 @@ describe('.getOrElseAll', function () { t.ok(_.has(eventsFound, 'AMQP_GOOD_PORT'), 'A_B[1]_A was defined should be printed'); }); - it('should handle $default object value and fallback on default value', function () { + it('should handle $default object value and fallback on default value', function() { var config = env.getOrElseAll({ a: { - b: [{}, { + b: [{ a: { $default: 'plop2', $aliases: ['BLABLA_BLABLA'] // `BLABLA_BLABLA` does not exist, it should fallback on "plop" @@ -195,13 +245,13 @@ describe('.getOrElseAll', function () { }] } }); - t.strictEqual(config.a.b[1].a, 'plop2'); - t.ok(_.has(eventsFallback, 'A_B[1]_A'), 'A_B[1]_A was not defined should be printed'); + t.strictEqual(config.a.b[0].a, 'plop2'); + t.ok(_.has(eventsFallback, 'A_B__0_A'), 'A_B__0_A was not defined should be printed'); }); - describe('if $type was specified', function () { - var env = envFactory(); - var tests = [ + describe('if $type was specified', function() { + const env = envFactory(); + const tests = [ { converter: env.types.Integer, val: '10', @@ -222,6 +272,10 @@ describe('.getOrElseAll', function () { converter: env.types.Float, val: '102039.23', converted: 102039.23 + }, { + converter: env.types.Float, + val: 'aaa', + converted: Error }, { converter: env.types.Boolean, val: 'true', @@ -262,6 +316,10 @@ describe('.getOrElseAll', function () { converter: env.types.Array(env.types.Integer), val: '1', converted: [1] + },{ + converter: env.types.Array(env.types.Integer), + val: 'a,a', + converted: Error }, { converter: env.types.Array(env.types.Float), val: '1,2.2,3,4.4', @@ -279,7 +337,7 @@ describe('.getOrElseAll', function () { val: 'true', converted: [true] } - ].map(function (test) { + ].map(function(test) { return _.extend({}, { varName: (test.converter._name.toUpperCase() + '_' + test.val).replace('.', '_') }, test); @@ -291,11 +349,15 @@ describe('.getOrElseAll', function () { }); beforeEach(function() { - _.forEach(tests, function(v){process.env[v.varName] = v.val;}); + _.forEach(tests, function(v) { + process.env[v.varName] = v.val; + }); }); afterEach(function() { - _.forEach(tests, function(v){ delete process.env[v.varName];}); + _.forEach(tests, function(v) { + delete process.env[v.varName]; + }); }); _.forEach(tests, function(v) { @@ -346,16 +408,57 @@ describe('.getOrElseAll', function () { }); }); + + + it('throws an error if `itemConverter` was not a function', () => { + t.throw(() => { + env.getOrElseAll({ + a:{ + $type: env.types.Array('plop') + } + }) + }); + }); }); }); - describe('fail-fast behaviour', function () { - it('should throw an error if $default is not defined and that no environment variables was specified', function () { - t.throws(function () { + describe('fail-fast behaviour', function() { + it('should throw an error $aliases was defined without nothing else', function() { + t.throws(function() { + env.getOrElseAll({ + thisIsA: { + missing: [{ + sadVar: { + $aliases: ['MISSING_VAR_' + (+new Date()), 'MISSING_VAR_2' + (+new Date())] + } + }] + } + }); + }, env.CommonEnvInvalidConfiguration); + }); + + it('should throw an error if aliases is not an array', function() { + t.throws(function() { + env.getOrElseAll({ + thisIsA: { + missing: [{ + sadVar: { + $default: 10, + $aliases: null + } + }] + } + }); + }, Error); + }); + + it('should throw an error if $default is not defined and that no environment variables was specified', function() { + t.throws(function() { env.getOrElseAll({ thisIsA: { missing: [{ sadVar: { + $type: env.types.Float, $aliases: ['MISSING_VAR_' + (+new Date()), 'MISSING_VAR_2' + (+new Date())] } }] @@ -365,7 +468,7 @@ describe('.getOrElseAll', function () { }); }); - afterEach(function () { + afterEach(function() { delete process.env.AMQP_LOGIN; delete process.env.AMQP_CONNECT; delete process.env.AMQP_CONNECT2; diff --git a/src/lib/env.js b/src/lib/env.js new file mode 100644 index 0000000..6de81b2 --- /dev/null +++ b/src/lib/env.js @@ -0,0 +1,268 @@ +/*global process, module */ +'use strict'; + +const _ = require('lodash'); + +const types = require('./types'); +const EventEmitter = require('events').EventEmitter; + +const errors = require('./errors'); +const { + CommonEnvInvalidConfiguration, + CommonEnvRootConfigurationObjectException, + CommonEnvGetOrDieAliasesException, + CommonEnvGetOrDieException +} = errors; + +const EVENT_FOUND = 'env:found'; +const EVENT_FALLBACK = 'env:fallback'; + +module.exports = function envFactory() { + var em = new EventEmitter(); + + var getOrDie = getOrDieFactory(function(fullKeyName) { + throw new CommonEnvGetOrDieException(fullKeyName); + }); + + var getOrDieWithAliases = getOrDieFactory(function(fullKeyName, $typeConverter, aliases) { + throw new CommonEnvGetOrDieAliasesException(aliases); + }); + + function createContext() { + return { + fullKeyName: '', + }; + } + + function addSuffix(context, suffix) { + const newContext = _.clone(context); + newContext.fullKeyName += suffix; + return newContext; + } + + function aliasesDeprecationNotice() { + if (aliasesDeprecationNotice.called) { + return; + } + console.warn('$aliases is deprecated and will be removed in common-env v7. More info at http://bit.ly/2bRSMN3'); + aliasesDeprecationNotice.called = true; + } + + function getOrElseAll(object, topContext) { + topContext = topContext || createContext(); + + if (isConfigurationObject(object)) { + throw new CommonEnvRootConfigurationObjectException(object, topContext.fullKeyName); + } + + function resolver(config, value, key) { + const innerContext = addSuffix(topContext, key.toUpperCase()); + + if (isConfigurationObject(value)) { + config[key] = _getOrElseConfigurationObject(value, innerContext); + } else if (_.isPlainObject(value)) { + config[key] = getOrElseAll(value, addSuffix(innerContext, '_')); + } else if (_.isArray(value) && !isArrayOfAtom(value)) { + config[key] = _getOrElseArray(value, innerContext); + } else { + config[key] = getOrElse(innerContext.fullKeyName, value, null, null, innerContext); + } + + return config; + } + + return _.reduce(object, resolver, {}); + } + + /** + * [_getOrElseArray description] + * @param {Array} descriptionValue configuration array description + * @param {Object} context + * @return {Array} + */ + function _getOrElseArray(arrayValues, context) { + + // use the first descriptionValue[0] as a template for other elements + + /** + * find the largest defined INDEX that starts with ${fullKeyName}__ OR that uses one of the aliases + * @param {String} envKeyNamePrefix env key name prefix + * @param {[type]} env [description] + * @return {[type]} [description] + */ + function getMaxIndex(envKeyNamePrefix, env) { + return _.chain(env) + .toPairs() + // only keep keys that are in the format {envKeyNamePrefix}__{NUMBER}[....] (because it can either be an array of array or an array of other objects) + .filter(([envKey, envVal], k) => { + // console.log(envKeyNamePrefix, envKey.substring(envKeyNamePrefix.length), (/^__[0-9]+/).test(envKey.substring(envKeyNamePrefix.length))); + return envKey.startsWith(envKeyNamePrefix) && (/^__[0-9]+/).test(envKey.substring(envKeyNamePrefix.length)); + }) + // then extract the number from the env key name + .map(([envKey, envVal], k) => { + const matches = envKey.substring(envKeyNamePrefix.length).match(/^__([0-9]+)/) + return parseInt(matches[1], 10); + }) + .max() + .value() || 0; + } + + return _.range(0, getMaxIndex(context.fullKeyName, process.env) + 1).map(function(___, index) { + const object = arrayValues[index] ||  arrayValues[0]; + // we always use the first element of the array as a full type descriptor of every other element of this array + return getOrElseAll(object, addSuffix(context, '__' + index + '_'), index); + }); + } + + /** + * [_getOrElseConfigurationObject description] + * @param {object} config configuration object e.g. {$default: '', $types, ...} + * @param {object} context context + * @return {mixed} a configuration primitive value + */ + function _getOrElseConfigurationObject(config, context) { + + if (!_.isUndefined(config.$secure) && typeof(config.$secure) !== 'boolean') { + throw new Error('$secure must be a boolean'); + } + if (!_.isUndefined(config.$aliases) && !Array.isArray(config.$aliases)) { + throw new Error('$aliases must be an array'); + } + + const aliaseOrSecureIsDefined = !_.isUndefined(config.$aliases) || !_.isUndefined(config.$secure); + if (aliaseOrSecureIsDefined && (_.isUndefined(config.$type) && _.isUndefined(config.$default))) { + throw new CommonEnvInvalidConfiguration(context.fullKeyName); + } + + if (_.isUndefined(config.$secure)) { + config.$secure = false; + } + if (_.isUndefined(config.$aliases)) { + config.$aliases = []; + } else { + aliasesDeprecationNotice(); + } + + // if `$type` is specified it will be used as a type checker and converter, otherwise infer the type from ``$default` + var $typeConverter = config.$type || getTypeConverter(config.$default); + + return config.$aliases.concat([context.fullKeyName]).reduce(function(memo, varEnvName, i, aliases) { + var isLast = i === aliases.length - 1; + + if (memo !== null) { + return memo; + } + + // only try to get an env var if memo is undefined + if (isLast) { + return _.isUndefined(config.$default) ? getOrDieWithAliases(varEnvName, $typeConverter, aliases) : getOrElse(varEnvName, config.$default, $typeConverter, config.$secure); + } + + return getOrElse(varEnvName, null, $typeConverter, config.$secure); + }, null); + } + + /** + * [getOrElse description] + * @param {String} fullKeyName env. var. name + * @param {B} $default default fallback value + * @param {Function} $typeConverter f(A) -> B + * @deprecated it will soon be merged into `context` + * @param {B} $secure hide output log value + * @deprecated it will soon be merged into `context` + * @param {Object} contenxt + * @return {B} + */ + function getOrElse(fullKeyName, $default, $typeConverter, $secure, context) { + $secure = typeof($secure) === 'boolean' ? $secure : false; + $typeConverter = $typeConverter || getTypeConverter($default); + + if (_.has(process.env, fullKeyName)) { + return emitFound(fullKeyName, $typeConverter(process.env[fullKeyName]), $secure); + } + + return emitFallback(fullKeyName, $default, $secure); + } + + function getOrDieFactory(f) { + return function(fullKeyName, $typeConverter /* args */ ) { + var value = getOrElse(fullKeyName, null, $typeConverter); + + if (value === null) { + f.apply(null, arguments); + } + + return value; + }; + } + + function emitFound(key, value, secure) { + em.emit(EVENT_FOUND, key, value, secure); + return value; + } + + function emitFallback(key, value, secure) { + em.emit(EVENT_FALLBACK, key, value, secure); + return value; + } + + return _.extend(em, errors, { + getOrElseAll: getOrElseAll, + getOrElse: getOrElse, + getOrDie: getOrDie, + + EVENT_FOUND: EVENT_FOUND, + EVENT_FALLBACK: EVENT_FALLBACK, + + types: types.convert + }); +}; + +// Helpers + +function getTypeConverter($default) { + return isArrayOfAtom($default) ? arrayTypeConverter($default) : function(value) { + + if (_.isNumber($default)) { // @todo it's a bug, it should be types.seems.Integer, changing this will be a breaking change + return toInt(value); + } + + if (types.seems.Boolean(value)) { + return String(value).toLowerCase() === 'true'; + } + + return value; + }; +} + +function toInt(str) { + return parseInt(str, 10); +} + +function isArrayOfAtom(array) { + return _.isArray(array) && array.every(isAtom); +} + +function isConfigurationObject(value) { + return _.isObject(value) && (_.has(value, '$default') || _.has(value, '$aliases') || _.has(value, '$type')); +} + +/** + * [arrayTypeConverter description] + * @param {string} value environment value + * @return {[type]} [description] + */ +function arrayTypeConverter($default) { + var typeConverter = getTypeConverter($default[0]); + return function(value) { + return value.split(',').map(typeConverter); + }; +} + +/** + * @param {mixed} value + * @return {Boolean} true if the specified value is either a string, a number or a boolean + */ +function isAtom(value) { + return _.isString(value) || _.isNumber(value) || types.seems.Boolean(value); +} diff --git a/src/lib/errors.js b/src/lib/errors.js new file mode 100644 index 0000000..5caf147 --- /dev/null +++ b/src/lib/errors.js @@ -0,0 +1,36 @@ +'use strict'; +const util = require('util'); +const _ = require('lodash'); + +function CommonEnvGetOrDieAliasesException(aliases) { + this.message = 'At least one environment variable of [{key}] MUST be defined'.replace('{key}', aliases.join(', ')); + this.name = 'CommonEnvGetOrDieAliasesException'; +} +util.inherits(CommonEnvGetOrDieAliasesException, Error); + + +function CommonEnvGetOrDieException(key) { + this.message = '{key} MUST be defined'.replace('{key}', key); + this.name = 'CommonEnvGetOrDieException'; +} +util.inherits(CommonEnvGetOrDieException, Error); + + +function CommonEnvRootConfigurationObjectException(object, prefix) { + this.message = '{prefix} array contains a special common-env configuration object (e.g. {$default:"", $types:"" [, $aliases:""]}) where it should contains an simple object'.replace('{prefix}', prefix); + this.name = 'CommonEnvRootConfigurationObjectException'; +} +util.inherits(CommonEnvRootConfigurationObjectException, Error); + +function CommonEnvInvalidConfiguration(key) { + this.message = 'Invalid configuration, `$aliases` or `$secure` must be defined along side $default or $type in key "{key}"'.replace('{key}', key); + this.name = 'CommonEnvInvalidConfiguration'; +} +util.inherits(CommonEnvInvalidConfiguration, Error); + +module.exports = { + CommonEnvGetOrDieAliasesException, + CommonEnvGetOrDieException, + CommonEnvRootConfigurationObjectException, + CommonEnvInvalidConfiguration +}; diff --git a/lib/index.js b/src/lib/index.js similarity index 100% rename from lib/index.js rename to src/lib/index.js diff --git a/lib/types.js b/src/lib/types.js similarity index 95% rename from lib/types.js rename to src/lib/types.js index cdb8d28..bb459fe 100644 --- a/lib/types.js +++ b/src/lib/types.js @@ -56,7 +56,7 @@ function BooleanConverter(mixed) { function ArrayConverter(itemConverter){ if(!_.isFunction(itemConverter)){ - throw new Error('itermConverter from ArrayConverter(itermConverter) should be a function'); + throw new Error('itemConverter from ArrayConverter(itemConverter) should be a function'); } return createType('Array('+itemConverter._name+')', function(mixed){ diff --git a/withLogger/withLogger.js b/src/withLogger.js similarity index 94% rename from withLogger/withLogger.js rename to src/withLogger.js index f9777c3..023c181 100644 --- a/withLogger/withLogger.js +++ b/src/withLogger.js @@ -3,7 +3,6 @@ var envFactory = require('..'); module.exports = function (logger) { - logger = logger || console; var env = envFactory(); env.on(env.EVENT_FOUND, function (fullKeyName, value, $secure) { diff --git a/test/withLogger.test.js b/src/withLogger.test.js similarity index 58% rename from test/withLogger.test.js rename to src/withLogger.test.js index b2ab338..0f9af7c 100644 --- a/test/withLogger.test.js +++ b/src/withLogger.test.js @@ -3,13 +3,13 @@ var t = require('chai').assert; var _ = require('lodash'); -describe('withLogger', function () { +describe('withLogger', function() { var env, logger; - beforeEach(function () { + beforeEach(function() { logger = { calls: [], - info: function () { + info: function() { this.calls.push(_.toArray(arguments)); } }; @@ -17,12 +17,12 @@ describe('withLogger', function () { env = require('../withLogger')(logger); }); - it('should display logs', function () { + it('should display logs', function() { env.getOrElse('HELLO_WORLD', 'fallback'); t.deepEqual(logger.calls, [["[env] %s was not defined, using default: %s", "HELLO_WORLD", "fallback"]]); }); - it('should handle $secure to silent log value', function () { + it('should handle $secure to silent log value', function() { process.env = {}; process.env.USERNAME = 'HeyIamPublic'; process.env.PASSWORD = 'ohMyGodImSoSecret'; @@ -32,13 +32,31 @@ describe('withLogger', function () { password: { $default: 'iAmUseless', $secure: true + }, + password2: { + $default: 'iAmUseless', + $secure: true } }); t.strictEqual(config.username, process.env.USERNAME); t.strictEqual(config.password, process.env.PASSWORD); t.deepEqual(logger.calls, [ ["[env] %s was defined, using: %s", "USERNAME", process.env.USERNAME], - ["[env] %s was defined, using: %s", "PASSWORD", "***"] + ["[env] %s was defined, using: %s", "PASSWORD", "***"], + ["[env] %s was not defined, using default: %s", "PASSWORD2", "***"] ]); }); + + it('should invalid $secure value', function() { + t.throw(() => { + env.getOrElseAll({ + username: 'HeyIamPublic', + password: { + $default: 'iAmUseless', + $secure: 'plop' + } + }); + }); + + }); }); diff --git a/withLogger/index.js b/withLogger/index.js index 8c164a6..93d0c23 100644 --- a/withLogger/index.js +++ b/withLogger/index.js @@ -1,2 +1,2 @@ 'use strict'; -module.exports = require('./withLogger'); +module.exports = require('../src/withLogger');