@webda/shell
Version:
Deploy a Webda app or configure it
1,228 lines • 52.3 kB
JavaScript
//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