@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
466 lines (408 loc) • 15.8 kB
JavaScript
const cds = require('../cds');
const { escapeRegex } = require('./util/strings');
const DEBUG = cds.debug('cli');
// PARAM BASE TYPES
const TokenStorage = Object.freeze({
plain: 0,
keyring: 1
});
const Persistence = Object.freeze({
none: 0,
setting: 1,
auth: 2
});
const ObfuscationLevel = Object.freeze({
none: 0, // Keep this at zero always!
full: 1,
partial: 2 // Allows a few start and end characters displayed in DEBUG output; only use with string-type params
});
const DISCLOSURE_LENGTH = 2;
/**
* Parameters hold a name and a value and can only be set to compatible values.
*/
class Param {
/**
* Constructor.
* @param name the name
* @param val the initial value
* @param options constructor options
*/
constructor(name,
val = undefined,
options = {}) {
this.persist = Persistence.none;
this.obfuscate = ObfuscationLevel.none;
this.abbreviate = false;
this.internal = false;
this.allowedValues = null;
this.type = val === undefined
? this.allowedValues
? typeof this.allowedValues[0]
: 'string' // default
: typeof val;
this.name = name;
this._val = val;
this.options = options;
Object.entries(options).forEach(([key, val]) => this[key] = val);
}
/**
* Get the name of the main param (as opposed to an alias).
*/
get mainName() {
return this.name;
}
/**
* Using Proxy to achieve aliasing by routing all but certain properties through to main param.
* @param name the alias name
*/
newAlias(name) {
return new Proxy(this, {
get(param, prop, alias) {
switch (prop) {
case 'clone':
return (val) => (val ? param.clone(val) : param).newAlias(alias.name);
case 'name':
return name;
default:
return param[prop];
}
}
});
}
/**
* Set value.
* @param newVal the new value
*/
set val(newVal) {
this.validate(newVal);
this._val = newVal;
}
/**
* Get value.
*/
get val() {
return this._val;
}
/**
* Validate the param value.
* @param val the value in question
* @private
*/
validate(val) {
if (val === undefined) {
return; // Allow `undefined` always.
}
if (typeof val !== this.type) {
throw `Failed to set '${(this.name)}' = ${JSON.stringify(val)}: expected value of type ${this.type}`;
}
if (this.allowedValues && !(this.allowedValues).includes(val)) {
throw `Failed to set '${(this.name)}' = ${JSON.stringify(val)}: expected one of ${JSON.stringify(this.allowedValues)}`;
}
}
/**
* Transform the value to make it suitable for output, i.e. return:
* - `undefined` for internal params (making them hidden)
* - `'...'` for (parts of) obfuscated or (when debug output is off) abbreviated params
* - `this.val` for all others
*/
format() {
return this.internal
? undefined
: this.obfuscate === ObfuscationLevel.none && (!this.abbreviate || process.env.DEBUG)
? this.val
: this.obfuscate === ObfuscationLevel.partial && process.env.DEBUG && this.type === 'string'
? this.val.slice(0, DISCLOSURE_LENGTH) + '...' + this.val.slice(-DISCLOSURE_LENGTH)
: '...';
}
/**
* Return a new instance of this class with the same properties as the current object.
* @param val used as value if given
*/
clone(val = this.val) {
this.validate(val);
return new Param(this.name, val, this.options);
}
/**
* Prevent this being changed.
*/
freeze() {
Object.freeze(this._val);
Object.freeze(this);
return this;
}
}
// type ParamType<P> = P extends Param<infer U> ? U : never;
// CONCRETE PARAMS
/**
* The known params and their default values, restricted to those needed by the MTXS Client as a whole.
*/
class BasicParams {
constructor() {
// GENERAL PARAMS
// - non-persisted
this.projectFolder = new Param('projectFolder');
this.directory = this.projectFolder.newAlias('directory');
// - persisted
this.appUrl = new Param('appUrl', undefined, { persist: Persistence.setting }); // any trailing slash (/) is removed
this.url = this.appUrl.newAlias('url');
this.from = this.appUrl.newAlias('from');
this.to = this.appUrl.newAlias('to');
this.at = this.appUrl.newAlias('at');
this.subdomain = new Param('subdomain', undefined, { persist: Persistence.setting }); // only needed to fetch token
this.passcodeUrl = new Param('passcodeUrl', undefined, { persist: Persistence.setting, abbreviate: true });
this.passcode_url = this.passcodeUrl.newAlias('passcode_url');
this.tokenUrl = new Param('tokenUrl', undefined, { persist: Persistence.setting, abbreviate: true })
// AUTH PARAMS
// - non-persisted
this.passcode = new Param('passcode', undefined, { obfuscate: ObfuscationLevel.partial });
this.password = new Param('password', undefined, { obfuscate: ObfuscationLevel.full });
this.clientid = new Param('clientid', undefined);
this.clientsecret = new Param('clientsecret', undefined, { obfuscate: ObfuscationLevel.partial });
this.key = new Param('key', undefined, { obfuscate: ObfuscationLevel.partial }); // private key of client certificate
this.tokenStorage = new Param('tokenStorage', undefined, { allowedValues: ['plain', 'keyring'] });
this.clearOtherTokenStorage = new Param('clearOtherTokenStorage', false, { internal: true });
this.plain = new Param('plain', false, { internal: true }); // for backward compatibility - superseded by tokenStorage
this.saveData = new Param('saveData', false, { internal: true });
this.renewLogin = new Param('renewLogin', false, { internal: true });
this.reqAuth = new Param('reqAuth', undefined, { internal: true, type: 'object', obfuscate: ObfuscationLevel.full });
// - persisted
this.username = new Param('username', undefined, { persist: Persistence.setting }); // only saved against localhost (non-productive); as setting due to ambiguous app URL
this.isEmptyPassword = new Param('isEmptyPassword', false, { internal: true, persist: Persistence.setting }); // extra param so we can ensure we never save a password
this.token = new Param('token', undefined, { persist: Persistence.auth, obfuscate: ObfuscationLevel.partial });
this.tokenExpirationDate = new Param('tokenExpirationDate', Number.MAX_SAFE_INTEGER, { persist: Persistence.auth });
this.refreshToken = new Param('refreshToken', undefined, { persist: Persistence.auth, obfuscate: ObfuscationLevel.partial });
}
}
// TODO pass these as extra arguments to lib functions
/**
* All known params including those needed for specific commands.
*/
class AllParams extends BasicParams {
constructor() {
super();
// `extend` PARAMS
this.defaultTag = new Param('defaultTag', undefined);
this.tagRule = new Param('tagRule', undefined);
// `push` PARAMS
this.sync = new Param('sync', false);
this.async = new Param('async', false);
// `subscribe` PARAMS
this.body = new Param('body', undefined, { internal: true, persist: Persistence.none });
// `login` PARAMS
this.skipToken = new Param('skipToken', false, { internal: true });
this.saveRefreshToken = new Param('saveRefreshToken', false, { internal: true });
// `logout` PARAMS
this.deleteSettings = new Param('deleteSettings', false);
this["delete-settings"] = this.deleteSettings.newAlias('delete-settings');
this.clearInvalid = new Param('clearInvalid', false);
this["clear-invalid"] = this.clearInvalid.newAlias('clear-invalid');
}
}
const ParamDefs = Object.freeze(new AllParams());
// PARAM COLLECTIONS
function collect(values, param) {
return Object.assign(values, {[param.name]: param.val});
}
/**
* A bundle of params with values. There are no requirements on the number of params contained.
*/
class ParamCollection {
constructor(paramValues = {}) {
this.params = {};
this.isFrozen = false;
for (const [name, val] of Object.entries(paramValues)) {
if (val != null) {
this.set(name, val);
}
}
}
/**
* Throw error if this is frozen.
* @private
*/
checkFrozen() {
if (this.isFrozen) {
throw new Error('cannot modify read-only parameter collection');
}
}
/**
* Create a new frozen `ParamCollection`.
* @param paramValues the param values to set
*/
static frozen(paramValues = {}) {
const paramCollection = new ParamCollection(paramValues);
paramCollection.freeze();
return paramCollection;
}
/**
* Get the name of a main param (as opposed to an alias).
* Throws an error if the param is not part of this collection.
* @param name the param in question
*/
static mainName(name) {
const paramDef = ParamDefs[name];
if (!paramDef) {
throw new Error(`Unknown parameter: '${name}'`);
}
return paramDef.mainName;
}
/**
* Return whether the given param is included in this collection.
* @param name the param in question
*/
has(name) {
return name in this.params;
}
/**
* Return the value of the given param or `undefined` if not part of this collection.
* @param name the param in question
*/
get(name) {
const param = this.getParam(name);
return param && param.val;
}
/**
* Return the `Param` object for the given param or `undefined` if not part of this collection.
* @param name the param in question
*/
getParam(name) {
return this.params[name] || ParamDefs[name];
}
/**
* Set a value for the given param. Will add the param to this collection unless already included.
* @param name the param in question
* @param val the value to set
*/
set(name, val) {
this.checkFrozen();
const mainName = ParamCollection.mainName(name);
if (this.has(mainName)) {
this.getParam(name).val = val;
} else {
this.params[mainName] = ParamDefs[mainName].clone(val);
}
if (mainName !== name) {
DEBUG?.(`Migrated setting '${name}' to new key '${mainName}'`);
}
}
/**
* Delete the given param from this collection.
* @param name the param in question
*/
delete(name) {
this.checkFrozen();
delete this.params[name];
}
/**
* Prevent this collection and all included params from being changed.
*/
freeze() {
this.toArray().forEach(param => param.freeze());
this.isFrozen = true;
return this;
}
/**
* Add all params contained in another collection to this, replacing existing params.
* @param params the other `ParamCollection`
*/
merge(params) {
this.checkFrozen();
for (const param of params) {
this.set(param.name, param.val);
}
}
/**
* Add all params contained in another collection to this, not replacing existing params.
* @param params the other `ParamCollection`
*/
mergeLower(params) {
this.checkFrozen();
for (const param of params) {
if (!this.has(param.name)) {
this.set(param.name, param.val);
}
}
}
/**
* Iterate through all contained param objects.
*/
*[Symbol.iterator]() {
for (const param of Object.values(this.params)) {
yield param;
}
}
/**
* Return a new array from all contained param objects.
*/
toArray() {
return Array.from(this);
}
/**
* Get the number of contained params.
*/
get size() {
return Object.keys(this.params).length;
}
/**
* Return a new `ParamCollection` containing clones of all params contained in this collection.
*/
clone() {
const clonedParams = this.toArray()
.map(param => param.clone())
.reduce(collect, {});
return new ParamCollection(clonedParams);
}
/**
* Return an object mapping the names of each of the contained params to their corresponding values.
* @param persistence the type of persistence to filter for (omit to disable filtering)
*/
toEntries(persistence) {
return this.toArray()
.filter(param => param.persist === persistence || persistence === undefined)
.reduce(collect, {});
}
/**
* Return an object mapping each of the contained params' names to its corresponding value.
* Used by `JSON.stringify`.
*/
toJSON() {
return this.toEntries();
}
/**
* Format this as a string. Omits `undefined` values.
* @param actualValues whether to include all actual param values, irrespective of any transformations normally
* applied (e.g. value obfuscations)
*/
format(actualValues = false) {
return this.toArray()
.reduce((result, param) => {
const val = actualValues ? param.val : param.format();
if (val !== undefined) {
result += `${param.name}: ${JSON.stringify(val)}, `;
}
return result;
}, '{ ')
.replace(/(,|(?<={)) $/, ' }');
}
/**
* Replace all occurrences of values of those params that are to be obfuscated with their (partly) obfuscated versions.
* Replacement applies only to values of query parameters.
* @param url the URL whose query parameters to replace
*/
obfuscateQueryParams(url) {
function reqParam(value) {
return new RegExp(`(?<==)${escapeRegex(value)}(?=&|$)`, 'g');
}
return this.toArray()
.filter(param => param.obfuscate)
.reduce((result, param) => result.replace(reqParam(param.val), param.format()), url);
}
}
ParamCollection.prototype.toString = ParamCollection.prototype.format;
module.exports = {
Param,
ParamCollection,
Persistence,
TokenStorage,
ObfuscationLevel
}