config-ninja
Version:
Environmental JSON config.
263 lines (206 loc) • 7.19 kB
JavaScript
'use strict';
const fileSystem = require(`fs`);
const path = require(`path`);
const deepProperty = require(`deep-property`);
const dotenv = require(`dotenv`);
const extender = require(`object-extender`);
const ConfigError = require(`./configError`);
module.exports = class ConfigBuilder {
/*
* Instantiate a new config instance.
*/
constructor (configId, dir = null, env = null, _options = null) {
this.init(configId, dir, env, _options);
}
/*
* Returns an absolute path to the config directory. If the user is not passing in an absolute path to the config then
* we use the current working directory as the base for the config directory path.
*/
prepareAbsoluteDirPath (_dir, defaultDir) {
const dir = _dir || defaultDir;
return (path.isAbsolute(dir) ? dir : path.join(process.cwd(), dir));
}
/*
* Initialise the config.
*/
init (configId, dir = null, env = null, options = null) {
if (!configId) { throw new ConfigError(`You must provide a valid config id!`); }
const defaultOptions = {
dir: this.prepareAbsoluteDirPath(dir, `./config`),
env: env || process.env.NODE_ENV || `development`,
shortFilenames: false,
environmentLevels: { production: 1, staging: 2, development: 3 },
localConfig: [ `local` ],
requireLocalConfig: false,
environmentVariables: {
enableDotenv: false,
dotenvPath: null, // <- Use default path chosen by 'dotenv' module.
mapping: {},
},
single: null,
immutable: false, // <- Not used in this class but listed here for completeness.
plain: false, // <- Not used in this class but listed here for completeness.
};
// Set the builder's configuration.
this.configId = configId;
this.options = extender.defaults(defaultOptions, options);
this.meta = {
loadedOn: 0,
loadedFiles: [],
};
this.rawFiles = {};
this.configValues = {};
// Ensure the localConfig option doesn't have any duplicates.
if (Array.isArray(this.options.localConfig)) {
this.options.localConfig = Array.from(new Set(this.options.localConfig));
}
// (Re)-load all the configuration.
this.loadFiles();
this.mapVariablesFromEnvironment();
}
/*
* Loads in variables from the environment, and optionally a dotenv file.
*/
mapVariablesFromEnvironment () {
// Skip or continue?
if (!this.options.environmentVariables || !Object.keys(this.options.environmentVariables.mapping).length) {
return;
}
// Handle dotenv file.
if (this.options.environmentVariables.enableDotenv) {
const dotenvPath = this.options.environmentVariables.dotenvPath || void (0);
const dotenvResult = dotenv.config({ path: dotenvPath });
if (dotenvResult.error) { throw new ConfigError(dotenvResult.error); }
}
// Handle variable mappings.
for (const envVarName in this.options.environmentVariables.mapping) {
if (!this.options.environmentVariables.mapping.hasOwnProperty(envVarName)) { continue; }
if (envVarName === `NODE_ENV`) { continue; }
const configPath = this.options.environmentVariables.mapping[envVarName];
const envVarValue = this.parseEnvironmentValue(process.env[envVarName]);
if (typeof envVarValue !== `undefined`) {
deepProperty.set(this.configValues, configPath, envVarValue);
}
}
}
/*
* Converts string representations of true, null, false, integers and floats to their correct data types.
*/
parseEnvironmentValue (inputStr) {
// We can only parse strings!
if (typeof inputStr !== `string`) { return inputStr; }
switch (inputStr.toLowerCase()) {
case `true`: return true;
case `null`: return null; // eslint-disable-line node/no-unsupported-features/es-syntax
case `false`: return false;
default:
if (inputStr.match(/^\d+(?:\.\d+)?$/)) { return parseFloat(inputStr); }
break;
}
// Nothing to do so return the value as-is.
return inputStr;
}
/*
* Read and parse all the config files.
*/
loadFiles () {
const mergeList = [];
const files = [];
// Single config mode.
if (this.options.single) {
// Load just one config file.
files.push({ type: `single`, filename: this.options.single });
}
// Multiple config mode.
else {
// Add the production config first.
files.push({ type: `production`, filename: `production` });
// Then add the environent's config, if it isn't also the production config.
if (this.options.env !== `production`) {
files.push({ type: this.options.env, filename: this.options.env });
}
// Add the additional local files to the files list, if any.
if (Array.isArray(this.options.localConfig)) {
this.options.localConfig.forEach(filename => files.push({ type: `local`, filename }));
}
}
// Parse each config file in order.
files.forEach(file => {
// Load the next file.
const json = this.readConfigFile(file.type, file.filename);
if (!json) { return; }
// Parse and save the values.
const values = this.parseConfigJson(file.type, file.filename, json);
this.rawFiles[file.filename] = values;
mergeList.push(values);
});
// Generate the config by merging it all together in order.
this.configValues = extender.merge(...mergeList);
// Automatically add the environment information to the config if 'env' isn't used for something else.
if (!this.configValues.env && this.options.environmentLevels) {
/* eslint id-length: 0 */
this.configValues.env = {
id: this.options.env,
level: this.options.environmentLevels[this.options.env] || `-not set-`,
};
}
// Update the meta data.
this.meta.loadedOn = Date.now();
this.meta.loadedFiles = files;
}
/*
* Loads in a config file and converts it to a JSON string ready for parsing, whilst handling errors.
*/
readConfigFile (type, filename) {
const fullFilename = path.join(this.options.dir, `${filename}${this.options.shortFilenames ? `` : `.config`}.json`);
let json;
try {
json = fileSystem.readFileSync(fullFilename).toString();
}
catch (err) {
if (type !== `local` || (type === `local` && this.options.requireLocalConfig)) {
const errInfo = err.id || err.code || err.name;
throw new ConfigError(`Unable to read ${type} config "${filename}" from path "${fullFilename}" (${errInfo}).`);
}
}
return json || null;
}
/*
* Takes a JSON string and returns a nicely formatted config object, whilst handling errors.
*/
parseConfigJson (type, filename, json) {
let output;
try {
output = JSON.parse(json);
}
catch (err) {
const errInfo = err.id || err.code || err.name;
throw new ConfigError(`The ${type} config "${filename}" is not valid JSON (${errInfo}).`);
}
return output;
}
/*
* Returns a copy of the merged config.
*/
getValues () {
return extender.clone(this.configValues);
}
/*
* Returns a copy of the options used to construct this instance of the config.
*/
getOptions () {
return extender.clone(this.options);
}
/*
* Returns a copy of the meta information for this instance of the config.
*/
getMeta () {
return extender.clone(this.meta);
}
/*
* Returns a copy of all the raw config files as they were when loaded.
*/
getRaw () {
return extender.clone(this.rawFiles);
}
};