diff --git a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruAuthService.ts b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruAuthService.ts index b92caf75..349a404f 100644 --- a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruAuthService.ts +++ b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruAuthService.ts @@ -191,4 +191,43 @@ export class ApexGuruAuthService { } return await this.mintOrgJwt(); } + + /** + * Perform HTTP request using fetch with Authorization Bearer token + * @param method - HTTP method (GET, POST, etc.) + * @param path - API path (e.g., '/services/data/v64.0/apexguru/validate') + * @param body - Optional request body + * @returns Promise - Parsed JSON response + * @throws Error if request fails or returns non-200 status + */ + async curlRequest(method: string, path: string, body?: unknown): Promise { + try { + const url = `${this.getInstanceUrl()}${path}`; + const accessToken = this.getAccessToken(); + + const response = await fetch(url, { + method, + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + body: body ? JSON.stringify(body) : undefined + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `HTTP ${response.status} ${response.statusText} at ${path}: ${errorText}` + ); + } + + return await response.json() as T; + } catch (error) { + if (error instanceof Error && error.message.startsWith('HTTP ')) { + throw error; + } + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Request failed for ${path}: ${errorMessage}`); + } + } } diff --git a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts index 2667b115..0a332529 100644 --- a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts +++ b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts @@ -1,6 +1,5 @@ -import { Connection } from '@salesforce/core'; import { LogLevel } from '@salesforce/code-analyzer-engine-api'; import { ApexGuruAuthService } from './ApexGuruAuthService'; import { @@ -110,14 +109,10 @@ export class ApexGuruService { * Internal validate implementation (without timeout wrapper) */ private async performValidate(): Promise { - const connection: Connection = this.authService.getConnection(); const apiVersion = this.authService.getApiVersion(); const url = `/services/data/v${apiVersion}/apexguru/validate`; - const response = await connection.request({ - method: 'GET', - url - }) as { status?: string }; + const response: { status?: string } = await (this.authService as any).curlRequest('GET', url); if (response.status && response.status.toLowerCase() === ApexGuruResponseStatus.SUCCESS) { return; @@ -167,7 +162,6 @@ export class ApexGuruService { * Submit Apex class for analysis */ private async submitAnalysis(classContent: string): Promise { - const connection: Connection = this.authService.getConnection(); const apiVersion = this.authService.getApiVersion(); const url = `/services/data/v${apiVersion}/apexguru/request`; @@ -175,12 +169,7 @@ export class ApexGuruService { const requestBody = { classContent: base64Content }; try { - const response: ApexGuruInitialResponse = await connection.request({ - method: 'POST', - url, - body: JSON.stringify(requestBody), - headers: { 'Content-Type': 'application/json' } - }); + const response: ApexGuruInitialResponse = await (this.authService as any).curlRequest('POST', url, requestBody); // Normalize status to lowercase if (response.status) { @@ -207,7 +196,6 @@ export class ApexGuruService { * Note: Timeout is handled by analyzeApexClass wrapper, not here */ private async pollForResults(requestId: string): Promise<{violations: ApexGuruViolation[], scanMetadata?: ApexGuruScanMetadata}> { - const connection: Connection = this.authService.getConnection(); const apiVersion = this.authService.getApiVersion(); const url = requestId === 'pending' ? `/services/data/v${apiVersion}/apexguru/request` @@ -234,10 +222,7 @@ export class ApexGuruService { this.progressCallback(asymptoticProgress); } - const response: ApexGuruQueryResponse = await connection.request({ - method: 'GET', - url - }); + const response: ApexGuruQueryResponse = await (this.authService as any).curlRequest('GET', url); // Normalize status if (response.status) { diff --git a/packages/code-analyzer-apexguru-engine/test/ApexGuruAuthService.test.ts b/packages/code-analyzer-apexguru-engine/test/ApexGuruAuthService.test.ts index c8fe8c35..dae83b22 100644 --- a/packages/code-analyzer-apexguru-engine/test/ApexGuruAuthService.test.ts +++ b/packages/code-analyzer-apexguru-engine/test/ApexGuruAuthService.test.ts @@ -118,4 +118,84 @@ describe('ApexGuruAuthService', () => { // - Should mint new JWT if not cached // - Should return cached JWT if available }); + + describe('curlRequest', () => { + beforeEach(async () => { + const mockOrg = { + getConnection: jest.fn().mockReturnValue(mockConnection) + }; + (Org.create as jest.Mock).mockResolvedValue(mockOrg); + await authService.initialize({ targetOrg: 'myorg' }); + }); + + it('should perform GET request with correct headers and URL', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ status: 'success' }) + }); + global.fetch = mockFetch as any; + + const result = await (authService as any).curlRequest('GET', '/services/data/v64.0/apexguru/validate'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://test.salesforce.com/services/data/v64.0/apexguru/validate', + { + method: 'GET', + headers: { + 'Authorization': 'Bearer mock_access_token', + 'Content-Type': 'application/json' + }, + body: undefined + } + ); + expect(result).toEqual({ status: 'success' }); + }); + + it('should perform POST request with body', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ requestId: '12345' }) + }); + global.fetch = mockFetch as any; + + const requestBody = { classContent: 'base64string' }; + const result = await (authService as any).curlRequest('POST', '/services/data/v64.0/apexguru/request', requestBody); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://test.salesforce.com/services/data/v64.0/apexguru/request', + { + method: 'POST', + headers: { + 'Authorization': 'Bearer mock_access_token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody) + } + ); + expect(result).toEqual({ requestId: '12345' }); + }); + + it('should throw error for non-200 response', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: jest.fn().mockResolvedValue('Invalid token') + }); + global.fetch = mockFetch as any; + + await expect((authService as any).curlRequest('GET', '/services/data/v64.0/apexguru/validate')) + .rejects + .toThrow('HTTP 401 Unauthorized at /services/data/v64.0/apexguru/validate: Invalid token'); + }); + + it('should handle network errors', async () => { + const mockFetch = jest.fn().mockRejectedValue(new Error('Network failure')); + global.fetch = mockFetch as any; + + await expect((authService as any).curlRequest('GET', '/services/data/v64.0/apexguru/validate')) + .rejects + .toThrow('Request failed for /services/data/v64.0/apexguru/validate: Network failure'); + }); + }); }); diff --git a/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts b/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts index 0cfc0330..45c23917 100644 --- a/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts +++ b/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts @@ -29,7 +29,8 @@ describe('ApexGuruService', () => { getAccessToken: jest.fn().mockReturnValue('test-token'), getInstanceUrl: jest.fn().mockReturnValue('https://test.salesforce.com'), getApiVersion: jest.fn().mockReturnValue('64.0'), - mintOrgJwt: jest.fn().mockResolvedValue('mock-jwt-token') + mintOrgJwt: jest.fn().mockResolvedValue('mock-jwt-token'), + curlRequest: jest.fn() } as any; jest.mocked(ApexGuruAuthService).mockImplementation(() => mockAuthService); @@ -59,20 +60,20 @@ describe('ApexGuruService', () => { describe('validate', () => { it('should succeed when validation returns success status', async () => { - (mockConnection.request as jest.Mock).mockResolvedValue({ + mockAuthService.curlRequest.mockResolvedValue({ status: ApexGuruResponseStatus.SUCCESS }); await expect(apexGuruService.validate()).resolves.toBeUndefined(); - expect(mockConnection.request).toHaveBeenCalledWith({ - method: 'GET', - url: '/services/data/v64.0/apexguru/validate' - }); + expect(mockAuthService.curlRequest).toHaveBeenCalledWith( + 'GET', + '/services/data/v64.0/apexguru/validate' + ); }); it('should succeed for uppercase SUCCESS status', async () => { - (mockConnection.request as jest.Mock).mockResolvedValue({ + mockAuthService.curlRequest.mockResolvedValue({ status: 'SUCCESS' }); @@ -80,7 +81,7 @@ describe('ApexGuruService', () => { }); it('should throw error when validation fails', async () => { - (mockConnection.request as jest.Mock).mockResolvedValue({ + mockAuthService.curlRequest.mockResolvedValue({ status: ApexGuruResponseStatus.FAILED }); @@ -89,7 +90,7 @@ describe('ApexGuruService', () => { }); it('should throw error on network failure', async () => { - (mockConnection.request as jest.Mock).mockRejectedValue(new Error('Network error')); + mockAuthService.curlRequest.mockRejectedValue(new Error('Network error')); await expect(apexGuruService.validate()) .rejects.toThrow('Network error'); @@ -98,7 +99,7 @@ describe('ApexGuruService', () => { it('should throw timeout error when validation takes too long', async () => { jest.useFakeTimers(); - (mockConnection.request as jest.Mock).mockImplementation(() => + mockAuthService.curlRequest.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve({ status: ApexGuruResponseStatus.SUCCESS }), 200000)) ); @@ -128,13 +129,13 @@ describe('ApexGuruService', () => { }]; // Mock submit response - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.NEW, requestId: mockRequestId }); // Mock poll response with success - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.SUCCESS, report: Buffer.from(JSON.stringify(mockViolations)).toString('base64') }); @@ -143,7 +144,7 @@ describe('ApexGuruService', () => { expect(result.violations).toEqual(mockViolations); expect(result.scanMetadata).toBeUndefined(); - expect(mockConnection.request).toHaveBeenCalledTimes(2); + expect(mockAuthService.curlRequest).toHaveBeenCalledTimes(2); }); it('should return scanMetadata when API response includes it', async () => { @@ -163,12 +164,12 @@ describe('ApexGuruService', () => { report_generated_ms: 1234567890 }; - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.NEW, requestId: 'req-123' }); - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.SUCCESS, report: Buffer.from(JSON.stringify(mockViolations)).toString('base64'), scanMetadata: mockScanMetadata @@ -181,34 +182,34 @@ describe('ApexGuruService', () => { }); it('should submit base64 encoded content', async () => { - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.NEW, requestId: 'req-123' }); - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.SUCCESS, report: Buffer.from(JSON.stringify([])).toString('base64') }); await apexGuruService.analyzeApexClass(testClassContent, testFilePath); - const submitCall = (mockConnection.request as jest.Mock).mock.calls[0][0]; - expect(submitCall.method).toBe('POST'); - expect(submitCall.url).toBe('/services/data/v64.0/apexguru/request'); + const submitCall = mockAuthService.curlRequest.mock.calls[0]; + expect(submitCall[0]).toBe('POST'); + expect(submitCall[1]).toBe('/services/data/v64.0/apexguru/request'); - const body = JSON.parse(submitCall.body); + const body = submitCall[2] as { classContent: string }; expect(body.classContent).toBe(Buffer.from(testClassContent).toString('base64')); }); it('should poll multiple times until success', async () => { - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.NEW, requestId: 'req-123' }); // First poll returns "new", second returns success - (mockConnection.request as jest.Mock) + mockAuthService.curlRequest .mockResolvedValueOnce({ status: ApexGuruResponseStatus.NEW }) .mockResolvedValueOnce({ status: ApexGuruResponseStatus.SUCCESS, @@ -217,18 +218,18 @@ describe('ApexGuruService', () => { await apexGuruService.analyzeApexClass(testClassContent, testFilePath); - expect(mockConnection.request).toHaveBeenCalledTimes(3); // 1 submit + 2 polls + expect(mockAuthService.curlRequest).toHaveBeenCalledTimes(3); // 1 submit + 2 polls }, 15000); it('should handle immediate success response', async () => { const mockViolations = [{ rule: 'Test', message: 'test', locations: [{ startLine: 1 }], primaryLocationIndex: 0, resources: [], severity: 1 }]; - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.SUCCESS, requestId: 'req-123' }); - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.SUCCESS, report: Buffer.from(JSON.stringify(mockViolations)).toString('base64') }); @@ -239,7 +240,7 @@ describe('ApexGuruService', () => { }); it('should throw error when analysis fails', async () => { - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.FAILED, message: 'Analysis failed' }); @@ -249,12 +250,12 @@ describe('ApexGuruService', () => { }); it('should throw error on poll failure', async () => { - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.NEW, requestId: 'req-123' }); - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.FAILED, message: 'Processing failed' }); @@ -264,12 +265,12 @@ describe('ApexGuruService', () => { }); it('should throw error on poll error status', async () => { - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.NEW, requestId: 'req-123' }); - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.ERROR, message: 'Internal error' }); @@ -285,12 +286,12 @@ describe('ApexGuruService', () => { const progressCallback = jest.fn(); apexGuruService.setProgressCallback(progressCallback); - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.NEW, requestId: 'req-123' }); - (mockConnection.request as jest.Mock) + mockAuthService.curlRequest .mockResolvedValueOnce({ status: ApexGuruResponseStatus.NEW }) .mockResolvedValueOnce({ status: ApexGuruResponseStatus.SUCCESS, @@ -323,12 +324,12 @@ describe('ApexGuruService', () => { } ]; - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.NEW, requestId: 'req-123' }); - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.SUCCESS, report: Buffer.from(JSON.stringify(mockViolations)).toString('base64') }); @@ -344,13 +345,13 @@ describe('ApexGuruService', () => { jest.useFakeTimers(); // Mock submit response - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.NEW, requestId: 'req-123' }); // Mock never-ending polling (keeps returning "processing") - (mockConnection.request as jest.Mock).mockImplementation(() => + mockAuthService.curlRequest.mockImplementation(() => new Promise(resolve => { setTimeout(() => resolve({ status: ApexGuruResponseStatus.NEW }), 100); }) @@ -389,12 +390,12 @@ describe('ApexGuruService', () => { report_generated_ms: 1234567890 }; - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.NEW, requestId: 'req-123' }); - (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + mockAuthService.curlRequest.mockResolvedValueOnce({ status: ApexGuruResponseStatus.SUCCESS, report: Buffer.from(JSON.stringify(mockViolations)).toString('base64'), scanMetadata: mockScanMetadata @@ -410,6 +411,90 @@ describe('ApexGuruService', () => { }); }); + describe('curl integration', () => { + it('should use curlRequest for all ApexGuru API calls', async () => { + const mockViolations = [{ + rule: 'TestRule', + message: 'test', + locations: [{ startLine: 1 }], + primaryLocationIndex: 0, + resources: [], + severity: 1 + }]; + + // Mock validate + mockAuthService.curlRequest.mockResolvedValueOnce({ + status: 'success' + }); + + // Mock submit + mockAuthService.curlRequest.mockResolvedValueOnce({ + status: 'new', + requestId: 'req-123' + }); + + // Mock poll + mockAuthService.curlRequest.mockResolvedValueOnce({ + status: 'success', + report: Buffer.from(JSON.stringify(mockViolations)).toString('base64') + }); + + await apexGuruService.validate(); + const result = await apexGuruService.analyzeApexClass('public class Test {}', 'Test.cls'); + + // Verify curlRequest was called for all operations (validate, submit, poll) + expect(mockAuthService.curlRequest).toHaveBeenCalledTimes(3); + + // Verify validate call + expect(mockAuthService.curlRequest).toHaveBeenNthCalledWith( + 1, + 'GET', + '/services/data/v64.0/apexguru/validate' + ); + + // Verify submit call + expect(mockAuthService.curlRequest).toHaveBeenNthCalledWith( + 2, + 'POST', + '/services/data/v64.0/apexguru/request', + expect.objectContaining({ + classContent: Buffer.from('public class Test {}').toString('base64') + }) + ); + + // Verify poll call + expect(mockAuthService.curlRequest).toHaveBeenNthCalledWith( + 3, + 'GET', + '/services/data/v64.0/apexguru/request/req-123' + ); + + // Verify result + expect(result.violations).toEqual(mockViolations); + }); + + it('should pass base64-encoded content in POST body', async () => { + const classContent = 'public class MyClass { void method() {} }'; + + mockAuthService.curlRequest.mockResolvedValueOnce({ + status: 'new', + requestId: 'req-123' + }); + + mockAuthService.curlRequest.mockResolvedValueOnce({ + status: 'success', + report: Buffer.from(JSON.stringify([])).toString('base64') + }); + + await apexGuruService.analyzeApexClass(classContent, 'MyClass.cls'); + + // Verify base64 encoding in submit call + const submitCall = mockAuthService.curlRequest.mock.calls[0]; + const requestBody = submitCall[2] as { classContent: string }; + expect(requestBody.classContent).toBe(Buffer.from(classContent, 'utf-8').toString('base64')); + }); + }); + describe('cleanup', () => { it('should not throw error', () => { expect(() => apexGuruService.cleanup()).not.toThrow();