// Copyright IBM Corp. 2016,2019. All Rights Reserved. // Node module: loopback-boot // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT 'use strict'; var fs = require('fs'); var path = require('path'); var debug = require('debug')('loopback:boot:plugin'); var assert = require('assert'); var _ = require('lodash'); var util = require('./utils'); var g = require('./globalize'); module.exports = PluginBase; function PluginBase(options, name, artifact) { this.options = options || {}; this.name = name || options.name; this.artifact = artifact || options.artifact; } PluginBase.prototype.getRootDir = function() { return this.options.rootDir; }; PluginBase.prototype.load = function(context) { var rootDir = this.getRootDir() || this.options.rootDir; var env = this.options.env; assert(this.name, 'Plugin name must to be set'); debug('Root dir: %s, env: %s, artifact: %s', rootDir, env, this.artifact); var config = {}; if (this.options[this.name]) { // First check if options have the corresponding config object debug('Artifact: %s is using provided config obj instead' + ' of config file'); config = this.options[this.name]; } else { if (this.artifact) { config = this.loadNamed(rootDir, env, this.artifact); } } // Register as context.configurations. return this.configure(context, config); }; PluginBase.prototype.configure = function(context, config) { config = config || {}; // Register as context.configurations. if (!context.configurations) { context.configurations = {}; } context.configurations[this.name] = config; return config; }; PluginBase.prototype.merge = function(target, config, keyPrefix) { return this._mergeObjects(target, config, keyPrefix); }; /** * Load named configuration. * @param {String} rootDir Directory where to look for files. * @param {String} env Environment, usually `process.env.NODE_ENV` * @param {String} name * @returns {Object} */ PluginBase.prototype.loadNamed = function(rootDir, env, name) { var files = this.findConfigFiles(rootDir, env, name); debug('Looking in dir %s for %s configs', rootDir, this.name); if (files.length) { debug('found %s %s files: %j', env, name, files); files.forEach(function(f) { debug(' %s', f); }); } var configs = this._loadConfigFiles(files); var merged = this._mergeConfigurations(configs); debug('merged %s %s configuration %j', env, name, merged); return merged; }; /** * Search `rootDir` for all files containing configuration for `name`. * @param {String} rootDir Root directory * @param {String} env Environment, usually `process.env.NODE_ENV` * @param {String} name Name * @param {Array.} exts An array of extension names * @returns {Array.} Array of absolute file paths. */ PluginBase.prototype.findConfigFiles = function(rootDir, env, name, exts) { var master = ifExists(name + '.json'); if (!master && (ifExistsWithAnyExt(name + '.local') || ifExistsWithAnyExt(name + '.' + env))) { g.warn('WARNING: Main config file "%s{{.json}}" is missing', name); } if (!master) return []; var candidates = [ master, ifExistsWithAnyExt(name + '.local'), ifExistsWithAnyExt(name + '.' + env), ]; return candidates.filter(function(c) { return c !== undefined; }); function ifExists(fileName) { var filePath = path.resolve(rootDir, fileName); return util.fileExistsSync(filePath) ? filePath : undefined; } function ifExistsWithAnyExt(fileName) { var extensions = exts || ['js', 'json']; var file; for (var i = 0, n = extensions.length; i < n; i++) { file = ifExists(fileName + '.' + extensions[i]); if (file) { return file; } } } }; /** * Load configuration files into an array of objects. * Attach non-enumerable `_filename` property to each object. * @param {Array.} files * @returns {Array.} */ PluginBase.prototype._loadConfigFiles = function(files) { return files.map(function(f) { var config = require(f); config = _.cloneDeep(config); Object.defineProperty(config, '_filename', { enumerable: false, value: f, }); return config; }); }; /** * Merge multiple configuration objects into a single one. * @param {Array.} configObjects */ PluginBase.prototype._mergeConfigurations = function(configObjects) { var result = configObjects.shift() || {}; while (configObjects.length) { var next = configObjects.shift(); this.merge(result, next, next._filename); } return result; }; PluginBase.prototype._mergeObjects = function(target, config, keyPrefix) { for (var key in config) { var fullKey = keyPrefix ? keyPrefix + '.' + key : key; var err = this._mergeSingleItemOrProperty(target, config, key, fullKey); if (err) throw err; } return null; // no error }; PluginBase.prototype._mergeNamedItems = function(arr1, arr2, key) { assert(Array.isArray(arr1), 'invalid array: ' + arr1); assert(Array.isArray(arr2), 'invalid array: ' + arr2); key = key || 'name'; var result = [].concat(arr1); for (var i = 0, n = arr2.length; i < n; i++) { var item = arr2[i]; var found = false; if (item[key]) { for (var j = 0, k = result.length; j < k; j++) { if (result[j][key] === item[key]) { this._mergeObjects(result[j], item); found = true; break; } } } if (!found) { result.push(item); } } return result; }; PluginBase.prototype._mergeSingleItemOrProperty = function(target, config, key, fullKey) { var origValue = target[key]; var newValue = config[key]; if (!hasCompatibleType(origValue, newValue)) { return 'Cannot merge values of incompatible types for the option `' + fullKey + '`.'; } if (Array.isArray(origValue)) { return this._mergeArrays(origValue, newValue, fullKey); } if (newValue !== null && typeof origValue === 'object') { return this._mergeObjects(origValue, newValue, fullKey); } target[key] = newValue; return null; // no error }; PluginBase.prototype._mergeArrays = function(target, config, keyPrefix) { if (target.length !== config.length) { return 'Cannot merge array values of different length' + ' for the option `' + keyPrefix + '`.'; } // Use for(;;) to iterate over undefined items, for(in) would skip them. for (var ix = 0; ix < target.length; ix++) { var fullKey = keyPrefix + '[' + ix + ']'; var err = this._mergeSingleItemOrProperty(target, config, ix, fullKey); if (err) return err; } return null; // no error }; function hasCompatibleType(origValue, newValue) { if (origValue === null || origValue === undefined) return true; if (Array.isArray(origValue)) return Array.isArray(newValue); if (typeof origValue === 'object') return typeof newValue === 'object'; // Note: typeof Array() is 'object' too, // we don't need to explicitly check array types return typeof newValue !== 'object'; } PluginBase.prototype.compile = function(context) { var instructions; if (typeof this.buildInstructions === 'function') { var rootDir = this.options.rootDir; var config = context.configurations[this.name] || {}; instructions = this.buildInstructions(context, rootDir, config); } else { instructions = context.configurations[this.name]; } // Register as context.instructions. if (!context.instructions) { context.instructions = {}; if (this.options.appId) { context.instructions.appId = this.options.appId; } } context.instructions[this.name] = instructions; return undefined; }; var DYNAMIC_CONFIG_PARAM = /\$\{(\w+)\}$/; function getConfigVariable(app, param, useEnvVars) { var configVariable = param; var match = configVariable.match(DYNAMIC_CONFIG_PARAM); if (match) { var varName = match[1]; if (useEnvVars && process.env[varName] !== undefined) { debug('Dynamic Configuration: Resolved via process.env: %s as %s', process.env[varName], param); configVariable = process.env[varName]; } else if (app.get(varName) !== undefined) { debug('Dynamic Configuration: Resolved via app.get(): %s as %s', app.get(varName), param); var appValue = app.get(varName); configVariable = appValue; } else { // previously it returns the original string such as "${restApiRoot}" // it will now return `undefined`, for the use case of // dynamic datasources url:`undefined` to fallback to other parameters configVariable = undefined; g.warn('%s does not resolve to a valid value, returned as %s. ' + '"%s" must be resolvable in Environment variable or by {{app.get()}}.', param, configVariable, varName); debug('Dynamic Configuration: Cannot resolve variable for `%s`, ' + 'returned as %s', varName, configVariable); } } return configVariable; } PluginBase.prototype.getUpdatedConfigObject = function(context, config, opts) { var app = context.app; var useEnvVars = opts && opts.useEnvVars; function interpolateVariables(config) { // config is a string and contains a config variable ('${var}') if (typeof config === 'string') return getConfigVariable(app, config, useEnvVars); // anything but an array or object if (typeof config !== 'object' || config == null) return config; // recurse into array elements if (Array.isArray(config)) return config.map(interpolateVariables); // Not a plain object. Examples: RegExp, Date, if (!config.constructor || config.constructor !== Object) return config; // recurse into object props var interpolated = {}; Object.keys(config).forEach(function(configKey) { var value = config[configKey]; if (Array.isArray(value)) { interpolated[configKey] = value.map(interpolateVariables); } else if (typeof value === 'string') { interpolated[configKey] = getConfigVariable(app, value, useEnvVars); } else if (value === null) { interpolated[configKey] = value; } else if (typeof value === 'object' && Object.keys(value).length) { interpolated[configKey] = interpolateVariables(value); } else { interpolated[configKey] = value; } }); return interpolated; } return interpolateVariables(config); };