diff --git a/firebase-vscode/.gitignore b/firebase-vscode/.gitignore index 31f52f88a1b..b1b11accfdc 100644 --- a/firebase-vscode/.gitignore +++ b/firebase-vscode/.gitignore @@ -6,4 +6,5 @@ resources/dist .wdio-vscode-service logs !*.tgz -prebuilt-extensions \ No newline at end of file +prebuilt-extensions +data-connect-test-*/ \ No newline at end of file diff --git a/firebase-vscode/CHANGELOG.md b/firebase-vscode/CHANGELOG.md index 1845fa2ffd3..df94fdf8291 100644 --- a/firebase-vscode/CHANGELOG.md +++ b/firebase-vscode/CHANGELOG.md @@ -1,5 +1,9 @@ ## NEXT +- [Fixed] Populate correct default values of missing required variables. +- [Added] Display the execution variables and auth params used. +- [Added] Allow rerun any executions in the history. + ## 1.9.0 - [Added] Refine / Generate Operation Code Lens. diff --git a/firebase-vscode/common/messaging/protocol.ts b/firebase-vscode/common/messaging/protocol.ts index 6587794140b..6edc9342c46 100644 --- a/firebase-vscode/common/messaging/protocol.ts +++ b/firebase-vscode/common/messaging/protocol.ts @@ -11,15 +11,21 @@ import { EmulatorsStatus, RunningEmulatorInfo } from "./types"; import { ExecutionResult } from "graphql"; import { SerializedError } from "../error"; -export enum UserMockKind { +export enum AuthParamsKind { ADMIN = "admin", UNAUTHENTICATED = "unauthenticated", AUTHENTICATED = "authenticated", } -export type UserMock = - | { kind: UserMockKind.ADMIN | UserMockKind.UNAUTHENTICATED } + +export const EXAMPLE_CLAIMS = `{ + "email_verified": true, + "sub": "exampleUserId" +}`; + +export type AuthParams = + | { kind: AuthParamsKind.ADMIN | AuthParamsKind.UNAUTHENTICATED } | { - kind: UserMockKind.AUTHENTICATED; + kind: AuthParamsKind.AUTHENTICATED; claims: string; }; @@ -84,13 +90,13 @@ export interface WebviewToExtensionParamsMap { selectEmulatorImportFolder: {}; - definedDataConnectArgs: string; + /** Execution parameters */ + defineVariables: string; + defineAuthParams: AuthParams; /** Prompts the user to select a directory in which to place the quickstart */ chooseQuickstartDir: {}; - notifyAuthUserMockChange: UserMock; - /** Deploy connectors/services to production */ "fdc.deploy": void; @@ -130,10 +136,11 @@ export interface WebviewToExtensionParamsMap { } export interface DataConnectResults { - query: string; displayName: string; - results?: ExecutionResult | SerializedError; - args?: string; + query: string; + results: ExecutionResult | SerializedError; + variables: string; + auth: AuthParams; } export type ValueOrError = @@ -185,13 +192,11 @@ export interface ExtensionToWebviewParamsMap { */ notifyPreviewChannelResponse: { id: string }; - // data connect specific - notifyDataConnectArgs: string; - + /** Update execution parameters and results panels */ + notifyVariables: { variables: string, fixes: string[] }; + notifyAuthParams: AuthParams; notifyDataConnectResults: DataConnectResults; - notifyLastOperation: string; - notifyIsLoadingUser: boolean; notifyDocksLink: string; diff --git a/firebase-vscode/package.json b/firebase-vscode/package.json index fb697cb0eca..f1373ebe891 100644 --- a/firebase-vscode/package.json +++ b/firebase-vscode/package.json @@ -158,13 +158,8 @@ "firebase-data-connect-execution-view": [ { "type": "webview", - "id": "data-connect-execution-configuration", - "name": "Configuration", - "when": "firebase-vscode.fdc.enabled" - }, - { - "id": "data-connect-execution-history", - "name": "History", + "id": "data-connect-execution-parameters", + "name": "Parameters", "when": "firebase-vscode.fdc.enabled" }, { @@ -172,6 +167,12 @@ "id": "data-connect-execution-results", "name": "Results", "when": "firebase-vscode.fdc.enabled" + }, + { + "id": "data-connect-execution-history", + "name": "History", + "when": "firebase-vscode.fdc.enabled", + "visibility": "collapsed" } ] }, diff --git a/firebase-vscode/src/analytics.ts b/firebase-vscode/src/analytics.ts index e157acabbfe..360a1e1cac7 100644 --- a/firebase-vscode/src/analytics.ts +++ b/firebase-vscode/src/analytics.ts @@ -25,7 +25,11 @@ export enum DATA_CONNECT_EVENT_NAME { RUN_PROD_MUTATION_WARNING_REJECTED = "run_prod_mutation_warning_rejected", RUN_PROD_MUTATION_WARNING_ACKED = "run_prod_mutation_warning_acked", RUN_PROD_MUTATION_WARNING_ACKED_ALWAYS = "run_prod_mutation_warning_acked_always", - MISSING_VARIABLES = "missing_variables", + RUN_AUTH_ADMIN = "run_auth_admin", + RUN_AUTH_UNAUTHENTICATED = "run_auth_unauthenticated", + RUN_AUTH_AUTHENTICATED = "run_auth_authenticated", + RUN_UNDEFINED_VARIABLES = "run_undefined_variables", + RUN_MISSING_VARIABLES = "run_missing_variables", GENERATE_OPERATION = "generate_operation", GIF_TOS_MODAL = "gif_tos_modal", GIF_TOS_MODAL_ACKED = "gif_tos_modal_acked", diff --git a/firebase-vscode/src/auth/service.ts b/firebase-vscode/src/auth/service.ts deleted file mode 100644 index 7d1b6525608..00000000000 --- a/firebase-vscode/src/auth/service.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Disposable } from "vscode"; -import { ExtensionBrokerImpl } from "../extension-broker"; -import { UserMock } from "../../common/messaging/protocol"; - -export class AuthService implements Disposable { - constructor(readonly broker: ExtensionBrokerImpl) { - this.disposable.push({ - dispose: broker.on( - "notifyAuthUserMockChange", - (userMock) => (this.userMock = userMock) - ), - }); - } - - userMock: UserMock | undefined; - disposable: Disposable[] = []; - - dispose() { - for (const disposable of this.disposable) { - disposable.dispose(); - } - } -} diff --git a/firebase-vscode/src/data-connect/ad-hoc-mutations.ts b/firebase-vscode/src/data-connect/ad-hoc-mutations.ts index ec3d6ada142..346edc2b6ff 100644 --- a/firebase-vscode/src/data-connect/ad-hoc-mutations.ts +++ b/firebase-vscode/src/data-connect/ad-hoc-mutations.ts @@ -284,16 +284,16 @@ function getDefaultScalarValueNode(type: string): ValueNode | undefined { } } -export function getDefaultScalarValue(type: string): string { +export function getDefaultScalarValue(type: string): any { switch (type) { case "Boolean": - return "false"; + return false; case "Date": return new Date().toISOString().substring(0, 10); case "Float": - return "0"; + return 0.0; case "Int": - return "0"; + return 0; case "Int64": return "0"; case "String": @@ -303,8 +303,8 @@ export function getDefaultScalarValue(type: string): string { case "UUID": return "11111111222233334444555555555555"; case "Vector": - return "[]"; + return [1.1, 2.2, 3.3]; default: - return ""; + return undefined; } } diff --git a/firebase-vscode/src/data-connect/code-lens-provider.ts b/firebase-vscode/src/data-connect/code-lens-provider.ts index fd101be8130..b440ccf3b76 100644 --- a/firebase-vscode/src/data-connect/code-lens-provider.ts +++ b/firebase-vscode/src/data-connect/code-lens-provider.ts @@ -1,12 +1,11 @@ import * as vscode from "vscode"; import { Kind, parse } from "graphql"; -import { OperationLocation } from "./types"; import { Disposable } from "vscode"; import { Signal } from "@preact/signals-core"; import { dataConnectConfigs, firebaseRC } from "./config"; import { EmulatorsController } from "../core/emulators"; -import { GenerateOperationInput } from "./execution/execution"; +import { ExecutionInput, GenerateOperationInput } from "./execution/execution"; import { findCommentsBlocks } from "../utils/find_comments"; export enum InstanceType { @@ -91,31 +90,40 @@ export class OperationCodeLensProvider extends ComputedCodeLensProvider { const line = x.loc.startToken.line - 1; const range = new vscode.Range(line, 0, line, 0); const position = new vscode.Position(line, 0); - const operationLocation: OperationLocation = { - document: documentText, - documentPath: document.fileName, - position: position, - }; - const service = fdcConfigs.findEnclosingServiceForPath( - document.fileName, - ); + const service = fdcConfigs.findEnclosingServiceForPath(document.fileName); if (service) { - codeLenses.push( - new vscode.CodeLens(range, { - title: `$(play) Run (local)`, - command: "firebase.dataConnect.executeOperation", - tooltip: "Execute the operation (⌘+enter or Ctrl+Enter)", - arguments: [x, operationLocation, InstanceType.LOCAL], - }), - ); + { + const arg: ExecutionInput = { + operationAst: x, + document: documentText, + documentPath: document.fileName, + position: position, + instance: InstanceType.LOCAL, + }; + codeLenses.push( + new vscode.CodeLens(range, { + title: `$(play) Run (local)`, + command: "firebase.dataConnect.executeOperation", + tooltip: "Execute the operation (⌘+enter or Ctrl+Enter)", + arguments: [arg], + }), + ); + } if (projectId) { + const arg: ExecutionInput = { + operationAst: x, + document: documentText, + documentPath: document.fileName, + position: position, + instance: InstanceType.PRODUCTION, + }; codeLenses.push( new vscode.CodeLens(range, { title: `$(play) Run (Production – Project: ${projectId})`, command: "firebase.dataConnect.executeOperation", tooltip: "Execute the operation (⌘+enter or Ctrl+Enter)", - arguments: [x, operationLocation, InstanceType.PRODUCTION], + arguments: [arg], }), ); } diff --git a/firebase-vscode/src/data-connect/execution/execution-history-provider.ts b/firebase-vscode/src/data-connect/execution/execution-history-provider.ts index 96cd6cdda79..4eaafabedc2 100644 --- a/firebase-vscode/src/data-connect/execution/execution-history-provider.ts +++ b/firebase-vscode/src/data-connect/execution/execution-history-provider.ts @@ -18,7 +18,7 @@ export class ExecutionTreeItem extends vscode.TreeItem { this.item = item; // Renders arguments in a single line - const prettyArgs = this.item.args?.replaceAll(/[\n \t]+/g, " "); + const prettyArgs = this.item.variables?.replaceAll(/[\n \t]+/g, " "); this.description = `${timeFormatter.format( item.timestamp )} | Arguments: ${prettyArgs}`; diff --git a/firebase-vscode/src/data-connect/execution/execution-params.ts b/firebase-vscode/src/data-connect/execution/execution-params.ts new file mode 100644 index 00000000000..726cbeb7cee --- /dev/null +++ b/firebase-vscode/src/data-connect/execution/execution-params.ts @@ -0,0 +1,118 @@ +import { print, Kind, OperationDefinitionNode } from "graphql"; +import { globalSignal } from "../../utils/globals"; +import { getDefaultScalarValue } from "../ad-hoc-mutations"; +import { AuthParams, AuthParamsKind } from "../../../common/messaging/protocol"; +import { Impersonation } from "../../dataconnect/types"; +import { Disposable } from "vscode"; +import { ExtensionBrokerImpl } from "../../extension-broker"; +import { AnalyticsLogger, DATA_CONNECT_EVENT_NAME } from "../../analytics"; + +/** + * Contains the unparsed JSON object mutation/query variables. + * The JSON may be invalid. + */ +export const executionVarsJSON = globalSignal("{}"); +export const executionAuthParams = globalSignal({kind: AuthParamsKind.ADMIN}); + +export class ExecutionParamsService implements Disposable { + constructor(readonly broker: ExtensionBrokerImpl, readonly analyticsLogger: AnalyticsLogger) { + this.disposable.push({ + dispose: broker.on( + "defineAuthParams", + (auth) => (executionAuthParams.value = auth) + ), + }); + this.disposable.push({ + dispose: broker.on( + "defineVariables", + (value) => (executionVarsJSON.value = value), + ) + }); + } + + disposable: Disposable[] = []; + + dispose() { + for (const disposable of this.disposable) { + disposable.dispose(); + } + } + + executeGraphqlVariables(): Record { + const variables = executionVarsJSON.value; + if (!variables) { + return {}; + } + try { + return JSON.parse(variables); + } catch (e: any) { + throw new Error( + "Unable to parse variables as JSON. Check the variables pane.\n" + e.message, + ); + } + } + + executeGraphqlExtensions(): { impersonate?: Impersonation } { + const auth = executionAuthParams.value; + switch (auth.kind) { + case AuthParamsKind.ADMIN: + this.analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.RUN_AUTH_ADMIN); + return {}; + case AuthParamsKind.UNAUTHENTICATED: + this.analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.RUN_AUTH_UNAUTHENTICATED); + return { impersonate: { unauthenticated: true, includeDebugDetails: true } }; + case AuthParamsKind.AUTHENTICATED: + this.analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.RUN_AUTH_AUTHENTICATED); + try { + return { + impersonate: + { authClaims: JSON.parse(auth.claims), includeDebugDetails: true } + }; + } catch (e: any) { + throw new Error( + "Unable to parse auth claims as JSON. Check the authentication panel.\n" + e.message, + ); + } + default: + throw new Error(`Unknown auth params kind: ${auth}`); + } + } + + async applyDetectedFixes(ast: OperationDefinitionNode): Promise { + const userVars = this.executeGraphqlVariables(); + const fixes = []; + { + const undefinedVars = []; + for (const varName in userVars) { + if (!ast.variableDefinitions?.find((v) => v.variable.name.value === varName)) { + delete userVars[varName]; + undefinedVars.push(varName); + } + } + if (undefinedVars.length > 0) { + this.analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.RUN_UNDEFINED_VARIABLES); + fixes.push(`Removed undefined variables: ${undefinedVars.map((v) => "$" + v).join(", ")}`); + } + } + { + const missingRequiredVars = []; + for (const variable of ast.variableDefinitions || []) { + const varName = variable.variable.name.value; + if (variable.type.kind === Kind.NON_NULL_TYPE && userVars[varName] === undefined) { + userVars[varName] = getDefaultScalarValue(print(variable.type.type)); + missingRequiredVars.push(varName); + } + } + if (missingRequiredVars.length > 0) { + this.analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.RUN_MISSING_VARIABLES); + fixes.push(`Included required variables: ${missingRequiredVars.map((v) => "$" + v).join(", ")}`); + } + } + if (fixes.length === 0) { + return; + } + executionVarsJSON.value = JSON.stringify(userVars, null, 2); + this.broker.send("notifyVariables", { variables: executionVarsJSON.value, fixes }); + return; + } +} \ No newline at end of file diff --git a/firebase-vscode/src/data-connect/execution/execution-store.ts b/firebase-vscode/src/data-connect/execution/execution-store.ts index 972b73a32d6..53b916a07f9 100644 --- a/firebase-vscode/src/data-connect/execution/execution-store.ts +++ b/firebase-vscode/src/data-connect/execution/execution-store.ts @@ -2,6 +2,8 @@ import { computed } from "@preact/signals-core"; import { ExecutionResult, OperationDefinitionNode } from "graphql"; import * as vscode from "vscode"; import { globalSignal } from "../../utils/globals"; +import { AuthParams } from "../../messaging/protocol"; +import { ExecutionInput } from "./execution"; export enum ExecutionState { INIT, @@ -16,11 +18,10 @@ export interface ExecutionItem { label: string; timestamp: number; state: ExecutionState; - operation: OperationDefinitionNode; - args?: string; - results?: ExecutionResult | Error; - documentPath: string; - position: vscode.Position; + input: ExecutionInput; + variables: string; + auth: AuthParams; + results: ExecutionResult | Error; } let executionId = 0; @@ -36,12 +37,6 @@ export const executions = globalSignal< export const selectedExecutionId = globalSignal(""); -/** The unparsed JSON object mutation/query variables. - * - * The JSON may be invalid. - */ -export const executionArgsJSON = globalSignal("{}"); - export function createExecution( executionItem: Omit ) { @@ -72,9 +67,9 @@ export async function selectExecutionId(executionId: string) { selectedExecutionId.value = executionId; // take user to operation location in editor - const { documentPath, position } = selectedExecution.value; - await vscode.window.showTextDocument(vscode.Uri.file(documentPath), { - selection: new vscode.Range(position, position), + const { input } = selectedExecution.value; + await vscode.window.showTextDocument(vscode.Uri.file(input.documentPath), { + selection: new vscode.Range(input.position, input.position), }); } diff --git a/firebase-vscode/src/data-connect/execution/execution.ts b/firebase-vscode/src/data-connect/execution/execution.ts index 35c85984079..4873c81e92f 100644 --- a/firebase-vscode/src/data-connect/execution/execution.ts +++ b/firebase-vscode/src/data-connect/execution/execution.ts @@ -10,45 +10,38 @@ import { ExecutionItem, ExecutionState, createExecution, - executionArgsJSON, selectExecutionId, selectedExecution, selectedExecutionId, updateExecution, } from "./execution-store"; -import { batch, effect, Signal } from "@preact/signals-core"; +import { batch, effect } from "@preact/signals-core"; import { OperationDefinitionNode, OperationTypeNode, print, buildClientSchema, validate, - DocumentNode, - Kind, - TypeNode, parse, } from "graphql"; import { DataConnectService } from "../service"; import { DataConnectError, toSerializedError } from "../../../common/error"; -import { OperationLocation } from "../types"; import { InstanceType } from "../code-lens-provider"; import { DATA_CONNECT_EVENT_NAME, AnalyticsLogger } from "../../analytics"; -import { getDefaultScalarValue } from "../ad-hoc-mutations"; import { EmulatorsController } from "../../core/emulators"; import { getConnectorGQLText, insertQueryAt } from "../file-utils"; import { pluginLogger } from "../../logger-wrapper"; import * as gif from "../../../../src/gemini/fdcExperience"; import { ensureGIFApiTos } from "../../../../src/dataconnect/ensureApis"; import { configstore } from "../../../../src/configstore"; - -interface TypedInput { - varName: string; - type: string | null; -} - -interface ExecutionInput { - ast: OperationDefinitionNode; - location: OperationLocation; +import { executionAuthParams, executionVarsJSON, ExecutionParamsService } from "./execution-params"; +import { ExecuteGraphqlRequest } from "../../dataconnect/types"; + +export interface ExecutionInput { + operationAst: OperationDefinitionNode; + document: string; + documentPath: string; + position: vscode.Position; instance: InstanceType; } @@ -60,12 +53,11 @@ export interface GenerateOperationInput { existingQuery: string; } -export const lastExecutionInputSignal = new Signal(null); - export function registerExecution( context: ExtensionContext, broker: ExtensionBrokerImpl, dataConnectService: DataConnectService, + paramsService: ExecutionParamsService, analyticsLogger: AnalyticsLogger, emulatorsController: EmulatorsController, ): Disposable { @@ -88,13 +80,14 @@ export function registerExecution( function notifyDataConnectResults(item: ExecutionItem) { broker.send("notifyDataConnectResults", { - args: item.args ?? "{}", - query: print(item.operation), + displayName: `${item.input.operationAst.operation} ${item.input.operationAst.name?.value ?? ""}`, + query: print(item.input.operationAst), results: item.results instanceof Error ? toSerializedError(item.results) : item.results, - displayName: item.operation.operation, + variables: item.variables || "", + auth: item.auth, }); } @@ -115,21 +108,14 @@ export function registerExecution( // re run called from execution panel; const rerunExecutionBroker = broker.on("rerunExecution", () => { - if (!lastExecutionInputSignal.value) { - return; + const item = selectedExecution.value; + if (item) { + executeOperation(item.input); } - executeOperation( - lastExecutionInputSignal.value.ast, - lastExecutionInputSignal.value.location, - lastExecutionInputSignal.value.instance, - ); }); - async function executeOperation( - ast: OperationDefinitionNode, - { document, documentPath, position }: OperationLocation, - instance: InstanceType, - ) { + async function executeOperation(arg: ExecutionInput) { + const { operationAst: ast, document, documentPath, instance } = arg; analyticsLogger.logger.logUsage( instance === InstanceType.LOCAL ? DATA_CONNECT_EVENT_NAME.RUN_LOCAL @@ -142,17 +128,9 @@ export function registerExecution( ); await vscode.window.activeTextEditor?.document.save(); - // hold last execution in memory, and send operation name to webview - lastExecutionInputSignal.value = { - ast, - location: { document, documentPath, position }, - instance, - }; - broker.send("notifyLastOperation", ast.name?.value ?? "anonymous"); - // focus on execution panel immediately vscode.commands.executeCommand( - "data-connect-execution-configuration.focus", + "data-connect-execution-parameters.focus", ); const configs = vscode.workspace.getConfiguration("firebase.dataConnect"); @@ -249,92 +227,51 @@ export function registerExecution( return; } } - - - // if execution args is empty, reset to {} - if (!executionArgsJSON.value) { - executionArgsJSON.value = "{}"; - } - // Check for missing arguments - const missingArgs = await verifyMissingArgs(ast, executionArgsJSON.value); - - // prompt user to continue execution or modify arguments - if (missingArgs.length > 0) { - analyticsLogger.logger.logUsage(DATA_CONNECT_EVENT_NAME.MISSING_VARIABLES); - // open a modal with option to run anyway or edit args - const editArgs = { title: "Edit variables" }; - const continueExecution = { title: "Continue Execution" }; - const result = await vscode.window.showInformationMessage( - `Missing required variables. Would you like to modify them?`, - { modal: !process.env.VSCODE_TEST_MODE }, - editArgs, - continueExecution, - ); - - if (result === editArgs) { - const missingArgsJSON = getDefaultArgs(missingArgs); - - // combine w/ existing args, and send to webview - const newArgsJsonString = JSON.stringify({ - ...JSON.parse(executionArgsJSON.value), - ...missingArgsJSON, - }); - - broker.send("notifyDataConnectArgs", newArgsJsonString); - return; - } + const servicePath = await dataConnectService.servicePath(documentPath); + if (!servicePath) { + throw new Error("No service found for document path: " + documentPath); } + const req: ExecuteGraphqlRequest = { + operationName: ast.name?.value, + variables: paramsService.executeGraphqlVariables(), + query: gqlText || document, + extensions: paramsService.executeGraphqlExtensions(), + }; const item = createExecution({ label: ast.name?.value ?? "anonymous", timestamp: Date.now(), state: ExecutionState.RUNNING, - operation: ast, - args: executionArgsJSON.value, - documentPath, - position, + input: arg, + variables: executionVarsJSON.value, + auth: executionAuthParams.value, + results: new Error("missing results"), }); - function updateAndSelect(updates: Partial) { - batch(() => { - updateExecution(item.executionId, { ...item, ...updates }); - selectExecutionId(item.executionId); - }); - } - try { // Execute queries/mutations from their source code. // That ensures that we can execute queries in unsaved files. + const results = await dataConnectService.executeGraphQL(servicePath, instance, req); + // Executing queries may return a response which contains errors + item.state = (results.errors?.length ?? 0) > 0 + ? ExecutionState.ERRORED + : ExecutionState.FINISHED; + item.results = results; + } catch (error) { + item.state = ExecutionState.ERRORED; + item.results = error instanceof Error + ? error + : new DataConnectError("Unknown error", error); + } - const results = await dataConnectService.executeGraphQL({ - operationName: ast.name?.value, - // We send the compiled GQL from the whole connector to support fragments - // In the case of adhoc operation, just send the sole document - query: gqlText || document, - variables: executionArgsJSON.value, - path: documentPath, - instance, - }); + batch(() => { + updateExecution(item.executionId, item); + selectExecutionId(item.executionId); + }); - updateAndSelect({ - state: - // Executing queries may return a response which contains errors - // without throwing. - // In that case, we mark the execution as errored. - (results.errors?.length ?? 0) > 0 - ? ExecutionState.ERRORED - : ExecutionState.FINISHED, - results, - }); - } catch (error) { - updateAndSelect({ - state: ExecutionState.ERRORED, - results: - error instanceof Error - ? error - : new DataConnectError("Unknown error", error), - }); + if (item.state === ExecutionState.ERRORED) { + await paramsService.applyDetectedFixes(ast); } } @@ -397,19 +334,13 @@ ${schema} return false; } - const sub4 = broker.on( - "definedDataConnectArgs", - (value) => (executionArgsJSON.value = value), - ); - return Disposable.from( { dispose: sub1 }, { dispose: sub2 }, { dispose: sub3 }, - { dispose: sub4 }, { dispose: rerunExecutionBroker }, registerWebview({ - name: "data-connect-execution-configuration", + name: "data-connect-execution-parameters", context, broker, }), @@ -421,8 +352,8 @@ ${schema} executionHistoryTreeView, vscode.commands.registerCommand( "firebase.dataConnect.executeOperation", - async (ast, location, instanceType: InstanceType) => { - await executeOperation(ast, location, instanceType); + async (arg: ExecutionInput) => { + await executeOperation(arg); }, ), vscode.commands.registerCommand( @@ -452,67 +383,3 @@ function executionError(message: string, error?: string) { ); throw new Error(error); } - -function getArgsWithTypeFromOperation( - ast: OperationDefinitionNode, -): TypedInput[] { - if (!ast.variableDefinitions) { - return []; - } - return ast.variableDefinitions.map((variable) => { - const varName = variable.variable.name.value; - - const typeNode = variable.type; - - function getType(typeNode: TypeNode): string | null { - // Same as previous example - switch (typeNode.kind) { - case "NamedType": - return typeNode.name.value; - case "ListType": - const innerTypeName = getType(typeNode.type); - return `[${innerTypeName}]`; - case "NonNullType": - const nonNullTypeName = getType(typeNode.type); - return `${nonNullTypeName}!`; - default: - return null; - } - } - - const type = getType(typeNode); - - return { varName, type }; - }); -} - -// checks if required arguments are present in payload -async function verifyMissingArgs( - ast: OperationDefinitionNode, - jsonArgs: string, -): Promise { - let userArgs: { [key: string]: any }; - try { - userArgs = JSON.parse(jsonArgs); - } catch (e: any) { - executionError("Invalid JSON: ", e); - return []; - } - - const argsWithType = getArgsWithTypeFromOperation(ast); - if (!argsWithType) { - return []; - } - return argsWithType - .filter((arg) => arg.type?.includes("!")) - .filter((arg) => !userArgs[arg.varName]); -} - -function getDefaultArgs(args: TypedInput[]) { - return args.reduce((acc: { [key: string]: any }, arg) => { - const defaultValue = getDefaultScalarValue(arg.type as string); - - acc[arg.varName] = defaultValue; - return acc; - }, {}); -} diff --git a/firebase-vscode/src/data-connect/index.ts b/firebase-vscode/src/data-connect/index.ts index 14856f0e6c4..8344520a4e6 100644 --- a/firebase-vscode/src/data-connect/index.ts +++ b/firebase-vscode/src/data-connect/index.ts @@ -11,7 +11,6 @@ import { SchemaCodeLensProvider, } from "./code-lens-provider"; import { registerConnectors } from "./connectors"; -import { AuthService } from "../auth/service"; import { currentProjectId } from "../core/project"; import { isTest } from "../utils/env"; import { setupLanguageClient } from "./language-client"; @@ -33,6 +32,7 @@ import { registerFdcSdkGeneration } from "./sdk-generation"; import { registerDiagnostics } from "./diagnostics"; import { AnalyticsLogger } from "../analytics"; import { registerFirebaseMCP } from "./ai-tools/firebase-mcp"; +import { ExecutionParamsService } from "./execution/execution-params"; class CodeActionsProvider implements vscode.CodeActionProvider { constructor( @@ -132,7 +132,7 @@ class CodeActionsProvider implements vscode.CodeActionProvider { export function registerFdc( context: ExtensionContext, broker: ExtensionBrokerImpl, - authService: AuthService, + paramsService: ExecutionParamsService, emulatorController: EmulatorsController, analyticsLogger: AnalyticsLogger, ): Disposable { @@ -151,10 +151,8 @@ export function registerFdc( ); const fdcService = new FdcService( - authService, dataConnectToolkit, emulatorController, - context, analyticsLogger, ); @@ -221,6 +219,7 @@ export function registerFdc( context, broker, fdcService, + paramsService, analyticsLogger, emulatorController, ), diff --git a/firebase-vscode/src/data-connect/service.ts b/firebase-vscode/src/data-connect/service.ts index ee55cbbd682..7bc80b5aaa8 100644 --- a/firebase-vscode/src/data-connect/service.ts +++ b/firebase-vscode/src/data-connect/service.ts @@ -1,4 +1,4 @@ -import fetch, { Response } from "node-fetch"; +import fetch from "node-fetch"; import { ExtensionContext } from "vscode"; import { ExecutionResult, @@ -6,8 +6,6 @@ import { getIntrospectionQuery, } from "graphql"; import { DataConnectError } from "../../common/error"; -import { AuthService } from "../auth/service"; -import { UserMockKind } from "../../common/messaging/protocol"; import { firstWhereDefined } from "../utils/signal"; import { EmulatorsController } from "../core/emulators"; import { dataConnectConfigs } from "../data-connect/config"; @@ -24,23 +22,21 @@ import { ExecuteGraphqlRequest, GraphqlResponse, GraphqlResponseError, - Impersonation, } from "../dataconnect/types"; import { Client, ClientResponse } from "../../../src/apiv2"; import { InstanceType } from "./code-lens-provider"; import { pluginLogger } from "../logger-wrapper"; import { DataConnectToolkit } from "./toolkit"; import { AnalyticsLogger, DATA_CONNECT_EVENT_NAME } from "../analytics"; +import { ExecutionParamsService } from "./execution/execution-params"; /** * DataConnect Emulator service */ export class DataConnectService { constructor( - private authService: AuthService, private dataConnectToolkit: DataConnectToolkit, private emulatorsController: EmulatorsController, - private context: ExtensionContext, private analyticsLogger: AnalyticsLogger, ) {} @@ -102,22 +98,6 @@ export class DataConnectService { }); } - private _auth(): { impersonate?: Impersonation } { - const userMock = this.authService.userMock; - if (!userMock || userMock.kind === UserMockKind.ADMIN) { - return {}; - } - return { - impersonate: - userMock.kind === UserMockKind.AUTHENTICATED - ? { - authClaims: JSON.parse(userMock.claims), - includeDebugDetails: true, - } - : { unauthenticated: true, includeDebugDetails: true }, - }; - } - // This introspection is used to generate a basic graphql schema // It will not include our predefined operations, which requires a DataConnect specific introspection query async introspect(): Promise<{ data?: IntrospectionQuery }> { @@ -200,35 +180,13 @@ export class DataConnectService { } } - async executeGraphQL(params: { - query: string; - operationName?: string; - variables: string; - path: string; - instance: InstanceType; - }) { - const servicePath = await this.servicePath(params.path); - if (!servicePath) { - throw new Error("No service found for path: " + params.path); - } - const prodBody: ExecuteGraphqlRequest = { - operationName: params.operationName, - variables: parseVariableString(params.variables), - query: params.query, - extensions: this._auth(), - }; - - const body = this._serializeBody({ - ...params, - name: `${servicePath}`, - extensions: this._auth(), - }); - if (params.instance === InstanceType.PRODUCTION) { + async executeGraphQL(servicePath: string, instance: InstanceType, body: ExecuteGraphqlRequest) { + if (instance === InstanceType.PRODUCTION) { const client = dataconnectDataplaneClient(); pluginLogger.info( - `ExecuteGraphQL (${dataconnectOrigin()}) request: ${JSON.stringify(prodBody, undefined, 4)}`, + `ExecuteGraphQL (${dataconnectOrigin()}) request: ${JSON.stringify(body, undefined, 4)}`, ); - const resp = await executeGraphQL(client, servicePath, prodBody); + const resp = await executeGraphQL(client, servicePath, body); return this.handleProdResponse(resp); } else { const endpoint = this.emulatorsController.getLocalEndpoint(); @@ -241,7 +199,7 @@ export class DataConnectService { urlPrefix: endpoint, apiVersion: DATACONNECT_API_VERSION, }); - const resp = await executeGraphQL(client, servicePath, prodBody); + const resp = await executeGraphQL(client, servicePath, body); return this.handleEmulatorResponse(resp); } } @@ -250,16 +208,3 @@ export class DataConnectService { return this.dataConnectToolkit.getGeneratedDocsURL(); } } - -function parseVariableString(variables: string): Record { - if (!variables) { - return {}; - } - try { - return JSON.parse(variables); - } catch (e: any) { - throw new Error( - "Unable to parse variables as JSON. Double check that that there are no unmatched braces or quotes, or unqouted keys in the variables pane.", - ); - } -} diff --git a/firebase-vscode/src/extension.ts b/firebase-vscode/src/extension.ts index f4daec6db29..473169bfc03 100644 --- a/firebase-vscode/src/extension.ts +++ b/firebase-vscode/src/extension.ts @@ -17,7 +17,7 @@ import { updateIdxSetting, } from "./utils/settings"; import { registerFdc } from "./data-connect"; -import { AuthService } from "./auth/service"; +import { ExecutionParamsService } from "./data-connect/execution/execution-params"; import { AnalyticsLogger, IDX_METRIC_NOTICE } from "./analytics"; import { env } from "./core/env"; @@ -27,7 +27,6 @@ import { setIsVSCodeExtension } from "../../src/vsCodeUtils"; export async function activate(context: vscode.ExtensionContext) { const analyticsLogger = new AnalyticsLogger(context); - await setupFirebasePath(analyticsLogger); const settings = getSettings(); logSetup(); @@ -39,7 +38,7 @@ export async function activate(context: vscode.ExtensionContext) { vscode.Webview >(new ExtensionBroker()); - const authService = new AuthService(broker); + const paramsService = new ExecutionParamsService(broker, analyticsLogger); // show IDX data collection notice if (settings.shouldShowIdxMetricNotice && env.value.isMonospace) { @@ -66,11 +65,11 @@ export async function activate(context: vscode.ExtensionContext) { broker, context, }), - authService, + paramsService, registerFdc( context, broker, - authService, + paramsService, emulatorsController, analyticsLogger, ), diff --git a/firebase-vscode/src/test/suite/src/data-connect/execution/execution-params.test.ts b/firebase-vscode/src/test/suite/src/data-connect/execution/execution-params.test.ts new file mode 100644 index 00000000000..cc12fd0b3b0 --- /dev/null +++ b/firebase-vscode/src/test/suite/src/data-connect/execution/execution-params.test.ts @@ -0,0 +1,119 @@ + +import * as assert from "assert"; +import { + ExecutionParamsService, + executionVarsJSON, +} from "../../../../../data-connect/execution/execution-params"; +import { firebaseSuite, firebaseTest } from "../../../../utils/test_hooks"; +import { OperationDefinitionNode, parse } from "graphql"; +import { spy } from "sinon"; + +firebaseSuite("ExecutionParamsService.applyDetectedFixes", () => { + firebaseTest("should remove undefined variables", async () => { + const broker = { + send: () => {}, + on: () => ({ dispose: () => {} }), + } as any; + const analyticsLogger = { + logger: { + logUsage: () => {}, + }, + } as any; + const sendSpy = spy(broker, "send"); + + executionVarsJSON.value = JSON.stringify({ + name: "test", + unused: "value", + }); + + const ast = parse(` + query MyQuery($name: String) { + users(name: $name) { + id + } + } + `).definitions[0] as OperationDefinitionNode; + + const service = new ExecutionParamsService(broker, analyticsLogger); + await service.applyDetectedFixes(ast); + + const expectedJSON = JSON.stringify({ name: "test" }, null, 2); + assert.equal(executionVarsJSON.value, expectedJSON); + assert.ok(sendSpy.calledOnce); + assert.deepEqual(sendSpy.getCall(0).args, [ + "notifyVariables", + { + variables: expectedJSON, + fixes: ["Removed undefined variables: $unused"], + }, + ]); + }); + + firebaseTest("should add missing required variables", async () => { + const broker = { + send: () => {}, + on: () => ({ dispose: () => {} }), + } as any; + const analyticsLogger = { + logger: { + logUsage: () => {}, + }, + } as any; + const sendSpy = spy(broker, "send"); + + executionVarsJSON.value = JSON.stringify({}); + + const ast = parse(` + query MyQuery($name: String!) { + users(name: $name) { + id + } + } + `).definitions[0] as OperationDefinitionNode; + + const service = new ExecutionParamsService(broker, analyticsLogger); + await service.applyDetectedFixes(ast); + + const expectedJSON = JSON.stringify({ name: "" }, null, 2); + assert.equal(executionVarsJSON.value, expectedJSON); + assert.ok(sendSpy.calledOnce); + assert.deepEqual(sendSpy.getCall(0).args, [ + "notifyVariables", + { + variables: expectedJSON, + fixes: ["Included required variables: $name"], + }, + ]); + }); + + firebaseTest("should do nothing if no fixes are needed", async () => { + const broker = { + send: () => {}, + on: () => ({ dispose: () => {} }), + } as any; + const analyticsLogger = { + logger: { + logUsage: () => {}, + }, + } as any; + const sendSpy = spy(broker, "send"); + + const originalJSON = JSON.stringify({ name: "test" }); + executionVarsJSON.value = originalJSON; + + const ast = parse(` + query MyQuery($name: String) { + users(name: $name) { + id + } + } + `).definitions[0] as OperationDefinitionNode; + + const service = new ExecutionParamsService(broker, analyticsLogger); + await service.applyDetectedFixes(ast); + + assert.equal(executionVarsJSON.value, originalJSON); + assert.ok(sendSpy.notCalled); + }); +}); + diff --git a/firebase-vscode/src/test/utils/page_objects/execution.ts b/firebase-vscode/src/test/utils/page_objects/execution.ts index 2f4abb9f140..f0bc828f926 100644 --- a/firebase-vscode/src/test/utils/page_objects/execution.ts +++ b/firebase-vscode/src/test/utils/page_objects/execution.ts @@ -11,7 +11,7 @@ export class ExecutionPanel { async open(): Promise { await browser.keys("F1"); await this.workbench.executeCommand( - "data-connect-execution-configuration.focus", + "data-connect-execution-parameters.focus", ); } diff --git a/firebase-vscode/webviews/data-connect/DataConnectExecutionResultsApp.tsx b/firebase-vscode/webviews/data-connect/DataConnectExecutionResultsApp.tsx index c00d9d11298..b17bd7a6e58 100644 --- a/firebase-vscode/webviews/data-connect/DataConnectExecutionResultsApp.tsx +++ b/firebase-vscode/webviews/data-connect/DataConnectExecutionResultsApp.tsx @@ -1,10 +1,12 @@ import React from "react"; -import { useBroker } from "../globals/html-broker"; +import { broker, useBroker } from "../globals/html-broker"; import { Label } from "../components/ui/Text"; import style from "./data-connect-execution-results.entry.scss"; import { SerializedError } from "../../common/error"; import { ExecutionResult, GraphQLError } from "graphql"; import { isExecutionResult } from "../../common/graphql"; +import { AuthParamsKind } from '../../common/messaging/protocol'; +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; // Prevent webpack from removing the `style` import above style; @@ -34,7 +36,6 @@ export function DataConnectExecutionResultsApp() { if (errors && errors.length !== 0) { errorsDisplay = ( <> - ); @@ -48,31 +49,73 @@ export function DataConnectExecutionResultsApp() { let resultsDisplay: JSX.Element | undefined; if (response) { resultsDisplay = ( + + +
{JSON.stringify(response, null, 2)}
+
+ ); + } + + let variablesDisplay: JSX.Element | undefined; + if ( + dataConnectResults.variables !== "" && + dataConnectResults.variables !== "{}" + ) { + variablesDisplay = ( <> - -
{JSON.stringify(response, null, 2)}
+ +
{dataConnectResults.variables}
+
); } + let authDisplay: JSX.Element | undefined; + switch (dataConnectResults.auth.kind) { + case AuthParamsKind.ADMIN: + // Default is admin. + break; + case AuthParamsKind.UNAUTHENTICATED: + authDisplay = ( + <> + +
+ + ); + break; + case AuthParamsKind.AUTHENTICATED: + authDisplay = ( + <> + + +
{dataConnectResults.auth.claims}
+
+
+ + ); + break; + } + return ( <> +

+ broker.send("rerunExecution")} appearance="secondary" style={{ transform: "scale(0.8)" }}> + Rerun + {" "} + {dataConnectResults.displayName} +

