diff --git a/package-lock.json b/package-lock.json index bb8dc8706a..79a953cdfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,7 +68,8 @@ "ts-jest": "^29.1.1", "ts-loader": "^9.3.0", "ts-node": "10.9.2", - "typescript": "^5.6.2" + "typescript": "^5.6.2", + "yaml": "2.6.0" }, "engines": { "node": ">=20.9.0" @@ -32487,10 +32488,13 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, "node_modules/yaml": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", - "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", "dev": true, + "bin": { + "yaml": "bin.mjs" + }, "engines": { "node": ">= 14" } diff --git a/package.json b/package.json index d0b4e43fe1..27c16e4997 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "ts-jest": "^29.1.1", "ts-loader": "^9.3.0", "ts-node": "10.9.2", - "typescript": "^5.6.2" + "typescript": "^5.6.2", + "yaml": "2.6.0" }, "scripts": { "postinstall": "npm run bootstrap && npm run reformat && npm run check:peer-deps && npm run check:typescript-project-references", diff --git a/packages/salesforcedx-vscode-apex/src/commands/apexActionController.ts b/packages/salesforcedx-vscode-apex/src/commands/apexActionController.ts index 3cb3e3cef3..48c56396cc 100644 --- a/packages/salesforcedx-vscode-apex/src/commands/apexActionController.ts +++ b/packages/salesforcedx-vscode-apex/src/commands/apexActionController.ts @@ -9,11 +9,10 @@ import { notificationService, workspaceUtils } from '@salesforce/salesforcedx-ut import * as fs from 'fs'; import { OpenAPIV3 } from 'openapi-types'; // Adjust the import path as necessary import * as path from 'path'; -import { QuickPickItem } from 'vscode'; import * as vscode from 'vscode'; -import { nls } from '../messages'; +import { stringify } from 'yaml'; import { getTelemetryService } from '../telemetry/telemetry'; -import { MetadataOrchestrator } from './metadataOrchestrator'; +import { MetadataOrchestrator, MethodMetadata } from './metadataOrchestrator'; export class ApexActionController { constructor( @@ -23,32 +22,8 @@ export class ApexActionController { increment?: number | undefined; }> ) {} - public listApexMethods = (apexClassPath: string): Promise => { - // Read the content of the Apex class file - const fileContent = fs.readFileSync(apexClassPath).toString(); - // Regular expression to match method declarations in Apex - const methodRegExp = /@[\w]+\s*\b(public|private|protected|global)\s+(static\s+)?[\w<>\[\]]+\s+(\w+)\s*\(/g; - - const methods: QuickPickItem[] = []; - let match; - - // Extract all method names that match the regular expression - while ((match = methodRegExp.exec(fileContent)) !== null) { - const methodName = match[3]; - methods.push({ - label: methodName, - description: apexClassPath - }); - } - - // Sort the methods alphabetically by name - methods.sort((a, b) => a.label.localeCompare(b.label)); - - return Promise.resolve(methods); - }; - - public createApexActionFromMethod = async (methodIdentifier: string): Promise => { + public createApexActionFromMethod = async (): Promise => { const telemetryService = await getTelemetryService(); const progressReporter: Progress = { report: value => { @@ -58,32 +33,46 @@ export class ApexActionController { } }; try { - // Step 1: Validate Method - if (!this.isMethodEligible(methodIdentifier)) { + // // Step 0: Validate Method + // if (!this.isMethodEligible(methodIdentifier)) { + // void notificationService.showErrorMessage( + // '`Method ${methodIdentifier} is not eligible for Apex Action creation.`' + // ); + // throw new Error(`Method ${methodIdentifier} is not eligible for Apex Action creation.`); + // } + + // Step 1: Extract Metadata + progressReporter.report({ message: 'Extracting metadata.' }); + const metadata = this.metadataOrchestrator.extractMethodMetadata(); + if (!metadata) { + void notificationService.showErrorMessage('Failed to extract metadata from selected method.'); + throw new Error('Failed to extract metadata from selected method.'); + } + + // Step 2: Validate Method + if (!this.metadataOrchestrator.validateAuraEnabledMethod(metadata.isAuraEnabled)) { void notificationService.showErrorMessage( - '`Method ${methodIdentifier} is not eligible for Apex Action creation.`' + `Method ${metadata.name} is not eligible for Apex Action creation. It is NOT annotated with @AuraEnabled.` + ); + throw new Error( + `Method ${metadata.name} is not eligible for Apex Action creation. It is NOT annotated with @AuraEnabled.` ); - throw new Error(`Method ${methodIdentifier} is not eligible for Apex Action creation.`); } - // Step 2: Extract Metadata - progressReporter.report({ message: 'Extracting metadata.' }); - const metadata = await this.metadataOrchestrator.extractMethodMetadata(methodIdentifier); - // Step 3: Generate OpenAPI Document progressReporter.report({ message: 'Generating OpenAPI document.' }); const openApiDocument = this.generateOpenAPIDocument(metadata); // Step 4: Write OpenAPI Document to File - const openApiFilePath = `${methodIdentifier}_openapi.json`; + const openApiFilePath = `${metadata.name}_openapi.yml`; await this.saveDocument(openApiFilePath, openApiDocument); // Step 6: Notify Success - notificationService.showInformationMessage(`Apex Action created for method: ${methodIdentifier}`); - telemetryService.sendEventData('ApexActionCreated', { method: methodIdentifier }); + notificationService.showInformationMessage(`Apex Action created for method: ${metadata.name}.`); + telemetryService.sendEventData('ApexActionCreated', { method: metadata.name }); } catch (error) { // Error Handling - notificationService.showErrorMessage(`Failed to create Apex Action: ${error.message}`); + notificationService.showErrorMessage(`Failed to create Apex Action: ${error.message}.`); telemetryService.sendException('ApexActionCreationFailed', error); throw error; } @@ -100,26 +89,38 @@ export class ApexActionController { fs.mkdirSync(openAPIdocumentsPath); } const saveLocation = path.join(openAPIdocumentsPath, fileName); - fs.writeFileSync(saveLocation, JSON.stringify(content)); + fs.writeFileSync(saveLocation, content); await vscode.workspace.openTextDocument(saveLocation).then((newDocument: any) => { void vscode.window.showTextDocument(newDocument); }); }; - public generateOpenAPIDocument = (metadata: any): OpenAPIV3.Document => { + public generateOpenAPIDocument = (metadata: MethodMetadata): string => { // Placeholder for OpenAPI generation logic - return { + // ProgressNotification.show(execution, cancellationTokenSource); + const openAPIDocument: OpenAPIV3.Document = { openapi: '3.0.0', info: { title: 'Apex Actions', version: '1.0.0' }, paths: { - [`/apex/${metadata}`]: { + [`/apex/${metadata.name}`]: { post: { - summary: `Invoke ${metadata}`, - operationId: metadata, - responses: { 200: { description: 'Success' } } + operationId: metadata.name, + summary: `Invoke ${metadata.name}`, + parameters: metadata.parameters as unknown as (OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject)[], + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { schema: { type: metadata.returnType as OpenAPIV3.NonArraySchemaObjectType } } + } + } + } } } } }; + + // Convert the OpenAPI document to YAML + return stringify(openAPIDocument); }; } diff --git a/packages/salesforcedx-vscode-apex/src/commands/createApexAction.ts b/packages/salesforcedx-vscode-apex/src/commands/createApexAction.ts index 5848886c6f..16989f0428 100644 --- a/packages/salesforcedx-vscode-apex/src/commands/createApexAction.ts +++ b/packages/salesforcedx-vscode-apex/src/commands/createApexAction.ts @@ -4,74 +4,13 @@ * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import { notificationService } from '@salesforce/salesforcedx-utils-vscode'; -import { readFileSync } from 'fs'; -import * as vscode from 'vscode'; import { ApexActionController } from './apexActionController'; import { MetadataOrchestrator } from './metadataOrchestrator'; const metadataOrchestrator = new MetadataOrchestrator(); const controller = new ApexActionController(metadataOrchestrator); -const validateAuraEnabledMethod = async ( - filePath: string, - cursorPosition: vscode.Position, - selectedMethod: string -): Promise => { - const lineNumber = cursorPosition.line; - // Read the content of the Apex class file - const fileContent = readFileSync(filePath, 'utf-8'); - const lines = fileContent.split('\n'); - - // Start from the current line and search upward for the method declaration - for (let i = lineNumber; i >= 0; i--) { - const line = lines[i].trim(); - - if (line.includes(selectedMethod)) continue; - // Check if the line contains @AuraEnabled - if (line.includes('@AuraEnabled')) { - return; - } - - // Check if the line contains a method declaration (regex matches methods) - const methodRegex = /\b(public|private|protected|global)\s+(static\s+)?[\w<>\[\]]+\s+\w+\s*\(/; - if (methodRegex.test(line)) { - notificationService.showWarningMessage(`The method "${selectedMethod}" is NOT annotated with @AuraEnabled.`); - throw Error(`The method "${selectedMethod}" is NOT annotated with @AuraEnabled.`); - } - } -}; - -export const createApexActionFromMethod = async (methodIdentifier: any): Promise => { - // Step 1: Prompt User to Select a Method - // const selectedMethod = await controller.listApexMethods(); - const editor = vscode.window.activeTextEditor; - if (!editor) { - notificationService.showErrorMessage('No active editor detected'); - throw Error('No active editor detected'); - } - - const document = editor.document; - let selectedMethod; - const cursorPosition = editor.selection.active; // Get cursor position - const lineText = document.lineAt(cursorPosition.line).text.trim(); // Get the line content - - // Regular expression to match a method declaration and extract its name - const methodRegex = /\b(public|private|protected|global)\s+(static\s+)?[\w<>\[\]]+\s+(\w+)\s*\(/; - - const match = methodRegex.exec(lineText); - if (match) { - selectedMethod = match[3]; // The third capture group is the method name - } - - if (!selectedMethod) { - notificationService.showErrorMessage('No method selected'); - return; - } - - const filePath = methodIdentifier.path; - await validateAuraEnabledMethod(filePath, cursorPosition, selectedMethod); - - // Step 2: Call Controller - await controller.createApexActionFromMethod(selectedMethod); +export const createApexActionFromMethod = async (): Promise => { + // Call Controller + await controller.createApexActionFromMethod(); }; diff --git a/packages/salesforcedx-vscode-apex/src/commands/metadataOrchestrator.ts b/packages/salesforcedx-vscode-apex/src/commands/metadataOrchestrator.ts index 36de850bff..f8b287d30c 100644 --- a/packages/salesforcedx-vscode-apex/src/commands/metadataOrchestrator.ts +++ b/packages/salesforcedx-vscode-apex/src/commands/metadataOrchestrator.ts @@ -4,25 +4,109 @@ * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ +import { notificationService } from '@salesforce/salesforcedx-utils-vscode'; +import * as vscode from 'vscode'; +export interface MethodMetadata { + name: string; + parameters: Parameter[]; + returnType: string; + isAuraEnabled: boolean; +} +export interface Parameter { + name: string; + in: string; + required: boolean; + description: string; + schema: { type: string }; +} export class MetadataOrchestrator { constructor() { // Initialization code here } - public async orchestrate(): Promise { - try { - // Orchestration logic here - } catch (error) { - console.error('Error during orchestration:', error); + public extractMethodMetadata = (): MethodMetadata | undefined => { + const editor = vscode.window.activeTextEditor; + if (!editor) { + notificationService.showErrorMessage('No active editor detected.'); + return; } - } - public async extractMethodMetadata(methodIdentifier: any): Promise { - try { - // Logic to extract method metadata here - return methodIdentifier; - } catch (error) { - console.error('Error extracting method metadata:', error); + + const document = editor.document; + const cursorPosition = editor.selection.active; + const lines = document.getText().split('\n'); + const currentLineIndex = cursorPosition.line; + + let methodSignature = ''; + let isAuraEnabled = false; + + // Check if the preceding line contains @AuraEnabled + if (currentLineIndex > 0 && lines[currentLineIndex - 1].includes('@AuraEnabled')) { + isAuraEnabled = true; } - return undefined; - } + + // Traverse lines starting from the cursor position to construct the method signature + for (let i = currentLineIndex; i < lines.length; i++) { + const line = lines[i].trim(); + methodSignature += ` ${line}`; + + // Stop once the closing parenthesis is reached + if (line.includes(')')) { + break; + } + } + + if (!methodSignature) { + notificationService.showWarningMessage('No valid method found at cursor position.'); + return; + } + + // Parse the method signature + const methodRegex = /\b(public|private|protected|global)\s+(static\s+)?([\w<>\[\]]+)\s+(\w+)\s*\((.*?)\)/s; + const match = methodRegex.exec(methodSignature); + if (!match) { + notificationService.showWarningMessage('Failed to parse method signature.'); + throw Error('Failed to parse method signature.'); + } + const returnType = match[3]; + const methodName = match[4]; + const parametersRaw = match[5] ? match[5].split(',').map(param => param.trim()) : []; + const parameters = parametersRaw.map(param => { + const [type, name] = param.split(/\s+/); + return { + name, + in: 'query', + required: true, + description: `The ${name} parameter of type ${type}.`, + schema: { type: this.mapApexTypeToJsonType(type) } + }; + }); + return { + name: methodName, + parameters, + returnType, + isAuraEnabled + }; + }; + private mapApexTypeToJsonType = (apexType: string): string => { + switch (apexType.toLowerCase()) { + case 'string': + return 'string'; + case 'integer': + case 'int': + case 'long': + return 'integer'; + case 'boolean': + return 'boolean'; + case 'decimal': + case 'double': + case 'float': + return 'number'; + default: + return 'string'; + } + }; + + public validateAuraEnabledMethod = (isAuraEnabled: boolean): boolean => { + return isAuraEnabled; + }; }