airent
Version:
Airent Framework for JavaScript Backend - a lightweight data entity and presentation framework
648 lines (597 loc) • 19.6 kB
JavaScript
// IMPORTS //
const ejs = require("ejs");
const fs = require("fs");
const yaml = require("js-yaml");
const path = require("path");
const readline = require("readline");
const utils = require("../resources/utils.js");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// UTILITIES //
async function sequential(functions) {
const results = [];
for (const func of functions) {
const result = await func();
results.push(result);
}
return results;
}
async function askQuestion(question, defaultAnswer) {
const a = await new Promise((resolve) =>
rl.question(`${question} (${defaultAnswer}): `, resolve)
);
return a?.length ? a : defaultAnswer;
}
async function writeFileContent(absoluteFilePath, fileContent) {
const folderPath = path.dirname(absoluteFilePath);
await fs.promises.mkdir(folderPath, { recursive: true });
await fs.promises.writeFile(absoluteFilePath, fileContent);
}
// TYPES AND CONSTANTS //
/** @typedef {Object} Type
* @property {string} name
* @property {?string} aliasOf
* @property {?string} enum
* @property {?string} define
* @property {?string} import
* @property {?boolean} deprecated
*/
/** @typedef {Object} Field
* @property {number} id
* @property {string} type
* @property {"primitive" | "association" | "computed" | "computedAsync"} strategy
* @property {string} name
* @property {?string} aliasOf
* @property {?boolean} cast
* @property {?boolean} deprecated
* @property {?string[]} [sourceKeys]
* @property {?string[]} [targetKeys]
* @property {?object[]} [targetFilters]
* @property {?boolean} skipGetter
* @property {?boolean} skipSetter
*/
/** @typedef {Object} Entity
* @property {string} name
* @property {string} model
* @property {?boolean} internal
* @property {?boolean} deprecated
* @property {?boolean} [skipSelfLoader]
* @property {Type[]} [types]
* @property {Field[]} [fields]
*/
/** @typedef {Object} Template
* @property {string} name
* @property {string} outputPath
* @property {?boolean} skippable
*/
/** @typedef {Object} Config
* @property {"commonjs" | "module"} type
* @property {?string} libImportPath
* @property {string} schemaPath
* @property {string} entityPath
* @property {string} generatedPath
* @property {string} contextImportPath
* @property {?string[]} [augmentors]
* @property {?Template[]} [templates]
*/
const PROJECT_PATH = process.cwd();
const CONFIG_FILE_PATH = path.join(PROJECT_PATH, "airent.config.json");
const AIRENT_PATH = path.join(__dirname, "..");
const AIRENT_RESOURCES_PATH = path.join(AIRENT_PATH, "resources");
const DEFALUT_AUGMENTOR_NAMES = [
path.join(AIRENT_RESOURCES_PATH, "augmentor.js"),
];
const BASE_TEMPLATE_NAME = path.join(
AIRENT_RESOURCES_PATH,
"base-template.ts.ejs"
);
const TYPE_TEMPLATE_NAME = path.join(
AIRENT_RESOURCES_PATH,
"type-template.ts.ejs"
);
const ENTITY_TEMPLATE_NAME = path.join(
AIRENT_RESOURCES_PATH,
"entity-template.ts.ejs"
);
const DEFAULT_TEMPLATE_NAMES = [
BASE_TEMPLATE_NAME,
TYPE_TEMPLATE_NAME,
ENTITY_TEMPLATE_NAME,
];
const BASE_TEMPLATE_CONFIG = {
name: BASE_TEMPLATE_NAME,
skippable: false,
outputPath: path.join("{generatedPath}", "entities", "{kababEntityName}.ts"),
};
const TYPE_TEMPLATE_CONFIG = {
name: TYPE_TEMPLATE_NAME,
skippable: false,
outputPath: path.join("{generatedPath}", "types", "{kababEntityName}.ts"),
};
const ENTITY_TEMPLATE_CONFIG = {
name: ENTITY_TEMPLATE_NAME,
skippable: true,
outputPath: path.join("{entityPath}", "{kababEntityName}.ts"),
};
const DEFAULT_TEMPLATE_CONFIGS = [
BASE_TEMPLATE_CONFIG,
TYPE_TEMPLATE_CONFIG,
ENTITY_TEMPLATE_CONFIG,
];
// CONFIGURE //
async function configure(config) {
config.type = await askQuestion(
"Project type [commonjs or module]",
config.type ?? "commonjs"
);
config.schemaPath = await askQuestion(
"Schema path",
config.schemaPath ?? "./schemas"
);
config.entityPath = await askQuestion(
"Entity path",
config.entityPath ?? "./src/entities"
);
config.generatedPath = await askQuestion(
"Generated code path",
config.generatedPath ?? "./src/generated"
);
config.contextImportPath = await askQuestion(
"Context import path",
config.contextImportPath ?? "./src/context"
);
const content = JSON.stringify(config, null, 2) + "\n";
await fs.promises.writeFile(CONFIG_FILE_PATH, content);
console.log(`[AIRENT/INFO] Configuration located at '${CONFIG_FILE_PATH}'`);
}
// GENERATE //
function validateConfig(config, isVerbose) {
const {
type,
augmentors: extAugmentorNames,
templates: extTemplateConfigs,
} = config;
// configure augmentors
const augmentors = [
...DEFALUT_AUGMENTOR_NAMES,
...(extAugmentorNames ?? []),
].filter((s) => s?.length);
// configure templates
const templates = [
...DEFAULT_TEMPLATE_CONFIGS,
...(extTemplateConfigs ?? []),
];
// validate config
if (config.type !== "commonjs" && config.type !== "module") {
throw new Error(
`[AIRENT/ERROR] config.type '${config.type}' must be one of 'commonjs' or 'module'.`
);
}
if (!config.schemaPath?.length) {
throw new Error(`[AIRENT/ERROR] config.schemaPath is missing.`);
}
if (!config.entityPath?.length) {
throw new Error(`[AIRENT/ERROR] config.entityPath is missing.`);
}
if (!config.generatedPath?.length) {
throw new Error(`[AIRENT/ERROR] config.generatedPath is missing.`);
}
if (!config.contextImportPath?.length) {
throw new Error(`[AIRENT/ERROR] config.contextImportPath is missing.`);
}
const templateNameCountMap = templates
.filter((t) => t.name?.length)
.reduce((map, t) => {
map.set(t.name, (map.get(t.name) ?? 0) + 1);
return map;
}, new Map());
templates.forEach((t) => {
if (!t.name?.length) {
throw new Error("[AIRENT/ERROR] template.name is missing.");
}
const count = templateNameCountMap.get(t.name) ?? 0;
if (count !== 1) {
throw new Error(
`[AIRENT/ERROR] template.name '${t.name}' is duplicated ${count} times.`
);
}
});
const validated = { ...config, augmentors, templates };
if (isVerbose) {
console.log(validated);
}
return validated;
}
async function loadTemplates(config, isVerbose) {
const { templates: templateConfigs } = config;
const functions = templateConfigs.map((c) => () => {
const templateFilePath = DEFAULT_TEMPLATE_NAMES.includes(c.name)
? c.name
: path.join(PROJECT_PATH, c.name);
if (isVerbose) {
console.log(`[AIRENT/INFO] Loading template ${templateFilePath} ...`);
}
return fs.promises.readFile(templateFilePath, "utf8");
});
const tepmlateContents = await sequential(functions);
const templates = templateConfigs.map((c, i) => ({
...c,
content: tepmlateContents[i],
}));
return templates;
}
function isEntityTemplate(template) {
return (
template.outputPath.includes("{kababEntityName}") ||
template.outputPath.includes("{kababEntitiesName}")
);
}
async function getAbsoluteSchemaFilePaths(schemaPath) {
const absoluteSchemaPath = path.join(PROJECT_PATH, schemaPath);
// Read all files in the YAML directory
const allFileNames = await fs.promises.readdir(absoluteSchemaPath);
// Filter only YAML files (with .yml or .yaml extension)
return allFileNames
.filter((fileName) => {
const extname = path.extname(fileName).toLowerCase();
return extname === ".yml" || extname === ".yaml";
})
.map((fileName) => path.join(absoluteSchemaPath, fileName));
}
async function loadSchema(absoluteSchemaFilePath, isVerbose) {
if (isVerbose) {
console.log(`[AIRENT/INFO] Loading entity ${absoluteSchemaFilePath} ...`);
}
const schemaContent = await fs.promises.readFile(
absoluteSchemaFilePath,
"utf8"
);
const entityRaw = yaml.load(schemaContent);
const types = entityRaw.types ?? [];
const fields = entityRaw.fields ?? [];
const entity = {
...entityRaw,
internal: entityRaw.internal ?? false,
deprecated: entityRaw.deprecated ?? false,
skipSelfLoader: entityRaw.skipSelfLoader ?? false,
types,
fields,
};
validoateSchema(entity);
return entity;
}
function validoateSchema(entity) {
if (!entity.name?.length) {
throw new Error("[AIRENT/ERROR] entity.name is missing.");
}
if (!entity.model?.length) {
throw new Error(
`[AIRENT/ERROR] entity.model on '${entity.name}' is missing.`
);
}
const typeNameCountMap = entity.types
.filter((t) => t.name?.length)
.reduce((map, t) => {
map.set(t.name, (map.get(t.name) ?? 0) + 1);
return map;
}, new Map());
entity.types.forEach((t) => {
if (!t.name?.length) {
throw new Error(
`[AIRENT/ERROR] type.name on '${entity.name}' is missing.`
);
}
const count = typeNameCountMap.get(t.name) ?? 0;
if (count !== 1) {
throw new Error(
`[AIRENT/ERROR] type.name '${entity.name}.${t.name}' is duplicated ${count} times.`
);
}
const point = (t.enum ? 1 : 0) + (t.define ? 1 : 0) + (t.import ? 1 : 0);
if (point !== 1) {
throw new Error(
`[AIRENT/ERROR] type '${entity.name}.${t.name}' must have exactly one of 'enum', 'define' or 'import'.`
);
}
});
const fieldNameCountMap = entity.fields
.filter((f) => f.name?.length)
.reduce((map, f) => {
map.set(f.name, (map.get(f.name) ?? 0) + 1);
return map;
}, new Map());
entity.fields.forEach((f) => {
if (!f.name?.length) {
throw new Error(
`[AIRENT/ERROR] field.name on '${entity.name}.' is missing.`
);
}
const count = fieldNameCountMap.get(f.name) ?? 0;
if (count !== 1) {
throw new Error(
`[AIRENT/ERROR] field.name '${entity.name}.${f.name}' is duplicated ${count} times.`
);
}
if (!f.type?.length) {
throw new Error(
`[AIRENT/ERROR] field.type on '${entity.name}.${f.name}' is missing.`
);
}
if (
!["primitive", "computed", "computedAsync", "association"].includes(
f.strategy ?? ""
)
) {
throw new Error(
`[AIRENT/ERROR] field.strategy '${f.strategy}' on '${entity.name}.${f.name}' is must be one of 'primitive', 'computed', 'computedAsync' or 'association'.`
);
}
if (
f.strategy === "association" &&
(!f.sourceKeys?.length ||
!f.targetKeys?.length ||
f.sourceKeys.length !== f.targetKeys.length)
) {
throw new Error(
`[AIRENT/ERROR] field.sourceKeys and field.targetKeys on '${entity.name}.${f.name}' must be present and match for association fields.`
);
}
});
}
async function loadEntityMap(schemaPath, isVerbose) {
const absoluteSchemaFilePaths = await getAbsoluteSchemaFilePaths(schemaPath);
const functions = absoluteSchemaFilePaths
.sort()
.map((p) => () => loadSchema(p, isVerbose));
const schemas = await sequential(functions);
const entityNameCountMap = schemas.reduce((map, e) => {
map.set(e.name, (map.get(e.name) ?? 0) + 1);
return map;
}, new Map());
schemas.forEach((e) => {
const count = entityNameCountMap.get(e.name) ?? 0;
if (count !== 1) {
throw new Error(
`[AIRENT/ERROR] entity.name '${e.name}' is duplicated ${count} times.`
);
}
});
const entityMap = schemas.reduce((map, entity) => {
map[entity.name] = entity;
return map;
}, {});
validateEntityMap(entityMap);
return entityMap;
}
function validateEntityMap(entityMap) {
Object.values(entityMap).forEach((entity) => {
entity.fields.forEach((f) => {
const primitiveType = utils.toPrimitiveTypeName(f.type);
switch (primitiveType) {
case "bigint":
case "boolean":
case "number":
case "string":
case "Date":
break;
default:
const type = entity.types.find((t) => t.name === primitiveType);
const typeEntity = entityMap[primitiveType];
if (!type && !typeEntity) {
throw new Error(
`[AIRENT/ERROR] field.type '${f.type}' on '${entity.name}.${f.name}' is not supported or pre-defined.`
);
}
break;
}
if (f.strategy === "association") {
const typeEntity = entityMap[primitiveType];
f.sourceKeys.forEach((sk, index) => {
const tk = f.targetKeys[index];
const sf = entity.fields.find((f) => f.name === sk);
const tf = typeEntity.fields.find((f) => f.name === tk);
if (!sf) {
throw new Error(
`[AIRENT/ERROR] field.sourceKey '${sk}' on '${entity.name}.${f.name}' is not found.`
);
}
if (!tf) {
throw new Error(
`[AIRENT/ERROR] field.targetKey '${tk}' on '${entity.name}.${f.name}' is not found.`
);
}
const spf = utils.toPrimitiveTypeName(sf.type);
const tpf = utils.toPrimitiveTypeName(tf.type);
if (spf !== tpf) {
throw new Error(
`[AIRENT/ERROR] field.sourceKey '${sk}' and field.targetKey '${tk}' on '${entity.name}.${f.name}' must have the same type.`
);
}
});
}
});
});
}
function augment(augmentorName, entityMap, templates, config, isVerbose) {
const isDefaultAugmentor = DEFALUT_AUGMENTOR_NAMES.includes(augmentorName);
const absoluteAugmentorFilePath = isDefaultAugmentor
? augmentorName
: path.join(PROJECT_PATH, augmentorName);
if (isVerbose) {
console.log(
`[AIRENT/INFO] Augmenting with ${absoluteAugmentorFilePath} ...`
);
}
const augmentor = require(absoluteAugmentorFilePath);
return augmentor.augment({ entityMap, templates, config, utils }, isVerbose);
}
function buildAbsoluteOutputPath(entity, template, config) {
const { entityPath } = config;
const kababEntityName = utils.toKababCase(entity?.name ?? "");
const kababEntitiesName = utils.pluralize(kababEntityName);
const { outputPath: outputPathRaw } = template;
let outputPath = "";
let variableStart = -1;
for (let i = 0; i < outputPathRaw.length; i++) {
switch (outputPathRaw[i]) {
case "{":
if (variableStart > -1) {
const previous = outputPathRaw.substring(variableStart, i);
outputPath += previous;
}
variableStart = i;
break;
case "}":
if (variableStart === -1) {
outputPath += outputPathRaw[i];
} else {
const variableName = outputPathRaw.substring(variableStart + 1, i);
switch (variableName) {
case "entityPath":
outputPath += entityPath;
break;
case "kababEntityName":
outputPath += kababEntityName;
break;
case "kababEntitiesName":
outputPath += kababEntitiesName;
break;
default:
const variablePathParts = variableName.split(".");
let variableValue = config;
do {
variableValue = variableValue[variablePathParts.shift()];
} while (variablePathParts.length > 0 && variableValue);
if (typeof variableValue === "string") {
outputPath += variableValue;
} else {
outputPath += `{${variableName}}`;
}
}
variableStart = -1;
}
break;
default:
if (variableStart === -1) {
outputPath += outputPathRaw[i];
} else if (i === outputPathRaw.length - 1) {
const previous = outputPathRaw.substring(variableStart, i + 1);
outputPath += previous;
}
break;
}
}
return path.join(PROJECT_PATH, outputPath);
}
async function generateEntity(
name,
entityMap,
entityTemplates,
config,
isVerbose
) {
if (isVerbose) {
console.log(`[AIRENT/INFO] Generating ${name} ...`);
}
const entity = entityMap[name];
for (const template of entityTemplates) {
const absoluteFilePath = buildAbsoluteOutputPath(entity, template, config);
const data = { entity, template, config, utils };
try {
const fileContent =
template.skippable && fs.existsSync(absoluteFilePath)
? ""
: ejs.render(template.content, data);
if (fileContent.length > 0) {
await writeFileContent(absoluteFilePath, fileContent);
if (isVerbose) {
console.log(`[AIRENT/INFO] - Generated '${absoluteFilePath}'`);
}
} else if (isVerbose) {
console.log(`[AIRENT/INFO] - Skipped '${absoluteFilePath}'`);
}
} catch (error) {
console.error(
`[AIRENT/ERROR] - Failed '${absoluteFilePath}' on '${template.name}'`
);
throw error;
}
}
}
async function generateNonEntity(entityMap, template, config, isVerbose) {
if (isVerbose) {
console.log(`[AIRENT/INFO] Generating ${template.name} ...`);
}
const absoluteFilePath = buildAbsoluteOutputPath(null, template, config);
const data = { entityMap, template, config, utils };
try {
const fileContent =
template.skippable && fs.existsSync(absoluteFilePath)
? ""
: ejs.render(template.content, data);
if (fileContent.length > 0) {
await writeFileContent(absoluteFilePath, fileContent);
if (isVerbose) {
console.log(`[AIRENT/INFO] - Generated '${absoluteFilePath}'`);
}
} else if (isVerbose) {
console.log(`[AIRENT/INFO] - Skipped '${absoluteFilePath}'`);
}
} catch (error) {
console.error(
`[AIRENT/ERROR] - Failed '${absoluteFilePath}' on '${template.name}'`
);
throw error;
}
}
async function generate(config, isVerbose) {
config = validateConfig(config, isVerbose);
// load templates
const templates = await loadTemplates(config, isVerbose);
// load schemas
const entityMap = await loadEntityMap(config.schemaPath, isVerbose);
// perform augmentation
config.augmentors.map((augmentorName) =>
augment(augmentorName, entityMap, templates, config, isVerbose)
);
// loop through each YAML file and generate code
const entityTemplates = templates.filter(isEntityTemplate);
const entityFunctions = Object.keys(entityMap).map(
(name) => () =>
generateEntity(name, entityMap, entityTemplates, config, isVerbose)
);
await sequential(entityFunctions);
// generate non entity files
const nonEntityTemplates = templates.filter(
(template) => !isEntityTemplate(template)
);
const nonEntityFunctions = nonEntityTemplates.map(
(template) => () =>
generateNonEntity(entityMap, template, config, isVerbose)
);
await sequential(nonEntityFunctions);
console.log("[AIRENT/INFO] Task completed.");
}
async function loadConfig(isVerbose) {
if (isVerbose) {
console.log(`[AIRENT/INFO] Loading config ${CONFIG_FILE_PATH} ...`);
}
const configContent = await fs.promises.readFile(CONFIG_FILE_PATH, "utf8");
return JSON.parse(configContent);
}
async function main(args) {
const isVerbose = args.includes("--verbose") || args.includes("-v");
const isConfigured = fs.existsSync(CONFIG_FILE_PATH);
const config = isConfigured ? await loadConfig(isVerbose) : {};
if (args.includes("configure") || !isConfigured) {
await configure(config);
} else {
await generate(config, isVerbose);
}
}
main(process.argv.slice(2))
.catch((error) => console.error(error))
.finally(() => rl.close());