+
{errorsDisplay} {resultsDisplay} - - +
+ {authDisplay} + {variablesDisplay} +
{dataConnectResults.query}
- - - -
{dataConnectResults.args}
-
); } @@ -85,11 +128,7 @@ function InternalErrorView({ error }: { error: SerializedError }) { <>

- { - // Stacktraces usually already include the message, so we only - // display the message if there is no stacktrace. - error.stack ? : error.message - } + {error.message} {error.cause && ( <>
diff --git a/firebase-vscode/webviews/data-connect/data-connect-execution-configuration.entry.scss b/firebase-vscode/webviews/data-connect/data-connect-execution-parameters.entry.scss similarity index 100% rename from firebase-vscode/webviews/data-connect/data-connect-execution-configuration.entry.scss rename to firebase-vscode/webviews/data-connect/data-connect-execution-parameters.entry.scss diff --git a/firebase-vscode/webviews/data-connect/data-connect-execution-configuration.entry.tsx b/firebase-vscode/webviews/data-connect/data-connect-execution-parameters.entry.tsx similarity index 53% rename from firebase-vscode/webviews/data-connect/data-connect-execution-configuration.entry.tsx rename to firebase-vscode/webviews/data-connect/data-connect-execution-parameters.entry.tsx index c991a703f6f..c33f9615198 100644 --- a/firebase-vscode/webviews/data-connect/data-connect-execution-configuration.entry.tsx +++ b/firebase-vscode/webviews/data-connect/data-connect-execution-parameters.entry.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; -import style from "./data-connect-execution-configuration.entry.scss"; +import style from "./data-connect-execution-parameters.entry.scss"; import { VSCodeButton, VSCodeDropdown, @@ -10,34 +10,39 @@ import { VSCodePanelView, VSCodeTextArea, } from "@vscode/webview-ui-toolkit/react"; -import { broker, useBroker } from "../globals/html-broker"; +import { broker } from "../globals/html-broker"; import { Spacer } from "../components/ui/Spacer"; -import { UserMockKind } from "../../common/messaging/protocol"; +import { EXAMPLE_CLAIMS, AuthParamsKind, AuthParams, DataConnectResults } from "../../common/messaging/protocol"; const root = createRoot(document.getElementById("root")!); root.render(); export function DataConnectExecutionArgumentsApp() { - function handleVariableChange(e: React.ChangeEvent) { - setText(e.target.value); - broker.send("definedDataConnectArgs", e.target.value); - } - - const lastOperation = useBroker("notifyLastOperation"); - const textareaRef = useRef(null); - const [textareaVariables, setText] = useState("{}"); + const [variables, setVariables] = useState("{}"); + const [fixes, setFixes] = useState([]); + useEffect(() => { + broker.send("defineVariables", variables); + }, [variables]); - const updateText = broker.on("notifyDataConnectArgs" , (newArgs: string) => { - setText(newArgs); - if (textareaRef.current) { - textareaRef.current.focus(); - textareaRef.current.setSelectionRange(0, 1); - } - }) + useEffect(() => { + const dispose1 = broker.on("notifyVariables", (v: {variables: string, fixes: string[]}) => { + setVariables(v.variables); + setFixes(v.fixes); + }); + const dispose2 = broker.on("notifyDataConnectResults", (results: DataConnectResults) => { + setVariables(results.variables); + setFixes([]); + }); + return () => { + dispose1(); + dispose2(); + }; + }, []); - const sendRerun = () => { - broker.send("rerunExecution"); + const handleVariableChange = (e: React.ChangeEvent) => { + setVariables(e.target.value); + setFixes([]); }; // Due to webview-ui-toolkit adding shadow-roots, css alone is not @@ -73,53 +78,71 @@ export function DataConnectExecutionArgumentsApp() { AUTHENTICATION - {lastOperation && ( - - Rerun last execution: {lastOperation} - + {fixes.length > 0 && ( + <> + Applied Fixes: +

    + {fixes.map((fix, index) => ( +
  • {fix}
  • + ))} +
+ )} - + ); } -function AuthUserMockForm() { - const [selectedKind, setSelectedMockKind] = useState( - UserMockKind.ADMIN, - ); - const [claims, setClaims] = useState( - `{\n "email_verified": true,\n "sub": "exampleUserId"\n}`, +function AuthParamForm() { + const [selectedKind, setSelectedMockKind] = useState( + AuthParamsKind.ADMIN, ); + const [claims, setClaims] = useState(EXAMPLE_CLAIMS); useEffect(() => { - broker.send( - "notifyAuthUserMockChange", - selectedKind === UserMockKind.AUTHENTICATED + const auth = selectedKind === AuthParamsKind.AUTHENTICATED ? { kind: selectedKind, claims: claims, } : { kind: selectedKind, - }, - ); + }; + broker.send("defineAuthParams", auth); }, [selectedKind, claims]); + function setAuthParams(auth: AuthParams) { + setSelectedMockKind(auth.kind); + if (auth.kind === AuthParamsKind.AUTHENTICATED) { + setClaims(auth.claims); + } + } + + useEffect(() => { + const dispose1 = broker.on("notifyAuthParams", setAuthParams); + const dispose2 = broker.on("notifyDataConnectResults", (results: DataConnectResults) => { + setAuthParams(results.auth); + }); + return () => { + dispose1(); + dispose2(); + }; + }, []); + let expandedForm: JSX.Element | undefined; - if (selectedKind === UserMockKind.AUTHENTICATED) { + if (selectedKind === AuthParamsKind.AUTHENTICATED) { expandedForm = ( <> - Claim and values + Claim JWT setSelectedMockKind((event.target as any).value)} > - Admin - + Admin + Unauthenticated - + Authenticated