Skip to content

Commit 8508d2e

Browse files
CHANGE: @W-19053527@: Handle new ApexGuru responses (#273)
1 parent 5eb6591 commit 8508d2e

File tree

7 files changed

+330
-119
lines changed

7 files changed

+330
-119
lines changed

src/lib/apexguru/apex-guru-run-action.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ export class ApexGuruRunAction {
5555
this.telemetryService.sendCommandEvent(Constants.TELEM_SUCCESSFUL_APEX_GURU_FILE_ANALYSIS, {
5656
executedCommand: commandName,
5757
duration: (Date.now() - startTime).toString(),
58-
violationCount: violations.length.toString(),
59-
// TODO: This telemetry might change in the near future so that we can track the number of suggestions and fixes
60-
violationsWithSuggestedCodeCount: violations.filter(v => v.suggestions?.length > 0).length.toString()
58+
numViolations: violations.length.toString(),
59+
numViolationsWithSuggestions: violations.filter(v => v.suggestions?.length > 0).length.toString(),
60+
numViolationsWithFixes: violations.filter(v => v.fixes?.length > 0).length.toString()
6161
});
6262
} catch (err) {
6363
this.display.displayError(messages.error.analysisFailedGenerator(getErrorMessage(err)));

src/lib/apexguru/apex-guru-service.ts

Lines changed: 85 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77

8-
import * as Constants from '../constants';
9-
import {CodeLocation, Violation} from '../diagnostics';
8+
import {CodeLocation, Fix, Suggestion, Violation} from '../diagnostics';
109
import {Logger} from "../logger";
1110
import {getErrorMessage, indent} from '../utils';
1211
import {HttpMethods, HttpRequest, OrgConnectionService} from '../external-services/org-connection-service';
1312
import {FileHandler} from '../fs-utils';
1413
import { messages } from '../messages';
1514

1615
export const APEX_GURU_ENGINE_NAME: string = 'apexguru';
16+
const APEX_GURU_MAX_TIMEOUT_SECONDS = 60;
17+
const APEX_GURU_RETRY_INTERVAL_MILLIS = 1000;
1718

1819
const RESPONSE_STATUS = {
1920
NEW: "new",
@@ -37,8 +38,8 @@ export class LiveApexGuruService implements ApexGuruService {
3738
orgConnectionService: OrgConnectionService,
3839
fileHandler: FileHandler,
3940
logger: Logger,
40-
maxTimeoutSeconds: number = Constants.APEX_GURU_MAX_TIMEOUT_SECONDS,
41-
retryIntervalMillis: number = Constants.APEX_GURU_RETRY_INTERVAL_MILLIS) {
41+
maxTimeoutSeconds: number = APEX_GURU_MAX_TIMEOUT_SECONDS,
42+
retryIntervalMillis: number = APEX_GURU_RETRY_INTERVAL_MILLIS) {
4243
this.orgConnectionService = orgConnectionService;
4344
this.fileHandler = fileHandler;
4445
this.logger = logger;
@@ -50,7 +51,7 @@ export class LiveApexGuruService implements ApexGuruService {
5051
if (!this.orgConnectionService.isAuthed()) {
5152
return false;
5253
}
53-
const response: ApexGuruResponse = await this.request('GET', Constants.APEX_GURU_VALIDATE_ENDPOINT);
54+
const response: ApexGuruResponse = await this.request('GET', await this.getValidateEndpoint());
5455
return response.status === RESPONSE_STATUS.SUCCESS;
5556
}
5657

@@ -59,16 +60,22 @@ export class LiveApexGuruService implements ApexGuruService {
5960
const requestId = await this.initiateRequest(fileContent);
6061
this.logger.debug(`Initialized ApexGuru Analysis with Request Id: ${requestId}`);
6162
const queryResponse: ApexGuruQueryResponse = await this.waitForResponse(requestId);
62-
this.logger.debug(`ApexGuru Analysis completed for Request Id: ${requestId}`);
63-
const reports: ApexGuruReport[] = toReportArray(queryResponse);
64-
return reports.map(r => toViolation(r, absFileToScan));
63+
const payloadStr: string = decodeFromBase64(queryResponse.report);
64+
this.logger.debug(`ApexGuru Analysis completed for Request Id: ${requestId}\n\nDecoded Response Payload:\n${payloadStr}`);
65+
const apexGuruViolations: ApexGuruViolation[] = parsePayload(payloadStr);
66+
return apexGuruViolations.map(v => toViolation(v, absFileToScan));
6567
}
6668

6769
private async initiateRequest(fileContent: string): Promise<string> {
68-
const base64EncodedContent = Buffer.from(fileContent).toString('base64');
69-
const response: ApexGuruInitialResponse = await this.request('POST', Constants.APEX_GURU_REQUEST_ENDPOINT,
70-
JSON.stringify({classContent: base64EncodedContent}));
71-
if (!response.requestId || response.status != RESPONSE_STATUS.NEW) {
70+
const requestBody: ApexGuruRequestBody = {
71+
classContent: encodeToBase64(fileContent)
72+
};
73+
const response: ApexGuruInitialResponse = await this.request('POST', await this.getRequestEndpoint(),
74+
JSON.stringify(requestBody));
75+
76+
if (response.status == RESPONSE_STATUS.FAILED) {
77+
throw new Error(messages.apexGuru.errors.unableToAnalyzeFile(response.message ?? ''));
78+
} else if (!response.requestId || response.status != RESPONSE_STATUS.NEW) {
7279
throw Error(messages.apexGuru.errors.returnedUnexpectedResponse(JSON.stringify(response, null, 2)));
7380
}
7481
return response.requestId;
@@ -81,11 +88,11 @@ export class LiveApexGuruService implements ApexGuruService {
8188
if (queryResponse) { // After the first attempt, we pause each time between requests
8289
await new Promise(resolve => setTimeout(resolve, this.retryIntervalMillis));
8390
}
84-
queryResponse = await this.request('GET', `${Constants.APEX_GURU_REQUEST_ENDPOINT}/${requestId}`);
91+
queryResponse = await this.request('GET', await this.getRequestEndpoint(requestId));
8592
if (queryResponse.status === RESPONSE_STATUS.SUCCESS && queryResponse.report) {
8693
return queryResponse;
87-
} else if (queryResponse.status === RESPONSE_STATUS.FAILED) { // TODO: I would love a failure message - but the response's report just gives back the file content and nothing else.
88-
throw new Error(messages.apexGuru.errors.unableToAnalyzeFile);
94+
} else if (queryResponse.status === RESPONSE_STATUS.FAILED) {
95+
throw new Error(messages.apexGuru.errors.unableToAnalyzeFile(queryResponse.message ?? ''));
8996
} else if (queryResponse.status === RESPONSE_STATUS.ERROR && queryResponse.message) {
9097
throw new Error(messages.apexGuru.errors.returnedUnexpectedError(queryResponse.message));
9198
}
@@ -117,73 +124,73 @@ export class LiveApexGuruService implements ApexGuruService {
117124
this.logger.trace('Call to ApexGuru Service failed:' + getErrorMessage(err));
118125
return {
119126
status: RESPONSE_STATUS.ERROR,
120-
message: getErrorMessage(err)
127+
message: getErrorMessage(err),
121128
} as T;
122129
}
123130
}
131+
132+
private async getValidateEndpoint(): Promise<string> {
133+
const apiVersion: string = await this.orgConnectionService.getApiVersion();
134+
return `/services/data/v${apiVersion}/apexguru/validate`;
135+
}
136+
137+
private async getRequestEndpoint(requestId?: string): Promise<string> {
138+
const apiVersion: string = await this.orgConnectionService.getApiVersion();
139+
return `/services/data/v${apiVersion}/apexguru/request` + (requestId ? `/${requestId}` : '');
140+
}
124141
}
125142

126143

127-
export function toReportArray(response: ApexGuruQueryResponse): ApexGuruReport[] {
128-
// TODO: This will change soon enough - once we receive the actual response
129-
const report: string = Buffer.from(response.report, 'base64').toString('utf-8');
144+
export function parsePayload(payloadStr: string): ApexGuruViolation[] {
130145
try {
131-
return JSON.parse(report) as ApexGuruReport[];
146+
return JSON.parse(payloadStr) as ApexGuruViolation[];
132147
} catch (err) {
133-
throw new Error(`Unable to parse response from ApexGuru.\n\n` +
134-
`Error:\n${indent(getErrorMessage(err))}\n\nDecoded report:\n${indent(report)}`);
148+
throw new Error(messages.apexGuru.errors.unableToParsePayload(indent(getErrorMessage(err))));
135149
}
136150
}
137151

138-
function toViolation(parsed: ApexGuruReport, file: string): Violation {
139-
// IMPORTANT: AS OF 08/13/2024 THIS ALL FAILS IN PRODUCTION BECAUSE THE NEW ApexGuru Service NOW HAS A NEW
140-
// RESPONSE. TODO: W-19053527
141-
const encodedCodeAfter = parsed.properties.find((prop: ApexGuruProperty) => prop.name === 'code_after')?.value
142-
?? parsed.properties.find((prop: ApexGuruProperty) => prop.name === 'class_after')?.value
143-
?? '';
144-
const suggestedCode: string = Buffer.from(encodedCodeAfter, 'base64').toString('utf8');
145-
146-
const lineNumber = parseInt(parsed.properties.find((prop: ApexGuruProperty) => prop.name === 'line_number')?.value);
147-
148-
const violationLocation: CodeLocation = {
149-
file: file,
150-
startLine: lineNumber,
151-
startColumn: 1
152+
function toViolation(apexGuruViolation: ApexGuruViolation, file: string): Violation {
153+
const codeAnalyzerViolation: Violation = {
154+
rule: apexGuruViolation.rule,
155+
engine: APEX_GURU_ENGINE_NAME,
156+
message: apexGuruViolation.message,
157+
severity: apexGuruViolation.severity,
158+
locations: apexGuruViolation.locations.map(l => addFile(l, file)),
159+
primaryLocationIndex: apexGuruViolation.primaryLocationIndex,
160+
tags: [], // Currently not used
161+
resources: apexGuruViolation.resources ?? [],
162+
suggestions: apexGuruViolation.suggestions?.map(s => {
163+
s.location = addFile(s.location, file);
164+
return s;
165+
}),
166+
fixes: apexGuruViolation.fixes?.map(f => {
167+
f.location = addFile(f.location, file);
168+
return f;
169+
})
152170
};
171+
return codeAnalyzerViolation;
172+
}
153173

154-
const violation: Violation = {
155-
rule: parsed.type,
156-
engine: APEX_GURU_ENGINE_NAME,
157-
message: parsed.value,
158-
severity: 1, // TODO: Should this really be critical level violation? This seems off.
159-
locations: [violationLocation],
160-
primaryLocationIndex: 0,
161-
tags: [],
162-
resources: [
163-
'https://help.salesforce.com/s/articleView?id=sf.apexguru_antipatterns.htm&type=5'
164-
]
174+
function addFile(apexGuruLocation: CodeLocation, filePath: string): CodeLocation {
175+
return {
176+
...apexGuruLocation,
177+
file: filePath
165178
};
179+
}
166180

167-
// TODO: Soon we'll be receiving a different looking payload which will help us differentiate between fixes and suggestions.
168-
// For now, we are going to treat suggestedCode as a fix and a suggestion (as the current pilot code does)
169-
if (suggestedCode.length > 0) {
170-
violation.fixes = [
171-
{
172-
location: violationLocation,
173-
fixedCode: `/*\n//ApexGuru Suggestions: \n${suggestedCode}\n*/`
174-
}
175-
]
176-
violation.suggestions = [
177-
{
178-
location: violationLocation,
179-
// This message is temporary and will be improved as we get a better response back and unify the suggestions experience
180-
message: suggestedCode
181-
}
182-
]
183-
}
184-
return violation;
181+
function encodeToBase64(value: string): string {
182+
return Buffer.from(value).toString('base64');
185183
}
186184

185+
function decodeFromBase64(value: string): string {
186+
return Buffer.from(value, 'base64').toString('utf-8');
187+
}
188+
189+
190+
type ApexGuruRequestBody = {
191+
// Must be base64 encoded
192+
classContent: string
193+
}
187194

188195
type ApexGuruResponse = {
189196
status: string;
@@ -195,17 +202,21 @@ type ApexGuruInitialResponse = ApexGuruResponse & {
195202
}
196203

197204
type ApexGuruQueryResponse = ApexGuruResponse & {
205+
// Is returned with base64 encoding
198206
report: string;
199207
}
200208

201-
type ApexGuruProperty = {
202-
name: string;
203-
value: string;
204-
};
209+
type ApexGuruViolation = {
210+
rule: string;
211+
message: string;
212+
213+
// Note that none of these location objects from ApexGuru will have a "file" field on it
214+
locations: CodeLocation[];
215+
primaryLocationIndex: number;
216+
severity: number;
217+
resources: string[];
205218

206-
type ApexGuruReport = {
207-
id: string;
208-
type: string;
209-
value: string;
210-
properties: ApexGuruProperty[];
219+
// Note that each suggestion and fix location from ApexGuru will not have a "file" field on it
220+
suggestions?: Suggestion[];
221+
fixes?: Fix[];
211222
}

src/lib/constants.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,6 @@ export const MINIMUM_REQUIRED_VERSION_CORE_EXTENSION = '58.4.1';
5555
export const RECOMMENDED_MINIMUM_REQUIRED_CODE_ANALYZER_CLI_PLUGIN_VERSION = '5.0.0';
5656
export const ABSOLUTE_MINIMUM_REQUIRED_CODE_ANALYZER_CLI_PLUGIN_VERSION = '5.0.0-beta.0';
5757

58-
// apex guru APIS
59-
export const APEX_GURU_VALIDATE_ENDPOINT = '/services/data/v62.0/apexguru/validate'
60-
export const APEX_GURU_REQUEST_ENDPOINT = '/services/data/v62.0/apexguru/request'
61-
export const APEX_GURU_MAX_TIMEOUT_SECONDS = 60;
62-
export const APEX_GURU_RETRY_INTERVAL_MILLIS = 1000;
63-
6458
// Context variables (dynamically set but consumed by the "when" conditions in the package.json "contributes" sections)
6559
export const CONTEXT_VAR_EXTENSION_ACTIVATED = 'sfca.extensionActivated';
6660
export const CONTEXT_VAR_APEX_GURU_ENABLED = 'sfca.apexGuruEnabled';

src/lib/external-services/org-connection-service.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as vscode from "vscode";
22

33
export interface OrgConnectionService {
44
isAuthed(): boolean;
5+
getApiVersion(): Promise<string>;
56
onOrgChange(callback: () => void): void;
67
request<T>(requestOptions: HttpRequest): Promise<T>;
78
}
@@ -15,9 +16,15 @@ export class NoOpOrgConnectionService implements OrgConnectionService {
1516
isAuthed(): boolean {
1617
return false;
1718
}
19+
20+
getApiVersion(): Promise<string> {
21+
throw new Error(`Cannot get the api verison because no org is authed.`);
22+
}
23+
1824
onOrgChange(_callback: () => void): void {
1925
// No-op
2026
}
27+
2128
request<T>(requestOptions: HttpRequest): Promise<T> {
2229
throw new Error(`Cannot make the following request because no org is authed:\n${JSON.stringify(requestOptions, null, 2)}`);
2330
}
@@ -34,6 +41,15 @@ export class LiveOrgConnectionService implements OrgConnectionService {
3441
return this.workpaceContext.orgId?.length > 0;
3542
}
3643

44+
async getApiVersion(): Promise<string> {
45+
if (!this.isAuthed()) {
46+
throw new Error(`Cannot get the api verison because no org is authed.`);
47+
}
48+
const connection: Connection = await this.workpaceContext.getConnection();
49+
return connection.getApiVersion();
50+
}
51+
52+
3753
onOrgChange(callback: (event: OrgUserInfo) => void): void {
3854
this.workpaceContext.onOrgChange(callback);
3955
}

src/lib/messages.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,17 @@ export const messages = {
2929
`ApexGuru can scan only one file at a time. Ignoring the other files in your multi-selection and scanning only this file: ${file}.`
3030
},
3131
errors: {
32-
unableToAnalyzeFile: 'ApexGuru was unable to analyze the file.',
32+
unableToAnalyzeFile: (reason: string) => `ApexGuru was unable to analyze the file. ${reason}`,
3333
returnedUnexpectedResponse: (responseStr: string) =>
3434
`ApexGuru returned an unexpected response:\n${responseStr}`,
3535
returnedUnexpectedError: (errMsg: string) => `ApexGuru returned an unexpected error: ${errMsg}`,
3636
failedToGetResponseBeforeTimeout: (maxSeconds: number, lastResponse: string) =>
3737
`Failed to get a successful response from ApexGuru after ${maxSeconds} seconds.\n` +
3838
`Last response:\n${lastResponse}`,
3939
expectedResponseToContainStatusField: (responseStr: string) =>
40-
`ApexGuru returned a response without a 'status' field containing a string value. Instead received:\n${responseStr}`
40+
`ApexGuru returned a response without a 'status' field containing a string value. Instead received:\n${responseStr}`,
41+
unableToParsePayload: (errMsg: string) =>
42+
`Unable to parse the payload from the response from ApexGuru. Error:\n${errMsg}`
4143
}
4244
},
4345
info: {

0 commit comments

Comments
 (0)