ui-modules/blueprint-composer/app/components/util/model/dsl.model.js (634 lines of code) (raw):

/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import {Entity} from './entity.model'; const FUNCTION_PREFIX = '$brooklyn:'; export const TARGET = { SELF: 'self', ROOT: 'root', CHILD: 'child', ENTITY: 'entity', PARENT: 'parent', SIBLING: 'sibling', ANCESTOR: 'ancestor', SCOPEROOT: 'scopeRoot', COMPONENT: 'component', DESCENDANT: 'descendant' } const TARGETS = Object.values(TARGET); const UTILITIES = [ 'literal', 'formatString', 'urlEncode', 'regexReplacement' ]; export const FAMILY = { CONSTANT: 'constant', FUNCTION: 'function', REFERENCE: 'reference', }; export const KIND = { UTILITY : {family: FAMILY.FUNCTION, name: 'utility function'}, TARGET : {family: FAMILY.FUNCTION, name: 'target entity function'}, METHOD : {family: FAMILY.FUNCTION, name: 'method'}, ENTITY : {family: FAMILY.REFERENCE, name: 'entity object'}, STRING : {family: FAMILY.CONSTANT, name: 'constant string'}, NUMBER : {family: FAMILY.CONSTANT, name: 'constant number'}, BOOLEAN : {family: FAMILY.CONSTANT, name: 'constant boolean'}, PORT : {family: FAMILY.CONSTANT, name: 'constant port'}, OTHER_CONST : {family: FAMILY.CONSTANT, name: 'constant other'}, }; const ID = new WeakMap(); const PREV = new WeakMap(); const NEXT = new WeakMap(); const PARENT = new WeakMap(); const PARAMS = new WeakMap(); const KINDS = new WeakMap(); const REF = new WeakMap(); const NAME = new WeakMap(); const RELATIONSHIPS = new WeakMap(); const ISSUES = new WeakMap(); const numberRegex = /^[+-]?\d+(?:\.\d*)?|^[+-]?\.\d+/; const portRangeRegex = /^[\d]+\+|[\d]+-[\d]+/; /** * A component of a Dsl expression. * This class can represent a constant (e.g., a string or number), * an Entity object, or a function call with parameters. * Dsl expression are composable, e.g. function parameters are also Dsl expressions. * Function calls can also be chained to other function calls. */ export class Dsl { /** * @param kind * @param {string} name * @param {Entity} entity */ constructor(kind = KIND.STRING, name, entity) { ID.set(this, Math.random().toString(36).slice(2)); PARAMS.set(this, new Array()); KINDS.set(this, kind); NAME.set(this, name === undefined || name === null ? ID.get(this).toString() : name.toString()); RELATIONSHIPS.set(this, new Array()); ISSUES.set(this, new Array()); } /** * The internal entity id * @returns {string} */ get _id() { return ID.get(this); } /** * Get {Dsl} name * @returns {string} */ get name() { return NAME.get(this); } /** * Set {Dsl} name * @param {string} name */ set name(name) { if (name instanceof String || typeof name === 'string') { NAME.set(this, name.toString()); } else { throw new DslError('Cannot set name ... name must be a string: ' + typeof name); } } /** * Set Entity reference * @param {Entity} entity */ set ref(entity) { if (entity instanceof Entity) { REF.set(this, entity); this.kind = KIND.ENTITY; } else { throw new DslError('Cannot set ref ... ref must be of type Entity'); } } /** * Get Entity reference * @return {Entity} */ get ref() { return REF.get(this); } /** * Get {Dsl} parent * @returns {Dsl} */ get parent() { return PARENT.get(this); } /** * Set {Dsl} parent * @param {Dsl} parent */ set parent(parent) { if (parent instanceof Dsl) { if (PARENT.get(this) !== parent) { PARENT.set(this, parent); } } else { throw new DslError('Cannot add parent ... parent must be of type Dsl'); } } /** * Get {Dsl} prev * @returns {Dsl} */ get prev() { return PREV.get(this); } /** * Set {Dsl} prev * @param {Dsl} prev */ set prev(prev) { if (prev instanceof Dsl) { if (PREV.get(this) !== prev) { PREV.set(this, prev); } } else { throw new DslError('Cannot set prev ... prev must be of type Dsl'); } } /** * Get {Dsl} next * @returns {Dsl} */ get next() { return NEXT.get(this); } /** * Set {Dsl} next * @param {Dsl} next */ set next(next) { if (next instanceof Dsl) { if (NEXT.get(this) !== next) { NEXT.set(this, next); } } else { throw new DslError('Cannot set next ... next must be of type Dsl'); } } /** * Get parameters * @return {Array} */ get params() { return PARAMS.get(this); } /** * Get kind * @return */ get kind() { return KINDS.get(this); } get kindFamily() { // guard against kind being null due to GC (eg in errors) let k = KINDS.get(this); return k && k.family; } /** * Set kind * @param kind */ set kind(kind) { if (Object.values(KIND).includes(kind)) { KINDS.set(this, kind); } else { throw new DslError('Cannot set kind ... not a valid KIND'); } } /** * Get relationships * @return {Array} an array of Entities */ get relationships() { return RELATIONSHIPS.get(this); } /** * Set relationships * @param {Array} relationships an array of Entities */ set relationships(relationships) { RELATIONSHIPS.set(this, relationships); } /** * Get issues * @return {Array} */ get issues() { return ISSUES.get(this); } /** * Set issues * @param {Array} issues */ set issues(issues) { ISSUES.set(this, issues); } /** * Push param {Dsl} * @param {Dsl} param * @returns {Dsl} */ param(param) { if (param instanceof Dsl) { if (this.kindFamily !== FAMILY.FUNCTION) { throw new DslError('Cannot push param to non-function... Dsl kind is: ' + this.kind.name); } PARAMS.get(this).push(param); param.parent = this; return this; } else { throw new DslError('Cannot push param ... param must be of type Dsl'); } } /** * Pop param * @param {string} id * @returns {Dsl} */ popParam() { if (this.hasParams()) { let dsl = PARAMS.get(this).pop(); PARENT.delete(dsl); } return this; } /** * Has {Dsl} got params * @returns {boolean} */ hasParams() { return PARAMS.get(this).length > 0; } /** * Has {Dsl} got a name * @return {boolean} */ hasName() { return NAME.has(this); } /** * Has {Dsl} got a parent * @returns {boolean} */ hasParent() { return PARENT.has(this); } /** * Has {Dsl} got a next * @returns {boolean} */ hasNext() { return NEXT.has(this); } /** * Has {Dsl} got a prev * @returns {boolean} */ hasPrev() { return PREV.has(this); } /** * Has {Dsl} got a ref * @return {boolean} */ hasRef() { return REF.has(this); } /** * Has {Dsl} got issues * @return {boolean} */ hasIssues() { return this.issues.length > 0; } /** * Retrieves the Dsl for the last chained call * @return {Dsl} */ getLastMethod() { if (this.next) { return this.next.getLastMethod(); } else { return this; } } /** * Chain a function call to this Dsl * @param method * @return {Dsl} */ chain(method) { if (method instanceof Dsl) { if (method.kindFamily !== FAMILY.FUNCTION) { throw new DslError('Cannot push method ... method must be a function'); } let last = this.getLastMethod(); last.next = method; method.prev = last; return method; } else { throw new DslError('Cannot push method ... method must be of type Dsl'); } } /** * Remove the last chained call from this Dsl * @return {Dsl} */ popChainedMethod() { let last = this.getLastMethod(); if (last.hasPrev()) { NEXT.delete(last.prev); PREV.delete(last); } return this; } /** * Get the root node for this Dsl * @return {Dsl} */ getRoot() { if (this.hasParent()) { return this.parent.getRoot(); } else if (this.hasPrev()) { return this.prev.getRoot(); } else return this; } /** * Equality comparison * @param value * @return {boolean} */ equals(value) { if (value && value instanceof Dsl) { try { if (this.kind === value.kind) { return this.toString() === value.toString(); } } catch (err) { } } return false; } /** * Return the DSL representation for this Dsl * @return {string} */ toString() { let current = this; let yaml = current.generate(); switch (current.kindFamily) { case FAMILY.FUNCTION: yaml = FUNCTION_PREFIX + yaml; // fallthrough to next case case FAMILY.REFERENCE: while (current.hasNext()) { current = current.next; yaml += '.' + current.generate(); } break; default: // TODO check if we need unquoted string constants (they're in quotes now) } return yaml; } toJSON() { // note the result of this is serialized, as per JSON.stringify docs for an Object.toJSON: // (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) // semantics of this method are toReplacedObjectForJsonSerialization _not_ toEncodedJsonString; // so we want the toString return this.toString(); } /** * Return a clone for this Dsl * @return {Dsl} */ clone() { let clone = new Dsl(this.kind, this.name); clone.relationships = Array.from(this.relationships); clone.issues = Array.from(this.issues); if (this.next) { clone.next = this.next.clone(); } this.params.forEach(param => clone.param(param.clone())); return clone; } /** * Return the DSL representation for this Dsl component (only) * @return {string} */ generate() { if (this.kindFamily === FAMILY.FUNCTION) { return this.name + '(' + this.generateParams() + ')'; } else if (this.kind === KIND.ENTITY) { return 'entity("' + this.ref.id + '")'; } else if (this.kind === KIND.STRING) { return JSON.stringify(this.name); } else if (this.kind === KIND.PORT) { // In case we get a single port range (i.e. 8080+, not part of a $brooklyn:...) // we need to return the value without double quote, otherwise, Brooklyn won't accept it. return this.hasParent() || this.hasNext() || this.hasPrev() ? JSON.stringify(this.name) : this.name; } return this.name; } /** * Return the DSL representation for a list of parameters, comma separated * @return {string} */ generateParams() { return this.params.map(param => param.toString()).join(', '); } /** * Recursively visit this Dsl * @param func a function to call on each node in the Dsl graph */ visit(func) { if (!this.visiting) { this.visiting = true; func(this); this.params.forEach(p => p.visit(func)); if (this.hasPrev()) { this.prev.visit(func); } if (this.hasNext()) { this.next.visit(func); } this.visiting = false; } } /** * Retrieve the issues for this Dsl, recursively */ getAllIssues() { let allIssues = new Array(); this.visit(dsl => { allIssues = allIssues.concat(dsl.issues); }); return allIssues; } /** * Retrieve the references contained in this Dsl, recursively * @return {Array} an array containing either Entities or other Dsls that can be resolved to Entities */ getReferences() { let refs = new Set(); this.visit(dsl => { if (dsl.kind === KIND.TARGET) { if (!dsl.hasPrev() || dsl.prev.kind !== KIND.ENTITY) { // only add the leftmost dsl in a chain of entity functions refs.add(dsl); } } else if (dsl.kind === KIND.ENTITY) { refs.add(dsl.ref); } }); return Array.from(refs); } /** * Retrieve the Entities referenced by this Dsl * @param {Entity} entity the base Entity, used to resolve relative references * @param {function} entityResolver a function to resolve an entity from an ID * @return {Array} the Entities referenced by this Dsl */ getRelationships(entity, entityResolver) { let references = this.getReferences(); let entitySet = new Set(); for (const ref of references) { if (ref instanceof Entity) { entitySet.add(ref); } else if (ref instanceof Dsl) { let resolvedEntity = ref.resolveEntity(entity, entityResolver); if (resolvedEntity && resolvedEntity !== entity) { // don't add relationships to self entitySet.add(resolvedEntity); } } else { throw new Error('Invalid value type in dsl references: ' + typeof ref); } } return Array.from(entitySet); } /** * Resolve the Entity referenced by this Dsl * @param {Entity} entity the base entity to resolve relative targets against * @param {function} entityResolver a function to resolve an Entity from an ID * @return {Entity} */ resolveEntity(entity, entityResolver) { let dsl = this; let curr = entity; while (dsl.kind === KIND.TARGET) { switch (dsl.name) { case TARGET.SELF: break; case TARGET.PARENT: if (!curr.parent) { this.issues.push('The entity with ID <code>' + curr.id + '</code> does not have a parent'); } curr = curr.parent; break; case TARGET.CHILD: case TARGET.SIBLING: case TARGET.DESCENDANT: case TARGET.ANCESTOR: case TARGET.ENTITY: case TARGET.COMPONENT: // component can have 1 or 2 params let name = dsl.params[dsl.params.length - 1].name; let resolvedEntity = entityResolver(name); if (resolvedEntity === null) { this.issues.push('The reference ID <code>' + name + '</code> does not exist'); } curr = resolvedEntity; break; case TARGET.ROOT: case TARGET.SCOPEROOT: curr = this.resolveRoot(curr); break; } if (dsl.hasNext()) { // follow the call chain dsl = dsl.next; } else { break; } } return curr; } /** * Utility method to get the root from an Entity * @param {Entity} e an Entity * @return {Entity} the root Entity */ resolveRoot(e) { while (e.hasParent()) { e = e.parent; } return e; } } function fnLookupInDescendantsById(root) { return id => { if (root.id === id) { return root; } for (let child of root.childrenAsMap.values()) { let ret = fnLookupInDescendantsById(child)(id); if (ret !== null) { return ret; } } return null; }; } /** * A parser for Dsl expressions. */ export class DslParser { /** * @param {*} s a Dsl expression to parse, e.g. a string */ constructor(s) { this.s = s; } /** * Parse this expression, or throw if it is malformed. * @param {Entity} entity the base Entity to resolve relative references from * @param {function} entityResolver a function to resolve an entity from an ID * @return {Dsl} the Dsl object representing this expression */ parse(entity, entityResolverOrRoot) { if (this.s instanceof String || typeof this.s === 'string') { return this.parseString(this.s.toString().trim(), entity, entityResolverOrRoot); } // NUMBER, BOOLEAN, OTHER_CONST kinds are in the CONSTANT family which means they aren't DSL expressions if (typeof this.s === 'number') { return new Dsl(KIND.NUMBER, this.s) } if (typeof this.s === 'boolean') { return new Dsl(KIND.BOOLEAN, this.s) } // TODO we could look at objects, nulls, etc, but right now we only parse DSL as string return new Dsl(KIND.OTHER_CONST, this.s) } /** * Parse an expression in string form, or throw. * @param {string} s the expression to parse * @param {Entity} entity the base Entity to resolve relative references from * @param {function} entityResolver a function to resolve an entity from an ID * @return {Dsl} the Dsl object representing the expression in s */ parseString(s, entity, entityResolverOrRoot) { const entityResolver = (typeof entityResolverOrRoot === 'function') ? entityResolverOrRoot : fnLookupInDescendantsById(entityResolverOrRoot); let t = new Tokenizer(s); let dsl = this.expression(t); t.skipWhitespace(); if (!t.atEndOfInput()) { throw new DslError('EXPRESSION followed by spurious content: ' + t.toJSON()); } if (entity && entityResolver) { dsl.relationships = dsl.getRelationships(entity, entityResolver); } return dsl; } /** * Parse a Dsl expression, or throw. * EXPRESSION ::= FUNCTION_CHAIN | CONSTANT * @param {Tokenizer} t the current Tokenizer * @return {Dsl} the Dsl object representing the expression */ expression(t) { if (t.peek(FUNCTION_PREFIX)) { t.next(FUNCTION_PREFIX); return this.functionChain(t); } else { return this.constant(t); } } /** * Parse a Dsl function call chain, or throw. * FUNCTION_CHAIN ::= FUNCTION_CALL { "." FUNCTION_CALL }* * @param {Tokenizer} t the Tokenizer * @return {Dsl} the Dsl object representing a function call chain */ functionChain(t) { let func = this.functionCall(t); while (t.peek('.')) { t.next('.'); func.chain(this.functionCall(t)); } return func; } /** * Parse a Dsl function call, or throw. * FUNCTION_CALL ::= IDENTIFIER "(" [ EXPRESSION {"," EXPRESSION}* ] ")" * @param {Tokenizer} t the Tokenizer * @return {Dsl} the Dsl object representing the function call */ functionCall(t) { let name = t.nextIdentifier(); let dsl = new Dsl(this.functionKind(name), name); t.next('('); while (!t.atEndOfInput()) { if (t.peek(')')) { // end of params break; } dsl.param(this.expression(t)); if (t.peek(',')) { t.next(','); if (t.atEndOfInput()) { throw new DslError('Expected: EXPRESSION but found: end-of-input'); } } } t.next(')'); return dsl; } /** * Parse a Dsl constant, e.g. a string or a number, or throw. * @param {Tokenizer} t the Tokenizer * @return {Dsl} the Dsl object representing the constant */ constant(t) { if (t.peek('"')) { // a string in double quotes return new Dsl(KIND.STRING, JSON.parse(t.nextQuotedString())); } else if (t.peek('\'')) { // a string in single quotes (YAML syntax) let s = t.nextSingleQuotedString(); // convert to double quoted string (JSON syntax) s = '"' + s.replace(/^'/, '').replace(/'$/, '').replace(/"/g, '\\\"').replace(/''/g, "'") + '"'; return new Dsl(KIND.STRING, JSON.parse(s)); } else if (t.peekPortRange()) { // a port range return new Dsl(KIND.PORT, t.nextPortRange()); } else if (t.peekNumber()) { // a floating point number return new Dsl(KIND.NUMBER, t.nextNumber()); } return new Dsl(KIND.STRING, t.remainder()); // previously we did this, but it caused all kinds of errors, as non-json input is common // throw new DslError('Expected: CONSTANT but found: ' + t.toJSON()); } /** * Find the most appropriate KIND for a function, by comparing * its name to all known TARGETS and UTILITIES. * @param name the function name * @return {*} one of KIND.TARGET, KIND.UTILITY, KIND.METHOD */ functionKind(name) { if (TARGETS.includes(name)) { return KIND.TARGET; } else if (UTILITIES.includes(name)) { return KIND.UTILITY; } return KIND.METHOD; } } /** * A string tokenizer. Retrieves tokens, such as symbols, characters, * quoted strings, numbers, from a string. * It is used by the DslParser to parse complex Dsl expressions. */ export class Tokenizer { /** * @param {string} s the string to tokenize */ constructor(s) { // this.s contains the current input buffer this.s = s.trim(); } /** * Return <code>true</code> if there are no more characters in the input. * @return {boolean} */ atEndOfInput() { return this.s.length === 0; } /** * Fetch one identifier, such as a function name, or throw. * @return {string} */ nextIdentifier() { this.skipWhitespace(); let spl = this.s.split(/\s|[^$A-Za-z0-9_]/, 1); if (spl.length > 1) { this.s = spl[1]; return spl[0]; } else if (spl.length === 1) { this.skipChars(spl[0].length); return spl[0]; } else { throw new DslError('Expected IDENTIFIER but found: ' + this.toJSON()); } } /** * Skip whitespace from the input. */ skipWhitespace() { // input was right trimmed, so this is effectively a left trim. this.s = this.s.trim(); } /** * Fetch the requested symbol, or throw. * @param {string} sym one or more non-whitespace characters, e.g., * a reserved keyword or a special character. * @return {string} */ next(sym) { sym = sym.trim(); if (sym.length === 0) { throw new DslError("Empty symbol"); } this.skipWhitespace(); if (this.s.startsWith(sym)) { this.skipChars(sym.length); this.skipWhitespace(); return sym; } else { throw new DslError('Expected: "' + sym + '" but found: ' + this.toJSON()); } } /** * Fetch a string in double quotes, or throw. * @return {string} */ nextQuotedString() { let str = this.next('"'); let prev = ''; let curr = ''; let terminated = false; while (!this.atEndOfInput()) { curr = this.nextChar(); str += curr; if (prev !== '\\' && curr === '"') { // end of string terminated = true; break; } prev = curr; } if (!terminated) { throw new DslError('Unterminated quoted string'); } return str; } /** * Fetch a string in single quotes (YAML syntax), or throw. * @return {string} */ nextSingleQuotedString() { let str = this.next('\''); let prev = ''; let curr = ''; let terminated = false; let doubles = false; while (!this.atEndOfInput()) { curr = this.nextChar(); str += curr; if (curr === '\'') { if (prev === '\'' && doubles === false) { doubles = true; } else if (this.peek('\'') === false) { // end of string terminated = true; break; } } else { doubles = false; } prev = curr; } if (!terminated) { throw new DslError('Unterminated quoted string'); } return str; } /** * Fetch a literal number, or throw. * @return {number} */ nextNumber() { let mm = this.s.match(numberRegex); if (mm === null) { throw new DslError('Expected: NUMBER but found: ' + this.toJSON()); } this.skipChars(mm[0].length); return Number(mm[0]); } /** * Fetch a port range, or throw * Valid port ranges are, e.g., <code>8080+</code> or <code>1024-32767</code> * @return {string} */ nextPortRange() { let mm = this.s.match(portRangeRegex); if (mm === null) { throw new DslError('Expected: PORT_RANGE but found: ' + this.toJSON()); } this.skipChars(mm[0].length); return mm[0]; } /** * Fetch the next character in the input buffer, or throw. * @return {string} the next character */ nextChar() { if (!this.atEndOfInput()) { let c = this.s.charAt(0); this.skipChars(1); return c; } else { throw new DslError('Expected CHAR but found: end-of-input'); } } /** * Consume characters from the start of the input buffer * @param num the number of characters to skip */ skipChars(num) { this.s = this.s.substring(num); } remainder() { let result = this.s; this.s = ""; return result; } /** * Return <code>true</code> if a call to <code>next(sym)</code> would succeed. * @param {string} sym one or more characters, e.g., * a reserved keyword or a special character. * @return {boolean} */ peek(sym) { return this.s.startsWith(sym); } /** * Return <code>true</code> if a call to <code>nextPortRange()</code> would succeed. * @return {boolean} */ peekPortRange() { return this.s.search(portRangeRegex) >= 0; } /** * Return <code>true</code> if a call to <code>nextNumber()</code> would succeed. * @return {boolean} */ peekNumber() { return this.s.search(numberRegex) >= 0; } /** * Return a JSON of the current buffer. * Mainly for diagnostic purposes. * @return {string} */ toJSON() { // FIXME - this is not compatible with JSON.stringify which expects _unserialized_ object // ie calling JSON.stringify(this) will result in _twice_ escaped JSON. // but need to review uses of this method (in case there is a caller elsewhere who expects // valid JSON). [as noted above in other toJSON, the method name is horribly ambiguous!] return JSON.stringify(this.s); } } export class DslError extends Error { constructor(message, options = {}) { super(message); this.name = 'DslError'; this.message = message; this.id = options.id || 'general-error'; this.data = options.data || null; if (typeof Error.captureStackTrace === 'function') { Error.captureStackTrace(this, this.constructor); } else { this.stack = (new Error(message)).stack; } } }