UNPKG

ucg

Version:

Universal CRUD Generator - Express.js plugin and CLI tool for generating complete Node.js REST APIs with database models, controllers, routes, validators, and admin interface. Supports PostgreSQL, MySQL, SQLite with Sequelize, TypeORM, and Knex.js. Develo

1,674 lines (1,434 loc) 51.9 kB
const fs = require('fs-extra'); const path = require('path'); const { pascalCase } = require('pascal-case'); const camelCase = require('camelcase'); const { paramCase } = require('param-case'); const pluralize = require('pluralize'); class CrudGenerator { constructor(configManager, databaseManager) { this.configManager = configManager; this.databaseManager = databaseManager; } async preview(modelName, operations) { const dbConfig = await this.configManager.getDatabase(); const normalizedOps = Array.isArray(operations) ? operations : ['create', 'read', 'update', 'delete']; const files = []; // Controller - only generate if operations are selected const controllerOperations = normalizedOps.filter(op => ['create', 'read', 'update', 'delete'].includes(op)); if (controllerOperations.length > 0) { files.push({ type: 'controller', fileName: `${camelCase(modelName)}Controller.js`, code: this.generateController(modelName, controllerOperations, dbConfig.orm) }); } // Service - only generate methods for selected operations files.push({ type: 'service', fileName: `${camelCase(modelName)}Service.js`, code: this.generateService(modelName, normalizedOps, dbConfig.orm) }); // Routes - only generate routes for selected operations files.push({ type: 'routes', fileName: `${camelCase(modelName)}Routes.js`, code: this.generateRoutes(modelName, normalizedOps) }); // Validators if (normalizedOps.includes('create') || normalizedOps.includes('update')) { files.push({ type: 'validator', fileName: `${camelCase(modelName)}Validator.js`, code: await this.generateValidator(modelName, normalizedOps) }); } // Swagger docs files.push({ type: 'swagger', fileName: `${camelCase(modelName)}.swagger.js`, code: await this.generateSwaggerDocs(modelName, normalizedOps) }); // Tests files.push({ type: 'test', fileName: `${camelCase(modelName)}.test.js`, code: this.generateTests(modelName, normalizedOps) }); return { modelName, operations: normalizedOps, files }; } async generate(modelName, operations, outputPath) { const previewData = await this.preview(modelName, operations); // Default output paths const basePath = outputPath || path.join(process.cwd(), 'src'); const paths = { controller: path.join(basePath, 'controllers'), service: path.join(basePath, 'services'), routes: path.join(basePath, 'routes'), validator: path.join(basePath, 'validators'), swagger: path.join(basePath, 'docs', 'swagger'), test: path.join(basePath, 'tests'), config: path.join(basePath, 'config'), models: path.join(basePath, 'models') }; const generatedFiles = []; // Ensure database configuration exists await this.ensureDatabaseConfig(paths.config); // Ensure models index exists for proper associations await this.ensureModelsIndex(paths.models); for (const file of previewData.files) { const dirPath = paths[file.type]; await fs.ensureDir(dirPath); const filePath = path.join(dirPath, file.fileName); await fs.writeFile(filePath, file.code, 'utf8'); generatedFiles.push(filePath); } return { type: 'crud', modelName, operations: previewData.operations, files: generatedFiles }; } generateController(modelName, operations, orm) { const serviceName = `${camelCase(modelName)}Service`; const validatorName = `${camelCase(modelName)}Validator`; let code = `const ${serviceName} = require('../services/${camelCase(modelName)}Service');\n`; if (operations.includes('create') || operations.includes('update')) { code += `const { validate${pascalCase(modelName)} } = require('../validators/${camelCase(modelName)}Validator');\n`; } code += `\nclass ${pascalCase(modelName)}Controller {\n`; if (operations.includes('read')) { code += ` async getAll(req, res) { try { const { page = 1, limit = 10, search, sort, order, ...filters } = req.query; const result = await ${serviceName}.findAll({ page: parseInt(page), limit: parseInt(limit), filters: { search, sort, order, ...filters } }); res.json({ success: true, data: result.data, pagination: { page: result.page, limit: result.limit, total: result.total, pages: Math.ceil(result.total / result.limit) } }); } catch (error) { res.status(500).json({ success: false, message: 'Error fetching ${pluralize(modelName.toLowerCase())}', error: error.message }); } } async getById(req, res) { try { const { id } = req.params; const ${camelCase(modelName)} = await ${serviceName}.findById(id); if (!${camelCase(modelName)}) { return res.status(404).json({ success: false, message: '${pascalCase(modelName)} not found' }); } res.json({ success: true, data: ${camelCase(modelName)} }); } catch (error) { res.status(500).json({ success: false, message: 'Error fetching ${modelName.toLowerCase()}', error: error.message }); } } `; } if (operations.includes('create')) { code += ` async create(req, res) { try { const { error, value } = validate${pascalCase(modelName)}(req.body); if (error) { return res.status(400).json({ success: false, message: 'Validation error', errors: error.details.map(detail => detail.message) }); } const ${camelCase(modelName)} = await ${serviceName}.create(value); res.status(201).json({ success: true, message: '${pascalCase(modelName)} created successfully', data: ${camelCase(modelName)} }); } catch (error) { res.status(500).json({ success: false, message: 'Error creating ${modelName.toLowerCase()}', error: error.message }); } } `; } if (operations.includes('update')) { code += ` async update(req, res) { try { const { id } = req.params; const { error, value } = validate${pascalCase(modelName)}(req.body); if (error) { return res.status(400).json({ success: false, message: 'Validation error', errors: error.details.map(detail => detail.message) }); } const ${camelCase(modelName)} = await ${serviceName}.update(id, value); if (!${camelCase(modelName)}) { return res.status(404).json({ success: false, message: '${pascalCase(modelName)} not found' }); } res.json({ success: true, message: '${pascalCase(modelName)} updated successfully', data: ${camelCase(modelName)} }); } catch (error) { res.status(500).json({ success: false, message: 'Error updating ${modelName.toLowerCase()}', error: error.message }); } } `; } if (operations.includes('delete')) { code += ` async delete(req, res) { try { const { id } = req.params; const deleted = await ${serviceName}.delete(id); if (!deleted) { return res.status(404).json({ success: false, message: '${pascalCase(modelName)} not found' }); } res.json({ success: true, message: '${pascalCase(modelName)} deleted successfully' }); } catch (error) { res.status(500).json({ success: false, message: 'Error deleting ${modelName.toLowerCase()}', error: error.message }); } } `; } code += `} module.exports = new ${pascalCase(modelName)}Controller(); `; return code; } generateService(modelName, operations, orm) { const modelFileName = pascalCase(modelName); let code = `const db = require('../models'); const ${modelFileName} = db.${modelFileName}; class ${pascalCase(modelName)}Service { `; if (operations.includes('read')) { code += ` async findAll({ page = 1, limit = 10, filters = {} }) { try {`; if (orm === 'sequelize') { code += ` const offset = (page - 1) * limit; const { search, sort, order, ...directFilters } = filters; const where = {}; const orderClause = []; // Apply direct column filters Object.keys(directFilters).forEach(key => { if (directFilters[key] !== undefined && directFilters[key] !== '') { // Support partial matching for text fields const attributes = Object.keys(${modelFileName}.rawAttributes); if (attributes.includes(key)) { const fieldType = ${modelFileName}.rawAttributes[key].type; if (fieldType.key === 'STRING' || fieldType.key === 'TEXT') { const { Op } = require('sequelize'); // Detect database dialect for proper operator usage const dbDialect = ${modelFileName}.sequelize.getDialect(); const likeOp = dbDialect === 'mysql' ? Op.like : Op.iLike; where[key] = { [likeOp]: \`%\${directFilters[key]}%\` }; } else { where[key] = directFilters[key]; } } } }); // Apply search across text fields (only if no direct filters conflict) if (search && search.trim()) { const { Op } = require('sequelize'); const searchTerm = search.trim(); // Detect database dialect for proper operator usage const dbDialect = ${modelFileName}.sequelize.getDialect(); const likeOp = dbDialect === 'mysql' ? Op.like : Op.iLike; // Get model attributes to search in text fields const attributes = Object.keys(${modelFileName}.rawAttributes); const searchableFields = attributes.filter(attr => { const type = ${modelFileName}.rawAttributes[attr].type; return type.key === 'STRING' || type.key === 'TEXT'; }).filter(field => !directFilters[field]); // Exclude fields already filtered if (searchableFields.length > 0) { // Create search condition const searchCondition = { [Op.or]: searchableFields.map(field => ({ [field]: { [likeOp]: \`%\${searchTerm}%\` } })) }; // If there are existing conditions, combine with AND if (Object.keys(where).length > 0) { // Create a new where object with both conditions const existingConditions = { ...where }; Object.keys(where).forEach(key => delete where[key]); where[Op.and] = [existingConditions, searchCondition]; } else { Object.assign(where, searchCondition); } } } // Apply sorting if (sort) { const sortDirection = order && order.toLowerCase() === 'asc' ? 'ASC' : 'DESC'; const attributes = Object.keys(${modelFileName}.rawAttributes); if (attributes.includes(sort)) { orderClause.push([sort, sortDirection]); } else { orderClause.push(['id', 'DESC']); } } else { orderClause.push(['id', 'DESC']); } // Try to include related data if associations exist const includeOptions = []; try { if (${modelFileName}.associations) { Object.keys(${modelFileName}.associations).forEach(associationName => { const association = ${modelFileName}.associations[associationName]; if (association.associationType === 'BelongsTo' || association.associationType === 'HasMany') { includeOptions.push({ model: association.target, as: associationName, required: false // Use LEFT JOIN to avoid filtering out records }); } }); } } catch (error) { // Associations not available or error occurred, continue without includes console.warn('Could not load associations for ${modelName}:', error.message); } const { count, rows } = await ${modelFileName}.findAndCountAll({ where, limit: parseInt(limit), offset: parseInt(offset), order: orderClause, include: includeOptions }); return { data: rows, total: count, page: parseInt(page), limit: parseInt(limit) };`; } else if (orm === 'typeorm') { code += ` const skip = (page - 1) * limit; const { search, sort, order, ...directFilters } = filters; const where = {}; const orderBy = {}; // Apply direct column filters Object.keys(directFilters).forEach(key => { if (directFilters[key] !== undefined && directFilters[key] !== '') { where[key] = directFilters[key]; } }); // Apply search across text fields (basic implementation for TypeORM) if (search && search.trim()) { const { Like } = require('typeorm'); const searchTerm = search.trim(); // Note: This is a simplified search - you may want to enhance based on your schema if (!where.name) where.name = Like(\`%\${searchTerm}%\`); } // Apply sorting if (sort) { const sortDirection = order && order.toLowerCase() === 'asc' ? 'ASC' : 'DESC'; orderBy[sort] = sortDirection; } else { orderBy.id = 'DESC'; } const [data, total] = await ${modelFileName}.findAndCount({ where, take: parseInt(limit), skip: parseInt(skip), order: orderBy }); return { data, total, page: parseInt(page), limit: parseInt(limit) };`; } else { // knex code += ` const offset = (page - 1) * limit; const { search, sort, order, ...directFilters } = filters; const query = ${modelFileName}.query(); // Apply direct column filters Object.keys(directFilters).forEach(key => { if (directFilters[key] !== undefined && directFilters[key] !== '') { query.where(key, directFilters[key]); } }); // Apply search across text fields if (search && search.trim()) { const searchTerm = search.trim(); query.where(function() { // Note: Adjust these fields based on your actual table schema this.whereILike('name', \`%\${searchTerm}%\`) .orWhereILike('description', \`%\${searchTerm}%\`) .orWhereILike('title', \`%\${searchTerm}%\`); }); } // Apply sorting const sortColumn = sort || 'id'; const sortDirection = order && order.toLowerCase() === 'asc' ? 'asc' : 'desc'; const total = await query.clone().resultSize(); const data = await query .limit(parseInt(limit)) .offset(parseInt(offset)) .orderBy(sortColumn, sortDirection); return { data, total, page: parseInt(page), limit: parseInt(limit) };`; } code += ` } catch (error) { throw new Error(\`Error fetching ${pluralize(modelName.toLowerCase())}: \${error.message}\`); } } async findById(id) { try {`; if (orm === 'sequelize') { code += ` // Try to include related data if associations exist const includeOptions = []; try { if (${modelFileName}.associations) { Object.keys(${modelFileName}.associations).forEach(associationName => { const association = ${modelFileName}.associations[associationName]; if (association.associationType === 'BelongsTo' || association.associationType === 'HasMany') { includeOptions.push({ model: association.target, as: associationName, required: false // Use LEFT JOIN to avoid filtering out records }); } }); } } catch (error) { // Associations not available or error occurred, continue without includes console.warn('Could not load associations for ${modelName}:', error.message); } return await ${modelFileName}.findByPk(id, { include: includeOptions });`; } else if (orm === 'typeorm') { code += ` return await ${modelFileName}.findOne({ where: { id } });`; } else { // knex code += ` return await ${modelFileName}.query().findById(id);`; } code += ` } catch (error) { throw new Error(\`Error fetching ${modelName.toLowerCase()}: \${error.message}\`); } } `; } if (operations.includes('create')) { code += ` async create(data) { try {`; if (orm === 'sequelize') { code += ` return await ${modelFileName}.create(data);`; } else if (orm === 'typeorm') { code += ` const ${camelCase(modelName)} = ${modelFileName}.create(data); return await ${modelFileName}.save(${camelCase(modelName)});`; } else { // knex code += ` return await ${modelFileName}.query().insert(data);`; } code += ` } catch (error) { throw new Error(\`Error creating ${modelName.toLowerCase()}: \${error.message}\`); } } `; } if (operations.includes('update')) { code += ` async update(id, data) { try {`; if (orm === 'sequelize') { code += ` const [updated] = await ${modelFileName}.update(data, { where: { id }, returning: true }); if (updated) { return await ${modelFileName}.findByPk(id); } return null;`; } else if (orm === 'typeorm') { code += ` const result = await ${modelFileName}.update(id, data); if (result.affected > 0) { return await ${modelFileName}.findOne({ where: { id } }); } return null;`; } else { // knex code += ` const updated = await ${modelFileName}.query() .findById(id) .patch(data); if (updated) { return await ${modelFileName}.query().findById(id); } return null;`; } code += ` } catch (error) { throw new Error(\`Error updating ${modelName.toLowerCase()}: \${error.message}\`); } } `; } if (operations.includes('delete')) { code += ` async delete(id) { try {`; if (orm === 'sequelize') { code += ` const result = await ${modelFileName}.destroy({ where: { id } }); return result > 0;`; } else if (orm === 'typeorm') { code += ` const result = await ${modelFileName}.delete(id); return result.affected > 0;`; } else { // knex code += ` const result = await ${modelFileName}.query().deleteById(id); return result > 0;`; } code += ` } catch (error) { throw new Error(\`Error deleting ${modelName.toLowerCase()}: \${error.message}\`); } } `; } code += `} module.exports = new ${pascalCase(modelName)}Service(); `; return code; } generateRoutes(modelName, operations) { const controllerName = `${camelCase(modelName)}Controller`; const routePath = paramCase(pluralize(modelName)); let code = `const express = require('express'); const ${controllerName} = require('../controllers/${camelCase(modelName)}Controller'); const router = express.Router(); `; if (operations.includes('read')) { code += `// GET /${routePath} - Get all ${pluralize(modelName.toLowerCase())} router.get('/', ${controllerName}.getAll); // GET /${routePath}/:id - Get ${modelName.toLowerCase()} by ID router.get('/:id', ${controllerName}.getById); `; } if (operations.includes('create')) { code += `// POST /${routePath} - Create new ${modelName.toLowerCase()} router.post('/', ${controllerName}.create); `; } if (operations.includes('update')) { code += `// PUT /${routePath}/:id - Update ${modelName.toLowerCase()} router.put('/:id', ${controllerName}.update); `; } if (operations.includes('delete')) { code += `// DELETE /${routePath}/:id - Delete ${modelName.toLowerCase()} router.delete('/:id', ${controllerName}.delete); `; } code += `module.exports = router; `; return code; } async generateValidator(modelName, operations) { let code = `const Joi = require('joi'); `; // Try to get actual table schema for better validation let schema = null; try { const tableName = paramCase(modelName).replace('-', '_'); schema = await this.databaseManager.getTableSchema(tableName); } catch (error) { console.warn('Could not fetch table schema for validation:', error.message); } if (schema && schema.columns) { // Generate validation schema based on actual database fields code += `const ${camelCase(modelName)}Schema = Joi.object({`; schema.columns .filter(col => !schema.primaryKeys || !schema.primaryKeys.includes(col.column_name)) // Exclude primary keys .filter(col => col.column_name !== 'created_at' && col.column_name !== 'updated_at') // Exclude timestamps .forEach((column, index, filteredColumns) => { const isRequired = (column.is_nullable === 'NO' || !column.is_nullable); const isLast = index === filteredColumns.length - 1; code += ` ${column.column_name}: `; // Map PostgreSQL types to Joi validation if (column.data_type === 'uuid') { code += 'Joi.string().uuid()'; } else if (column.data_type === 'text' || column.data_type.includes('character')) { code += 'Joi.string()'; if (column.character_maximum_length) { code += `.max(${column.character_maximum_length})`; } } else if (column.data_type === 'integer' || column.data_type === 'smallint' || column.data_type === 'bigint') { code += 'Joi.number().integer()'; if (column.column_name === 'position') { code += '.min(0)'; } } else if (column.data_type === 'boolean') { code += 'Joi.boolean()'; } else if (column.data_type === 'jsonb' || column.data_type === 'json') { code += 'Joi.object()'; } else if (column.data_type.includes('timestamp')) { code += 'Joi.date()'; } else { // Default to string for unknown types code += 'Joi.string()'; } // Add required validation if (isRequired) { code += '.required()'; } else { code += '.optional()'; } // Add description const description = column.column_name.replace(/_/g, ' '); code += `.description('${description}')`; if (!isLast) { code += ','; } }); code += ` });`; } else { // Fallback to generic schema code += `const ${camelCase(modelName)}Schema = Joi.object({ // Add your model fields here based on your database schema // Example: // name: Joi.string().required(), // email: Joi.string().email().required(), // age: Joi.number().integer().min(0), // isActive: Joi.boolean().default(true) });`; } code += ` const validate${pascalCase(modelName)} = (data) => { return ${camelCase(modelName)}Schema.validate(data, { abortEarly: false }); }; module.exports = { ${camelCase(modelName)}Schema, validate${pascalCase(modelName)} }; `; return code; } async generateSwaggerDocs(modelName, operations) { // Generate route path to match the actual mounting format (kebab-case) const routePath = paramCase(pluralize(modelName)); // Try to get actual table schema for better swagger docs let schema = null; try { // Get table name from model name const tableName = paramCase(modelName).replace('-', '_'); schema = await this.databaseManager.getTableSchema(tableName); } catch (error) { console.warn('Could not fetch table schema for Swagger docs:', error.message); } let code = `/** * @swagger * components: * schemas: * ${pascalCase(modelName)}: * type: object * properties:`; if (schema && schema.columns) { // Generate properties from actual table schema schema.columns.forEach(column => { const swaggerType = this.mapPostgreSQLToSwaggerType(column.data_type); const isPrimary = schema.primaryKeys && schema.primaryKeys.includes(column.column_name); code += ` * ${column.column_name}:`; if (swaggerType === 'string' && column.data_type === 'uuid') { code += ` * type: string * format: uuid`; } else if (swaggerType === 'string' && column.data_type.includes('timestamp')) { code += ` * type: string * format: date-time`; } else { code += ` * type: ${swaggerType}`; } if (isPrimary) { code += ` * description: Primary key`; } else if (column.column_name === 'created_at') { code += ` * description: Creation timestamp`; } else if (column.column_name === 'updated_at') { code += ` * description: Last update timestamp`; } if (!column.is_nullable || column.is_nullable === 'NO') { // Mark as required (will be handled in required array) } }); // Add example with actual field names code += ` * required:`; const requiredFields = schema.columns .filter(col => col.is_nullable === 'NO' || !col.is_nullable) .filter(col => !schema.primaryKeys || !schema.primaryKeys.includes(col.column_name)) // Exclude auto-generated primary keys .filter(col => col.column_name !== 'created_at' && col.column_name !== 'updated_at') // Exclude timestamps .map(col => col.column_name); if (requiredFields.length > 0) { code += ` * - ${requiredFields.join('\n * - ')}`; } // Generate example based on actual fields (exclude auto-generated fields) const exampleObj = {}; schema.columns .filter(col => !schema.primaryKeys || !schema.primaryKeys.includes(col.column_name)) // Exclude primary keys .filter(col => col.column_name !== 'created_at' && col.column_name !== 'updated_at') // Exclude timestamps .forEach(column => { if (column.data_type === 'uuid') { exampleObj[column.column_name] = 'a1b2c3d4-e5f6-4789-9abc-123456789def'; } else if (column.data_type.includes('timestamp')) { exampleObj[column.column_name] = '2023-01-01T00:00:00.000Z'; } else if (column.data_type === 'boolean') { exampleObj[column.column_name] = column.column_name.includes('is_') ? true : false; } else if (column.data_type === 'integer') { exampleObj[column.column_name] = column.column_name === 'position' ? 1 : 123; } else if (column.data_type === 'jsonb' || column.data_type === 'json') { exampleObj[column.column_name] = {}; } else { // Text fields exampleObj[column.column_name] = `Sample ${column.column_name.replace('_', ' ')}`; } }); // Generate properly formatted YAML example const exampleLines = JSON.stringify(exampleObj, null, 2).split('\n'); code += ` * example:`; exampleLines.forEach(line => { code += ` * ${line}`; }); } else { // Fallback to generic schema if table schema not available code += ` * id: * type: integer * description: Unique identifier * createdAt: * type: string * format: date-time * description: Creation timestamp * updatedAt: * type: string * format: date-time * description: Last update timestamp * example: * id: 1 * createdAt: "2023-01-01T00:00:00.000Z" * updatedAt: "2023-01-01T00:00:00.000Z"`; } code += ` */ `; // Generate separate schema for create/update operations (without read-only fields) if (operations.includes('create') || operations.includes('update')) { code += `/** * @swagger * components: * schemas: * ${pascalCase(modelName)}Input: * type: object * properties:`; if (schema && schema.columns) { // Generate properties excluding read-only fields schema.columns .filter(col => !schema.primaryKeys || !schema.primaryKeys.includes(col.column_name)) // Exclude primary keys .filter(col => col.column_name !== 'created_at' && col.column_name !== 'updated_at') // Exclude timestamps .forEach(column => { const swaggerType = this.mapPostgreSQLToSwaggerType(column.data_type); code += ` * ${column.column_name}:`; if (swaggerType === 'string' && column.data_type === 'uuid') { code += ` * type: string * format: uuid`; } else if (swaggerType === 'string' && column.data_type.includes('timestamp')) { code += ` * type: string * format: date-time`; } else { code += ` * type: ${swaggerType}`; } // Add descriptions for better UX if (column.column_name.endsWith('_id')) { code += ` * description: Reference ID`; } }); // Required fields for input code += ` * required:`; const inputRequiredFields = schema.columns .filter(col => col.is_nullable === 'NO' || !col.is_nullable) .filter(col => !schema.primaryKeys || !schema.primaryKeys.includes(col.column_name)) .filter(col => col.column_name !== 'created_at' && col.column_name !== 'updated_at') .map(col => col.column_name); if (inputRequiredFields.length > 0) { code += ` * - ${inputRequiredFields.join('\n * - ')}`; } // Generate input example const inputExampleObj = {}; schema.columns .filter(col => !schema.primaryKeys || !schema.primaryKeys.includes(col.column_name)) .filter(col => col.column_name !== 'created_at' && col.column_name !== 'updated_at') .forEach(column => { if (column.data_type === 'uuid') { inputExampleObj[column.column_name] = 'a1b2c3d4-e5f6-4789-9abc-123456789def'; } else if (column.data_type === 'boolean') { inputExampleObj[column.column_name] = column.column_name.includes('is_') ? true : false; } else if (column.data_type === 'integer') { inputExampleObj[column.column_name] = column.column_name === 'position' ? 1 : 123; } else if (column.data_type === 'jsonb' || column.data_type === 'json') { inputExampleObj[column.column_name] = {}; } else { inputExampleObj[column.column_name] = `Sample ${column.column_name.replace('_', ' ')}`; } }); const inputExampleLines = JSON.stringify(inputExampleObj, null, 2).split('\n'); code += ` * example:`; inputExampleLines.forEach(line => { code += ` * ${line}`; }); } code += ` */ `; } if (operations.includes('read')) { code += `/** * @swagger * /api/${routePath}: * get: * tags: [${pascalCase(modelName)}] * summary: Get all ${pluralize(modelName.toLowerCase())} * parameters: * - in: query * name: page * schema: * type: integer * default: 1 * description: Page number for pagination * - in: query * name: limit * schema: * type: integer * default: 10 * description: Number of items per page * - in: query * name: search * schema: * type: string * description: Search term to filter results across text fields * - in: query * name: sort * schema: * type: string * description: Field name to sort by (e.g., id, created_at, name) * - in: query * name: order * schema: * type: string * enum: [asc, desc] * default: desc * description: Sort order (ascending or descending)`; // Add field-specific filtering parameters from schema if (schema && schema.columns) { schema.columns.forEach(column => { const swaggerType = this.mapPostgreSQLToSwaggerType(column.data_type); const fieldDescription = column.data_type === 'uuid' ? 'Filter by UUID' : column.data_type.includes('timestamp') ? 'Filter by date/time' : swaggerType === 'string' ? 'Filter by text (partial match)' : swaggerType === 'integer' ? 'Filter by exact number' : swaggerType === 'boolean' ? 'Filter by true/false' : 'Filter by value'; code += ` * - in: query * name: ${column.column_name} * schema: * type: ${swaggerType}`; if (column.data_type === 'uuid') { code += ` * format: uuid`; } else if (column.data_type.includes('timestamp')) { code += ` * format: date-time`; } code += ` * description: ${fieldDescription}`; }); } code += ` * responses: * 200: * description: List of ${pluralize(modelName.toLowerCase())} * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * data: * type: array * items: * $ref: '#/components/schemas/${pascalCase(modelName)}' * pagination: * type: object * properties: * page: * type: integer * limit: * type: integer * total: * type: integer * pages: * type: integer */ /** * limit: * type: integer * total: * type: integer * pages: * type: integer */ /** * @swagger * /api/${routePath}/{id}: * get: * tags: [${pascalCase(modelName)}] * summary: Get ${modelName.toLowerCase()} by ID * parameters: * - in: path * name: id * required: true * schema: * type: string * format: uuid * responses: * 200: * description: ${pascalCase(modelName)} details * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * data: * $ref: '#/components/schemas/${pascalCase(modelName)}' * 404: * description: ${pascalCase(modelName)} not found */ `; } if (operations.includes('create')) { code += `/** * @swagger * /api/${routePath}: * post: * tags: [${pascalCase(modelName)}] * summary: Create new ${modelName.toLowerCase()} * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/${pascalCase(modelName)}Input' * responses: * 201: * description: ${pascalCase(modelName)} created successfully * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * message: * type: string * data: * $ref: '#/components/schemas/${pascalCase(modelName)}' * 400: * description: Validation error */ `; } if (operations.includes('update')) { code += `/** * @swagger * /api/${routePath}/{id}: * put: * tags: [${pascalCase(modelName)}] * summary: Update ${modelName.toLowerCase()} * parameters: * - in: path * name: id * required: true * schema: * type: string * format: uuid * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/${pascalCase(modelName)}Input' * responses: * 200: * description: ${pascalCase(modelName)} updated successfully * 404: * description: ${pascalCase(modelName)} not found */ `; } if (operations.includes('delete')) { code += `/** * @swagger * /api/${routePath}/{id}: * delete: * tags: [${pascalCase(modelName)}] * summary: Delete ${modelName.toLowerCase()} * parameters: * - in: path * name: id * required: true * schema: * type: string * format: uuid * responses: * 200: * description: ${pascalCase(modelName)} deleted successfully * 404: * description: ${pascalCase(modelName)} not found */ `; } return code; } generateTests(modelName, operations) { const modelLower = modelName.toLowerCase(); const routePath = paramCase(pluralize(modelName)); let code = `const request = require('supertest'); const app = require('../app'); // Adjust path as needed describe('${pascalCase(modelName)} API', () => { let ${camelCase(modelName)}Id; beforeAll(async () => { // Setup test database or mock data }); afterAll(async () => { // Cleanup test database }); `; if (operations.includes('create')) { code += ` describe('POST /${routePath}', () => { it('should create a new ${modelLower}', async () => { const ${camelCase(modelName)}Data = { // Add test data here }; const response = await request(app) .post('/${routePath}') .send(${camelCase(modelName)}Data) .expect(201); expect(response.body.success).toBe(true); expect(response.body.data).toBeDefined(); ${camelCase(modelName)}Id = response.body.data.id; }); it('should return validation error for invalid data', async () => { const response = await request(app) .post('/${routePath}') .send({}) .expect(400); expect(response.body.success).toBe(false); expect(response.body.errors).toBeDefined(); }); }); `; } if (operations.includes('read')) { code += ` describe('GET /${routePath}', () => { it('should get all ${pluralize(modelLower)}', async () => { const response = await request(app) .get('/${routePath}') .expect(200); expect(response.body.success).toBe(true); expect(response.body.data).toBeInstanceOf(Array); expect(response.body.pagination).toBeDefined(); }); it('should get ${modelLower} by id', async () => { const response = await request(app) .get(\`/${routePath}/\${${camelCase(modelName)}Id}\`) .expect(200); expect(response.body.success).toBe(true); expect(response.body.data).toBeDefined(); }); it('should return 404 for non-existent ${modelLower}', async () => { const response = await request(app) .get('/${routePath}/99999') .expect(404); expect(response.body.success).toBe(false); }); }); `; } if (operations.includes('update')) { code += ` describe('PUT /${routePath}/:id', () => { it('should update ${modelLower}', async () => { const updateData = { // Add update data here }; const response = await request(app) .put(\`/${routePath}/\${${camelCase(modelName)}Id}\`) .send(updateData) .expect(200); expect(response.body.success).toBe(true); expect(response.body.data).toBeDefined(); }); it('should return 404 for non-existent ${modelLower}', async () => { const response = await request(app) .put('/${routePath}/99999') .send({}) .expect(404); expect(response.body.success).toBe(false); }); }); `; } if (operations.includes('delete')) { code += ` describe('DELETE /${routePath}/:id', () => { it('should delete ${modelLower}', async () => { const response = await request(app) .delete(\`/${routePath}/\${${camelCase(modelName)}Id}\`) .expect(200); expect(response.body.success).toBe(true); }); it('should return 404 for non-existent ${modelLower}', async () => { const response = await request(app) .delete('/${routePath}/99999') .expect(404); expect(response.body.success).toBe(false); }); }); `; } code += `}); `; return code; } /** * Ensure database configuration file exists for generated models to use */ async ensureDatabaseConfig(configPath) { const dbConfigFile = path.join(configPath, 'database.js'); // Check if database configuration already exists if (await fs.pathExists(dbConfigFile)) { console.log('✅ Database configuration already exists'); return; } try { // Get database configuration from config manager const dbConfig = await this.configManager.getDatabase(); if (!dbConfig) { throw new Error('No database configuration found. Please configure database settings first.'); } // Ensure config directory exists await fs.ensureDir(configPath); // Generate database configuration file based on ORM let configContent = ''; if (dbConfig.orm === 'sequelize') { // Map database type to Sequelize dialect const dialectMap = { 'postgres': 'postgres', 'mysql': 'mysql' }; const dialect = dialectMap[dbConfig.type] || 'postgres'; configContent = `const { Sequelize } = require('sequelize'); require('dotenv').config(); // Create Sequelize instance with ${dbConfig.type === 'mysql' ? 'MySQL' : 'PostgreSQL'} configuration const sequelize = new Sequelize( process.env.DB_NAME || '${dbConfig.database || 'ucg'}', process.env.DB_USER || '${dbConfig.username || 'postgres'}', process.env.DB_PASS || '${dbConfig.password || ''}', { host: process.env.DB_HOST || '${dbConfig.host || 'localhost'}', port: process.env.DB_PORT || ${dbConfig.port || 5432}, dialect: '${dialect}', logging: process.env.NODE_ENV === 'development' ? console.log : false, define: { timestamps: true, underscored: false, }, pool: { max: 5, min: 0, acquire: 30000, idle: 10000 } } ); // Test the connection async function testConnection() { try { await sequelize.authenticate(); console.log('✅ Database connection established successfully.'); } catch (error) { console.error('❌ Unable to connect to the database:', error.message); } } // Initialize connection on require testConnection(); module.exports = { sequelize, Sequelize }; `; } else if (dbConfig.orm === 'typeorm') { // Map database type to TypeORM type const typeMap = { 'postgres': 'postgres', 'mysql': 'mysql' }; const dbType = typeMap[dbConfig.type] || 'postgres'; configContent = `const { DataSource } = require('typeorm'); require('dotenv').config(); const AppDataSource = new DataSource({ type: '${dbType}', host: process.env.DB_HOST || '${dbConfig.host || 'localhost'}', port: process.env.DB_PORT || ${dbConfig.port || 5432}, username: process.env.DB_USER || '${dbConfig.username || 'postgres'}', password: process.env.DB_PASS || '${dbConfig.password || ''}', database: process.env.DB_NAME || '${dbConfig.database || 'ucg'}', synchronize: process.env.NODE_ENV === 'development', logging: process.env.NODE_ENV === 'development', entities: ['src/models/*.js'], migrations: ['src/migrations/*.js'], subscribers: ['src/subscribers/*.js'], }); // Initialize connection AppDataSource.initialize() .then(() => { console.log('✅ Database connection established successfully.'); }) .catch((error) => { console.error('❌ Unable to connect to the database:', error.message); }); module.exports = { AppDataSource }; `; } else { // knex // Map database type to Knex client const clientMap = { 'postgres': 'pg', 'mysql': 'mysql2' }; const client = clientMap[dbConfig.type] || 'pg'; configContent = `const knex = require('knex'); require('dotenv').config(); const db = knex({ client: '${client}', connection: { host: process.env.DB_HOST || '${dbConfig.host || 'localhost'}', port: process.env.DB_PORT || ${dbConfig.port || 5432}, user: process.env.DB_USER || '${dbConfig.username || 'postgres'}', password: process.env.DB_PASS || '${dbConfig.password || ''}', database: process.env.DB_NAME || '${dbConfig.database || 'ucg'}' }, pool: { min: 0, max: 10 }, migrations: { tableName: 'knex_migrations' } }); // Test the connection db.raw('SELECT 1') .then(() => { console.log('✅ Database connection established successfully.'); }) .catch((error) => { console.error('❌ Unable to connect to the database:', error.message); }); module.exports = db; `; } // Write the configuration file await fs.writeFile(dbConfigFile, configContent, 'utf8'); console.log(`✅ Created database configuration file: ${dbConfigFile}`); } catch (error) { console.warn(`⚠️ Could not create database configuration: ${error.message}`); throw error; } } /** * Ensure models index file exists to properly initialize associations */ async ensureModelsIndex(modelsPath) { const indexFile = path.join(modelsPath, 'index.js'); // Check if models index already exists if (await fs.pathExists(indexFile)) { console.log('✅ Models index file already exists'); return; } try { // Ensure models directory exists await fs.ensureDir(modelsPath); // Get database configuration to determine ORM const dbConfig = await this.configManager.getDatabase(); if (!dbConfig) { throw new Error('No database configuration found. Please configure database settings first.'); } let indexContent = ''; if (dbConfig.orm === 'sequelize') { indexContent = `const fs = require('fs'); const path = require('path'); const { sequelize } = require('../config/database'); const db = {}; // Load all model files fs.readdirSync(__dirname) .filter(file => { return (file.indexOf('.') !== 0) && (file !== 'index.js') && (file.slice(-3) === '.js'); }) .forEach(file => { const model = require(path.join(__dirname, file)); if (model.name) { db[model.name] = model; } }); // Set up associations Object.keys(db).forEach(modelName => { if (db[modelName].associate) { db[modelName].associate(db); } }); // Add sequelize instance and constructor db.sequelize = sequelize; db.Sequelize = require('sequelize'); module.exports = db; `; } else if (dbConfig.orm === 'typeorm') { indexContent = `const fs = require('fs'); const path = require('path'); const { AppDataSource } = require('../config/database'); const models = {}; // Load all model files fs.readdirSync(__dirname) .filter(file => { return (file.indexOf('.') !== 0) && (file !== 'index.js') && (file.slice(-3) === '.js'); }) .forEach(file => { const model = require(path.join(__dirname, file)); if (model.name) { models[model.name] = model; } }); models.AppDataSource = AppDataSource; module.exports = models; `; } else { // knex indexContent = `const fs = require('fs'); const path = require('path'); const db = require('../config/database'); const models = {}; // Load all model files fs.readdirSync(__dirname) .filter(file => { return (file.indexOf('.') !== 0) && (file !== 'index.js') && (file.slice(-3) === '.js'); }) .forEach(file => { const model = require(path.join(__dirname, file)); if (model.name) { models[model.name] = model; } }); models.db = db; module.exports = models; `; } // Write the index file await fs.writeFile(indexFile, indexContent, 'utf8'); console.log(`✅ Created models index file: ${indexFile}`); } catch (error) { console.warn(`⚠️ Could not create models index: ${error.message}`); throw error; } } /** * Map PostgreSQL data types to Swagger types */ mapPostgreSQLToSwaggerType(pgType) {