grapi-cli
Version:
a cli tool to generate loopback 4 applications with extra features like caching & fuzzy search
657 lines (646 loc) • 32.9 kB
JavaScript
import { Command, Flags } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs';
import { processOptions, toPascalCase, toKebabCase, execute, addImport } from '../../utils/index.js';
import { Project, SyntaxKind } from 'ts-morph';
export default class ExternalOperation extends Command {
static description = 'creating rest endpoints based on configs only.';
static flags = {
config: Flags.string({ char: 'c', description: 'Config JSON object' }),
ds: Flags.string({ description: 'datasource name to attach APIs to.' }),
controller: Flags.string({ description: 'controller under which the API should reside.' }),
method: Flags.string({ description: 'api method type.' }),
url: Flags.string({ description: 'url of the external api.' }),
bodyParams: Flags.string({ description: 'Stringified JSON Object for request body.' }),
createModel: Flags.boolean({ description: 'generate the model.' }),
additionalProperties: Flags.string({ description: 'stringified additional model properties.' }),
apiFunction: Flags.string({ description: 'api function name.' }),
pathParams: Flags.string({ description: 'Stringified JSON Object for path parameter.' }),
queryParams: Flags.string({ description: 'Stringified JSON Object for query parameters.' }),
headers: Flags.string({ description: 'Stringified JSON Object for headers.' }),
apiUri: Flags.string({ description: 'uri of the controller.' }),
responses: Flags.string({ description: 'Stringified JSON Object for responses.' }),
description: Flags.string({ description: 'Description of request.' }),
};
async run() {
const parsed = await this.parse(ExternalOperation);
let optionsArray = processOptions(parsed.flags);
if (!Array.isArray(optionsArray)) {
optionsArray = [optionsArray];
}
;
for (let i = 0; i < optionsArray.length; i++) {
const options = optionsArray[i];
const { ds, url, createModel, additionalProperties, apiFunction, headers, count, replaceById, singleFetchMethodName, fetchMethodName, updateAll } = options;
let { method, controller, modelName, requestModelName, pathParams, bodyParams, queryParams, apiUri, responses, spliter, description, } = options;
if (!method)
method = 'get';
method = method.toLowerCase();
const project = new Project({});
const invokedFrom = process.cwd();
project.addSourceFilesAtPaths(`${invokedFrom}/src/**/*.ts`);
const dsPath = `${invokedFrom}/src/datasources/${ds}.datasource.ts`;
const restBasedDS = fs.existsSync(dsPath);
if (!restBasedDS)
throw new Error(`the ${ds} doesn't exist.`);
const dsFile = project.getSourceFile(dsPath);
const configVariable = dsFile?.getVariableDeclarationOrThrow('config');
const initializer = configVariable.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
const connectorProperty = initializer?.getProperty('connector');
if (!connectorProperty.getText().includes('\'rest\'')) {
throw new Error(`the ${ds} is not a rest based datasource.`);
}
const operationsProperty = initializer.getPropertyOrThrow('operations');
const operationsArray = operationsProperty.getInitializerIfKindOrThrow(SyntaxKind.ArrayLiteralExpression);
let operationExists = false;
operationsArray.getElements().forEach(element => {
if (element.getText().includes(method) && element.getText().includes(url)) {
operationExists = true;
}
});
if (operationExists) {
throw new Error(`operation ${method} ${url} already exists.`);
}
if (!spliter && !apiUri)
spliter = 'v1';
if (spliter)
apiUri = url.split(spliter)[1];
if (!pathParams)
pathParams = {};
const pathKey = Object.keys(pathParams)[0];
let queryParamList = [];
let bodyParamList = [];
let serviceName = toPascalCase(controller);
modelName = toPascalCase(modelName);
controller = toPascalCase(controller);
if (!description) {
switch (method) {
case 'get':
description = `fetch${pathKey ? '' : ' all'} ${toPascalCase(modelName)}.`;
break;
case 'post':
description = `create ${toPascalCase(modelName)}.`;
break;
case 'patch':
description = `update ${toPascalCase(modelName)}.`;
break;
case 'put':
description = `replace ${toPascalCase(modelName)}.`;
break;
case 'del':
description = `delete ${toPascalCase(modelName)}`;
break;
default:
break;
}
}
let addFilters = false;
if (!queryParams)
queryParams = {}; // prevents checking existing queryParams in the future too.
Object.keys(queryParams).forEach(key => {
queryParamList.push(key);
if (key === 'where') {
addFilters = true;
}
});
if (!bodyParams)
bodyParams = { properties: {} }; // prevents checking existing queryParams in the future too.
Object.keys(bodyParams.properties).forEach((key) => { bodyParamList.push(key); });
let query = '';
let body = '';
let path = '';
let functionParams = '';
Object.keys(pathParams).forEach((key) => { functionParams += `'${key}',`; });
queryParamList.forEach((key) => {
query += `${key}: '{${key}}', `;
functionParams += `'${key}',`;
});
bodyParamList.forEach((key) => {
body += `${key}: '{${key}}', `;
functionParams += `'${key}',`;
});
if (pathKey)
path = `${pathKey}: '{${pathKey}}'`;
let operation = `{
template: {
method: '${method === 'del' ? 'delete' : method}',
url: '${url}',
${headers ? `options: {headers: ${headers}},` : ''}
${query ? `query: {${query}},` : ''}
${body ? `body: {${body}},` : ''}
${path ? `path: {${path}},` : ''}
},
functions: {${apiFunction}: [${functionParams}]}
}`;
let lines = operation.split('\n').filter(line => line.trim() !== '');
operation = lines.join('\n');
operationsArray.addElement(operation);
dsFile?.formatText();
let serviceParams = '';
let parameters = [];
if (pathKey) {
parameters.push({
name: pathKey,
type: pathParams[pathKey]['type'],
decorators: [{
name: `param.path.${pathParams[pathKey]['type']}`,
arguments: [`'${pathKey}'`]
}]
});
}
if (pathKey) {
serviceParams += `${pathKey}: ${pathParams[pathKey]['type']}`;
}
if (serviceParams !== '' && !serviceParams.endsWith(',')) {
serviceParams += ',';
}
Object.keys(queryParams).forEach((key) => {
const type = queryParams[key]['type'];
parameters.push({
name: key,
type,
decorators: [{
name: `param.query.${type}`,
arguments: [`'${key}'`]
}]
});
serviceParams += `${key}?: ${type},`;
});
const controllerFilePath = `${invokedFrom}/src/controllers/${toKebabCase(controller)}.controller.ts`;
let controllerFile = project.getSourceFile(controllerFilePath);
const model = fs.existsSync(`./src/models/${toKebabCase(requestModelName || modelName)}.model.ts`);
if (model)
addImport(controllerFile, requestModelName || modelName, '../models', true);
let name = `requestBody({content: {'application/json': {schema: getModelSchemaRef(${requestModelName || modelName})}}})`;
if (body) {
if (bodyParams.type === 'array') {
name = `requestBody({content: {'application/json': {schema: {type: 'array', items: getModelSchemaRef(${requestModelName || modelName})}}}})`;
}
const properties = {};
const parameter = {
name: 'body',
type: 'any',
decorators: [{
name
}]
};
parameters.push(parameter);
Object.keys(bodyParams.properties).forEach((key) => {
const property = {};
property.type = bodyParams.properties[key]['type'];
property.required = bodyParams.properties[key]['required'] || false;
let type = property.type;
const enumValues = bodyParams.properties[key]['enum'];
if (enumValues && enumValues.length) {
property.jsonSchema = { enum: enumValues };
}
properties[key] = property;
if (!property.required) {
type += ' | undefined';
}
if (serviceParams !== '' && !serviceParams.endsWith(',')) {
serviceParams += ',';
}
serviceParams += `${key}: ${type},`;
});
if (additionalProperties) {
Object.keys(additionalProperties).forEach(key => {
properties[key] = additionalProperties[key];
});
}
if (!model && createModel) {
const configs = {
base: 'Entity',
name: requestModelName || modelName,
properties
};
const stringifiedConfigs = JSON.stringify(configs);
let command = `lb4 model --config='${stringifiedConfigs}' --yes`;
let executed = await execute(command, 'generating models.');
if (executed.stderr)
console.log(chalk.bold(chalk.green(executed.stderr)));
if (executed.stdout)
console.log(chalk.bold(chalk.green(executed.stdout)));
addImport(controllerFile, requestModelName || modelName, '../models', true);
}
}
const existingController = fs.existsSync(`./src/controllers/${toKebabCase(controller)}.controller.ts`);
if (!existingController) {
let command = `lb4 controller --config='{"name": "${controller.toLowerCase()}"}' --yes`;
let executed = await execute(command, 'generating controller.');
if (executed.stderr)
console.log(chalk.bold(chalk.green(executed.stderr)));
if (executed.stdout)
console.log(chalk.bold(chalk.green(executed.stdout)));
}
project.addSourceFilesAtPaths(`${invokedFrom}/src/**/*.ts`);
controllerFile = project.getSourceFile(controllerFilePath);
if (controllerFile) {
addImport(controllerFile, `${method}`, '@loopback/rest', true);
addImport(controllerFile, `requestBody, getModelSchemaRef, param`, '@loopback/rest', true);
addImport(controllerFile, 'inject', '@loopback/core');
addImport(controllerFile, `${serviceName} as ${serviceName}Service`, '../services');
// Find all class declarations within the source file
this.addFilterCode(controllerFile);
const classDeclaration = controllerFile?.getClass(`${toPascalCase(controller)}Controller`);
const classConstructors = classDeclaration?.getConstructors() || [];
let apiMethod = classDeclaration?.getMethod(apiFunction);
let constructedResponses = {};
let finalResponses = {};
Object.keys(responses).forEach(responseCode => {
constructedResponses[responseCode] = {};
const response = responses[responseCode];
constructedResponses[responseCode]['description'] = response.description || description;
let schema = {};
schema.type = response.schema.type;
if (response.schema.model) {
if (schema.type === 'array') {
schema.items = `getModelSchemaRef(${modelName})`;
}
else {
schema = `getModelSchemaRef(${modelName})`;
}
}
if (response.schema.properties) {
schema = { properties: response.schema.properties };
}
constructedResponses[responseCode]['content'] = { 'application/json': { schema } };
finalResponses = { responses: constructedResponses };
});
if (!apiMethod) {
let methodParameters = '';
let replaceByIdParameters = '';
if (pathKey) {
methodParameters = `${pathKey}`;
replaceByIdParameters = `${pathKey},`;
}
if (methodParameters &&
!methodParameters.endsWith(',')) {
methodParameters += ',';
}
methodParameters += queryParamList.toString();
if (body) {
if (methodParameters &&
!methodParameters.endsWith(',')) {
methodParameters += ',';
}
if (replaceByIdParameters &&
!replaceByIdParameters.endsWith(',')) {
replaceByIdParameters += ',';
}
bodyParamList.forEach(bodyParam => {
methodParameters += `body.${bodyParam},`;
replaceByIdParameters += `newItem.${bodyParam},`;
});
}
let temp = JSON.stringify(finalResponses);
temp = temp.replaceAll(`"getModelSchemaRef(${modelName})"`, `getModelSchemaRef(${modelName})`);
const statements = [];
queryParams;
if (addFilters) {
statements.push(`let items = (await this.service.${apiFunction}(${methodParameters})) as any[];`);
statements.push(`if (where) items = applyWhereFilter(items, where);`);
statements.push(`return items;`);
}
else {
statements.push(`return this.service.${apiFunction}(${methodParameters});`);
}
let methodStructure = {
name: apiFunction,
parameters,
statements,
isAsync: true,
decorators: [
{
name: (method).toLowerCase(),
arguments: [`'${apiUri}'`, temp]
}
],
returnType: `Promise<any>`
};
classDeclaration?.addMethod(methodStructure);
// add count method based on findAll method
if (count) {
let methodStructure = {
name: `count${modelName}`,
parameters: [
{
name: 'where',
type: 'object',
decorators: [{
name: `param.query.object`,
arguments: [`'where'`]
}]
}
],
statements: [`
let items = (await this.service.${apiFunction}(${methodParameters})) as any[];
if (where) items = applyWhereFilter(items, where);
return { count: items.length };
`],
isAsync: true,
decorators: [
{
name: 'get',
arguments: [
`'${apiUri}/count'`,
`{"responses":{"200":{"description":"Contact count","content":{"application/json":{"schema":{"properties":{"count":{"type":"number"}}}}}}}}`
]
}
],
returnType: `Promise<any>`
};
classDeclaration?.addMethod(methodStructure);
}
// add replaceById by fetching the single record and updating it
if (replaceById) {
let methodStructure = {
name: `replace${modelName}ById`,
parameters: [
{
name: 'id',
type: 'number',
decorators: [{
name: `param.path.number`,
arguments: [`'id'`]
}]
},
{
name: 'body',
type: 'any',
decorators: [{ name }]
}
],
statements: [`
const item = (await this.service.${singleFetchMethodName}(id)) as any[];
const newItem: any = {};
Object.keys(item).forEach(key => { newItem[key] = body[key] || null; });
return this.service.${apiFunction}(${replaceByIdParameters});
`],
isAsync: true,
decorators: [
{
name: 'put',
arguments: [
`'${apiUri}'`,
`{"responses":{"200":{"description":"repalce contact","content":{"application/json":{"schema":{"properties":{"count":{"type":"number"}}}}}}}}`
]
}
],
returnType: `Promise<any>`
};
addImport(controllerFile, 'put', '@loopback/rest', true);
classDeclaration?.addMethod(methodStructure);
}
// add update all by fetching all records and updating them
if (updateAll) {
if (pathKey) {
if (methodParameters.includes(`${pathKey},`)) {
methodParameters = methodParameters.replace(`${pathKey},`, '');
}
else if (methodParameters.includes(`${pathKey}`)) {
methodParameters = methodParameters.replace(`${pathKey}`, '');
}
}
name = name.replace(`getModelSchemaRef(${requestModelName || modelName}`, `getModelSchemaRef(${requestModelName || modelName}, {partial: true}`);
let methodStructure = {
name: `updateAll${modelName}`,
parameters: [
{
name: 'body',
type: 'any',
decorators: [{ name }]
},
{
name: 'where',
type: 'object',
decorators: [{
name: `param.query.object`,
arguments: [`'where'`]
}]
}
],
statements: [`
let items = (await this.service.${fetchMethodName}(where)) as any[];
if (where) items = applyWhereFilter(items, where);
const newItems: any = {};
const primaryKey = getPrimaryKeyFromModel(${modelName});
items.forEach((item: any) => {
newItems[item[primaryKey]] = { ...item }
delete newItems[item[primaryKey]][primaryKey];
Object.keys(body).forEach(bodyKey => {
newItems[item[primaryKey]][bodyKey] = body[bodyKey];
});
});
const promises: any[] = [];
Object.keys(newItems).forEach(id => {
promises.push(this.service.${apiFunction}(parseInt(id),${methodParameters}));
});
try {
await Promise.all(promises);
} catch (error) {
throw error;
}
return Object.keys(newItems).length;
`],
isAsync: true,
decorators: [
{
name: 'patch',
arguments: [
`'${apiUri.split('/{')[0]}'`,
`{"responses":{"200":{"description":"update contacts","content":{"application/json":{"schema":{"properties":{"count":{"type":"number"}}}}}}}}`
]
}
],
returnType: `Promise<any>`
};
classDeclaration?.addMethod(methodStructure);
}
}
if (!classConstructors[0].getParameter('service')) {
classConstructors[0].insertParameter(0, {
name: `protected service`,
type: `${serviceName}Service`,
decorators: [
{
name: 'inject',
arguments: [`'services.${serviceName}'`]
}
]
});
}
}
controllerFile?.formatText();
controllerFile?.saveSync();
const service = fs.existsSync(`./src/services/${toKebabCase(serviceName)}.service.ts`);
if (!service) {
let command = `lb4 service --config='{"type":"proxy", "name": "${serviceName}"}' --yes`;
let executed = await execute(command, 'generating service.');
if (executed.stderr)
console.log(chalk.bold(chalk.green(executed.stderr)));
if (executed.stdout)
console.log(chalk.bold(chalk.green(executed.stdout)));
}
project.addSourceFilesAtPaths(`${invokedFrom}/src/**/*.ts`);
const serviceFilePath = `${invokedFrom}/src/services/${toKebabCase(serviceName)}.service.ts`;
let serviceFile = project.getSourceFile(serviceFilePath);
const serviceInterface = serviceFile?.getInterface(serviceName);
serviceInterface?.addMember(`${apiFunction}(${serviceParams}): Promise<object | object[]>;`);
serviceFile?.formatText();
serviceFile?.saveSync();
dsFile?.saveSync();
}
}
addFilterCode(controllerFile) {
// Define the functions to add
const functionsToAdd = `
interface WhereFilter {
[key: string]: any;
}
function getPrimaryKeyFromModel(model: any) {
// Get the model definition
const modelDefinition = model.definition;
// Directly check for ID properties
const idProperties = [];
for (const property in modelDefinition.properties) {
if (modelDefinition.properties[property].id) {
idProperties.push(property);
}
}
// Return the first ID property or default to 'id'
return idProperties.length > 0 ? idProperties[0] : 'id';
}
function applyWhereFilter<T>(items: T[], where: WhereFilter): T[] {
return items.filter(item => evaluateCondition(item, where));
}
function evaluateCondition<T>(item: T, condition: WhereFilter): boolean {
// Handle empty condition
if (!condition || Object.keys(condition).length === 0) {
return true;
}
// Check each condition
return Object.entries(condition).every(([key, value]) => {
// Handle special operators
if (key === 'and' && Array.isArray(value)) {
return value.every(subCondition => evaluateCondition(item, subCondition));
}
if (key === 'or' && Array.isArray(value)) {
return value.some(subCondition => evaluateCondition(item, subCondition));
}
if (key === 'nor' && Array.isArray(value)) {
return !value.some(subCondition => evaluateCondition(item, subCondition));
}
// Regular field comparison
const itemValue = getNestedProperty(item, key);
// Handle different types of value conditions
if (value === null) {
return itemValue === null;
} else if (typeof value === 'object' && !Array.isArray(value)) {
return evaluateOperators(itemValue, value);
} else if (value instanceof RegExp) {
return value.test(String(itemValue));
} else {
return itemValue === value;
}
});
}
/**
* Get a potentially nested property from an object using dot notation
*/
function getNestedProperty<T>(obj: T, path: string): any {
return path.split('.').reduce((current: any, part) => {
return current && current[part] !== undefined ? current[part] : null;
}, obj);
}
/**
* Evaluate the comparison operators (gt, lt, gte, lte, neq, etc.)
*/
function evaluateOperators(itemValue: any, operators: Record<string, any>): boolean {
return Object.entries(operators).every(([operator, operatorValue]) => {
switch (operator) {
case 'eq':
return itemValue === operatorValue;
case 'neq':
return itemValue !== operatorValue;
case 'gt':
return itemValue > operatorValue;
case 'gte':
return itemValue >= operatorValue;
case 'lt':
return itemValue < operatorValue;
case 'lte':
return itemValue <= operatorValue;
case 'inq':
return Array.isArray(operatorValue) && operatorValue.includes(itemValue);
case 'nin':
return Array.isArray(operatorValue) && !operatorValue.includes(itemValue);
case 'between':
return Array.isArray(operatorValue) &&
operatorValue.length >= 2 &&
itemValue >= operatorValue[0] &&
itemValue <= operatorValue[1];
case 'like':
return typeof itemValue === 'string' &&
new RegExp(String(operatorValue).replace(/%/g, '.*')).test(itemValue);
case 'nlike':
return typeof itemValue === 'string' &&
!new RegExp(String(operatorValue).replace(/%/g, '.*')).test(itemValue);
case 'regexp':
const regex = operatorValue instanceof RegExp ?
operatorValue :
new RegExp(operatorValue);
return regex.test(String(itemValue));
default:
return false;
}
});
}
`;
const fileText = controllerFile.getFullText();
const functionSignatures = [
'function applyWhereFilter<T>',
'function evaluateCondition<T>',
'function getNestedProperty<T>',
'function evaluateOperators'
];
// Check if at least one of these function signatures exists in the file
const functionsExist = functionSignatures.some(signature => fileText.includes(signature));
if (!functionsExist) {
const classDeclaration1 = controllerFile.getFirstDescendantByKind(SyntaxKind.ClassDeclaration);
if (classDeclaration1) {
// Find suitable position after imports but before class
let insertPosition = 0;
// Get all imports to find the last one
const importDeclarations = controllerFile.getImportDeclarations();
if (importDeclarations.length > 0) {
const lastImport = importDeclarations[importDeclarations.length - 1];
insertPosition = lastImport.getEnd() + 1; // Position after the last import
}
// Check if there are comments after imports (like the "Uncomment these imports" comment)
const comments = controllerFile.getDescendantsOfKind(SyntaxKind.SingleLineCommentTrivia);
for (const comment of comments) {
const commentPos = comment.getEnd();
if (commentPos > insertPosition && commentPos < classDeclaration1.getStart()) {
// Find the end of the comment block
const commentText = comment.getText();
if (commentText.includes("Uncomment these imports") ||
commentText.includes("cool features")) {
insertPosition = commentPos + 1;
}
}
}
// Add a newline before insertion if needed
const textToInsert = insertPosition > 0 ? '\n\n' + functionsToAdd : functionsToAdd;
// Insert the functions
controllerFile.insertText(insertPosition, textToInsert);
// Save the changes
controllerFile.saveSync();
console.log("Functions added successfully to the controller file.");
}
else {
console.error("Could not find the class declaration in the file.");
}
}
}
}