UNPKG

apiconnect-config

Version:

Configuration module IBM API Connect Developer Toolkit

440 lines (406 loc) 13.3 kB
/********************************************************* {COPYRIGHT-TOP} *** * Licensed Materials - Property of IBM * 5725-Z22, 5725-Z63, 5725-U33, 5725-Z63 * * (C) Copyright IBM Corporation 2016, 2017 * * All Rights Reserved. * US Government Users Restricted Rights - Use, duplication or disclosure * restricted by GSA ADP Schedule Contract with IBM Corp. ********************************************************** {COPYRIGHT-END} **/ // Node module: apiconnect-config /** * @fileoverview Contains the {@link Store} and {@link Config} * classes. Manages persisting configuration between several * different files. * * @module lib/config */ 'use strict'; var _ = require('lodash'); var consts = require('./consts'); var d = require('debug')('apiconnect-config:lib:config'); var extend = require('util')._extend; var getUserConfigDir = require('./user-config-dir'); var fs = require('fs'); var fsExtra = require('fs-extra'); var g = require('strong-globalize')(); var jsYaml = require('js-yaml'); var mkdirp = require('mkdirp'); var osenv = require('osenv'); var path = require('path'); var url = require('url'); /** * Store - a logical backend access point * for persisting configuration data. * Currently only supports files as backends. * * @class * @param {String} filePath */ function Store(filePath) { d('Loading store %s', filePath); this.fullPath = path.resolve(filePath); this.dir = path.dirname(this.fullPath); this.filename = path.basename(this.fullPath); this._configData = {}; this._load(); } /** * Store.prototype._load - synchronously loads a configuration file. * Upon errors, such as the file does not exist, it will create a new document. * @see Store.prototype._save * @private */ Store.prototype._load = function() { try { this._configData = jsYaml.safeLoad(fs.readFileSync(this.fullPath, 'utf8')) || {}; d('Loaded data: %j', this._configData); } catch (err) { d(err); // Don't create an empty file if store does not exists. this._configData = {}; } }; /** * Store.prototype._save - dumps configuration as YAML to * a file on the local system. * @private */ Store.prototype._save = function() { try { mkdirp.sync(this.dir); } catch (err) { d('Unable to make dir: %s', this.dir); d(err); throw err; } try { fs.writeFileSync(this.fullPath, jsYaml.safeDump(this._configData), 'utf8'); } catch (err) { d('Unable to save file %s', this.fullPath); d(err); throw err; } }; /** * Store.prototype.set - reads config from file store into memory. * Cleanses input keys that have undefined values or empty strings/arrays. * Cleanse only occurs at a single depth. (Needs to deep clean). * Saves config data to file on disk. * @param {Object} KVs an objects containings key value mappings. * @see Store.prototype._save */ Store.prototype.set = function(KVs) { var self = this; this._load(); _.forEach(KVs, function(v, k) { if (v === 'undefined' || v.length === 0) { delete self._configData[k]; return; } self._configData[k] = v; }); this._save(); }; /** * Store.prototype.get - description * * @param {String|Number} k key to retrieve. * @return {Object|String|Number|Boolean} value at k in the config. * Returns the entire config if k is "falsey". (0, false, null, undefined, etc.) */ Store.prototype.get = function(k) { this._load(); if (!k) { return extend({}, this._configData); } else { var ret = _.pickBy(this._configData, function(_v, _k) { return _k.match(k); }); return ret; } }; /** * Store.prototype.delete - deletes keys from config * by either matching the key exactly, or if passed a RegExp * will reject all values from the config matching the RegExp. * Saves config back to disk afterwards. * * @param {RegExp|Number|String} k key to find, or a regex to match. * @see Store.prototype._load * @see Store.prototype._save */ Store.prototype.delete = function(k) { this._load(); if (k instanceof RegExp) { this._configData = _.reject(this._configData, function(_v, _k) { return _k.match(k); }); } else { delete this._configData[k]; } this._save(); }; /** * @constructor * Configuration object backed by several configuration stores. * <dl> * <dt>UserConfig</dt> * <dd> * Stores user configuration data such as meta data about servers. * Store located at ~/.apiconnect/config. {@link getUserConfigDir} * </dd> * <dt>TokenConfig</dt> * <dd> * Access to a user's login tokens for different servers. * Store located at ~/.apiconnect/token. {@link getUserConfigDir} * </dd> * <dt>ProjectConfig</dt> * <dd> * Configuration data relating to a user's Loopback project. * The Store location is configurable via options. * </dd> * </dl> * @param {Object} options * @param {String} options.projectDir project directory. Overridden by * the environment variable APIC_CLI_CONFIG_DIR. * @param {Boolean} options.shouldParseUris Defaults to true. Whether or not to * parse URIs into URI Objects. {@link Config.prototype.get} * @param {Boolean} options.loadFromEnv Whether or not to override configuration * values with environment variables of the same name. {@link Config.prototype.get}. * @see Store */ function Config(options) { options = options || {}; d('Loading config with options %j', options); this._userConfig = new Store(path.join(getUserConfigDir(options), 'config')); this._tokenConfig = new Store(path.join(getUserConfigDir(options), 'token')); if (options.projectDir && path.normalize(options.projectDir) !== path.normalize(osenv.home())) { process.env.APIC_CLI_CONFIG_DIR = process.env.APIC_CLI_CONFIG_DIR || '.apiconnect'; var projectConfigDir = path.join(options.projectDir, process.env.APIC_CLI_CONFIG_DIR); // Upgrade project config to directory try { var stat = fs.statSync(projectConfigDir); if (stat.isFile()) { fsExtra.copySync(projectConfigDir, projectConfigDir + '.old'); fsExtra.unlinkSync(projectConfigDir); mkdirp.sync(projectConfigDir); fsExtra.copySync(projectConfigDir + '.old', path.join(projectConfigDir, 'config')); fsExtra.unlinkSync(projectConfigDir + '.old'); } } catch (_) { // nothing to upgrade } this._projectConfig = new Store(path.join(projectConfigDir, 'config')); } else { this._projectConfig = this._userConfig; } this._shouldParseUris = options.shouldParseUris !== false; // Parse by default unless false is passed in this._loadFromEnv = options.loadFromEnv; }; module.exports = Config; /** * Set config values. * @param {object} key/value pairs to store. * @param {Store} store What level (project/common) to store the data for. */ Config.prototype.set = function(KVs, store) { store = store || consts.PROJECT_STORE; d('set: store %s. KVs: %j', store, KVs); KVs = this.validate(KVs); this[store].set(KVs); return KVs; }; /** * Get config values. * @param {string|RegExp} key to look up. * @param {Store} [store] What level (project/common) to lookup the value. Defaults to global value unless project * level override was set. * @return {string|object} The value stored for keys matching the parameter passed in. If a URI and {@link Config} was * not setup with `shouldParseUris=false`, then parses the URI and returns an object with mapped values. */ Config.prototype.get = function(k, store) { d('user config', this._userConfig.get(k)); d('project config', this._projectConfig.get(k)); var data = {}; switch (store) { case consts.PROJECT_STORE: data = this._projectConfig.get(k); break; case consts.USER_STORE: data = this._userConfig.get(k); break; case consts.TOKEN_STORE: data = this._tokenConfig.get(k); break; default: var udata = this._userConfig.get(k); data = extend(udata, this._projectConfig.get(k)); break; } if (this._loadFromEnv) { var envData = _.filter(process.env, function(_v, _k) { return _k.toLowerCase().match(k); }); data = extend(envData, data); } var self = this; if (this._shouldParseUris) { d('get %s --> data = %j', k, data); _.forEach(data, function(_v, _k) { if (typeof _v === 'string' && _v.match(/^[a-zA-Z_\-]+:\/\//)) { data[_k] = self.parseUriValue(_v); } }); } return data; }; /** * Similar to {@link Config#get} but searches for an exact match. * @param {string} key to look up. * @param {Store} [store] What level (project/common) to loopkup the value. Defaults to global value unless project * level override was set. * @return {string|object} The value stored for keys matching the parameter passed in. If a URI and {@link Config} was * not setup with `shouldParseUris=false`, then parses the URI and returns an object with mapped values. */ Config.prototype.getOne = function(k, store) { var data = this.get(k, store); return data[k]; }; /** * Delete config values. * @param {object} key to delete. * @param {Store} store What level (project/common) to delete the data from. */ Config.prototype.delete = function(k, store) { if (store !== consts.PROJECT_STORE && store !== consts.USER_STORE && store !== consts.TOKEN_STORE) { throw new Error(g.f('Unknown config store')); } this[store].delete(k); }; Config.prototype.parseUriValue = function(uri) { var data = url.parse(uri); return { protocol: data.protocol, auth: data.auth, hostname: data.hostname, host: data.host, port: data.port, data: _hashFromPath(data.pathname), }; }; Config.prototype.toUri = function(opts) { var uri = url.format({ protocol: opts.protocol, slashes: true, host: opts.host, hostname: opts.hostname, port: opts.port, pathname: _.reduce(opts.data, function(res, v, k) { return res + '/' + k + '/' + v; }, ''), }); d('toUri: %j --> %s', opts, uri); return uri; }; Config.prototype.validate = function(KVs) { var self = this; var ret = {}; _.each(KVs, function(_v, _k) { var validRegex = { 'accessibility-mode': '^enabled$|^disabled$', app: '^apic-app://[a-zA-Z0-9|.\\-_]+(:[0-9]+)?/orgs/[a-z0-9|.\\-_]+/apps/[a-z0-9|.\\-_]+$', catalog: '^apic-catalog://[a-zA-Z0-9|.\\-_]+(:[0-9]+)?/orgs/[a-z0-9|.\\-_]+/catalogs/[a-z0-9|.\\-_]+$', concurrency: '^[0-9]+$', space: '^apic-space://[a-zA-Z0-9|.\\-_]+(:[0-9]+)?/orgs/[a-z0-9|.\\-_]+/catalogs/[a-z0-9|.\\-_]+' + '/spaces/[a-z0-9|.\\-_]+$', org: '^apic-org://[a-zA-Z0-9|.\\-_]+(:[0-9]+)?/orgs/[a-z0-9|.\\-_]+$', }; if (validRegex[_k]) { var r = new RegExp(validRegex[_k]); if (!r.test(_v)) { throw new Error(g.f( 'The value `%s` is invalid for `%s`.', _v || undefined, _k)); }; } switch (_k) { case 'app': _v = self.parseUriValue(_v); _v.protocol = 'apic-app:'; if (!_v.hostname) { throw new Error(g.f('The \'hostname\' is invalid for \'app\' config.')); } _.each(_v.data, function(_dv, _dk) { if ([ 'orgs', 'apps' ].indexOf(_dk) === -1) { throw new Error(g.f( 'The key \'%s\' is invalid for \'%s\' config.', _dk, _k)); } }); ret[_k] = self.toUri(_v); break; case 'catalog': _v = self.parseUriValue(_v); _v.protocol = 'apic-catalog:'; if (!_v.hostname) { throw new Error(g.f('The \'hostname\' is invalid for \'catalog\' config.')); } _.each(_v.data, function(_dv, _dk) { if ([ 'orgs', 'catalogs' ].indexOf(_dk) === -1) { throw new Error(g.f( 'The key \'%s\' is invalid for \'%s\' config.', _dk, _k)); } }); ret[_k] = self.toUri(_v); break; case 'microgateway': try { var mgwDir = path.resolve(_v); fs.accessSync(mgwDir, fs.F_OK); var pkgJson = require(path.resolve(mgwDir, 'package.json')); if (pkgJson.APIConnectGateway) { ret[_k] = _v; } else { throw new Error(g.f('It is not a valid microgateway directory.')); } } catch (e) { d(e); throw new Error(g.f('The value \'%s\' is invalid for \'%s\' config.\n%s', _v, _k, e.message)); } break; case 'org': _v = self.parseUriValue(_v); _v.protocol = 'apic-org:'; if (!_v.hostname) { throw new Error(g.f('The \'hostname\' is invalid for \'org\' config.')); } _.each(_v.data, function(_dv, _dk) { if ([ 'orgs' ].indexOf(_dk) === -1) { throw new Error(g.f( 'The key \'%s\' is invalid for \'%s\' config.', _dk, _k)); } }); ret[_k] = self.toUri(_v); break; default: ret[_k] = _v; } }); return ret; }; function _hashFromPath(path) { if (!path) { return {}; } path = path.split('/'); var hash = {}; for (var i = 1; i < path.length; i += 2) { hash[path[i]] = path[i + 1] || ''; } // remove any empty keys hash = _.pick(hash, _.compact(_.keys(hash))); return hash; }