diff --git a/index.js b/index.js index fb336b5..692790f 100644 --- a/index.js +++ b/index.js @@ -10,6 +10,7 @@ const Injector = require("./lib/injector"); const Component = require("./lib/component"); const Logger = require("./lib/logger"); const utils = require("./lib/utils"); +const EnvValidator = require("./lib/env_validator"); function merapi(options) { return new Container(options); @@ -150,6 +151,10 @@ class Container extends Component.mixin(AsyncEmitter) { } *_initialize() { + yield this.emit("beforeValidateConfig", this); + this.validateConfig(); + yield this.emit("afterValidateConfig", this); + yield this.emit("beforeInit", this); yield this.emit("beforeConfigResolve", this); this.config.resolve(); @@ -315,6 +320,34 @@ class Container extends Component.mixin(AsyncEmitter) { } } } + + validateConfig() { + const systemEnv = () => { + const result = {}; + const env = process.env; + for(const key of Object.keys(env)) + result["$"+key] = env[key]; // system env, append $ to key + return result; + }; + const combinedEnv = Object.assign( + {}, + this.options.envConfig && this.options.envConfig[this.config.env], + this.options.extConfig, + systemEnv() + ); + const { config, delimiters } = this.options; + const result = EnvValidator.validateEnvironment(combinedEnv, config, delimiters); + + if (result.empty.length > 0) { + this.logger.warn("WARNING! These configurations are empty string: ", result.empty); + } + + if (result.undefined.length > 0) { + this.logger.error("These configurations are not set on env variables: ", result.undefined); + throw new Error("Configuration error, some env variables are not set"); + } + return true; + } } merapi.Container = Container; diff --git a/lib/config.js b/lib/config.js index 7fe89a3..8c0aa70 100644 --- a/lib/config.js +++ b/lib/config.js @@ -12,7 +12,7 @@ function trimify(str) { /** * Config creator - * + * * @param {Object} data * Config data */ @@ -60,7 +60,6 @@ class Config { } data = val; } - return data; } /** @@ -109,7 +108,7 @@ class Config { /** * Check if config path exist - * + * * @param {String} path * config path * @returns {Boolean} @@ -118,7 +117,7 @@ class Config { has(path) { return this.get(path, true) !== undefined; } - + /** * Get or use default value * @param {String} path @@ -128,7 +127,7 @@ class Config { default(path, def) { return this.has(path) ? this.get(path) : def; } - + /** * Internal flatten function * @param {Object} data @@ -149,7 +148,7 @@ class Config { } }); } - + return res; } @@ -192,7 +191,6 @@ class Config { } return tpl(params); } - return tpl(params); } @@ -226,12 +224,13 @@ class Config { for (let n in flat) { ret[n] = this.set(n, this.resolve(n, false)); } + return ret; } - + /** * Create subconfig by path - * + * * @method path * @param {String} path * config path @@ -241,7 +240,7 @@ class Config { path(path, opts) { return this.create(this.get(path), opts); } - + /** * Extend config with data * @param {Object} data @@ -255,7 +254,7 @@ class Config { } return this; } - + /** * Create new config * @param {Object} data @@ -278,7 +277,7 @@ class Config { return config; } - + /** * Create new config * @param {Object} data diff --git a/lib/env_validator.js b/lib/env_validator.js new file mode 100644 index 0000000..c0d95c9 --- /dev/null +++ b/lib/env_validator.js @@ -0,0 +1,73 @@ +"use strict"; +const Config = require("./config"); +const isNil = require("lodash/isNil"); +const isEmpty = require("lodash/isEmpty"); + +/** + * Make sure environment variables needed in configuration exists. + * + * Return environment variables needed to be defined. + * + * @param {object} environment Environment variables to be validated, intended to be filled with process.env object + * @param {object} configuration Configuration used for merapi + * @param {object} delimiters Delimiters object, used to parse variables. Examples: + * formatted in { + * left: "${" + * right: "}" + * } + * for entry ${$SOME_ENV} + * + */ +exports.validateEnvironment = (environment, configuration, delimiters = { left: "{", right: "}" }) => { + if (isNil(environment)) throw new Error("No environment variable set in this system"); + if (isNil(configuration) || isEmpty(environment)) throw new Error("No configuration is set"); + + const config = new Config(); + const flattenConfiguration = config._flatten(configuration); + const neededValues = { + undefined: [], + empty: [] + }; + + for (const key of Object.keys(flattenConfiguration)) { + const value = flattenConfiguration[key]; + if (isNil(value)) { + throw new Error(`Error on Config, '${key}' is needed, but the value is ${value}`); + } + if (containDelimiters(value, delimiters)) { + const envKey = value.substring(delimiters.left.length, value.length - delimiters.right.length); + const envValue = environment[envKey]; + const sanitisedEnvKey = envKey.replace(/\$/,""); // remove $ + + if (envValue === "") { + neededValues["empty"].push(sanitisedEnvKey); + } else if (isNil(envValue) && !isNestedValue(envKey, configuration)) { + neededValues["undefined"].push(sanitisedEnvKey); + } + } + } + return neededValues; +}; + +const containDelimiters = (string, delimiters) => { + if (isNil(string)) return false; + if (isNil(delimiters)) return false; + return typeof string === "string" && + string.includes(delimiters.left) && + string.includes(delimiters.right); +}; +exports.containDelimiters = containDelimiters; + +const isNestedValue = (value, config) => { + let data = config; + const parts = value.split(".").map(val => /^\d+$/.test(val) ? parseInt(val) : val); + if (parts.length === 1) return false; + for(let i = 0; i < parts.length; i++) { + let value = data[parts[i]]; + if (data === undefined) { + return false; + } + data = value; + } + return true; +}; diff --git a/test/env_validator.spec.js b/test/env_validator.spec.js new file mode 100644 index 0000000..5688b39 --- /dev/null +++ b/test/env_validator.spec.js @@ -0,0 +1,118 @@ +"use strict"; +const assert = require("assert"); +const envValidator = require("../lib/env_validator"); + +/* eslint-env mocha */ + +describe("Env validator", () => { + let config; + let env = { + "$GEIST_URI": "https://example.com", + "$GEIST_TOKEN": "asasaklns12io1u31oi2u3" + }; + + const delimiters = { + left: "${", + right: "}" + }; + + beforeEach(() => { + config = { + geist: { + type: "proxy", + uri: "${$GEIST_URI}", + version: "v1", + secret: "${$GEIST_TOKEN}" + } + }; + }); + + it("should return object of empty and undefined env variables, if not set", () => { + env["$GEIST_EMPTY"] = ""; + config.geist.lala = "${$LALA}"; + config.geist.empty = "${$GEIST_EMPTY}"; + config.diaenne = { + type: "proxy", + uri: "${$DIAENNE_URI}", + version: "${$VERSION}" + }; + config.auth = "${$SECRET}"; + + const result = { + undefined: ["LALA", "DIAENNE_URI", "VERSION", "SECRET"], + empty: ["GEIST_EMPTY"] + }; + const actualResult = envValidator.validateEnvironment(env, config, delimiters); + assert.deepEqual(actualResult, result); + }); + + it("should return empty list of undefined and empty if env needed is set already", () => { + const result = envValidator.validateEnvironment(env, config, delimiters); + assert.deepStrictEqual(result, { + undefined: [], + empty: [] + }); + }); + + it("should throw error if one of the variable contains null", () => { + config.diaenne = { + type: null, + uri: "${$DIAENNE_URI}", + version: "${$VERSION}" + }; + try { + envValidator.validateEnvironment(env, config, delimiters); + } catch(e) { + assert.equal(e.message, "Error on Config, 'diaenne.type' is needed, but the value is null"); + } + }); + + it("should throw error if no environment variables is not installed in this system", () => { + try { + envValidator.validateEnvironment(null, config, delimiters); + } catch(e) { + assert.equal(e.message, "No environment variable set in this system"); + } + }); + + it("should throw error if no configuration is set", () => { + try { + envValidator.validateEnvironment({}, null, delimiters); + } catch(e) { + assert.equal(e.message, "No configuration is set"); + } + }); +}); + +describe("containDelimiters", () => { + let delimiters; + before(() => { + delimiters = {left: "{", right: "}"}; + }); + + it("should return false if string contains NO delimiters", () => { + const res = envValidator.containDelimiters("LALAJO", delimiters); + assert.deepStrictEqual(res, false); + }); + + it("should return true if string contains delimiters", () => { + const res = envValidator.containDelimiters("{LALAJO}", delimiters); + assert.deepStrictEqual(res, true); + }); + + it("should return false if string is null / undefined", () => { + let result = envValidator.containDelimiters(null, { left: "{", right: "}" }); + assert.deepStrictEqual(result, false); + + result = envValidator.containDelimiters(undefined, delimiters); + assert.deepStrictEqual(result, false); + }); + + it("should return false if delimiters is null / undefined", () => { + let res = envValidator.containDelimiters("{LALAJO}", null); + assert.deepStrictEqual(res, false); + + res = envValidator.containDelimiters("{LALAJO}", undefined); + assert.deepStrictEqual(res, false); + }); +}); diff --git a/test/merapi_test.spec.js b/test/merapi_test.spec.js index 728d06d..ade2ef8 100644 --- a/test/merapi_test.spec.js +++ b/test/merapi_test.spec.js @@ -10,13 +10,14 @@ process.env.NODE_ENV = process.NODE_ENV || "test"; /* eslint-env mocha */ describe("Merapi Test", function() { - - + + describe("Config", function() { - + let container = null; - + beforeEach(asyn(function*() { + process.env.TOKEN="123"; container = merapi({ delimiters: { left: "${", @@ -28,11 +29,12 @@ describe("Merapi Test", function() { "myEnvConfig": "${resolved.b}", "myStrEnvConfig": "${resolved.c}", "myCrlfStrEnvConfig": "${resolved.d}", + "myToken": "${$TOKEN}", // for system env variables, $ is appended "resolved": { "a": 1 } }, - + envConfig: { test: { "resolved.b": 2, @@ -40,24 +42,31 @@ describe("Merapi Test", function() { "resolved.d": "test\r", } }, - + extConfig: { more: true } }); - + yield container.initialize(); })); - - it("can resolve config", function() { + + it("can resolve config from envConfig", function() { assert.notEqual(container, null); - + let myConfig = container.config.get("myConfig"); let pkg = container.config.get("package"); assert.equal(myConfig, 1); assert.equal(pkg.name, "merapi-test"); }); - + + it("can resolve config from system env variables", () => { + assert.notEqual(container, null); + + const myToken = container.config.get("myToken"); + assert.equal(myToken, 123); + }); + it("can resolve environment config", function() { let myEnvConfig = container.config.get("myEnvConfig"); let myStrEnvConfig = container.config.get("myStrEnvConfig"); @@ -66,26 +75,130 @@ describe("Merapi Test", function() { assert.equal(myStrEnvConfig, "test"); assert.equal(myCrlfStrEnvConfig, "test"); }); - + it("can resolve extended config", function() { assert.equal(container.config.get("more"), true); }); - + it("can resolve environment variables", function() { let ENV = container.config.get("ENV"); let env = container.config.get("env"); - + assert.equal(env, process.env.NODE_ENV); assert.equal(ENV.NODE_ENV, process.env.NODE_ENV); assert.equal(ENV.PATH, process.env.PATH); }); + + it("can throw error if config value is not set on env variable", asyn(function*() { + container = merapi({ + delimiters: { + left: "${", + right: "}" + }, + config: { + "package.name": "${SOME_NAME}" + } + }); + + try { + yield container.initialize(); + } catch(e) { + assert.equal(e.message, "Configuration error, some env variables are not set"); + } + })); + + it("should produce warning if some configurations are empty string", asyn(function*() { + process.env.SOME_NAME=""; + container = merapi({ + delimiters: { + left: "${", + right: "}" + }, + config: { + "package.name": "${$SOME_NAME}" + } + }); + let a = 0; + container.logger = { + warn: () => { + a = 1; // warn is called + } + }; + + yield container.initialize(); + assert.equal(a, 1); + })); + + it("should produce warning and throw error if some are empty string and some are undefined", asyn(function*() { + process.env.SOME_NAME=""; + container = merapi({ + delimiters: { + left: "${", + right: "}" + }, + config: { + "package.name": "${$SOME_NAME}" + } + }); + let a = 0; + container.logger = { + warn: () => { + a = 1; // warn is called + } + }; + + try { + yield container.initialize(); + } catch(e) { + assert.equal(a, 1); + assert.equal(e.message, "Configuration error, some env variables are not set"); + } + })); + + it("can use custom delimiters", asyn(function*() { + container = merapi({ + delimiters: { + left: "[", + right: "]" + }, + config: { + "nameEnv": "[SOME_NAME]" + }, + envConfig: { + test: { + SOME_NAME: "mamazo" + } + } + }); + yield container.initialize(); + + let name = container.config.get("nameEnv"); + assert.equal(name, "mamazo"); + })); + + it("can use default delimiters (left:`{`, right: `}`) if no custom delimiters specified", asyn(function*() { + container = merapi({ + config: { + "nameEnv": "{SOME_NAME}" + }, + envConfig: { + test: { + SOME_NAME: "mamazo" + } + } + }); + yield container.initialize(); + + let name = container.config.get("nameEnv"); + assert.equal(name, "mamazo"); + })); }); - + describe("Components", function() { - + let container = null; let obj = {}; - + beforeEach(asyn(function*() { container = merapi({ @@ -103,7 +216,7 @@ describe("Merapi Test", function() { container.register("obj", obj, true); container.alias("alias", "obj"); - + yield container.initialize(); })); @@ -131,12 +244,12 @@ describe("Merapi Test", function() { yield container.start(); assert.equal(config.default("autoloaded", false), true); })); - + it("can resolve component loader", asyn(function*() { const comTest = yield container.resolve("comTest"); assert.notEqual(comTest, null); })); - + it("can resolve component class", asyn(function*() { const comClassTest = yield container.resolve("comClassTest"); assert.notEqual(comClassTest, null); @@ -228,19 +341,19 @@ describe("Merapi Test", function() { assert.equal(warningMessage, "No main defined"); })); }); - + describe("Starter", function() { - + it("can start a main module from component loader", asyn(function*() { - + let container = merapi({ basepath: __dirname, config: { } }); - + let testVal = 0; - + container.register("mainCom", function() { return { start() { @@ -248,18 +361,18 @@ describe("Merapi Test", function() { } }; }); - + let config = yield container.resolve("config"); - + config.set("main", "mainCom"); - + assert.equal(testVal, 0); yield container.initialize(); assert.equal(testVal, 0); yield container.start(); assert.equal(testVal, 1); })); - + it("can start a main module from component class", asyn(function*() { let testVal = 0; let container = merapi({ @@ -268,21 +381,20 @@ describe("Merapi Test", function() { main: "mainCom" } }); - + container.register("mainCom", class MainComponent extends Component { *start() { yield sleep(1); testVal = 1; } }); - - + + assert.equal(testVal, 0); yield container.initialize(); assert.equal(testVal, 0); yield container.start(); assert.equal(testVal, 1); - })); }); }); \ No newline at end of file