Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NEW (Extension) @W-16096256@ Send class content as request to ApexGuru and get requestId back #114

Merged
merged 4 commits into from
Aug 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src/apexguru/apex-guru-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2024, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import * as vscode from 'vscode';
import * as fspromises from 'fs/promises';
import { CoreExtensionService } from '../lib/core-extension-service';
import * as Constants from '../lib/constants';

export async function isApexGuruEnabledInOrg(outputChannel: vscode.LogOutputChannel): Promise<boolean> {
try {
const connection = await CoreExtensionService.getConnection();
const response:ApexGuruAuthResponse = await connection.request({
method: 'GET',
url: Constants.APEX_GURU_AUTH_ENDPOINT,
body: ''
});
return response.status == 'Success';
} catch(e) {
// This could throw an error for a variety of reasons. The API endpoint has not been deployed to the instance, org has no perms, timeouts etc,.
// In all of these scenarios, we return false.
const errMsg = e instanceof Error ? e.message : e as string;
outputChannel.error('***ApexGuru perm check failed with error:***' + errMsg);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed! I would like to follow the pattern that IDX team follows that helps with i18n as well. I am not doing it as part of this. But definitely worth doing it when the 5.x integration work happens in the extension.

outputChannel.show();
return false;
}
}

export async function runApexGuruOnFile(selection: vscode.Uri, outputChannel: vscode.LogOutputChannel) {
try {
const requestId = await initiateApexGuruRequest(selection, outputChannel);
// TODO: Logging the request Id for easy QA. Future stories will use this requestId to poll and retrieve the Apex Guru report.
outputChannel.appendLine('***Apex Guru request Id:***' + requestId);
} catch (e) {
const errMsg = e instanceof Error ? e.message : e as string;
outputChannel.appendLine('***Apex Guru initiate request failed***');
outputChannel.appendLine(errMsg);
}
}

export async function initiateApexGuruRequest(selection: vscode.Uri, outputChannel: vscode.LogOutputChannel): Promise<string> {
const fileContent = await fileSystem.readFile(selection.fsPath);
const base64EncodedContent = Buffer.from(fileContent).toString('base64');
const connection = await CoreExtensionService.getConnection();
const response: ApexGuruInitialResponse = await connection.request({
method: 'POST',
url: Constants.APEX_GURU_REQUEST,
body: JSON.stringify({
classContent: base64EncodedContent
})
});
Comment on lines +48 to +54
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like you are assuming that the response will always be of type ApexGuruInitialResponse. But is it possible that you do not get a response back that has a status field?
Besides the fact that this request method is template, the other reason why you aren't getting a compiler error here from typescript is because you put this type in a d.ts file which is unchecked when compiled.

I suggest you move your ApexGuruInitialResponse and other types into typescript officially (in a .ts - not a .d.ts file) to be compiled, then this will complain unless you cast with "as ApexGuruInitialResponse" to the request output.

But do we really want to do this casting? I would guess that you would want to first check the status code of the response. But now I see that the request method is templated so it is inferring the output type.

Do we know if the response object universally as something like a response code that can be used? Typically when making http requests, we validate the response to see if it is a 2** or a 4** or a 5** before we try to parse the response body.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved the ApexGuruInitialResponse out out of the d.ts file. I noticed we don't get 2**/4** responses as part of the response. I'll bring this up with the ApexGuru team tomorrow in the meeting. I hope it should be easy fix for them. Btw, the connection request also does not give the status codes. So, I'll have to leave this as is for now.


if (response.status != 'new' && response.status != 'success') {
outputChannel.warn('***Apex Guru returned unexpected response:***' + response.status);
throw Error('***Apex Guru returned unexpected response:***' + response.status);
}

const requestId = response.requestId;
return requestId;
}

export const fileSystem = {
readFile: (path: string) => fspromises.readFile(path, 'utf8')
};

export type ApexGuruAuthResponse = {
status: string;
}

