UNPKG

weaver-frontend-cli

Version:

🕷️ Weaver CLI - Generador completo de arquitectura Clean Architecture para entidades CRUD y flujos de negocio desde OpenAPI/Swagger

764 lines 37.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SwaggerAnalyzer = void 0; const axios_1 = __importDefault(require("axios")); const swagger_parser_1 = __importDefault(require("@apidevtools/swagger-parser")); class SwaggerAnalyzer { constructor() { this.openApiDoc = null; this.detectedApiName = null; } async loadFromUrl(url) { try { console.log(`🔍 Descargando OpenAPI desde: ${url}`); const response = await axios_1.default.get(url); this.openApiDoc = await swagger_parser_1.default.validate(response.data); this.detectedApiName = this.detectApiName(); console.log(`✅ OpenAPI cargado exitosamente`); if (this.detectedApiName) { console.log(`🔗 API detectada: ${this.detectedApiName}`); } } catch (error) { throw new Error(`Error cargando OpenAPI: ${error}`); } } getAvailableEntities() { if (!this.openApiDoc?.paths || !this.openApiDoc?.components?.schemas) { return []; } const allTags = new Set(); const tagOperations = new Map(); // Recopilar todos los tags y sus operaciones for (const path in this.openApiDoc.paths) { const pathItem = this.openApiDoc.paths[path]; if (!pathItem || typeof pathItem !== 'object') continue; const methods = ['get', 'post', 'put', 'delete', 'patch']; for (const method of methods) { const operation = pathItem[method]; if (operation && operation.tags) { operation.tags.forEach(tag => { allTags.add(tag); if (!tagOperations.has(tag)) { tagOperations.set(tag, new Set()); } // Mapear método HTTP a operación CRUD const crudOperation = this.mapHttpMethodToCrud(method, path); if (crudOperation) { tagOperations.get(tag).add(crudOperation); } }); } } } // Filtrar tags que SÍ tienen CRUD completo (son entidades) const entities = []; for (const tag of allTags) { const operations = tagOperations.get(tag) || new Set(); const hasCompleteCrud = this.hasCompleteCrudOperations(operations); if (hasCompleteCrud) { entities.push(tag); } } return entities.sort(); } /** * Obtiene los servicios de negocio disponibles basándose en tags que NO tienen CRUD completo */ getAvailableBusinessServices() { if (!this.openApiDoc?.paths) { return []; } const allTags = new Set(); const tagOperations = new Map(); // Recopilar todos los tags y sus operaciones for (const path in this.openApiDoc.paths) { const pathItem = this.openApiDoc.paths[path]; if (!pathItem || typeof pathItem !== 'object') continue; const methods = ['get', 'post', 'put', 'delete', 'patch']; for (const method of methods) { const operation = pathItem[method]; if (operation && operation.tags) { operation.tags.forEach(tag => { allTags.add(tag); if (!tagOperations.has(tag)) { tagOperations.set(tag, new Set()); } // Mapear método HTTP a operación CRUD const crudOperation = this.mapHttpMethodToCrud(method, path); if (crudOperation) { tagOperations.get(tag).add(crudOperation); } }); } } } // Filtrar tags que NO tienen CRUD completo (son servicios de negocio) const businessServices = []; for (const tag of allTags) { const operations = tagOperations.get(tag) || new Set(); const hasCompleteCrud = this.hasCompleteCrudOperations(operations); if (!hasCompleteCrud) { businessServices.push(tag); } } return businessServices.sort(); } /** * Mapea un método HTTP y path a una operación CRUD */ mapHttpMethodToCrud(method, path) { switch (method.toLowerCase()) { case 'post': // POST /entity/list es list, no save if (path.includes('/list')) { return 'list'; } return 'save'; case 'get': // Si el path tiene parámetros, es read; si no, es list return (path.includes('{') || path.includes('/:')) ? 'read' : 'list'; case 'put': case 'patch': return 'update'; case 'delete': return 'delete'; default: return null; } } /** * Verifica si un conjunto de operaciones incluye CRUD completo */ hasCompleteCrudOperations(operations) { const requiredOperations = ['save', 'update', 'list', 'delete', 'read']; return requiredOperations.every(op => operations.has(op)); } /** * Obtiene el schema de un servicio de negocio basándose en sus operaciones Request/Response */ getBusinessServiceSchema(serviceName) { if (!this.openApiDoc?.paths) { return null; } const operations = { create: false, read: false, update: false, delete: false, list: false }; const businessOperations = []; let description = ''; // Buscar operaciones relacionadas con este servicio for (const path in this.openApiDoc.paths) { const pathItem = this.openApiDoc.paths[path]; if (!pathItem || typeof pathItem !== 'object') continue; const methods = ['get', 'post', 'put', 'delete', 'patch']; for (const method of methods) { const operation = pathItem[method]; if (operation && operation.tags && operation.tags.includes(serviceName)) { // Determinar tipo de operación if (method === 'post') operations.create = true; if (method === 'get') { if (path.includes('{') || path.includes('/:')) { operations.read = true; } else { operations.list = true; } } if (method === 'put' || method === 'patch') operations.update = true; if (method === 'delete') operations.delete = true; // Obtener descripción if (operation.description && !description) { description = operation.description; } // Extraer información de la operación de negocio const businessOp = { operationId: operation.operationId, method: method.toUpperCase(), path: path, summary: operation.summary, requestSchema: null, responseSchema: null, fields: [] }; // Extraer schema del requestBody if (operation.requestBody && 'content' in operation.requestBody) { const content = operation.requestBody.content; if (content['application/json'] && content['application/json'].schema) { const schema = content['application/json'].schema; if (schema.$ref) { businessOp.requestSchema = schema.$ref.split('/').pop(); } // Extraer campos del request if (schema.properties) { for (const [fieldName, fieldSchema] of Object.entries(schema.properties)) { if (typeof fieldSchema === 'object' && fieldSchema !== null) { const field = this.parseFieldSchema(fieldName, fieldSchema, schema.required || []); businessOp.fields.push(field); } } } } } // Extraer schema de la response if (operation.responses && operation.responses['200']) { const response200 = operation.responses['200']; if ('content' in response200 && response200.content && response200.content['application/json'] && response200.content['application/json'].schema) { const schema = response200.content['application/json'].schema; // Manejar tanto referencias ($ref) como schemas expandidos let responseSchemaObj = null; let responseSchemaName = null; if (schema.$ref) { // Schema por referencia responseSchemaName = schema.$ref.split('/').pop(); businessOp.responseSchema = responseSchemaName; if (this.openApiDoc?.components?.schemas && responseSchemaName) { responseSchemaObj = this.openApiDoc.components.schemas[responseSchemaName]; } } else if (schema.properties) { // Schema expandido directamente responseSchemaObj = schema; businessOp.responseSchema = 'InlineResponse'; } if (responseSchemaObj && responseSchemaObj.properties) { businessOp.responseFields = []; // Buscar específicamente el campo "response" y extraer su contenido if (responseSchemaObj.properties.response) { const responseField = responseSchemaObj.properties.response; // Si el campo response tiene propiedades (es un objeto) if (responseField.properties) { for (const [fieldName, fieldSchema] of Object.entries(responseField.properties)) { if (typeof fieldSchema === 'object' && fieldSchema !== null) { const field = this.parseFieldSchemaWithRefs(fieldName, fieldSchema, responseField.required || []); businessOp.responseFields.push(field); } } } else if (responseField.anyOf || responseField.oneOf) { // Manejar casos donde response tiene anyOf/oneOf const schemas = responseField.anyOf || responseField.oneOf; let foundSpecificSchema = false; // Buscar el primer schema útil en anyOf (que no sea null) for (const subSchema of schemas) { if (subSchema.$ref) { // Si es una referencia directa, resolver y usar sus propiedades const refName = subSchema.$ref.split('/').pop(); if (this.openApiDoc?.components?.schemas && refName) { const refSchemaObj = this.openApiDoc.components.schemas[refName]; if (refSchemaObj && refSchemaObj.properties) { for (const [fieldName, fieldSchema] of Object.entries(refSchemaObj.properties)) { if (typeof fieldSchema === 'object' && fieldSchema !== null) { const field = this.parseFieldSchemaWithRefs(fieldName, fieldSchema, refSchemaObj.required || []); businessOp.responseFields.push(field); } } foundSpecificSchema = true; break; } } } else if (subSchema.properties && Object.keys(subSchema.properties).length > 0) { // Encontramos un schema específico con propiedades directas for (const [fieldName, fieldSchema] of Object.entries(subSchema.properties)) { if (typeof fieldSchema === 'object' && fieldSchema !== null) { const field = this.parseFieldSchemaWithRefs(fieldName, fieldSchema, subSchema.required || []); businessOp.responseFields.push(field); } } foundSpecificSchema = true; break; } } // Si no encontramos schema específico, generar campos por contexto if (!foundSpecificSchema) { this.generateContextualResponseFields(operation.operationId || '', businessOp); } } } else { // Si no hay campo response específico, usar todos los campos del schema for (const [fieldName, fieldSchema] of Object.entries(responseSchemaObj.properties)) { if (typeof fieldSchema === 'object' && fieldSchema !== null) { const field = this.parseFieldSchemaWithRefs(fieldName, fieldSchema, responseSchemaObj.required || []); businessOp.responseFields.push(field); } } } } } } businessOperations.push(businessOp); } } } return { name: serviceName, description: description || `Servicio de negocio ${serviceName}`, fields: businessOperations.length > 0 ? businessOperations[0].fields : [], // Para compatibilidad operations, businessOperations // Nueva propiedad con operaciones de negocio }; } getEntitySchema(entityName) { if (!this.openApiDoc?.components?.schemas) { return null; } const schemas = this.openApiDoc.components.schemas; // Buscar el schema base de la entidad const saveSchemaName = `${entityName}Save`; const updateSchemaName = `${entityName}Update`; const baseSchemaName = entityName; // Priorizar el schema que tenga el campo 'id' (normalmente Update o base) let primarySchema = schemas[updateSchemaName] || schemas[baseSchemaName] || schemas[saveSchemaName]; // Si ninguno existe, usar el primero disponible if (!primarySchema) { const availableSchemas = Object.keys(schemas).filter(name => name.includes(entityName) && ('properties' in (schemas[name] || {}))); if (availableSchemas.length > 0) { primarySchema = schemas[availableSchemas[0]]; } } if (!primarySchema || !('properties' in primarySchema)) { return null; } const fields = this.extractFields(primarySchema); const operations = this.analyzeOperations(entityName); return { name: entityName, description: primarySchema.description, fields, operations }; } isEntitySchema(schemaName) { // Excluir schemas que claramente no son entidades const excludePatterns = [ 'Response', 'Error', 'HTTPValidationError', 'ValidationError', 'Pagination', 'MESSAGE_TYPE', 'NOTIFICATION_TYPE' ]; if (excludePatterns.some(pattern => schemaName.includes(pattern))) { return false; } // Incluir schemas que terminan en Save, Update o que están en operaciones CRUD return schemaName.endsWith('Save') || schemaName.endsWith('Update') || this.hasEntityOperations(schemaName); } extractEntityName(schemaName) { // Extraer nombre de la entidad removiendo sufijos if (schemaName.endsWith('Save')) { return schemaName.replace('Save', ''); } if (schemaName.endsWith('Update')) { return schemaName.replace('Update', ''); } // Si es un schema base, verificar si tiene operaciones CRUD if (this.hasEntityOperations(schemaName)) { return schemaName; } return null; } hasEntityOperations(entityName) { if (!this.openApiDoc?.paths) return false; const entityPath = `/${entityName.toLowerCase()}`; const entityPaths = Object.keys(this.openApiDoc.paths).filter(path => path.includes(entityPath) || path.includes(entityName.toLowerCase())); return entityPaths.length > 0; } extractFields(schema) { const fields = []; if (!schema.properties) return fields; for (const [fieldName, fieldSchema] of Object.entries(schema.properties)) { if (typeof fieldSchema === 'boolean') continue; const field = this.parseFieldSchema(fieldName, fieldSchema, schema.required || []); fields.push(field); } return fields; } parseFieldSchema(name, schema, requiredFields) { let type = 'string'; let isArray = false; let isEnum = false; let enumValues; // Manejar arrays let nestedFields; if (schema.type === 'array' && schema.items && typeof schema.items === 'object') { isArray = true; const itemSchema = schema.items; // Si el item es una referencia, obtener el tipo de la referencia if (itemSchema.$ref) { const refName = itemSchema.$ref.split('/').pop(); type = refName || 'any'; // Extraer los campos anidados del elemento del array si es un objeto if (this.openApiDoc?.components?.schemas && refName) { const refSchema = this.openApiDoc.components.schemas[refName]; if (refSchema && refSchema.type === 'object' && refSchema.properties) { nestedFields = []; for (const [propName, propSchema] of Object.entries(refSchema.properties)) { if (typeof propSchema === 'object' && propSchema !== null) { const nestedField = this.parseFieldSchemaWithRefs(propName, propSchema, refSchema.required || []); nestedFields.push(nestedField); } } } } } else { type = this.getTypeFromSchema(itemSchema); } } else { type = this.getTypeFromSchema(schema); } // Manejar enums if (schema.enum) { isEnum = true; enumValues = schema.enum.map(val => String(val)); type = 'enum'; } // Manejar anyOf (generalmente para campos opcionales) if (schema.anyOf) { const nonNullSchema = schema.anyOf.find(s => typeof s === 'object' && !('$ref' in s) && s.type !== 'null'); if (nonNullSchema) { type = this.getTypeFromSchema(nonNullSchema); } } return { name, type, required: requiredFields.includes(name), format: schema.format, description: schema.description, maxLength: schema.maxLength, isArray, isEnum, enumValues, nestedFields }; } getTypeFromSchema(schema) { if (schema.format === 'uuid4') return 'string'; if (schema.format === 'date-time') return 'Date'; if (schema.format === 'date') return 'Date'; switch (schema.type) { case 'string': return 'string'; case 'integer': return 'number'; case 'number': return 'number'; case 'boolean': return 'boolean'; case 'array': return 'any[]'; case 'object': return 'object'; default: return 'any'; } } analyzeOperations(entityName) { if (!this.openApiDoc?.paths) { return { create: false, read: false, update: false, delete: false, list: false }; } const operations = { create: false, read: false, update: false, delete: false, list: false }; const entityLower = entityName.toLowerCase(); const entityKebab = entityName.replace(/([A-Z])/g, '-$1').toLowerCase().substring(1); for (const [path, pathItem] of Object.entries(this.openApiDoc.paths)) { if (!pathItem || typeof pathItem !== 'object') continue; // Buscar paths relacionados con la entidad const pathLower = path.toLowerCase(); if (!pathLower.includes(entityLower) && !pathLower.includes(entityKebab)) { continue; } // Analizar operaciones HTTP if (pathItem.post) operations.create = true; if (pathItem.get && path.includes('{id}')) operations.read = true; if (pathItem.get && path.includes('/list')) operations.list = true; if (pathItem.put) operations.update = true; if (pathItem.delete) operations.delete = true; } return operations; } /** * Detecta el nombre de la API basándose en el título, URL base o paths */ detectApiName() { if (!this.openApiDoc) return null; // 1. Intentar extraer del título if (this.openApiDoc.info?.title) { const title = this.openApiDoc.info.title.toLowerCase(); // Buscar patrones comunes const patterns = [ /(\w+)\s+api/i, // "Platform API" -> "platform" /api\s+(\w+)/i, // "API Platform" -> "platform" /(\w+)\s+backend/i, // "Platform Backend" -> "platform" /(\w+)\s+service/i, // "Platform Service" -> "platform" /(\w+)\s+aws/i, // "Platform AWS" -> "platform" ]; for (const pattern of patterns) { const match = title.match(pattern); if (match && match[1] && match[1] !== 'api') { return match[1].toLowerCase(); } } // Si no hay patrón, usar la primera palabra significativa const words = title.split(/\s+/).filter(word => word.length > 2 && !['api', 'backend', 'service', 'rest'].includes(word.toLowerCase())); if (words.length > 0) { return words[0].toLowerCase(); } } // 2. Intentar extraer de los servers/base URL if (this.openApiDoc.servers && this.openApiDoc.servers.length > 0) { const serverUrl = this.openApiDoc.servers[0].url; const urlMatch = serverUrl.match(/\/api\/(\w+)/i); if (urlMatch) { return urlMatch[1].toLowerCase(); } } // 3. Intentar extraer de los paths más comunes if (this.openApiDoc.paths) { const paths = Object.keys(this.openApiDoc.paths); const pathSegments = paths .flatMap(path => path.split('/')) .filter(segment => segment && segment !== 'api' && !segment.startsWith('{')) .map(segment => segment.toLowerCase()); // Buscar el segmento más común que no sea una entidad const segmentCounts = {}; pathSegments.forEach(segment => { segmentCounts[segment] = (segmentCounts[segment] || 0) + 1; }); const sortedSegments = Object.entries(segmentCounts) .sort(([, a], [, b]) => b - a) .map(([segment]) => segment); // Filtrar nombres de entidades conocidas const entityNames = this.getAvailableEntities().map(e => e.toLowerCase()); const nonEntitySegments = sortedSegments.filter(segment => !entityNames.includes(segment) && segment.length > 2 && !['list', 'create', 'update', 'delete', 'get', 'post', 'put'].includes(segment)); if (nonEntitySegments.length > 0) { return nonEntitySegments[0]; } } return null; } /** * Obtiene el nombre de la API detectado */ getDetectedApiName() { return this.detectedApiName; } /** * Sugiere posibles nombres de API basándose en análisis */ suggestApiNames() { const suggestions = []; if (this.detectedApiName) { suggestions.push(this.detectedApiName); } // Agregar sugerencias comunes const commonApiNames = ['platform', 'api', 'core', 'backend', 'service', 'main']; commonApiNames.forEach(name => { if (!suggestions.includes(name)) { suggestions.push(name); } }); return suggestions; } printEntityInfo(entityName) { const schema = this.getEntitySchema(entityName); if (!schema) { console.log(`❌ No se encontró información para la entidad: ${entityName}`); return; } console.log(`\n📋 Información de la entidad: ${entityName}`); if (schema.description) { console.log(`📝 Descripción: ${schema.description}`); } console.log(`\n🔧 Operaciones disponibles:`); console.log(` • Crear: ${schema.operations.create ? '✅' : '❌'}`); console.log(` • Leer: ${schema.operations.read ? '✅' : '❌'}`); console.log(` • Actualizar: ${schema.operations.update ? '✅' : '❌'}`); console.log(` • Eliminar: ${schema.operations.delete ? '✅' : '❌'}`); console.log(` • Listar: ${schema.operations.list ? '✅' : '❌'}`); console.log(`\n📊 Campos (${schema.fields.length}):`); schema.fields.forEach(field => { const required = field.required ? '🔴' : '🔵'; const array = field.isArray ? '[]' : ''; const description = field.description ? ` - ${field.description}` : ''; console.log(` ${required} ${field.name}: ${field.type}${array}${description}`); }); } /** * Genera campos de respuesta basándose en el contexto de la operación */ generateContextualResponseFields(operationId, businessOp) { const opId = operationId.toLowerCase(); if (opId.includes('login')) { // Para login, típicamente se retorna información del usuario/rol businessOp.responseFields.push({ name: 'user_id', type: 'string', required: false }, { name: 'rol_id', type: 'string', required: false }, { name: 'rol_code', type: 'string', required: false }, { name: 'permissions', type: 'string[]', required: false, isArray: true }, { name: 'token', type: 'string', required: false }, { name: 'expires_at', type: 'string', required: false }); } else if (opId.includes('refresh')) { // Para refresh token businessOp.responseFields.push({ name: 'token', type: 'string', required: false }, { name: 'expires_at', type: 'string', required: false }); } else if (opId.includes('create') && opId.includes('token')) { // Para crear API token businessOp.responseFields.push({ name: 'token', type: 'string', required: false }, { name: 'token_id', type: 'string', required: false }, { name: 'expires_at', type: 'string', required: false }); } else { // Fallback genérico businessOp.responseFields.push({ name: 'data', type: 'any', required: false }); } } /** * Parsea un campo resolviendo referencias $ref */ parseFieldSchemaWithRefs(name, schema, required) { // Si es una referencia, resolverla if (schema.$ref) { const refName = schema.$ref.split('/').pop(); if (this.openApiDoc?.components?.schemas && refName) { const refSchema = this.openApiDoc.components.schemas[refName]; if (refSchema) { // Para enums o tipos simples, usar el nombre de la referencia if (refSchema.enum) { return { name, type: refName, // Usar el nombre del enum required: required.includes(name), isEnum: true, enumValues: refSchema.enum }; } // Para objetos complejos, usar el nombre de la referencia y extraer campos anidados if (refSchema.type === 'object') { let nestedFields; if (refSchema.properties) { nestedFields = []; for (const [propName, propSchema] of Object.entries(refSchema.properties)) { if (typeof propSchema === 'object' && propSchema !== null) { let nestedField; if (propSchema.$ref) { nestedField = this.parseFieldSchemaWithRefs(propName, propSchema, refSchema.required || []); } else if (propSchema.type === 'object' && propSchema.properties) { // Recursividad para objetos anidados inline nestedField = this.parseFieldSchemaWithRefs(propName, propSchema, refSchema.required || []); } else if (propSchema.type === 'array' && propSchema.items) { // Recursividad para arrays anidados nestedField = this.parseFieldSchemaWithRefs(propName, propSchema, refSchema.required || []); } else { nestedField = this.parseFieldSchema(propName, propSchema, refSchema.required || []); if (propSchema.title && nestedField.type === 'object') { nestedField.type = propSchema.title; } } nestedFields.push(nestedField); } } } return { name, type: refName, required: required.includes(name), nestedFields: nestedFields }; } // Para arrays referenciados if (refSchema.type === 'array' && refSchema.items) { const itemField = this.parseFieldSchemaWithRefs(name, refSchema.items, refSchema.required || []); return { name, type: itemField.type, required: required.includes(name), isArray: true, nestedFields: itemField.nestedFields }; } // Para tipos primitivos, usar el tipo base if (refSchema.type) { return { name, type: this.getTypeFromSchema(refSchema), required: required.includes(name) }; } } } } // Si es un objeto con propiedades directas (ya expandido por el parser de Swagger) if (schema.type === 'object' && schema.properties) { const nestedFields = []; for (const [propName, propSchema] of Object.entries(schema.properties)) { if (typeof propSchema === 'object' && propSchema !== null) { let nestedField; if (propSchema.$ref) { nestedField = this.parseFieldSchemaWithRefs(propName, propSchema, schema.required || []); } else if (propSchema.type === 'object' && propSchema.properties) { // Recursividad para objetos anidados inline nestedField = this.parseFieldSchemaWithRefs(propName, propSchema, schema.required || []); } else if (propSchema.type === 'array' && propSchema.items) { // Recursividad para arrays anidados nestedField = this.parseFieldSchemaWithRefs(propName, propSchema, schema.required || []); } else { nestedField = this.parseFieldSchema(propName, propSchema, schema.required || []); if (propSchema.title && nestedField.type === 'object') { nestedField.type = propSchema.title; } } nestedFields.push(nestedField); } } return { name, type: schema.title || 'object', required: required.includes(name), nestedFields: nestedFields }; } // Si es un array, manejar el tipo del elemento y sus nestedFields if (schema.type === 'array' && schema.items) { const itemField = this.parseFieldSchemaWithRefs(name, schema.items, required); return { name, type: itemField.type, required: required.includes(name), isArray: true, nestedFields: itemField.nestedFields }; } // Si no es una referencia, usar el parser normal return this.parseFieldSchema(name, schema, required); } } exports.SwaggerAnalyzer = SwaggerAnalyzer; //# sourceMappingURL=swagger-parser.js.map