apiconnect-config
Version:
Configuration module IBM API Connect Developer Toolkit
440 lines (406 loc) • 13.3 kB
JavaScript
/********************************************************* {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;
}