export type ApexGuruInitialResponse = {
status: string;
requestId: string;
message: string;
}

export type ApexGuruQueryResponse = {
status: string;
message: string;
report: string;
}

export type ApexGuruProperty = {
name: string;
value: string;
};

export type ApexGuruReport = {
id: string;
type: string;
value: string;
properties: ApexGuruProperty[];
}
24 changes: 3 additions & 21 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ import {ScanRunner} from './lib/scanner';
import {SettingsManager} from './lib/settings';
import {SfCli} from './lib/sf-cli';

import {RuleResult, ApexGuruAuthResponse} from './types';
import {RuleResult} from './types';
import {DiagnosticManager} from './lib/diagnostics';
import {messages} from './lib/messages';
import {Fixer} from './lib/fixer';
import { CoreExtensionService, TelemetryService } from './lib/core-extension-service';
import * as Constants from './lib/constants';
import * as path from 'path';
import { SIGKILL } from 'constants';
import * as ApexGuruFunctions from './apexguru/apex-guru-service'

export type RunInfo = {
diagnosticCollection?: vscode.DiagnosticCollection;
Expand Down Expand Up @@ -53,7 +54,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<vscode
await CoreExtensionService.loadDependencies(context, outputChannel);

// Set the necessary flags to control showing the command
await vscode.commands.executeCommand('setContext', 'sfca.apexGuruEnabled', Constants.APEX_GURU_FEATURE_FLAG_ENABLED && await _isApexGuruEnabledInOrg());
await vscode.commands.executeCommand('setContext', 'sfca.apexGuruEnabled', Constants.APEX_GURU_FEATURE_FLAG_ENABLED && await ApexGuruFunctions.isApexGuruEnabledInOrg(outputChannel));

// Define a diagnostic collection in the `activate()` scope so it can be used repeatedly.
diagnosticCollection = vscode.languages.createDiagnosticCollection('sfca');
Expand Down Expand Up @@ -141,25 +142,6 @@ export async function activate(context: vscode.ExtensionContext): Promise<vscode
return Promise.resolve(context);
}

export async function _isApexGuruEnabledInOrg(): Promise<boolean> {
try {
const connection = await CoreExtensionService.getConnection();
const response:ApexGuruAuthResponse = await connection.request({
method: 'GET',
url: Constants.APEX_GURU_AUTH_ENDPOINT,
body: ''
});
return response.status == 'Success';
} catch(e) {
// This could throw an error for a variety of reasons. The API endpoint has not been deployed to the instance, org has no perms, timeouts etc,.
// In all of these scenarios, we return false.
const errMsg = e instanceof Error ? e.message : e as string;
outputChannel.error('***ApexGuru perm check failed with error:***' + errMsg);
outputChannel.show();
return false;
}
}

