diff --git a/actions/add-utils/file-utils.ts b/actions/add-utils/file-utils.ts new file mode 100644 index 00000000..dac0b16a --- /dev/null +++ b/actions/add-utils/file-utils.ts @@ -0,0 +1,29 @@ +import { existsSync } from 'fs'; +import { join, basename } from 'path'; + +export function getControllerFilePath(modelName: string): string | null { + const controllerFilePath = join(process.cwd(), modelName, `${basename(modelName)}.controller.ts`); + if (existsSync(controllerFilePath)) { + return controllerFilePath; + } + return null; +} + +export function getDtoFilePath(modelName: string): string | null { + const dtoFilePath = join(process.cwd(), modelName, 'dto',`${basename(modelName)}.dto.ts`); + if (existsSync(dtoFilePath)) { + return dtoFilePath; + } + return null; +} + +export function findProjectRoot(): string { + let dir = process.cwd(); + while (dir !== '/') { + if (existsSync(join(dir, 'package.json'))) { + return dir; + } + dir = join(dir, '..'); + } + throw new Error('Project root not found'); +} diff --git a/actions/add-utils/import-manager.ts b/actions/add-utils/import-manager.ts new file mode 100644 index 00000000..7b5eaf62 --- /dev/null +++ b/actions/add-utils/import-manager.ts @@ -0,0 +1,32 @@ +export function addImports(content: string, fileType: 'controller' | 'dto'): string { + const existingSwaggerImports = content.match(/import\s+{\s*([^}]+)\s*}\s+from\s+'@nestjs\/swagger';/); + + let swaggerDecorators: string[]; + + switch (fileType) { + case 'controller': + swaggerDecorators = ['ApiOperation', 'ApiResponse', 'ApiParam', 'ApiBody', 'ApiTags']; + break; + case 'dto': + swaggerDecorators = ['ApiProperty', 'ApiPropertyOptional']; + break; + default: + throw new Error('Unsupported file type'); + } + + const existingDecorators = existingSwaggerImports ? existingSwaggerImports[1].split(',').map((d) => d.trim()) : []; + const newDecorators = swaggerDecorators.filter((d) => !existingDecorators.includes(d)); + + if (newDecorators.length > 0) { + const newImportStatement = `import { ${[...existingDecorators, ...newDecorators].join(', ')} } from '@nestjs/swagger';`; + if (existingSwaggerImports) { + content = content.replace(existingSwaggerImports[0], newImportStatement); + } else { + const lastImportIndex = content.lastIndexOf('import '); + const insertPosition = content.indexOf(';', lastImportIndex) + 1; + content = `${content.slice(0, insertPosition)}\n${newImportStatement}${content.slice(insertPosition)}`; + } + } + + return content; +} diff --git a/actions/add-utils/swagger-controller.ts b/actions/add-utils/swagger-controller.ts new file mode 100644 index 00000000..3f3a811d --- /dev/null +++ b/actions/add-utils/swagger-controller.ts @@ -0,0 +1,117 @@ +import * as fs from 'fs'; +import * as chalk from 'chalk'; +import { addImports } from './import-manager'; + +export function addSwaggerControllers(controllerPath: string) { + try { + const content = fs.readFileSync(controllerPath, 'utf-8'); + let updatedContent = content; + + const httpMethods = { + Get: /@Get\(['"]?[^'"]*['"]?\)?\s*\n\s*async\s+(\w+)\(([\w\s,@(){}:'"=]*)\)\s*:\s*Promise<([\w\[\]]+)>/g, + Post: /@Post\(['"]?[^'"]*['"]?\)?\s*\n\s*async\s+(\w+)\(([\w\s,@(){}:'"=]*)\)\s*:\s*Promise<([\w\[\]]+)>/g, + Put: /@Put\(['"]?[^'"]*['"]?\)?\s*\n\s*async\s+(\w+)\(([\w\s,@(){}:'"=]*)\)\s*:\s*Promise<([\w\[\]]+)>/g, + Delete: /@Delete\(['"]?[^'"]*['"]?\)?\s*\n\s*async\s+(\w+)\(([\w\s,@(){}:'"=]*)\)\s*:\s*Promise<([\w\[\]]+)>/g + }; + + let processedMethods: Set = new Set(); + + for (const [method, regex] of Object.entries(httpMethods)) { + let match; + while ((match = regex.exec(updatedContent)) !== null) { + const methodSignature = match[0]; + if (!processedMethods.has(methodSignature)) { + processedMethods.add(methodSignature); + const updatedMethod = replaceMethod(methodSignature, method); + updatedContent = updatedContent.replace(methodSignature, updatedMethod); + } + } + } + + if (!/@ApiTags\(/.test(updatedContent)) { + const classRegex = /@Controller\(['"]([\w-]+)['"]\)/; + updatedContent = updatedContent.replace(classRegex, (match, p1) => { + return `${match}\n@ApiTags('${p1}')`; + }); + } + + updatedContent = addImports(updatedContent,"controller"); + + fs.writeFileSync(controllerPath, updatedContent, 'utf-8'); + console.info(chalk.green(`Swagger decorators added to ${controllerPath}`)); + } catch (error) { + console.error(chalk.red(`Error adding Swagger decorators to ${controllerPath}`), error); + } +} + +function replaceMethod(methodSignature: string, method: string): string { + const decorators = generateSwaggerDecorators(methodSignature, method); + return decorators + methodSignature; +} + +function generateSwaggerDecorators(methodSignature: string, method: string): string { + let decorators = ''; + if (!/@ApiOperation\(/.test(methodSignature)) { + decorators += `@ApiOperation({ summary: '${getOperationSummary(method)}' })\n`; + } + + if (!/@ApiResponse\(/.test(methodSignature)) { + decorators += `@ApiResponse({ status: 200, description: 'Successful response', type: '${getReturnType(methodSignature)}' })\n`; + } + + if (/@Body\(/.test(methodSignature) && !/@ApiBody\(/.test(methodSignature)) { + decorators += `@ApiBody({ type: ${getBodyType(methodSignature)}, description: 'Item data' })\n`; + } + + if (/@Param\(['"]\w+['"]\)/.test(methodSignature) && !/@ApiParam\({ name: /.test(methodSignature)) { + const paramName = getParamName(methodSignature); + const paramType = getParamType(methodSignature); + decorators += `@ApiParam({ name: '${paramName}', description: 'ID of the item', type: '${paramType}' })\n`; + } + + if (method === 'Delete') { + if (!/@ApiResponse\({ status: 204/.test(methodSignature)) { + decorators += `@ApiResponse({ status: 204, description: 'Item deleted' })\n`; + } + if (!/@ApiResponse\({ status: 404/.test(methodSignature)) { + decorators += `@ApiResponse({ status: 404, description: 'Item not found' })\n`; + } + } + + return decorators; +} + +function getOperationSummary(method: string): string { + switch (method) { + case 'Get': + return 'Retrieve items'; + case 'Post': + return 'Create an item'; + case 'Put': + return 'Update an item'; + case 'Delete': + return 'Delete an item'; + default: + return ''; + } +} + +function getReturnType(methodSignature: string): string { + const match = methodSignature.match(/:\s*Promise<([\w\s@(){}]+)>/); + return match ? match[1].trim() : 'any'; +} + +function getBodyType(methodSignature: string): string { + const match = methodSignature.match(/@Body\(\)\s*([\w<>,]+):\s*([\w<>,]+)/); + return match ? match[2].trim() : 'any'; +} + +function getParamType(methodSignature: string): string { + const match = methodSignature.match(/@Param\(\s*['"]\w+['"]\s*\)\s*\w+:\s*(\w+)/); + return match ? match[1] : 'string'; +} + +function getParamName(methodSignature: string): string { + const match = methodSignature.match(/@Param\(\s*['"](\w+)['"]\s*\)/); + return match ? match[1] : 'id'; +} diff --git a/actions/add-utils/swagger-dto.ts b/actions/add-utils/swagger-dto.ts new file mode 100644 index 00000000..85daaa15 --- /dev/null +++ b/actions/add-utils/swagger-dto.ts @@ -0,0 +1,36 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { addImports } from './import-manager'; +import * as chalk from 'chalk'; + +export function addSwaggerDto(dtoFilePath: string) { + + const dtoContent = fs.readFileSync(dtoFilePath, 'utf-8'); + + let updatedContent = addSwaggerDecorators(dtoContent); + updatedContent = addImports(updatedContent, 'dto'); + + fs.writeFileSync(dtoFilePath, updatedContent, 'utf-8'); + console.info(chalk.green(`Swagger decorators added to ${dtoFilePath}`)); +} + +const addSwaggerDecorators = (content: string): string => { + const lines = content.split('\n'); + + const updatedLines = lines.map(line => { + if (line.includes('export class Create') || line.includes('export class Update')) { + return line; + } + + if (line.includes(':')) { + const [name, type] = line.split(':'); + const isOptional = name.includes('?'); + const decorator = isOptional ? '@ApiPropertyOptional' : '@ApiProperty'; + return ` ${decorator}({ description: '${name.trim()}' })\n ${line}`; + } + + return line; + }); + + return updatedLines.join('\n'); +}; diff --git a/actions/add-utils/swagger-init.ts b/actions/add-utils/swagger-init.ts new file mode 100644 index 00000000..82af8901 --- /dev/null +++ b/actions/add-utils/swagger-init.ts @@ -0,0 +1,44 @@ +import * as fs from 'fs'; +import * as chalk from 'chalk'; +import { join } from 'path'; +import { findProjectRoot } from './file-utils'; + +export function addSwaggerInitialization() { + const projectRoot = findProjectRoot(); + const mainPath = join(projectRoot, 'src', 'main.ts'); + + try { + let mainContent = fs.readFileSync(mainPath, 'utf-8'); + + if (!/import\s+{\s*SwaggerModule\s*,\s*DocumentBuilder\s*}\s+from\s+'@nestjs\/swagger';/.test(mainContent)) { + mainContent = mainContent.replace( + 'import { NestFactory } from \'@nestjs/core\';', + `import { NestFactory } from '@nestjs/core'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';` + ); + } + + if (!/const\s+config\s+=\s+new\s+DocumentBuilder\(\)/.test(mainContent)) { + mainContent = mainContent.replace( + /await\s+app\.listen\(\d+\);/, + `const config = new DocumentBuilder() + .setTitle('API Documentation') + .setDescription('The API description') + .setVersion('1.0') + .addTag('api') + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api', app, document); + + await app.listen(3000);` + ); + } + + fs.writeFileSync(mainPath, mainContent, 'utf-8'); + console.info(chalk.green('Swagger initialized in main.ts')); + + } catch (error) { + console.error(chalk.red(`Error adding Swagger initialization to ${mainPath}`), error); + } +} diff --git a/actions/add.action.ts b/actions/add.action.ts index 5c5d1d09..20e7f651 100644 --- a/actions/add.action.ts +++ b/actions/add.action.ts @@ -1,180 +1,43 @@ import * as chalk from 'chalk'; -import { Input } from '../commands'; -import { getValueOrDefault } from '../lib/compiler/helpers/get-value-or-default'; -import { - AbstractPackageManager, - PackageManagerFactory, -} from '../lib/package-managers'; -import { - AbstractCollection, - CollectionFactory, - SchematicOption, -} from '../lib/schematics'; -import { MESSAGES } from '../lib/ui'; -import { loadConfiguration } from '../lib/utils/load-configuration'; -import { - askForProjectName, - hasValidOptionFlag, - moveDefaultProjectToStart, - shouldAskForProject, -} from '../lib/utils/project-utils'; import { AbstractAction } from './abstract.action'; - -const schematicName = 'nest-add'; - +import { Input } from '../commands/command.input'; +import { getControllerFilePath, getDtoFilePath } from './add-utils/file-utils'; +import { addSwaggerInitialization } from './add-utils/swagger-initializer'; +import { addSwaggerControllers } from './add-utils/swagger-decorator'; +import { addSwaggerDto } from './add-utils/swagger-dto'; export class AddAction extends AbstractAction { - public async handle(inputs: Input[], options: Input[], extraFlags: string[]) { - const libraryName = this.getLibraryName(inputs); - const packageName = this.getPackageName(libraryName); - const collectionName = this.getCollectionName(libraryName, packageName); - const tagName = this.getTagName(packageName); - const skipInstall = hasValidOptionFlag('skip-install', options); - const packageInstallSuccess = - skipInstall || (await this.installPackage(collectionName, tagName)); - if (packageInstallSuccess) { - const sourceRootOption: Input = await this.getSourceRoot( - inputs.concat(options), - ); - options.push(sourceRootOption); - - await this.addLibrary(collectionName, options, extraFlags); - } else { - console.error( - chalk.red( - MESSAGES.LIBRARY_INSTALLATION_FAILED_BAD_PACKAGE(libraryName), - ), - ); - throw new Error( - MESSAGES.LIBRARY_INSTALLATION_FAILED_BAD_PACKAGE(libraryName), - ); - } - } - - private async getSourceRoot(inputs: Input[]): Promise { - const configuration = await loadConfiguration(); - const configurationProjects = configuration.projects; - - const appName = inputs.find((option) => option.name === 'project')! - .value as string; - - let sourceRoot = appName - ? getValueOrDefault(configuration, 'sourceRoot', appName) - : configuration.sourceRoot; - - const shouldAsk = shouldAskForProject( - schematicName, - configurationProjects, - appName, - ); - if (shouldAsk) { - const defaultLabel = ' [ Default ]'; - let defaultProjectName = configuration.sourceRoot + defaultLabel; - - for (const property in configurationProjects) { - if ( - configurationProjects[property].sourceRoot === - configuration.sourceRoot - ) { - defaultProjectName = property + defaultLabel; - break; - } + public async handle(inputs: Input[]) { + const subcommand = inputs.find((input) => input.name === 'subcommand')!.value as string; + const modelPath = inputs.find((input) => input.name === 'modelPath')?.value as string; + const init = inputs.find((input) => input.name === 'init')?.value; + + if (subcommand === 'swagger') { + if (init) { + addSwaggerInitialization(); } - const projects = moveDefaultProjectToStart( - configuration, - defaultProjectName, - defaultLabel, - ); - - const answers = await askForProjectName( - MESSAGES.LIBRARY_PROJECT_SELECTION_QUESTION, - projects, - ); - const project = answers.appName.replace(defaultLabel, ''); - if (project !== configuration.sourceRoot) { - sourceRoot = configurationProjects[project].sourceRoot; + if (!modelPath) { + console.error(chalk.red('Model path is required.')); + return; } - } - return { name: 'sourceRoot', value: sourceRoot }; - } - private async installPackage( - collectionName: string, - tagName: string, - ): Promise { - const manager: AbstractPackageManager = await PackageManagerFactory.find(); - tagName = tagName || 'latest'; - let installResult = false; - try { - installResult = await manager.addProduction([collectionName], tagName); - } catch (error) { - if (error && error.message) { - console.error(chalk.red(error.message)); - } - } - return installResult; - } + const controllerPath = getControllerFilePath(modelPath); - private async addLibrary( - collectionName: string, - options: Input[], - extraFlags: string[], - ) { - console.info(MESSAGES.LIBRARY_INSTALLATION_STARTS); - const schematicOptions: SchematicOption[] = []; - schematicOptions.push( - new SchematicOption( - 'sourceRoot', - options.find((option) => option.name === 'sourceRoot')!.value as string, - ), - ); - const extraFlagsString = extraFlags ? extraFlags.join(' ') : undefined; + if (!controllerPath) { + console.error(chalk.red(`Controller file not found in path: ${modelPath}`)); + return; + } + const dtoFilePath = getDtoFilePath(modelPath); - try { - const collection: AbstractCollection = - CollectionFactory.create(collectionName); - await collection.execute( - schematicName, - schematicOptions, - extraFlagsString, - ); - } catch (error) { - if (error && error.message) { - console.error(chalk.red(error.message)); - return Promise.reject(); + if (!dtoFilePath) { + console.error(chalk.red(`DTO file not found for model: ${modelPath}`)); + return; } - } - } + - private getLibraryName(inputs: Input[]): string { - const libraryInput: Input = inputs.find( - (input) => input.name === 'library', - ) as Input; + addSwaggerControllers(controllerPath); + addSwaggerDto(dtoFilePath); - if (!libraryInput) { - throw new Error('No library found in command input'); } - return libraryInput.value as string; - } - - private getPackageName(library: string): string { - return library.startsWith('@') - ? library.split('/', 2).join('/') - : library.split('/', 1)[0]; - } - - private getCollectionName(library: string, packageName: string): string { - return ( - (packageName.startsWith('@') - ? packageName.split('@', 2).join('@') - : packageName.split('@', 1).join('@')) + - library.slice(packageName.length) - ); - } - - private getTagName(packageName: string): string { - return packageName.startsWith('@') - ? packageName.split('@', 3)[2] - : packageName.split('@', 2)[1]; } } diff --git a/actions/crud.action.ts b/actions/crud.action.ts new file mode 100644 index 00000000..d452dfbb --- /dev/null +++ b/actions/crud.action.ts @@ -0,0 +1,195 @@ +import * as chalk from 'chalk'; +import * as fs from 'fs'; +import { getDMMF } from '@prisma/internals'; +import { + AbstractPackageManager, + PackageManagerFactory, +} from '../lib/package-managers'; +import { AbstractAction } from './abstract.action'; +import { controllerTemplate } from '../lib/templates/controller-template'; +import { serviceTemplate } from '../lib/templates/service-template'; +import { dtoTemplate } from '../lib/templates/dto-template'; +import { Input } from '../commands/command.input'; + +export class CrudAction extends AbstractAction { + private manager!: AbstractPackageManager; + + public async handle(inputs: Input[]) { + this.manager = await PackageManagerFactory.find(); + await this.generateCrud(inputs); + } + + private async generateCrud(inputs: Input[]): Promise { + try { + const dmmf = await this.generateDMMFJSON(); + if (dmmf) { + const existingModels = dmmf.datamodel.models.map((model: any) => model.name); + const inputModelNames = inputs.map(input => input.name); + const invalidInputs = inputModelNames.filter(name => name !== '*' && !existingModels.includes(name)); + + if (invalidInputs.length > 0) { + console.error(chalk.red('The following models do not exist:'), invalidInputs.join(', ')); + return; + } + console.info(chalk.green('Generating CRUD API')); + const modelsToGenerate = inputModelNames.includes('*') ? existingModels : inputModelNames; + + this.generateTypes(dmmf, modelsToGenerate); + this.createAPIs(dmmf, modelsToGenerate); + this.updateAppModule(dmmf, modelsToGenerate); + } + } catch (error) { + console.error(chalk.red('Error generating CRUD API'), error); + } + } + + private async generateDMMFJSON(): Promise { + try { + const datamodel = fs.readFileSync('./schema.prisma', 'utf-8'); + const dmmf = await getDMMF({ datamodel }); + fs.writeFileSync('./dmmf.json', JSON.stringify(dmmf, null, 2)); + return dmmf; + } catch (error) { + console.error(chalk.red('Error generating DMMF JSON')); + return null; + } + } + + private generateTypes(dmmf: any, modelNames: string[]): void { + try { + const models = dmmf.datamodel.models.filter((model: any) => + modelNames.includes(model.name) + ); + + models.forEach((model: any) => { + const modelDir = `./src/${model.name.toLowerCase()}`; + const dtoDir = `${modelDir}/dto`; + + if (!fs.existsSync(modelDir)) { + fs.mkdirSync(modelDir, { recursive: true }); + } + if (!fs.existsSync(dtoDir)) { + fs.mkdirSync(dtoDir, { recursive: true }); + } + + const interfaceContent = this.interfaceTemplate(model); + fs.writeFileSync(`${modelDir}/${model.name.toLowerCase()}.interface.ts`, interfaceContent); + + const dtoContent = dtoTemplate(model); + fs.writeFileSync(`${dtoDir}/${model.name.toLowerCase()}.dto.ts`, dtoContent); + }); + + console.info(chalk.green('Types generated successfully')); + } catch (error) { + console.error(chalk.red('Error generating types'), error); + } + } + + private interfaceTemplate(model: any): string { + const fields = model.fields.map((field: any) => { + return `${field.name}${field.isRequired ? '' : '?'}: ${this.getType(field)};`; + }).join('\n '); + + return `export interface ${model.name} {\n ${fields}\n}`; + } + + private getType(field: any): string { + switch (field.type) { + case 'Int': return 'number'; + case 'String': return 'string'; + case 'Boolean': return 'boolean'; + case 'DateTime': return 'Date'; + case 'Json': return 'any'; + default: return 'any'; + } + } + + private createAPIs(dmmf: any, modelNames: string[]): void { + try { + const models = dmmf.datamodel.models.filter((model: any) => + modelNames.includes(model.name) + ); + models.forEach((model: any) => { + this.createModelAPI(model); + }); + console.info(chalk.green('APIs created successfully')); + } catch (error) { + console.error(chalk.red('Error creating APIs'), error); + } + } + + private createModelAPI(model: any): void { + const modelDir = `./src/${model.name.toLowerCase()}`; + + const controllerPath = `${modelDir}/${model.name.toLowerCase()}.controller.ts`; + const servicePath = `${modelDir}/${model.name.toLowerCase()}.service.ts`; + + if (!fs.existsSync(controllerPath)) { + const controllerContent = controllerTemplate(model); + fs.writeFileSync(controllerPath, controllerContent); + console.info(chalk.green(`${model.name.toLowerCase()}.controller.ts created successfully`)); + } else { + console.info(chalk.yellow(`${model.name.toLowerCase()}.controller.ts already exists`)); + } + + if (!fs.existsSync(servicePath)) { + const serviceContent = serviceTemplate(model); + fs.writeFileSync(servicePath, serviceContent); + console.info(chalk.green(`${model.name.toLowerCase()}.service.ts created successfully`)); + } else { + console.info(chalk.yellow(`${model.name.toLowerCase()}.service.ts already exists`)); + } + } + + private updateAppModule(dmmf: any, modelNames: string[]): void { + try { + const models = dmmf.datamodel.models.filter((model: any) => + modelNames.includes(model.name) + ); + const appModulePath = './src/app.module.ts'; + let appModuleContent = fs.readFileSync(appModulePath, 'utf-8'); + const newImports = models.map((model: any) => { + const controllerImport = `import { ${model.name}Controller } from './${model.name.toLowerCase()}/${model.name.toLowerCase()}.controller';`; + const serviceImport = `import { ${model.name}Service } from './${model.name.toLowerCase()}/${model.name.toLowerCase()}.service';`; + + return `${serviceImport}\n${controllerImport}`; + }); + const uniqueNewImports = newImports.filter((importLine: string) => !appModuleContent.includes(importLine)).join('\n'); + if (uniqueNewImports) { + appModuleContent = appModuleContent.replace( + /(import {[^}]*} from '[@a-zA-Z0-9\/]*';\n*)+/, + `$&${uniqueNewImports}\n` + ); + } + + const controllersRegex = /controllers: \[([^\]]*)\]/s; + const providersRegex = /providers: \[([^\]]*)\]/s; + + const controllersMatch = appModuleContent.match(controllersRegex); + const providersMatch = appModuleContent.match(providersRegex); + + const currentControllers = controllersMatch ? controllersMatch[1].split(',').map(controller => controller.trim()).filter(Boolean) : []; + const currentProviders = providersMatch ? providersMatch[1].split(',').map(provider => provider.trim()).filter(Boolean) : []; + + const newControllers = models.map((model: any) => `${model.name}Controller`); + const newProviders = models.map((model: any) => `${model.name}Service`); + const updatedControllers = Array.from(new Set([...currentControllers, ...newControllers])).join(', '); + const updatedProviders = Array.from(new Set([...currentProviders, ...newProviders])).join(', '); + + appModuleContent = appModuleContent.replace( + controllersRegex, + `controllers: [${updatedControllers}]` + ); + + appModuleContent = appModuleContent.replace( + providersRegex, + `providers: [${updatedProviders}]` + ); + + fs.writeFileSync(appModulePath, appModuleContent); + console.info(chalk.green('app.module.ts updated successfully')); + } catch (error) { + console.error(chalk.red('Error updating app.module.ts'), error); + } + } +} diff --git a/actions/index.ts b/actions/index.ts index 29083e82..2121a5dc 100644 --- a/actions/index.ts +++ b/actions/index.ts @@ -6,3 +6,4 @@ export * from './list.action'; export * from './new.action'; export * from './start.action'; export * from './add.action'; +export * from './crud.action'; \ No newline at end of file diff --git a/commands/add.command.ts b/commands/add.command.ts index a2006dbe..fd47ab06 100644 --- a/commands/add.command.ts +++ b/commands/add.command.ts @@ -1,39 +1,25 @@ -import { Command, CommanderStatic } from 'commander'; -import { getRemainingFlags } from '../lib/utils/remaining-flags'; +import { CommanderStatic } from 'commander'; import { AbstractCommand } from './abstract.command'; import { Input } from './command.input'; export class AddCommand extends AbstractCommand { - public load(program: CommanderStatic): void { + public load(program: CommanderStatic) { program - .command('add ') - .allowUnknownOption() - .description('Adds support for an external library to your project.') - .option( - '-d, --dry-run', - 'Report actions that would be performed without writing out results.', - ) - .option('-s, --skip-install', 'Skip package installation.', false) - .option('-p, --project [project]', 'Project in which to generate files.') - .usage(' [options] [library-specific-options]') - .action(async (library: string, command: Command) => { - const options: Input[] = []; - options.push({ name: 'dry-run', value: !!command.dryRun }); - options.push({ name: 'skip-install', value: command.skipInstall }); - options.push({ - name: 'project', - value: command.project, - }); + .command('add [modelName]') + .alias('as') + .description('Add various swagger methods to the specified model.') + .option('--init', 'Initialize with default options') + .action(async (subcommand: string, modelPath: string, options: any) => { + const inputs: Input[] = [ + { name: 'subcommand', value: subcommand }, + { name: 'modelPath', value: modelPath } + ]; - const inputs: Input[] = []; - inputs.push({ name: 'library', value: library }); - - const flags = getRemainingFlags(program); - try { - await this.action.handle(inputs, options, flags); - } catch (err) { - process.exit(1); + if (options.init) { + inputs.push({ name: 'init', value: options.init }); } + + await this.action.handle(inputs); }); } } diff --git a/commands/command.loader.ts b/commands/command.loader.ts index deb98590..245ac860 100644 --- a/commands/command.loader.ts +++ b/commands/command.loader.ts @@ -8,6 +8,7 @@ import { ListAction, NewAction, StartAction, + CrudAction } from '../actions'; import { ERROR_PREFIX } from '../lib/ui'; import { AddCommand } from './add.command'; @@ -17,6 +18,7 @@ import { InfoCommand } from './info.command'; import { ListCommand } from './list.command'; import { NewCommand } from './new.command'; import { StartCommand } from './start.command'; +import { CrudCommand } from './crud.command'; export class CommandLoader { public static async load(program: CommanderStatic): Promise { new NewCommand(new NewAction()).load(program); @@ -25,6 +27,7 @@ export class CommandLoader { new InfoCommand(new InfoAction()).load(program); new ListCommand(new ListAction()).load(program); new AddCommand(new AddAction()).load(program); + new CrudCommand(new CrudAction()).load(program); await new GenerateCommand(new GenerateAction()).load(program); this.handleInvalidCommand(program); diff --git a/commands/crud.command.ts b/commands/crud.command.ts new file mode 100644 index 00000000..c2d8978a --- /dev/null +++ b/commands/crud.command.ts @@ -0,0 +1,20 @@ +import { CommanderStatic } from 'commander'; +import { AbstractCommand } from './abstract.command'; +import { Input } from './command.input'; + +export class CrudCommand extends AbstractCommand { + public load(program: CommanderStatic) { + program + .command('crud [inputs...]') + .alias('cr') + .description('Generate CRUD API for specified models.') + .action(async (inputArgs: string[] = []) => { + if (inputArgs.length === 0) { + console.error('No model provided. Please specify a model or use "*" to generate all models.'); + return; + } + const inputs: Input[] = inputArgs.map(arg => ({ name: arg, value: arg })); + await this.action.handle(inputs); + }); + } +} diff --git a/crudReadme.md b/crudReadme.md new file mode 100644 index 00000000..d409d531 --- /dev/null +++ b/crudReadme.md @@ -0,0 +1,47 @@ +## CRUD Command Overview +When the crud command is invoked through the Stencil CLI, a folder is created for each model specified. This folder contains all necessary files, such as Controller, Service, Interface and DTOs. + +In addition to generating these files, the main module of the application is also updated to include the newly generated services and controllers. + + +## Crud Command + +``` +stencil crud [inputs...] +stencil cr [inputs...] +``` + +Example: `stencil crud model1` or `stencil crud *` + +**Description** + +Generate CRUD API for specified models. + +**Arguments** + +| Argument | Description | +|-----------|--------------| +| `[inputs...]` | The model name for which crud api needs to be generated | + +**Inputs** + +| Name | Description | +|---|---| +| `modelName` | Generates a crud api for modelName | +| `*` | Generates a crud api for all models present in schema.prisma | + +### Example structure of `schema.prisma` +``` +model Book { + id Int @id @default(autoincrement()) + title String + description String? +} +model Car { + id Int @id @default(autoincrement()) + title String + description String? + phone Int + add String +} +``` diff --git a/lib/templates/controller-template.ts b/lib/templates/controller-template.ts new file mode 100644 index 00000000..c102c77e --- /dev/null +++ b/lib/templates/controller-template.ts @@ -0,0 +1,59 @@ +export function controllerTemplate(model: any): string { + const modelName = model.name; + const modelNameLowerCase = modelName.toLowerCase(); + + const swaggerImports = ` +import { Controller, Get, Param, Post, Body, Put, Delete, NotFoundException } from '@nestjs/common'; +import { ${modelName}Service } from './${modelNameLowerCase}.service'; +import { Create${modelName}Dto, Update${modelName}Dto } from './dto/${modelNameLowerCase}.dto'; +import { ${modelName} } from './${modelNameLowerCase}.interface'; +`; + + const controllerClass = ` +@Controller('${modelNameLowerCase}') +export class ${modelName}Controller { + constructor(private readonly ${modelNameLowerCase}Service: ${modelName}Service) {} + +@Get() + async findAll(): Promise<${modelName}[]> { + return this.${modelNameLowerCase}Service.findAll(); + } + +@Get(':id') + async findOne(@Param('id') id: string): Promise<${modelName}> { + const ${modelNameLowerCase}Id = parseInt(id, 10); + const ${modelNameLowerCase} = await this.${modelNameLowerCase}Service.findOne(${modelNameLowerCase}Id); + if (!${modelNameLowerCase}) { + throw new NotFoundException(\`${modelName} with ID \${id} not found\`); + } + return ${modelNameLowerCase}; + } + +@Post() + async create(@Body() ${modelNameLowerCase}Dto: Create${modelName}Dto): Promise<${modelName}> { + return this.${modelNameLowerCase}Service.create(${modelNameLowerCase}Dto); + } + +@Put(':id') + async update(@Param('id') id: string, @Body() ${modelNameLowerCase}Dto: Update${modelName}Dto): Promise<${modelName}> { + const ${modelNameLowerCase}Id = parseInt(id, 10); + const updated${modelName} = await this.${modelNameLowerCase}Service.update(${modelNameLowerCase}Id, ${modelNameLowerCase}Dto); + if (!updated${modelName}) { + throw new NotFoundException(\`${modelName} with ID \${id} not found\`); + } + return updated${modelName}; + } + +@Delete(':id') + async remove(@Param('id') id: string): Promise { + const ${modelNameLowerCase}Id = parseInt(id, 10); + const result = await this.${modelNameLowerCase}Service.remove(${modelNameLowerCase}Id); + if (!result) { + throw new NotFoundException(\`${modelName} with ID \${id} not found\`); + } + } +} +`; + + return `${swaggerImports}${controllerClass}`; +} diff --git a/lib/templates/dto-template.ts b/lib/templates/dto-template.ts new file mode 100644 index 00000000..248d7a35 --- /dev/null +++ b/lib/templates/dto-template.ts @@ -0,0 +1,52 @@ +export const dtoTemplate = (model: any): string => { + const createDtoFields = model.fields.map((field: any) => { + return `${getValidators(field, false)}\n ${field.name}${field.isRequired ? '' : '?'}: ${getFieldType(field)};`; + }).join('\n\n '); + + const updateDtoFields = model.fields.map((field: any) => { + return `${getValidators(field, true)}\n ${field.name}?: ${getFieldType(field)};`; + }).join('\n\n '); + + return ` +import { IsInt, IsString, IsBoolean, IsDate, IsOptional } from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class Create${model.name}Dto { + ${createDtoFields} +} + +export class Update${model.name}Dto { + ${updateDtoFields} +} + `; +}; + +const getFieldType = (field: any): string => { + switch (field.type) { + case 'Int': return 'number'; + case 'String': return 'string'; + case 'Boolean': return 'boolean'; + case 'DateTime': return 'Date'; + case 'Json': return 'any'; + default: return 'any'; + } +}; + +const getValidators = (field: any, isUpdate: boolean): string => { + const validators = []; + validators.push(getTypeValidator(field.type)); + if (!field!.isRequired! || isUpdate) { + validators.push('@IsOptional()'); + } + return validators.filter(Boolean).join('\n '); +}; + +const getTypeValidator = (type: string): string | null => { + switch (type) { + case 'Int': return '@IsInt()'; + case 'String': return '@IsString()'; + case 'Boolean': return '@IsBoolean()'; + case 'DateTime': return `@IsDate()\n @Transform(({ value }) => value ? new Date(value) : undefined)`; + default: return null; + } +}; diff --git a/lib/templates/service-template.ts b/lib/templates/service-template.ts new file mode 100644 index 00000000..fa7cf37b --- /dev/null +++ b/lib/templates/service-template.ts @@ -0,0 +1,51 @@ +export const serviceTemplate = (model: any): string => { + const modelName = model.name; + const modelNameLowerCase = modelName.toLowerCase(); + + return ` +import { Injectable } from '@nestjs/common'; +import { ${modelName} } from './${modelNameLowerCase}.interface'; +import { Create${modelName}Dto, Update${modelName}Dto } from './dto/${modelNameLowerCase}.dto'; + +@Injectable() +export class ${modelName}Service { + private ${modelNameLowerCase}s: ${modelName}[] = []; + private idCounter: number = 1; + + async findAll(): Promise<${modelName}[]> { + return this.${modelNameLowerCase}s; + } + + async findOne(id: number): Promise<${modelName}> { + return this.${modelNameLowerCase}s.find(${modelNameLowerCase} => ${modelNameLowerCase}.id === id); + } + + async create(data: Create${modelName}Dto): Promise<${modelName}> { + const new${modelName}: ${modelName} = { + id: this.idCounter++, + ...data, + }; + this.${modelNameLowerCase}s.push(new${modelName}); + return new${modelName}; + } + + async update(id: number, data: Update${modelName}Dto): Promise<${modelName}> { + const ${modelNameLowerCase}Index = this.${modelNameLowerCase}s.findIndex(${modelNameLowerCase} => ${modelNameLowerCase}.id === id); + if (${modelNameLowerCase}Index === -1) { + return null; + } + this.${modelNameLowerCase}s[${modelNameLowerCase}Index] = { ...this.${modelNameLowerCase}s[${modelNameLowerCase}Index], ...data }; + return this.${modelNameLowerCase}s[${modelNameLowerCase}Index]; + } + + async remove(id: number): Promise { + const ${modelNameLowerCase}Index = this.${modelNameLowerCase}s.findIndex(${modelNameLowerCase} => ${modelNameLowerCase}.id === id); + if(${modelNameLowerCase}Index === -1) { + return false; + } + this.${modelNameLowerCase}s.splice(${modelNameLowerCase}Index, 1); + return true; + } +} +`; +}; diff --git a/package-lock.json b/package-lock.json index a6f50af5..2b9b710b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,9 @@ "@angular-devkit/core": "16.2.3", "@angular-devkit/schematics": "16.2.3", "@angular-devkit/schematics-cli": "16.2.3", + "@prisma/internals": "^5.16.2", "@samagra-x/schematics": "^0.0.5", - "bun": "^1.0.35", + "bun": "^1.1.26", "chalk": "4.1.2", "chokidar": "3.5.3", "cli-table3": "0.6.3", @@ -1095,6 +1096,52 @@ "node": ">=v14" } }, + "node_modules/@commitlint/top-level/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/top-level/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/top-level/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@commitlint/types": { "version": "17.8.1", "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-17.8.1.tgz", @@ -1933,77 +1980,109 @@ } }, "node_modules/@oven/bun-darwin-aarch64": { - "version": "1.0.35", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.0.35.tgz", - "integrity": "sha512-9bkgwxyp5OafSC9UqCx5LzXoojIT7XN9yGWb8G2+BIWETPCHYyrf5TKO/aaz6mgCRZv36bcjHSWG/c3dqYpGSg==", + "version": "1.1.26", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.1.26.tgz", + "integrity": "sha512-E8/3i0RIvsIWS+kyeIlbwBh+4qB5DsQIfcO6xr4p3t7tEzvRWnrFkJrbJthru/eB1UsVV9PJ/hsxTrp3m3za4A==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@oven/bun-darwin-x64": { - "version": "1.0.35", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.0.35.tgz", - "integrity": "sha512-9VSiK8S00BN6Z2yGaO4urnFf/p5QzpgcvAaH1zOt+S2rPvoRJFPWTqNzQ7Izo6WOZTLMLL3OTMHmm2hlh6bgPg==", + "version": "1.1.26", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.1.26.tgz", + "integrity": "sha512-ENRAAGBr2zh0VfETZXqcNPO3ZnnKDX3U6E/oWY+J70uWa9dJqRlRaj1oLB63AGoYJBNdhEcsSmTAk7toCJ+PGQ==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@oven/bun-darwin-x64-baseline": { - "version": "1.0.35", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.0.35.tgz", - "integrity": "sha512-rArqPS+mJbqOcXyPXZ4ACmLaMFdHi5rUTX+N4eGaZxsD5WFhM8cZfVhs6wYEixFS+BVlHcXPSPHsDE3DTPuf/Q==", + "version": "1.1.26", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.1.26.tgz", + "integrity": "sha512-36HQlQfbrwP//xOS5VFN9AR/iH6BDQo3y8j5282DmRO+h6jylwlg+2+Sfz+1uXDOLDQWCbnNv3Mpl8+Ltso6cQ==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@oven/bun-linux-aarch64": { - "version": "1.0.35", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.0.35.tgz", - "integrity": "sha512-qDfGcjZyn/uwFIX+mNwJOeASqUNzrw625HBdUGoOU2HpqjocU3vermbYBFqDMDsLV9hoqsI7VG3Y1EcGPsuHBg==", + "version": "1.1.26", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.1.26.tgz", + "integrity": "sha512-MqE/ClaEMW6B5i5UIYJnHbadWLt6QQQHV3NBlXd78Mhx1OiZY0YmARQmAItPUp9mxIEgGuA2QyrKvgGD3pzWPQ==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@oven/bun-linux-x64": { - "version": "1.0.35", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.0.35.tgz", - "integrity": "sha512-fAHdyilg7tRw4vp9lgxmkimKJom8Tfds/epcx8IFF8Oj1KXWmX5edbTfLnYvxI24IF/0IbqQ1Mw7bz9XwbB/Xw==", + "version": "1.1.26", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.1.26.tgz", + "integrity": "sha512-sD/ZegJpnBg93qsKsiGnJgTROc68CWONwZpvtL65cBROLBqKb965ofhPUaM5oV8HckfaTDmT37cks59hG+tHvw==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@oven/bun-linux-x64-baseline": { - "version": "1.0.35", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.0.35.tgz", - "integrity": "sha512-LfJu69nqrxx5teuEXJZbCxedfNhVtkqdkyE7MUuHVAFLOBIuDHIoBBtW/Q3UxhdHe+lHwGBh4ePrnR4YCwJyCA==", + "version": "1.1.26", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.1.26.tgz", + "integrity": "sha512-jQeSLodwfQu5pG529jYG73VSFq26hdrTspxo9E/1B1WvwKrs2Vtz3w32zv+JWH+gvZqc28A/yK6pAmzQMiscNg==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@oven/bun-windows-x64": { + "version": "1.1.26", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.1.26.tgz", + "integrity": "sha512-EkyW6JYnZPFxD9XsdEDqFxVCnWnAoyacUAiOEUYAiz8LsnbHLMlOfbdw7KYzvm7UPFoEkUZKD78eSdpg6q6c+Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oven/bun-windows-x64-baseline": { + "version": "1.1.26", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.1.26.tgz", + "integrity": "sha512-qb593xu9WIKBCHd47z7ZaZTC9h8r4T6qDbBV/XGLhxdZEJb24ePWdhW8WoHxa9hsATio9SByozqwblXb2tJncw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -2045,6 +2124,102 @@ "node": ">=12" } }, + "node_modules/@prisma/debug": { + "version": "5.16.2", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.16.2.tgz", + "integrity": "sha512-ItzB4nR4O8eLzuJiuP3WwUJfoIvewMHqpGCad+64gvThcKEVOtaUza9AEJo2DPqAOa/AWkFyK54oM4WwHeew+A==" + }, + "node_modules/@prisma/engines": { + "version": "5.16.2", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.16.2.tgz", + "integrity": "sha512-qUxwMtrwoG3byd4PbX6T7EjHJ8AUhzTuwniOGkh/hIznBfcE2QQnGakyEq4VnwNuttMqvh/GgPFapHQ3lCuRHg==", + "hasInstallScript": true, + "dependencies": { + "@prisma/debug": "5.16.2", + "@prisma/engines-version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303", + "@prisma/fetch-engine": "5.16.2", + "@prisma/get-platform": "5.16.2" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303.tgz", + "integrity": "sha512-HkT2WbfmFZ9WUPyuJHhkiADxazHg8Y4gByrTSVeb3OikP6tjQ7txtSUGu9OBOBH0C13dPKN2qqH12xKtHu/Hiw==" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.16.2", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.16.2.tgz", + "integrity": "sha512-sq51lfHKfH2jjYSjBtMjP+AznFqOJzXpqmq6B9auWrlTJrMgZ7lPyhWUW7VU7LsQU48/TJ+DZeIz8s9bMYvcHg==", + "dependencies": { + "@prisma/debug": "5.16.2", + "@prisma/engines-version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303", + "@prisma/get-platform": "5.16.2" + } + }, + "node_modules/@prisma/generator-helper": { + "version": "5.16.2", + "resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-5.16.2.tgz", + "integrity": "sha512-ajdZ5OTKuLEYB7KQQPNYGPr4s56wD4+vH6KqIGiyQVw8ze8dPaxUB3MLzf0vCq2yYq6CZynSExf4InFXYBliTA==", + "dependencies": { + "@prisma/debug": "5.16.2" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.16.2", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.16.2.tgz", + "integrity": "sha512-cXiHPgNLNyj22vLouPVNegklpRL/iX2jxTeap5GRO3DmCoVyIHmJAV1CgUMUJhHlcol9yYy7EHvsnXTDJ/PKEA==", + "dependencies": { + "@prisma/debug": "5.16.2" + } + }, + "node_modules/@prisma/internals": { + "version": "5.16.2", + "resolved": "https://registry.npmjs.org/@prisma/internals/-/internals-5.16.2.tgz", + "integrity": "sha512-EyNy1A3V61buK4XEI3u8IpG/lYzJSWxGWxOuDeArEYkz5PbI0eN06MxzzIY/wRFK1Fa1rLbqtmnJQnMb6X7s1g==", + "dependencies": { + "@prisma/debug": "5.16.2", + "@prisma/engines": "5.16.2", + "@prisma/fetch-engine": "5.16.2", + "@prisma/generator-helper": "5.16.2", + "@prisma/get-platform": "5.16.2", + "@prisma/prisma-schema-wasm": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303", + "@prisma/schema-files-loader": "5.16.2", + "arg": "5.0.2", + "prompts": "2.4.2" + } + }, + "node_modules/@prisma/internals/node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "node_modules/@prisma/prisma-schema-wasm": { + "version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303", + "resolved": "https://registry.npmjs.org/@prisma/prisma-schema-wasm/-/prisma-schema-wasm-5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303.tgz", + "integrity": "sha512-l2yUdEkR3eKEBKsEs18/ZjMNsS7IUMLFWZOvtylhHs2pMY6UaxJN1ho0x8IB2z54VsKUp0fhqPm5LSi9FWmeCA==" + }, + "node_modules/@prisma/schema-files-loader": { + "version": "5.16.2", + "resolved": "https://registry.npmjs.org/@prisma/schema-files-loader/-/schema-files-loader-5.16.2.tgz", + "integrity": "sha512-YuNphq5QYwVwFpLWUZpM800UU1Ejg5TUk39WJj1nfgGVUzT4J2Q/Jw3fQ+9XvyTVX+XwwmrLyvZb5N8KBYClZQ==", + "dependencies": { + "@prisma/prisma-schema-wasm": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303", + "fs-extra": "11.1.1" + } + }, + "node_modules/@prisma/schema-files-loader/node_modules/fs-extra": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", + "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/@samagra-x/schematics": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/@samagra-x/schematics/-/schematics-0.0.5.tgz", @@ -4279,29 +4454,33 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, "node_modules/bun": { - "version": "1.0.35", - "resolved": "https://registry.npmjs.org/bun/-/bun-1.0.35.tgz", - "integrity": "sha512-Or8sWSYSXBdcc7FgK+bPNIv2oOB3EVvDK7LGyNL5vBCbgyuUmPE4Mh+ZlInl3IF/HfkQ7REopx7B1jnbTw4OsQ==", + "version": "1.1.26", + "resolved": "https://registry.npmjs.org/bun/-/bun-1.1.26.tgz", + "integrity": "sha512-dWSewAqE7sVbYmflJxgG47dW4vmsbar7VAnQ4ao45y3ulr3n7CwdsMLFnzd28jhPRtF+rsaVK2y4OLIkP3OD4A==", "cpu": [ "arm64", "x64" ], "hasInstallScript": true, + "license": "MIT", "os": [ "darwin", - "linux" + "linux", + "win32" ], "bin": { - "bun": "bin/bun", - "bunx": "bin/bun" + "bun": "bin/bun.exe", + "bunx": "bin/bun.exe" }, "optionalDependencies": { - "@oven/bun-darwin-aarch64": "1.0.35", - "@oven/bun-darwin-x64": "1.0.35", - "@oven/bun-darwin-x64-baseline": "1.0.35", - "@oven/bun-linux-aarch64": "1.0.35", - "@oven/bun-linux-x64": "1.0.35", - "@oven/bun-linux-x64-baseline": "1.0.35" + "@oven/bun-darwin-aarch64": "1.1.26", + "@oven/bun-darwin-x64": "1.1.26", + "@oven/bun-darwin-x64-baseline": "1.1.26", + "@oven/bun-linux-aarch64": "1.1.26", + "@oven/bun-linux-x64": "1.1.26", + "@oven/bun-linux-x64-baseline": "1.1.26", + "@oven/bun-windows-x64": "1.1.26", + "@oven/bun-windows-x64-baseline": "1.1.26" } }, "node_modules/bundle-name": { @@ -6136,6 +6315,22 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint/node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -6154,6 +6349,36 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -6706,22 +6931,6 @@ "node": ">=8" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/find-versions": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-5.1.0.tgz", @@ -10249,7 +10458,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, "engines": { "node": ">=6" } @@ -10695,21 +10903,6 @@ "node": ">=6.11.5" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -12085,21 +12278,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -12767,7 +12945,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" @@ -14745,8 +14922,7 @@ "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" }, "node_modules/slash": { "version": "3.0.0", diff --git a/package.json b/package.json index c1b731de..a745ef2a 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "prepublish:npm": "npm run build", "test": "jest --config test/jest-config.json", "test:dev": "npm run clean && jest --config test/jest-config.json --watchAll", + "test:e2e": "jest --config test/e2e-test/jest-e2e.json", "prerelease": "npm run build", "release": "release-it", "prepare": "husky install" @@ -37,8 +38,9 @@ "@angular-devkit/core": "16.2.3", "@angular-devkit/schematics": "16.2.3", "@angular-devkit/schematics-cli": "16.2.3", + "@prisma/internals": "^5.16.2", "@samagra-x/schematics": "^0.0.5", - "bun": "^1.0.35", + "bun": "^1.1.26", "chalk": "4.1.2", "chokidar": "3.5.3", "cli-table3": "0.6.3", diff --git a/swaggerReadme.md b/swaggerReadme.md new file mode 100644 index 00000000..d1ded8f3 --- /dev/null +++ b/swaggerReadme.md @@ -0,0 +1,38 @@ +## Swagger Command Overview +The `stencil add swagger` command helps developers to automatically generate Swagger decorators for controllers and DTOs. This ensures that the generated APIs are well-documented and aligned with the Swagger/OpenAPI specification. It can be used to initialize Swagger for a project or update existing files with the necessary decorators. + +## Swagger Command + +``` +stencil add [modelPath] [options] + +stencil as [modelPath] [options] +``` + +Example: `stencil add swagger modelPath` or `stencil as swagger modelPath --init` + +**Description** + +The Swagger subcommand automates the process of adding Swagger decorators to existing controllers and DTOs. It initializes Swagger setup for the project and adds necessary `@nestjs/swagger` imports and decorators like `ApiProperty`, `ApiPropertyOptional`, `ApiOperation`, `ApiResponse`, etc., for DTOs and controllers. + + +**Arguments** + +| Argument | Description | +|-----------|--------------| +| `[modelPath]` | The model path for which Swagger decorators need to be added + | + + +**Options** + +| Option | Description | +|---|---| +| `--init` | Initializes Swagger in the project (adds configuration to main.ts) + | + +**Supported Swagger Decorators:** + +For Controllers: `ApiOperation`, `ApiResponse`, `ApiParam`, `ApiBody`, `ApiTags` + +For DTOs: `ApiProperty`, `ApiPropertyOptional` diff --git a/test/e2e-test/crud.e2e-spec.ts b/test/e2e-test/crud.e2e-spec.ts new file mode 100644 index 00000000..8ef3f588 --- /dev/null +++ b/test/e2e-test/crud.e2e-spec.ts @@ -0,0 +1,172 @@ +import { exec } from 'child_process'; +import { join } from 'path'; +import { existsSync, readFileSync, writeFileSync, rmSync, rm } from 'fs'; + +describe('Stencil CLI e2e Test - CRUD & Swagger commands', () => { + const schemaFilePath = join('schema.prisma'); + const schemaFileContent = ` + model Book { + id Int @id @default(autoincrement()) + title String + description String? + } + model Book1 { + id Int @id @default(autoincrement()) + title String + description String? + } + model Car { + id Int @id @default(autoincrement()) + title String + description String? + phone Int + add String + } + `; + + beforeAll((done) => { + exec('stencil new test-project --prisma no --user-service no --monitoring no --monitoringService no --temporal no --logging no --fileUpload no --package-manager npm', (newError, newStdout, newStderr) => { + expect(newError).toBeNull(); + process.chdir('test-project'); + + writeFileSync(schemaFilePath, schemaFileContent); + done(); + }); + },60000); + + + afterAll(() => { + process.chdir('..'); + rmSync('test-project', { recursive: true, force: true }); + }); + + + it('should log an error when no model is provided', (done) => { + exec('stencil crud', (error, stdout, stderr) => { + expect(stderr).toContain('No model provided. Please specify a model or use "*" to generate all models.'); + done(); + }); + }); + + it('should log an error for an empty or missing Prisma schema file', (done) => { + rmSync(schemaFilePath); + + exec('stencil crud Book', (error, stdout, stderr) => { + expect(stderr).toContain('Error generating DMMF JSON'); + writeFileSync(schemaFilePath, schemaFileContent); + done(); + }); + }); + + it('should generate files for a single model', (done) => { + exec('stencil crud Book', (error, stdout, stderr) => { + expect(error).toBeNull(); + const modelDir = join('src', 'book'); + expect(existsSync(modelDir)).toBeTruthy(); + expect( + [`book.controller.ts`, `book.service.ts`, `book.interface.ts`, `dto/book.dto.ts`] + .map(file => existsSync(join(modelDir, file))) + .every(exists => exists) + ).toBeTruthy(); + }); done(); + }); + + it('should generate files for non-existing model', (done) => { + exec('stencil crud Random', (error, stdout, stderr) => { + expect(stderr).toContain('The following models do not exist: Random'); + done(); + }); + }); + + + it('should generate files for all models', (done) => { + exec('stencil crud *', (error, stdout, stderr) => { + expect(error).toBeNull(); + ['book', 'book1', 'car'].forEach((model) => { + const modelDir = join('src', model); + expect(existsSync(modelDir)).toBeTruthy(); + expect( + [`${model}.controller.ts`, `${model}.service.ts`, `${model}.interface.ts`, `dto/${model}.dto.ts`] + .map(file => existsSync(join(modelDir, file))) + .every(exists => exists) + ).toBeTruthy(); + }); + done(); + }); + }); + + it('should log error for wrong/missing model while adding Swagger decorators', (done) => { + exec('stencil crud Book', (crudError, crudStdout, crudStderr) => { + expect(crudError).toBeNull(); + exec('stencil add swagger src/random', (swaggerError, swaggerStdout, swaggerStderr) => { + expect(swaggerStderr).toContain('Controller file not found in path: src/random'); + done(); + }); + }); + }); + + it('should add Swagger decorators for the Book model', (done) => { + exec('stencil crud Book', (crudError, crudStdout, crudStderr) => { + expect(crudError).toBeNull(); + const modelDir = join('src', 'book'); + expect(existsSync(modelDir)).toBeTruthy(); + exec('stencil add swagger src/book', (swaggerError, swaggerStdout, swaggerStderr) => { + expect(swaggerError).toBeNull(); + + const controllerPath = join('src', 'book', 'book.controller.ts'); + const controllerContent = readFileSync(controllerPath, 'utf-8'); + + expect(existsSync(controllerPath)).toBeTruthy(); + + const controllerDecorators = ['@ApiOperation', '@ApiResponse', '@ApiParam', '@ApiBody', '@ApiTags']; + controllerDecorators.forEach(decorator => { + expect(controllerContent).toContain(decorator); + }); + + const dtoPath = join('src', 'book', 'dto', 'book.dto.ts'); + const dtoContent = readFileSync(dtoPath, 'utf-8'); + + expect(existsSync(dtoPath)).toBeTruthy(); + + const dtoDecorators = ['@ApiProperty', '@ApiPropertyOptional']; + dtoDecorators.forEach(decorator => { + expect(dtoContent).toContain(decorator); + }); + + done(); + }); + }); + }); + + + it('should log error for missing dto while adding Swagger decorators', (done) => { + exec('stencil crud Book', (crudError, crudStdout, crudStderr) => { + rmSync(join('src', 'book', 'dto', 'book.dto.ts')); + expect(crudError).toBeNull(); + + exec('stencil add swagger src/book', (swaggerError, swaggerStdout, swaggerStderr) => { + expect(swaggerStderr).toContain('DTO file not found for model: src/book'); + done(); + }); + }); + }); + + + it('should check Swagger initialization in main.ts', (done) => { + exec('stencil crud Book', (crudError, crudStdout, crudStderr) => { + expect(crudError).toBeNull(); + const modelDir = join('src', 'book'); + expect(existsSync(modelDir)).toBeTruthy(); + exec('stencil add swagger src/book --init', (swaggerError, swaggerStdout, swaggerStderr) => { + expect(swaggerError).toBeNull(); + + const mainTsPath = join('src', 'main.ts'); + const mainTsContent = readFileSync(mainTsPath, 'utf-8'); + + expect(mainTsContent).toContain("SwaggerModule.setup('api', app, document);"); + + done(); + }); + }); + }); +}); diff --git a/test/e2e-test/jest-e2e.json b/test/e2e-test/jest-e2e.json new file mode 100644 index 00000000..1a990c7c --- /dev/null +++ b/test/e2e-test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } + } \ No newline at end of file