UNPKG

@webda/shell

Version:

Deploy a Webda app or configure it

1,228 lines 52.3 kB
//node.kind === ts.SyntaxKind.ClassDeclaration import { tsquery } from "@phenomnomnominal/tsquery"; import { FileUtils, JSONUtils } from "@webda/core"; import { writer } from "@webda/tsc-esm"; import { existsSync } from "fs"; import * as path from "path"; import { AnnotatedNodeParser, AnnotatedType, ArrayType, CircularReferenceNodeParser, ExtendedAnnotationsReader, FunctionType, InterfaceAndClassNodeParser, LiteralType, ObjectProperty, ObjectType, SchemaGenerator, StringType, UnionType, createFormatter, createParser } from "ts-json-schema-generator"; import ts from "typescript"; class WebdaSchemaResults { constructor() { this.store = {}; this.byNode = new Map(); } get(node) { if (this.byNode.has(node)) { return this.byNode.get(node); } } /** * Generate all schemas * @param info */ generateSchemas(compiler) { let schemas = {}; Object.entries(this.store) .sort((a, b) => a[0].localeCompare(b[0])) .forEach(([name, { schemaNode, link, title, addOpenApi }]) => { var _a; if (schemaNode) { schemas[name] = compiler.generateSchema(schemaNode, title || name); if (addOpenApi && schemas[name]) { (_a = schemas[name]).properties ?? (_a.properties = {}); schemas[name].properties["openapi"] = { type: "object", additionalProperties: true }; } } else { schemas[name] = link; } }); return schemas; } add(name, info, title, addOpenApi = false) { if (typeof info === "object") { this.store[name] = { name: name, schemaNode: info, title, addOpenApi }; this.byNode.set(info, name); } else { this.store[name] = { name: name, link: info, title, addOpenApi }; } } } /** * Copy from https://github.com/vega/ts-json-schema-generator/blob/next/src/Utils/modifiers.ts * They are not exported correctly */ /** * Checks if given node has the given modifier. * * @param node - The node to check. * @param modifier - The modifier to look for. * @return True if node has the modifier, false if not. */ export function hasModifier(node, modifier) { return ts.canHaveModifiers(node) && node.modifiers?.some(nodeModifier => nodeModifier.kind === modifier); } /** * Checks if given node is public. A node is public if it has the public modifier or has no modifiers at all. * * @param node - The node to check. * @return True if node is public, false if not. */ export function isPublic(node) { return !(hasModifier(node, ts.SyntaxKind.PrivateKeyword) || hasModifier(node, ts.SyntaxKind.ProtectedKeyword)); } /** * Checks if given node has the static modifier. * * @param node - The node to check. * @return True if node is static, false if not. */ export function isStatic(node) { return hasModifier(node, ts.SyntaxKind.StaticKeyword); } /** * Temporary fix while waiting for https://github.com/vega/ts-json-schema-generator/pull/1182 */ /* c8 ignore start */ export class FunctionTypeFormatter { supportsType(type) { return type instanceof FunctionType; } getDefinition(_type) { // Return a custom schema for the function property. return {}; } getChildren(_type) { return []; } } export class NullTypeFormatter { supportsType(type) { return type === undefined; } getDefinition(_type) { // Return a custom schema for the function property. return {}; } getChildren(_type) { return []; } } export function hash(a) { if (typeof a === "number") { return a; } const str = typeof a === "string" ? a : JSON.stringify(a); // short strings can be used as hash directly, longer strings are hashed to reduce memory usage if (str.length < 20) { return str; } // from http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/ let h = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); h = (h << 5) - h + char; h = h & h; // Convert to 32bit integer } // we only want positive integers if (h < 0) { return -h; } return h; } function getKey(node, context) { const ids = []; while (node) { const file = node .getSourceFile() .fileName.substring(process.cwd().length + 1) .replace(/\//g, "_"); ids.push(hash(file), node.pos, node.end); node = node.parent; } const id = ids.join("-"); const argumentIds = context.getArguments().map(arg => arg?.getId()); return argumentIds.length ? `${id}<${argumentIds.join(",")}>` : id; } /** * Temporary fix */ class ConstructorNodeParser { supportsNode(node) { return node.kind === ts.SyntaxKind.ConstructorType; } createType(_node, _context, _reference) { return undefined; } } /* c8 ignore stop */ class WebdaAnnotatedNodeParser extends AnnotatedNodeParser { createType(node, context, reference) { let type = super.createType(node, context, reference); if (node.parent.kind === ts.SyntaxKind.PropertyDeclaration) { if (node.parent.name.getText().startsWith("_")) { /* c8 ignore next 3 - do not know how to generate this one */ if (!(type instanceof AnnotatedType)) { type = new AnnotatedType(type, { readOnly: true }, false); } else { type.getAnnotations().readOnly = true; } } } return type; } } class WebdaModelNodeParser extends InterfaceAndClassNodeParser { supportsNode(node) { return node.kind === ts.SyntaxKind.ClassDeclaration || node.kind === ts.SyntaxKind.InterfaceDeclaration; } /** * Override to filter __ properties * @param node * @param context * @returns */ getProperties(node, context) { let hasRequiredNever = false; const properties = node.members .reduce((members, member) => { if (ts.isConstructorDeclaration(member)) { const params = member.parameters.filter(param => ts.isParameterPropertyDeclaration(param, param.parent)); members.push(...params); } else if (ts.isPropertySignature(member)) { members.push(member); } else if (ts.isPropertyDeclaration(member)) { // Ensure NotEnumerable is not part of the property annotation if (!(ts.getDecorators(member) || []).find(annotation => { return "NotEnumerable" === annotation?.expression?.getText(); })) { members.push(member); } } return members; }, []) .filter(member => isPublic(member) && !isStatic(member) && member.type && !this.getPropertyName(member.name).startsWith("__")) .map(member => { // Check for other tags let ignore = false; let jsDocs = ts.getAllJSDocTags(member, (tag) => { return true; }); jsDocs.forEach(n => { if (n.tagName.text === "SchemaIgnore") { ignore = true; } }); if (ignore) { return undefined; } // @ts-ignore let typeName = member.type?.typeName?.escapedText; let readOnly = jsDocs.filter(n => n.tagName.text === "readOnly").length > 0 || this.getPropertyName(member.name).startsWith("_"); let optional = readOnly || member.questionToken || jsDocs.find(n => "SchemaOptional" === n.tagName.text) !== undefined; let type; if (typeName === "ModelParent" || typeName === "ModelLink") { type = new StringType(); } else if (typeName === "ModelLinksSimpleArray") { type = new ArrayType(new StringType()); } else if (typeName === "ModelLinksArray") { let subtype = (this.childNodeParser.createType(member.type.typeArguments[1], context)); subtype.properties.push(new ObjectProperty("uuid", new StringType(), true)); type = new ArrayType(subtype); } else if (typeName === "ModelLinksMap") { let subtype = (this.childNodeParser.createType(member.type.typeArguments[1], context)); subtype.properties.push(new ObjectProperty("uuid", new StringType(), true)); type = new ObjectType("modellinksmap-test", [], [], subtype); } else if (typeName === "ModelsMapped") { let subtype = (this.childNodeParser.createType(member.type.typeArguments[0], context)); let attrs = this.childNodeParser.createType(member.type.typeArguments[2], context); let keep = []; if (attrs instanceof LiteralType) { keep.push(attrs.getValue()); } else if (attrs instanceof UnionType) { attrs .getTypes() .filter(t => t instanceof LiteralType) .forEach((t) => { keep.push(t.getValue()); }); } subtype.properties = subtype.properties.filter(o => keep.includes(o.name)); subtype.properties.push(new ObjectProperty("uuid", new StringType(), true)); type = new ArrayType(new ObjectType("modelmapped-test", [], subtype.properties.filter(o => !["get", "set", "toString"].includes(o.name)), false)); optional = true; readOnly = true; } else if (typeName === "ModelRelated") { // ModelRelated are only helpers for backend development return undefined; } else if (typeName === "Binary" || typeName === "Binaries") { // Binary and Binaries should be readonly as they are only modifiable by a BinaryService optional = true; readOnly = true; if (member.type.typeArguments?.length) { type = this.childNodeParser.createType(member.type.typeArguments[0], context); } else { type = new ObjectType(typeName + "_" + this.getPropertyName(member.name), [], [], true); //type["additionalProperties"] = true; } if (typeName !== "Binary") { type = new ArrayType(type); } //return new ObjectProperty(this.getPropertyName(member.name), type, false); } type ?? (type = this.childNodeParser.createType(member.type, context)); if (readOnly) { type = new AnnotatedType(type, { readOnly: true }, false); } // If property is in readOnly then we do not want to require it return new ObjectProperty(this.getPropertyName(member.name), type, !optional); }) .filter(prop => { if (!prop) { return false; } if (prop.isRequired() && prop.getType() === undefined) { /* c8 ignore next 2 */ hasRequiredNever = true; } return prop.getType() !== undefined; }); if (hasRequiredNever) { /* c8 ignore next 2 */ return undefined; } return properties; } } export class Compiler { /** * Construct a compiler for a WebdaApplication * @param app */ constructor(app) { this.types = {}; this.app = app; } /** * Load the tsconfig.json */ loadTsconfig(app) { const configFileName = app.getAppPath("tsconfig.json"); // basically a copy of https://github.com/Microsoft/TypeScript/blob/3663d400270ccae8b69cbeeded8ffdc8fa12d7ad/src/compiler/tsc.ts -> parseConfigFile this.configParseResult = ts.parseJsonConfigFileContent(ts.parseConfigFileTextToJson(configFileName, ts.sys.readFile(configFileName)).config, ts.sys, path.dirname(configFileName), {}, configFileName); } /** * Generate a program from app * @param app * @returns */ createProgramFromApp(app = this.app) { this.loadTsconfig(app); this.tsProgram = ts.createProgram({ rootNames: this.configParseResult.fileNames, ...this.configParseResult }); } /** * Return the Javascript target file for a source * @param sourceFile * @param absolutePath * @returns */ getJSTargetFile(sourceFile, absolutePath = false) { let filePath = ts.getOutputFileNames(this.configParseResult, sourceFile.fileName, true)[0]; if (absolutePath) { return filePath; } return path.relative(this.app.getAppPath(), filePath); } /** * Get the name of the export for a class * * Will also check if it is exported with a `export { MyClass }` * * @param node * @returns */ getExportedName(node) { let exportNodes = tsquery(node, "ExportKeyword"); const className = node.name.escapedText.toString(); if (exportNodes.length === 0) { // Try to find a generic export let namedExports = tsquery(this.getParent(node, ts.SyntaxKind.SourceFile), `ExportSpecifier [name=${className}]`); if (namedExports.length === 0) { return undefined; } // Return the export alias // Return the export alias const alias = namedExports.shift().parent; if (ts.isIdentifier(alias.name)) { return alias.name.escapedText.toString(); } else { return alias.name.getText(); } } if (tsquery(node, "DefaultKeyword").length) { return "default"; } return className; } /** * Generate a single schema * @param schemaNode */ generateSchema(schemaNode, title) { let res; try { this.app.log("INFO", "Generating schema for " + title); let schema = this.schemaGenerator.createSchemaFromNodes([schemaNode]); let definitionName = decodeURI(schema.$ref.split("/").pop()); res = schema.definitions[definitionName]; res.$schema = schema.$schema; // Copy sub definition if needed if (Object.keys(schema.definitions).length > 1) { res.definitions = schema.definitions; // Avoid cycle ref delete res.definitions[definitionName]; } if (title) { res.title = title; } } catch (err) { this.app.log("WARN", `Cannot generate schema for ${schemaNode.getText()}`, err); } return res; } /** * Get a schema for a typed node * @param classTree * @param typeName * @param packageName * @returns */ getSchemaNode(classTree, typeName = "ServiceParameters", packageName = "@webda/core") { let schemaNode; classTree.some(type => { let res = type.symbol.valueDeclaration.heritageClauses?.some(t => { return t.types?.some(subtype => { return subtype.typeArguments?.some(arg => { if (this.extends(this.getClassTree(this.typeChecker.getTypeFromTypeNode(arg)), packageName, typeName)) { schemaNode = arg; return true; } }); }); }); if (res) { return true; } return type.symbol.valueDeclaration.typeParameters?.some(t => { // @ts-ignore let paramType = ts.getEffectiveConstraintOfTypeParameter(t); if (this.extends(this.getClassTree(this.typeChecker.getTypeFromTypeNode(paramType)), packageName, typeName)) { schemaNode = t.constraint; return true; } }); }); return schemaNode; } /** * Load all JSDoc tags for a node * @param node * @returns */ getTagsName(node) { let tags = {}; ts.getAllJSDocTags(node, (tag) => { return true; }).forEach(n => { const tagName = n.tagName.escapedText.toString(); if (tagName.startsWith("Webda")) { tags[tagName] = n.comment?.toString().trim().replace("\n", " ").split(" ").shift() || node.name?.escapedText; } else if (tagName.startsWith("Schema")) { tags[tagName] = n.comment?.toString().trim() || ""; } }); return tags; } /** * Retrieve all webda objects from source * * If an object have a @WebdaIgnore tag, it will be ignored * Every CoreModel object will be added if it is exported and not abstract * @returns */ searchForWebdaObjects() { const result = { schemas: new WebdaSchemaResults(), models: {}, moddas: {}, deployers: {}, beans: {} }; this.tsProgram.getSourceFiles().forEach(sourceFile => { if (!this.tsProgram.isSourceFileDefaultLibrary(sourceFile) && //this.tsProgram.getRootFileNames().includes(sourceFile.fileName) && !sourceFile.fileName.endsWith(".spec.ts")) { this.sourceFile = sourceFile; ts.forEachChild(sourceFile, (node) => { // Skip everything except class and interface\ // Might want to allow type for schema const tags = this.getTagsName(node); const type = this.typeChecker.getTypeAtLocation(node); // Manage schemas if (tags["WebdaSchema"]) { const name = this.app.completeNamespace(tags["WebdaSchema"] || node.name?.escapedText.toString()); result.schemas.add(name, node, node.name?.escapedText.toString()); return; // Only Schema work with other than class declaration } else if (!ts.isClassDeclaration(node)) { return; } const classNode = node; const symbol = this.typeChecker.getSymbolAtLocation(classNode.name); // Explicit ignore this class if (tags["WebdaIgnore"]) { return; } const classTree = this.getClassTree(type); if (!this.tsProgram.getRootFileNames().includes(sourceFile.fileName)) { if (this.extends(classTree, "@webda/core", "CoreModel")) { const name = this.getLibraryModelName(sourceFile.fileName, this.getExportedName(classNode)); // This should not happen likely bad module not worth checking /* c8 ignore next 3 */ if (!name) { return; } result["models"][name] = { name, tags: {}, lib: true, type, node, symbol, jsFile: sourceFile.fileName.replace(/\.d\.ts$/, ".js") }; } return; } const importTarget = this.getJSTargetFile(sourceFile).replace(/\.js$/, ""); let section; let schemaNode; if (this.extends(classTree, "@webda/core", "CoreModel")) { section = "models"; schemaNode = node; } else if (tags["WebdaModda"]) { if (!this.extends(classTree, "@webda/core", "Service")) { this.app.log("WARN", `${importTarget}(${classNode.name?.escapedText}) have a @WebdaModda annotation but does not inherite from Service`); return; } section = "moddas"; schemaNode = this.getSchemaNode(classTree); } else if (tags["WebdaDeployer"]) { if (!this.extends(classTree, "@webda/core", "AbstractDeployer")) { this.app.log("WARN", `${importTarget}(${classNode.name?.escapedText}) have a @WebdaDeployer annotation but does not inherite from AbstractDeployer`); return; } section = "deployers"; schemaNode = this.getSchemaNode(classTree, "DeployerResources"); } else if (this.extends(classTree, "@webda/core", "Service")) { // Check if a Bean is declared if (!ts.getDecorators(classNode)?.find(decorator => decorator.expression.getText() === "Bean")) { return; } section = "beans"; schemaNode = this.getSchemaNode(classTree); } else { return; } const exportName = this.getExportedName(classNode); if (!exportName) { this.app.log("WARN", `WebdaObjects need to be exported ${classNode.name.escapedText} in ${sourceFile.fileName}`); return; } let info = { type, symbol, node, tags, lib: false, jsFile: `${importTarget}:${exportName}`, name: this.app.completeNamespace(tags[`Webda${section.substring(0, 1).toUpperCase()}${section.substring(1, section.length - 1)}`] || classNode.name.escapedText) }; if (schemaNode && !result["schemas"][info.name]) { result["schemas"].add(info.name, schemaNode, classNode.name?.escapedText.toString(), section === "beans" || section === "moddas"); } result[section][info.name] = info; }); } }); return result; } /** * Get a model name from a library path based on file and classname * @param fileName * @param className * @returns */ getLibraryModelName(fileName, className) { let mod = path.dirname(fileName); let moduleInfo; while (mod !== "/") { if (existsSync(path.join(mod, "webda.module.json"))) { moduleInfo = FileUtils.load(path.join(mod, "webda.module.json")); break; } mod = path.dirname(mod); } // Should not happen /* c8 ignore next 3 */ if (!moduleInfo) { return; } const importEntry = `${path.relative(mod, fileName.replace(/\.d\.ts$/, ""))}:${className}`; return Object.keys(moduleInfo.models.list || {}).find(f => moduleInfo.models.list[f] === importEntry); } /** * Generating the local module from source * * It scans for JSDoc @WebdaModda and @WebdaModel * to detect Modda and Model * * The @Bean and @Route Decorator will detect the Bean and ImplicitBean * * @param this.tsProgram * @returns */ generateModule() { // Ensure we have compiled the application this.compile(); // Generate the Module const objects = this.searchForWebdaObjects(); // Check for @Action methods this.exploreModelsAction(objects.models, objects.schemas); // Check for @Operation and @Route methods this.exploreServices(objects.moddas, objects.schemas); this.exploreServices(objects.beans, objects.schemas); const jsOnly = a => a.jsFile; return { beans: JSONUtils.sortObject(objects.beans, jsOnly), deployers: JSONUtils.sortObject(objects.deployers, jsOnly), moddas: JSONUtils.sortObject(objects.moddas, jsOnly), models: this.processModels(objects.models), schemas: objects.schemas.generateSchemas(this) }; } /** * Explore services or beans for @Operation and @Route methods * @param services * @param schemas */ exploreServices(services, schemas) { Object.values(services).forEach(service => { service.type .getProperties() .filter(prop => prop.valueDeclaration?.kind === ts.SyntaxKind.MethodDeclaration && ts.getDecorators(prop.valueDeclaration) && ts.getDecorators(prop.valueDeclaration).find(annotation => { return ["Operation"].includes(annotation.expression.expression && annotation.expression.expression.getText()); })) .map(prop => prop.valueDeclaration) .forEach((method) => { this.checkMethodForContext(service.type.getSymbol().getName(), method, schemas); }); }); } /** * Ensure each method that are supposed to have a context have one * And detect their input/output schema * * @param rootName * @param method * @param schemas * @returns */ checkMethodForContext(rootName, method, schemas) { // If first parameter is not a OperationContext, display an error if (method.parameters.length === 0 || !this.extends(this.getClassTree(this.typeChecker.getTypeFromTypeNode(method.parameters[0].type)), "@webda/core", "OperationContext")) { this.app.log("ERROR", `${rootName}.${method.name.getText()} does not have a OperationContext as first parameter`); return; } if (method.parameters.length > 1) { // Warn user if there is more than 1 parameter this.app.log("WARN", `${rootName}.${method.name.getText()} have more than 1 parameter, only the first one will be used as context`); } let obj = method.parameters[0].type; if (!obj.typeArguments) { this.app.log("INFO", `${rootName}.${method.name.getText()} have no input defined, no validation will happen`); return; } const infos = [".input", ".output"]; obj.typeArguments.slice(0, 2).forEach((schemaNode, index) => { // TODO Check if id is overriden and use it or fallback to method.name let name = rootName + "." + method.name.getText() + infos[index]; if (ts.isTypeReferenceNode(schemaNode)) { let decl = schemas.get(this.typeChecker.getTypeFromTypeNode(schemaNode).getSymbol().declarations[0]); if (decl) { schemas.add(name, decl); return; } } schemas.add(name, schemaNode); }); } /** * Explore models * @param models * @param schemas */ exploreModelsAction(models, schemas) { Object.values(models).forEach(model => { model.type .getProperties() .filter(prop => prop.valueDeclaration?.kind === ts.SyntaxKind.MethodDeclaration && ts.getDecorators(prop.valueDeclaration) && ts.getDecorators(prop.valueDeclaration).find(annotation => { return ["Action"].includes( // @ts-ignore annotation.expression.expression && // @ts-ignore annotation.expression.expression.getText()); })) .map(prop => prop.valueDeclaration) .forEach((method) => { this.checkMethodForContext(model.name, method, schemas); }); }); } /** * Get id from TypeNode * * The id is not exposed in the TypeNode * @param type * @returns */ getTypeIdFromTypeNode(type) { return this.typeChecker.getTypeFromTypeNode(type).id; } /** * Generate the graph relationship between models * And the hierarchy tree * @param models */ processModels(models) { let graph = {}; let tree = {}; let plurals = {}; let symbolMap = new Map(); let list = {}; let reflections = {}; Object.values(models).forEach(({ name, type, tags, lib }) => { // @ts-ignore symbolMap.set(type.id, name); // Do not process external models apart from adding them to the symbol map if (lib) { return; } if (tags["WebdaPlural"]) { plurals[name] = tags["WebdaPlural"].split(" ")[0]; } graph[name] ?? (graph[name] = {}); reflections[name] ?? (reflections[name] = {}); type .getProperties() .filter(p => ts.isPropertyDeclaration(p.valueDeclaration)) .forEach((prop) => { var _a, _b, _c, _d; const pType = prop.valueDeclaration .getChildren() .filter(c => c.kind === ts.SyntaxKind.TypeReference) .shift(); let children = prop.valueDeclaration.getChildren(); let type; let captureNext = false; for (let i in children) { if (captureNext) { type = children[i]; } captureNext = children[i].kind === ts.SyntaxKind.ColonToken; } reflections[name][prop.getName()] = type?.getText() || "unknown"; if (pType) { const addLinkToGraph = (type) => { var _a; (_a = graph[name]).links ?? (_a.links = []); graph[name].links.push({ attribute: prop.getName(), model: this.getTypeIdFromTypeNode(pType.typeArguments[0]), type }); }; switch (pType.typeName.getText()) { case "ModelParent": graph[name].parent = { attribute: prop.getName(), model: this.getTypeIdFromTypeNode(pType.typeArguments[0]) }; break; case "ModelRelated": (_a = graph[name]).queries ?? (_a.queries = []); graph[name].queries.push({ attribute: prop.getName(), model: this.getTypeIdFromTypeNode(pType.typeArguments[0]), targetAttribute: pType.typeArguments[1].getText().replace(/"/g, "") }); break; case "ModelsMapped": (_b = graph[name]).maps ?? (_b.maps = []); const cascadeDelete = prop.getJsDocTags().find(p => p.name === "CascadeDelete") !== undefined; const map = { attribute: prop.getName(), cascadeDelete, // @ts-ignore model: this.getTypeIdFromTypeNode(pType.typeArguments[0]), targetLink: pType.typeArguments[1].getText().replace(/"/g, ""), targetAttributes: pType.typeArguments[2] .getText() .replace(/"/g, "") .split("|") .map(t => t.trim()) }; if (!map.targetAttributes.includes("uuid")) { map.targetAttributes.push("uuid"); } graph[name].maps.push(map); break; case "ModelLink": addLinkToGraph("LINK"); break; case "ModelLinksMap": addLinkToGraph("LINKS_MAP"); break; case "ModelLinksArray": addLinkToGraph("LINKS_ARRAY"); break; case "ModelLinksSimpleArray": addLinkToGraph("LINKS_SIMPLE_ARRAY"); break; case "Binary": (_c = graph[name]).binaries ?? (_c.binaries = []); graph[name].binaries.push({ attribute: prop.getName(), cardinality: "ONE" }); break; case "Binaries": (_d = graph[name]).binaries ?? (_d.binaries = []); graph[name].binaries.push({ attribute: prop.getName(), cardinality: "MANY" }); break; } } }); }); Object.values(graph).forEach(graph => { if (graph.parent && typeof graph.parent.model === "number") { graph.parent.model = symbolMap.get(graph.parent.model) || "unknown"; } graph.links?.forEach(link => { if (typeof link.model === "number") { link.model = symbolMap.get(link.model) || "unknown"; } }); graph.queries?.forEach(query => { if (typeof query.model === "number") { query.model = symbolMap.get(query.model) || "unknown"; } }); graph.maps?.forEach(map => { if (typeof map.model === "number") { map.model = symbolMap.get(map.model) || "unknown"; } }); }); const ancestorsMap = {}; // Construct the hierarchy tree Object.values(models) .filter(p => !p.lib) .forEach(({ type, name, jsFile }) => { list[name] = jsFile; let root = tree; const ancestors = this.getClassTree(type) .map((t) => symbolMap.get(t.id)) .filter(t => t !== undefined && t !== "Webda/CoreModel"); ancestorsMap[name] = ancestors[1]; ancestors.reverse(); ancestors.forEach((ancestorName) => { root[ancestorName] ?? (root[ancestorName] = {}); root = root[ancestorName]; }); }); // Compute children now Object.keys(graph) .filter(k => graph[k].parent && graph[graph[k].parent.model]) .forEach(k => { const parent = graph[graph[k].parent.model]; parent.children ?? (parent.children = []); if (!ancestorsMap[k] || !graph[ancestorsMap[k]]?.parent) { parent.children.push(k); } }); return { graph: JSONUtils.sortObject(graph), tree: JSONUtils.sortObject(tree), plurals: JSONUtils.sortObject(plurals), list: JSONUtils.sortObject(list), reflections: JSONUtils.sortObject(reflections) }; } /** * Get the package name for a type * @param type * @returns */ getPackageFromType(type) { const fileName = type.symbol.getDeclarations()[0]?.getSourceFile()?.fileName; if (!fileName) { return; } let folder = path.dirname(fileName); // if / or C: while (folder.length > 2) { const pkg = path.join(folder, "package.json"); if (existsSync(pkg)) { return FileUtils.load(pkg).name; } folder = path.dirname(folder); } return undefined; } /** * Check if a type extends a certain subtype (packageName/symbolName) * * types can be obtained by using this.getClassTree(type: ts.Type) */ extends(types, packageName, symbolName) { for (const type of types) { if (type.symbol?.name === symbolName && this.getPackageFromType(type) === packageName) { return true; } } return false; } createSchemaGenerator(program) { this.typeChecker = this.tsProgram.getTypeChecker(); const config = { expose: "all", encodeRefs: true, jsDoc: "extended", additionalProperties: true, sortProps: true, minify: true, topRef: true, markdownDescription: false, strictTuples: true, skipTypeCheck: true, extraTags: [], discriminatorType: "json-schema", functions: "comment" }; const extraTags = new Set(["Modda", "Model"]); const parser = createParser(program, config, (chainNodeParser) => { chainNodeParser.addNodeParser(new ConstructorNodeParser()); chainNodeParser.addNodeParser(new CircularReferenceNodeParser(new AnnotatedNodeParser(new WebdaModelNodeParser(this.typeChecker, new WebdaAnnotatedNodeParser(chainNodeParser, new ExtendedAnnotationsReader(this.typeChecker, extraTags)), true), new ExtendedAnnotationsReader(this.typeChecker, extraTags)))); }); const formatter = createFormatter(config, (fmt, _circularReferenceTypeFormatter) => { // If your formatter DOES NOT support children, e.g. getChildren() { return [] }: fmt.addTypeFormatter(new FunctionTypeFormatter()); fmt.addTypeFormatter(new NullTypeFormatter()); }); this.schemaGenerator = new SchemaGenerator(program, parser, formatter, config); } /** * Compile typescript */ compile(force = false) { if (this.compiled && !force) { return true; } let result = true; // https://convincedcoder.com/2019/01/19/Processing-TypeScript-using-TypeScript/ this.app.log("INFO", "Compiling..."); this.createProgramFromApp(); // Emit all code const { diagnostics } = this.tsProgram.emit(undefined, writer); const allDiagnostics = ts.getPreEmitDiagnostics(this.tsProgram).concat(diagnostics, this.configParseResult.errors); if (allDiagnostics.length) { const formatHost = { getCanonicalFileName: p => p, getCurrentDirectory: ts.sys.getCurrentDirectory, getNewLine: () => ts.sys.newLine }; const message = ts.formatDiagnostics(allDiagnostics, formatHost); this.app.log("WARN", message); result = false; } this.app.log("INFO", "Analyzing..."); // Generate schemas this.createSchemaGenerator(this.tsProgram); this.compiled = result; return result; } /** * Generate the configuration schema * * @param filename to save for * @param full to keep all required */ generateConfigurationSchemas(filename = ".webda-config-schema.json", deploymentFilename = ".webda-deployment-schema.json", full = false) { // Ensure we have compiled already this.compile(); let rawSchema = this.schemaGenerator.createSchema("UnpackedConfiguration"); let res = rawSchema.definitions["UnpackedConfiguration"]; res.definitions ?? (res.definitions = {}); // Add the definition for types res.definitions.ServicesType = { type: "string", enum: Object.keys(this.app.getModdas() || {}) }; res.properties.services = { type: "object", additionalProperties: { oneOf: [] } }; const addServiceSchema = (type) => { return serviceType => { var _a, _b, _c; const key = `${type}$${serviceType.replace(/\//g, "$")}`; const definition = (res.definitions[key] = this.app.getSchema(serviceType)); /* should try to mock the getSchema */ /* c8 ignore next 3 */ if (!definition) { return; } definition.title ?? (definition.title = serviceType); definition.properties.type.pattern = this.getServiceTypePattern(serviceType); res.properties.services.additionalProperties.oneOf.push({ $ref: `#/definitions/${key}` }); delete res.definitions[key]["$schema"]; // Flatten definition (might not be the best idea) for (let def in definition.definitions) { (_a = res.definitions)[def] ?? (_a[def] = definition.definitions[def]); } delete definition.definitions; // Remove mandatory depending on option if (!full) { res.definitions[key]["required"] = ["type"]; } // Predefine beans if (type === "BeanType") { (_b = res.properties.services).properties ?? (_b.properties = {}); res.properties.services[definition.title] = { $ref: `#/definitions/${key}` }; (_c = res.definitions[key]).required ?? (_c.required = []); res.definitions[key].required = res.definitions[key].required.filter(p => p !== "type"); } }; }; Object.keys(this.app.getModdas()).forEach(addServiceSchema("ServiceType")); Object.keys(this.app.getBeans()).forEach(addServiceSchema("BeanType")); FileUtils.save(res, filename); // Build the deployment schema // Ensure builtin deployers are there const definitions = JSONUtils.duplicate(res.definitions); res = { properties: { parameters: { type: "object", additionalProperties: true }, resources: { type: "object", additionalProperties: true }, services: { type: "object", additionalProperties: false, properties: {} }, units: { type: "array", items: { oneOf: [] } } }, definitions: res.definitions }; const appServices = this.app.getConfiguration().services; Object.keys(appServices).forEach(k => { if (!appServices[k]) { return; } const key = `Service$${k}`; res.properties.services.properties[k] = { type: "object", oneOf: [ { $ref: `#/definitions/${key}` }, ...Object.keys(definitions) .filter(name => name.startsWith("ServiceType")) .map(dkey => ({ $ref: `#/definitions/${dkey}` })) ] }; }); Object.keys(this.app.getDeployers()).forEach(serviceType => { const key = `DeployerType$${serviceType.replace(/\//g, "$")}`; const definition = (res.definitions[key] = this.app.getSchema(serviceType)); if (!definition) { return; } definition.title = serviceType; definition.properties.type.pattern = this.getServiceTypePattern(serviceType); res.properties.units.items.oneOf.push({ $ref: `#/definitions/${key}` }); delete definition["$schema"]; // Remove mandatory depending on option if (!full) { definition["required"] = ["type"]; } }); FileUtils.save(res, deploymentFilename); } /** * Generate regex based on a service name * * The regex will ensure the namespace is optional * * @param type * @returns */ getServiceTypePattern(type) { // Namespace is optional let split = this.app.completeNamespace(type).split("/"); return `^(${split[0]}/)?${split[1]}$`; } /** * Retrieve a schema from a Modda * @param type */ getSchema(type) { this.compile(); return this.schemaGenerator.createSchema(type); } /** * Launch compiler in watch mode * @param callback */ watch(callback, logger) { // Load tsconfig this.loadTsconfig(this.app); const formatHost = { // This method is not easily reachable and is straightforward getCanonicalFileName: /* c8 ignore next */ /* c8 ignore next */ p => p, getCurrentDirectory: ts.sys.getCurrentDirectory, getNewLine: () => ts.sys.newLine }; const reportDiagnostic = (diagnostic) => { callback(diagnostic); logger.log("WARN", ts .formatDiagnostics([diagnostic], { ...formatHost, getNewLine: () => "" }) .trim()); }; const generateModule = async () => { callback("MODULE_GENERATION"); this.loadTsconfig(this.app); this.tsProgram = this.watchProgram.getProgram().getProgram(); this.createSchemaGenerator(this.tsProgram); await this.app.generateModule(); callback("MODULE_GENERATED"); logger.logTitle("Compilation done"); }; const reportWatchStatusChanged = (diagnostic) => { if ([6031, 6032, 6194, 6193].includes(diagnostic.code)) { // Launching compile if (diagnostic.code === 6032 || diagnostic.code === 6031) { logger.log("INFO", diagnostic.messageText); logger.logTitle("Compiling..."); } else { if (diagnostic.messageText.match(/Found [1-9]\d* error/)) { logger.log("ERROR", diagnostic.messageText); /* c8 ignore start */ } else if (!diagnostic.messageText.toString().startsWith("Found 0 errors")) { logger.log("INFO", diagnostic.messageText); } /* c8 ignore stop */ // Compilation is successful, start schemas generation if (diagnostic.messageText.toString().startsWith("Found 0 errors")) { this.compiled = true; logger.logTitle("Analyzing..."); if (this.watchProgram) { generateModule(); } } } /* c8 ignore start */ } else { // Haven't seen other code yet so display them but cannot reproduce logger.log("INFO", diagnostic, ts.formatDiagnostic(diagnostic, formatHost)); } /* c8 ignore stop */ callback(diagnostic); }; const host = ts.createWatchCompilerHost(this.app.getAppPath("tsconfig.json"), {}, { ...ts.sys, writeFile: writer }, ts.createEmitAndSemanticDiagnosticsBuilderProgram, reportDiagnostic, reportWatchStatusChanged, Compiler.watchOptions); this.watchProgram = ts.createWatchProgram(host); if (this.compiled) { generateModule(); } } /** * Stop watching for chan