async function _runDfa(context: vscode.ExtensionContext) {
if (violationsCacheExists()) {
const choice = await vscode.window.showQuickPick(
Expand Down
1 change: 1 addition & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const WORKSPACE_DFA_PROCESS = 'dfaScanProcess';

// apex guru APIS
export const APEX_GURU_AUTH_ENDPOINT = '/services/data/v62.0/apexguru/validate'
export const APEX_GURU_REQUEST = '/services/data/v62.0/apexguru/request'

// feature gates
export const APEX_GURU_FEATURE_FLAG_ENABLED = false;
149 changes: 149 additions & 0 deletions src/test/suite/apexguru/apex-guru-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2024, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import {expect} from 'chai';
import Sinon = require('sinon');
import {CoreExtensionService} from '../../../lib/core-extension-service';
import * as Constants from '../../../lib/constants';
import * as ApexGuruFunctions from '../../../apexguru/apex-guru-service'

// You can import and use all API from the 'vscode' module
// as well as import your extension to test it
import * as vscode from 'vscode';

suite('Apex Guru Test Suite', () => {
let outputChannel = vscode.window.createOutputChannel('Salesforce Code Analyzer', {log: true});

suite('#_isApexGuruEnabledInOrg', () => {
let getConnectionStub: Sinon.SinonStub;
let requestStub: Sinon.SinonStub;

setup(() => {
getConnectionStub = Sinon.stub(CoreExtensionService, 'getConnection');
requestStub = Sinon.stub();
});

teardown(() => {
Sinon.restore();
});

test('Returns true if response status is Success', async () => {
// ===== SETUP =====
getConnectionStub.resolves({
request: requestStub.resolves({ status: 'Success' })
});

// ===== TEST =====
const result = await ApexGuruFunctions.isApexGuruEnabledInOrg(outputChannel);

// ===== ASSERTIONS =====
expect(result).to.be.true;
Sinon.assert.calledOnce(getConnectionStub);
Sinon.assert.calledOnce(requestStub);
Sinon.assert.calledWith(requestStub, {
method: 'GET',
url: Constants.APEX_GURU_AUTH_ENDPOINT,
body: ''
});
});

test('Returns false if response status is not Success', async () => {
// ===== SETUP =====
getConnectionStub.resolves({
request: requestStub.resolves({ status: 'Failure' })
});

// ===== TEST =====
const result = await ApexGuruFunctions.isApexGuruEnabledInOrg(outputChannel);

// ===== ASSERTIONS =====
expect(result).to.be.false;
Sinon.assert.calledOnce(getConnectionStub);
Sinon.assert.calledOnce(requestStub);
Sinon.assert.calledWith(requestStub, {
method: 'GET',
url: Constants.APEX_GURU_AUTH_ENDPOINT,
body: ''
});
});

test('Returns false if an error is thrown', async () => {
// ===== SETUP =====
getConnectionStub.resolves({
request: requestStub.rejects(new Error('Resource not found'))
});

// ===== TEST =====
const result = await ApexGuruFunctions.isApexGuruEnabledInOrg(outputChannel);

// ===== ASSERTIONS =====
expect(result).to.be.false;
Sinon.assert.calledOnce(getConnectionStub);
Sinon.assert.calledOnce(requestStub);
Sinon.assert.calledWith(requestStub, {
method: 'GET',
url: Constants.APEX_GURU_AUTH_ENDPOINT,
body: ''
});
});
});
suite('#initiateApexGuruRequest', () => {
let getConnectionStub: Sinon.SinonStub;
let requestStub: Sinon.SinonStub;
let readFileStub: Sinon.SinonStub;

setup(() => {
getConnectionStub = Sinon.stub(CoreExtensionService, 'getConnection');
requestStub = Sinon.stub();
readFileStub = Sinon.stub(ApexGuruFunctions.fileSystem, 'readFile');
});

teardown(() => {
Sinon.restore();
});

test('Returns requestId if response status is new', async () => {
// ===== SETUP =====
getConnectionStub.resolves({
request: requestStub.resolves({ status: 'new', requestId: '12345' })
});
readFileStub.resolves('console.log("Hello World");');

// ===== TEST =====
const result = await ApexGuruFunctions.initiateApexGuruRequest(vscode.Uri.file('dummyPath'), outputChannel);

// ===== ASSERTIONS =====
expect(result).to.equal('12345');
Sinon.assert.calledOnce(getConnectionStub);
Sinon.assert.calledOnce(requestStub);
Sinon.assert.calledOnce(readFileStub);
Sinon.assert.calledWith(requestStub, Sinon.match({
method: 'POST',
url: Constants.APEX_GURU_REQUEST,
body: Sinon.match.string
}));
});

test('Logs warning if response status is not new', async () => {
// ===== SETUP =====
getConnectionStub.resolves({
request: requestStub.resolves({ status: 'failed' })
});
readFileStub.resolves('console.log("Hello World");');
const outputChannelSpy = Sinon.spy(outputChannel, 'warn');

// ===== TEST =====
try {
await ApexGuruFunctions.initiateApexGuruRequest(vscode.Uri.file('dummyPath'), outputChannel);
} catch (e) {
// ===== ASSERTIONS =====
Sinon.assert.calledOnce(outputChannelSpy);
Sinon.assert.calledWith(outputChannelSpy, Sinon.match.string);
}
});
});
});
76 changes: 1 addition & 75 deletions src/test/suite/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {expect} from 'chai';
import path = require('path');
import {SfCli} from '../../lib/sf-cli';
import Sinon = require('sinon');
import { _runAndDisplayPathless, _runAndDisplayDfa, _clearDiagnostics, _shouldProceedWithDfaRun, _stopExistingDfaRun, _isValidFileForAnalysis, _isApexGuruEnabledInOrg, verifyPluginInstallation, _clearDiagnosticsForSelectedFiles, _removeDiagnosticsInRange, RunInfo } from '../../extension';
import { _runAndDisplayPathless, _runAndDisplayDfa, _clearDiagnostics, _shouldProceedWithDfaRun, _stopExistingDfaRun, _isValidFileForAnalysis, verifyPluginInstallation, _clearDiagnosticsForSelectedFiles, _removeDiagnosticsInRange, RunInfo } from '../../extension';
import {messages} from '../../lib/messages';
import {CoreExtensionService, TelemetryService} from '../../lib/core-extension-service';
import * as Constants from '../../lib/constants';
Expand Down Expand Up @@ -386,80 +386,6 @@ suite('Extension Test Suite', () => {
});
});

suite('#_isApexGuruEnabledInOrg', () => {
let getConnectionStub: Sinon.SinonStub;
let requestStub: Sinon.SinonStub;

setup(() => {
getConnectionStub = Sinon.stub(CoreExtensionService, 'getConnection');
requestStub = Sinon.stub();
});

teardown(() => {
Sinon.restore();
});

test('Returns true if response status is Success', async () => {
// ===== SETUP =====
getConnectionStub.resolves({
request: requestStub.resolves({ status: 'Success' })
});

// ===== TEST =====
const result = await _isApexGuruEnabledInOrg();

// ===== ASSERTIONS =====
expect(result).to.be.true;
Sinon.assert.calledOnce(getConnectionStub);
Sinon.assert.calledOnce(requestStub);
Sinon.assert.calledWith(requestStub, {
method: 'GET',
url: Constants.APEX_GURU_AUTH_ENDPOINT,
body: ''
});
});

test('Returns false if response status is not Success', async () => {
// ===== SETUP =====
getConnectionStub.resolves({
request: requestStub.resolves({ status: 'Failure' })
});

// ===== TEST =====
const result = await _isApexGuruEnabledInOrg();

// ===== ASSERTIONS =====
expect(result).to.be.false;
Sinon.assert.calledOnce(getConnectionStub);
Sinon.assert.calledOnce(requestStub);
Sinon.assert.calledWith(requestStub, {
method: 'GET',
url: Constants.APEX_GURU_AUTH_ENDPOINT,
body: ''
});
});

test('Returns false if an error is thrown', async () => {
// ===== SETUP =====
getConnectionStub.resolves({
request: requestStub.rejects(new Error('Resource not found'))
});

// ===== TEST =====
const result = await _isApexGuruEnabledInOrg();

// ===== ASSERTIONS =====
expect(result).to.be.false;
Sinon.assert.calledOnce(getConnectionStub);
Sinon.assert.calledOnce(requestStub);
Sinon.assert.calledWith(requestStub, {
method: 'GET',
url: Constants.APEX_GURU_AUTH_ENDPOINT,
body: ''
});
});
});

suite('#isValidFileForAnalysis', () => {
test('Returns true for valid files', async() => {
// ===== SETUP ===== and ===== ASSERTIONS =====
Expand Down
Loading