UNPKG

@o3r/rules-engine

Version:

This module provides a rule engine that can be executed on your Otter application to customize your application (translations, placeholders and configs) based on a json file generated by your CMS.

366 lines • 19.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.RulesEngineExtractor = void 0; const tslib_1 = require("tslib"); const node_fs_1 = require("node:fs"); const path = tslib_1.__importStar(require("node:path")); const core_1 = require("@angular-devkit/core"); const extractors_1 = require("@o3r/extractors"); const schematics_1 = require("@o3r/schematics"); const globby_1 = tslib_1.__importDefault(require("globby")); const rxjs_1 = require("rxjs"); const operators_1 = require("rxjs/operators"); const ts = tslib_1.__importStar(require("typescript")); const tjs = tslib_1.__importStar(require("typescript-json-schema")); const rules_engine_extractor_interfaces_1 = require("./rules-engine.extractor.interfaces"); /** * Extracts rules engine facts and operator from code */ class RulesEngineExtractor { /** Interface of a Fact definition */ static { this.FACT_DEFINITIONS_INTERFACE = 'FactDefinitions'; } /** Interface of an operator definition */ static { this.OPERATOR_DEFINITIONS_INTERFACES = ['Operator', 'UnaryOperator']; } /** Interface of an action definition */ static { this.OPERATOR_ACTIONS_INTERFACE = 'RulesEngineAction'; } /** Reserved fact names that will be filtered out of metadata */ static { this.RESERVED_FACT_NAMES = ['portalFacts']; } constructor(tsconfigPath, basePath, logger) { this.basePath = basePath; this.logger = logger; /** Instance of the comment parser */ this.commentParser = new extractors_1.ConfigDocParser(); const { config } = ts.readConfigFile(tsconfigPath, (p) => ts.sys.readFile(p)); this.tsconfig = config; } /** * Extract type definition of a type reference into schema file * @param type Name of the type to extract * @param sourceFile path to the code source file */ async extractTypeRef(type, sourceFile) { const internalLibFiles = this.tsconfig.extraOptions?.otterSubModuleRefs?.length > 0 ? await (0, rxjs_1.lastValueFrom)((0, rxjs_1.zip)(...(this.tsconfig.extraOptions?.otterSubModuleRefs).map((value) => (0, globby_1.default)(value))).pipe((0, operators_1.map)((globFiles) => globFiles.reduce((acc, files) => [ ...acc, ...files.map((f) => path.resolve(f)) ], [])))) : []; internalLibFiles.push(sourceFile); const program = tjs.getProgramFromFiles(internalLibFiles, this.tsconfig.compilerOptions, this.basePath); const settings = { required: true, aliasRef: this.tsconfig.compilerOptions?.paths, ignoreErrors: true }; const schema = tjs.generateSchema(program, type, settings); if (schema?.definitions?.['utils.Date']) { schema.definitions['utils.Date'] = { type: 'string', format: 'date' }; } if (schema?.definitions?.['utils.DateTime']) { schema.definitions['utils.DateTime'] = { type: 'string', format: 'date-time' }; } return schema; } /** * Extract the type node used to generate the type metadata. * In the case of an array, it will look for the child node with the relevant information * @param typeNode * @private */ extractTypeNode(typeNode) { return ts.isArrayTypeNode(typeNode) ? (ts.isParenthesizedTypeNode(typeNode.elementType) ? typeNode.elementType.type : typeNode.elementType) : typeNode; } /** * Return the list of types matching the type node. If the type is not supported, it will be replaced with an unknown entry * @param type * @param source * @private */ extractSimpleTypesData(type, source) { if (type.kind === ts.SyntaxKind.UnknownKeyword) { return ['unknown']; } if (type.kind === ts.SyntaxKind.AnyKeyword) { return rules_engine_extractor_interfaces_1.allSupportedTypes; } else { const typeText = type.getText(source).replace('Date', 'date'); if (typeText === 'SupportedSimpleTypes') { return rules_engine_extractor_interfaces_1.allDefaultSupportedTypes; } else if (typeText === 'dateInput') { return ['date', 'string', 'number']; } return [type.getText(source).replace('Date', 'date')]; } } /** * Return the nbValue number associated to the simple type. * If it is an array, it will be -1 * If it is an unknown type (or any), it will return 0 * If it is any other type of object it will return 1 * @param type * @private */ getTypeNbValue(type) { if (type.kind === ts.SyntaxKind.UnknownKeyword || type.kind === ts.SyntaxKind.AnyKeyword) { return 0; } else if (ts.isArrayTypeNode(type)) { return -1; } return 1; } /** * Construct a metadata object that will describe the operator hand type as a list of types and a nbValue describing * the structure of the operand * nbValue reflects whether the operand support a single object (1), an array (-1), both (0) or a n-tuple (n) * @param type * @param source * @param nbValue * @private */ extractComplexTypeData(type, source, nbValue) { if (ts.isUnionTypeNode(type)) { return type.types.reduce((acc, t) => { const childTypeNode = this.extractTypeNode(t); const childNbValue = this.getTypeNbValue(t); const childType = this.extractSimpleTypesData(childTypeNode, source); return { types: Array.from(new Set([...acc.types, ...childType])), // If union is of type array, it takes precedence over child types // (any[], string)[] will be considered as an array and no an "anything goes" type // Else, it will compare between the children and return 0 if the child nb values differ nbValue: nbValue === -1 ? -1 : (!!acc.nbValue && acc.nbValue !== childNbValue ? 0 : childNbValue) }; }, { types: [], nbValue: undefined }); } else if (ts.isTupleTypeNode(type)) { return { types: Array.from(new Set(type.elements.flatMap((elementTypeNode) => (ts.isUnionTypeNode(elementTypeNode) ? elementTypeNode.types : [elementTypeNode]).flatMap((typeNode) => this.extractSimpleTypesData(typeNode, source))))), nbValue: type.elements.length }; } return { types: this.extractSimpleTypesData(type, source), nbValue: nbValue }; } /** * Extract facts from source code * @param sourceFile path to the code source file * @param schemaFolderFullPath full path to the schema folder * @param schemaFolderRelativePath path to the schema folder from the metadata file */ async extractFacts(sourceFile, schemaFolderFullPath, schemaFolderRelativePath) { const program = ts.createProgram([sourceFile], this.tsconfig); const source = program.getSourceFile(sourceFile); const facts = []; source?.forEachChild((node) => { if (ts.isInterfaceDeclaration(node) && node.heritageClauses?.some((clause) => clause.types.some((type) => type.getText(source) === RulesEngineExtractor.FACT_DEFINITIONS_INTERFACE))) { facts.push(...node.members .filter((memberNode) => ts.isPropertySignature(memberNode)) .filter((prop) => { const name = prop.name.getText(source); if (RulesEngineExtractor.RESERVED_FACT_NAMES.includes(name)) { this.logger.error(`Fact named "${name}" found in ${sourceFile} has a reserved name and will be ignored by the extractor, please consider renaming it.`); return false; } return true; }) .map(async (prop) => { const name = prop.name.getText(source); const description = this.commentParser.parseConfigDocFromNode(source, prop)?.description; if (!prop.type || (!this.isNativeType(prop.type) && !ts.isTypeReferenceNode(prop.type) && !ts.isUnionTypeNode(prop.type))) { throw new schematics_1.O3rCliError(`The fact ${name} has an unsupported type "${prop.type?.getText(source) || 'unknown'}" in ${sourceFile}`); } let mainType = prop.type; if (ts.isUnionTypeNode(mainType)) { const mainTypes = mainType.types.filter((t) => t.kind !== ts.SyntaxKind.NullKeyword && t.kind !== ts.SyntaxKind.UndefinedKeyword && !(ts.isLiteralTypeNode(t) && (t.literal.kind === ts.SyntaxKind.NullKeyword || t.literal.kind === ts.SyntaxKind.UndefinedKeyword))); if (mainTypes.length !== 1) { throw new schematics_1.O3rCliError(`The fact ${name} has an unsupported union-type "${prop.type?.getText(source) || 'unknown'}" in ${sourceFile}`); } mainType = mainTypes[0]; } const typeName = mainType.getText(source); const type = ts.isTypeReferenceNode(mainType) && typeName !== 'Date' ? 'object' : typeName.replace('Date', 'date'); let fact; if (type === 'object') { fact = { type: 'object', name, description }; try { const schema = await this.extractTypeRef(typeName, sourceFile); if (schema === null) { this.logger.warn(`Schema of ${typeName} is null.`); } else if (schema.type === 'object') { const schemaFile = `${core_1.strings.dasherize(typeName)}.schema.json`; await node_fs_1.promises.writeFile(path.resolve(schemaFolderFullPath, schemaFile), JSON.stringify(schema, null, 2)); fact = { ...fact, schemaFile: path.join(schemaFolderRelativePath, schemaFile).replace(/\\/g, '/') }; } else if (schema.type === 'array') { fact = { ...fact, type: 'array', items: schema.items }; } else if (schema.enum) { fact = { ...fact, type: 'string', // eslint-disable-next-line @typescript-eslint/no-base-to-string -- convert non-null values to string enum: schema.enum?.filter((value) => !!value).map((v) => v.toString()) }; } else { fact = { ...fact, type: schema.type }; } } catch { this.logger.warn(`Error when parsing ${type} in ${sourceFile}`); } } else { fact = { type: type, name, description }; } return fact; })); } }); return Promise.all(facts); } /** * Check if typescript type node is a native type or a list of native element * @param type */ isNativeType(type) { return [ts.SyntaxKind.BooleanKeyword, ts.SyntaxKind.NumberKeyword, ts.SyntaxKind.StringKeyword].some((typeKind) => typeKind === type.kind || (ts.isArrayTypeNode(type) && type.elementType.kind === typeKind)); } /** * Extract operators from source code * @param sourceFile path to the code source file */ extractOperators(sourceFile) { const program = ts.createProgram([sourceFile], this.tsconfig); const source = program.getSourceFile(sourceFile); const operators = []; source?.forEachChild((node) => { if (ts.isVariableStatement(node)) { const operatorDeclarations = node.declarationList.declarations.filter((declaration) => !!declaration.type && ts.isTypeReferenceNode(declaration.type) && RulesEngineExtractor.OPERATOR_DEFINITIONS_INTERFACES.includes(declaration.type.typeName.getText(source))); if (operatorDeclarations.length === 0) { return; } operatorDeclarations.forEach((declaration) => { const operatorType = declaration.type.typeName.getText(source); const commentParsedDeclaration = this.commentParser.parseConfigDocFromNode(source, declaration); const commentParsedNode = this.commentParser.parseConfigDocFromNode(source, node); const operator = { id: declaration.name.getText(source), description: commentParsedDeclaration?.description || commentParsedNode?.description || '', display: commentParsedDeclaration?.title || commentParsedNode?.title || '', leftOperand: { types: rules_engine_extractor_interfaces_1.allDefaultSupportedTypes, nbValues: 1 } }; if (declaration.initializer && ts.isObjectLiteralExpression(declaration.initializer)) { declaration.initializer.properties.forEach((property) => { if (ts.isPropertyAssignment(property) && property.name.getText(source) === 'factImplicitDependencies') { operator.factImplicitDependencies = JSON.parse(property.initializer.getText(source).replace(/'/g, '"')); } }); } if (operatorType === 'Operator') { operator.rightOperand = { types: rules_engine_extractor_interfaces_1.allDefaultSupportedTypes, nbValues: 1 }; } const operands = ['leftOperand', 'rightOperand']; declaration.type.typeArguments?.slice(0, 2).forEach((argType, idx) => { const operand = operands[idx]; const operandObject = operator[operand]; operandObject.nbValues = this.getTypeNbValue(argType); const typeNode = this.extractTypeNode(argType); if (typeNode.kind === ts.SyntaxKind.UndefinedKeyword) { delete operator[operand]; } else { const typeData = this.extractComplexTypeData(typeNode, source, operandObject.nbValues); operandObject.nbValues = typeData.nbValue; operandObject.types = typeData.types; if (operandObject.types.some((type) => type !== 'unknown' && !rules_engine_extractor_interfaces_1.allSupportedTypes.includes(type))) { this.logger.warn(`Operator ${operator.id} has an unsupported type in operand ${operand}: "${operandObject.types.join(', ')}"`); operandObject.types = ['unknown']; } } }); operators.push(operator); }); } }); return Array.from((new Map(operators.map((operator) => [operator.id, operator]))).values()); } /** * Not used for the moment, kept for later updates * @param sourceFile */ extractActions(sourceFile) { const program = ts.createProgram([sourceFile], this.tsconfig); const source = program.getSourceFile(sourceFile); const actions = []; source?.forEachChild((node) => { if (ts.isClassDeclaration(node) && node.heritageClauses?.some((nodeHeritage) => nodeHeritage.types.some((tNode) => tNode.expression.getText(source) === RulesEngineExtractor.OPERATOR_ACTIONS_INTERFACE))) { let parameters; node.heritageClauses.forEach((nodeHeritage) => { nodeHeritage.types .forEach((tNode) => { if (tNode.expression.getText(source) === RulesEngineExtractor.OPERATOR_ACTIONS_INTERFACE && tNode.typeArguments) { tNode.typeArguments .filter((argNode) => ts.isTypeLiteralNode(argNode)) .forEach((argNode) => argNode.members .filter((memberNode) => ts.isPropertySignature(memberNode)) .forEach((memberNode) => { if (memberNode.name && memberNode.type) { parameters ||= {}; parameters[memberNode.name.getText(source)] = memberNode.type.getText(source); } })); } }); }); const type = node.members .filter((methodNode) => ts.isPropertyDeclaration(methodNode)) .find((methodNode) => methodNode.name.getText(source) === 'name') ?.initializer?.getText(source).replace(/^["'](.*)["']$/, '$1'); if (!type) { this.logger.error(`No type found for ${node.name?.getText(source) || 'unknown class'} in ${sourceFile}. It will be skipped.`); return; } actions.push({ type, parameters, description: this.commentParser.parseConfigDocFromNode(source, node)?.description || '' }); } }); return actions; } } exports.RulesEngineExtractor = RulesEngineExtractor; //# sourceMappingURL=rules-engine.extractor.js.map