Skip to content

Commit 247c718

Browse files
authored
Implement dataconnect:execute command. (#9274)
1 parent f29c7ec commit 247c718

File tree

12 files changed

+336
-17
lines changed

12 files changed

+336
-17
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
- Add a confirmation in `firebase init dataconnect` before asking for app idea description. (#9282)
2+
- Add a command `firebase dataconnect:execute` to run queries and mutations (#9274).

firebase-vscode/src/data-connect/service.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,10 @@ export class DataConnectService {
110110
return {
111111
impersonate:
112112
userMock.kind === UserMockKind.AUTHENTICATED
113-
? { authClaims: JSON.parse(userMock.claims), includeDebugDetails: true }
113+
? {
114+
authClaims: JSON.parse(userMock.claims),
115+
includeDebugDetails: true,
116+
}
114117
: { unauthenticated: true, includeDebugDetails: true },
115118
};
116119
}
@@ -212,7 +215,6 @@ export class DataConnectService {
212215
operationName: params.operationName,
213216
variables: parseVariableString(params.variables),
214217
query: params.query,
215-
name: `${servicePath}`,
216218
extensions: this._auth(),
217219
};
218220

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import * as clc from "colorette";
2+
import { Command } from "../command";
3+
import { Options } from "../options";
4+
import { getProjectId, needProjectId } from "../projectUtils";
5+
import { pickService, readGQLFiles, squashGraphQL } from "../dataconnect/load";
6+
import { requireAuth } from "../requireAuth";
7+
import { Constants } from "../emulator/constants";
8+
import { Client } from "../apiv2";
9+
import { DATACONNECT_API_VERSION, executeGraphQL } from "../dataconnect/dataplaneClient";
10+
import { dataconnectDataplaneClient } from "../dataconnect/dataplaneClient";
11+
import { isGraphqlName } from "../dataconnect/names";
12+
import { FirebaseError } from "../error";
13+
import { statSync } from "node:fs";
14+
import { isGraphQLResponse, isGraphQLResponseError, ServiceInfo } from "../dataconnect/types";
15+
import { EmulatorHub } from "../emulator/hub";
16+
import { readFile } from "node:fs/promises";
17+
import { EOL } from "node:os";
18+
import { relative } from "node:path";
19+
import { text } from "node:stream/consumers";
20+
import { logger } from "../logger";
21+
import { responseToError } from "../responseToError";
22+
23+
let stdinUsedFor: string | undefined = undefined;
24+
25+
export const command = new Command("dataconnect:execute [file] [operationName]")
26+
.description(
27+
"execute a Data Connect query or mutation. If FIREBASE_DATACONNECT_EMULATOR_HOST is set (such as during 'firebase emulator:exec', executes against the emulator instead.",
28+
)
29+
.option(
30+
"--service <serviceId>",
31+
"The service ID to execute against (optional if there's only one service)",
32+
)
33+
.option(
34+
"--location <locationId>",
35+
"The location ID to execute against (optional if there's only one service). Ignored by the emulator.",
36+
)
37+
.option(
38+
"--vars, --variables <vars>",
39+
"Supply variables to the operation execution, which must be a JSON object whose keys are variable names. If vars begin with the character @, the rest is interpreted as a file name to read from, or - to read from stdin.",
40+
)
41+
.option(
42+
"--no-debug-details",
43+
"Disables debug information in the response. Executions returns helpful errors or GQL extensions by default, which may expose too much for unprivilleged user or programs. If that's the case, this flag turns those output off.",
44+
)
45+
.action(
46+
// eslint-disable-next-line @typescript-eslint/no-inferrable-types
47+
async (file: string = "", operationName: string | undefined, options: Options) => {
48+
const emulatorHost = process.env[Constants.FIREBASE_DATACONNECT_EMULATOR_HOST];
49+
let projectId: string;
50+
if (emulatorHost) {
51+
projectId = getProjectId(options) || EmulatorHub.MISSING_PROJECT_PLACEHOLDER;
52+
} else {
53+
projectId = needProjectId(options);
54+
}
55+
let serviceName: string | undefined = undefined;
56+
const serviceId = options.service as string | undefined;
57+
const locationId = options.location as string | undefined;
58+
59+
if (!file && !operationName) {
60+
if (process.stdin.isTTY) {
61+
throw new FirebaseError(
62+
"At least one of the [file] [operationName] arguments is required.",
63+
);
64+
}
65+
file = "-";
66+
}
67+
let query: string;
68+
if (file === "-") {
69+
stdinUsedFor = "operation source code";
70+
if (process.stdin.isTTY) {
71+
process.stderr.write(
72+
`${clc.cyan("Reading GraphQL operation from stdin. EOF (CTRL+D) to finish and execute.")}${EOL}`,
73+
);
74+
}
75+
query = await text(process.stdin);
76+
} else {
77+
const stat = statSync(file, { throwIfNoEntry: false });
78+
if (stat?.isFile()) {
79+
const opDisplay = operationName ? clc.bold(operationName) : "operation";
80+
process.stderr.write(`${clc.cyan(`Executing ${opDisplay} in ${clc.bold(file)}`)}${EOL}`);
81+
query = await readFile(file, "utf-8");
82+
} else if (stat?.isDirectory()) {
83+
query = await readQueryFromDir(file);
84+
} else {
85+
if (operationName === undefined /* but not an empty string */ && isGraphqlName(file)) {
86+
// Command invoked with one single arg that looks like an operationName.
87+
operationName = file;
88+
file = "";
89+
}
90+
if (file) {
91+
throw new FirebaseError(`${file}: no such file or directory`);
92+
}
93+
file = await pickConnectorDir();
94+
query = await readQueryFromDir(file);
95+
}
96+
}
97+
98+
let apiClient: Client;
99+
if (emulatorHost) {
100+
const url = new URL("http://placeholder");
101+
url.host = emulatorHost;
102+
apiClient = new Client({
103+
urlPrefix: url.toString(),
104+
apiVersion: DATACONNECT_API_VERSION,
105+
});
106+
} else {
107+
await requireAuth(options);
108+
apiClient = dataconnectDataplaneClient();
109+
}
110+
111+
if (!serviceName) {
112+
if (serviceId && (locationId || emulatorHost)) {
113+
serviceName = `projects/${projectId}/locations/${locationId || "unused"}/services/${serviceId}`;
114+
} else {
115+
serviceName = (await getServiceInfo()).serviceName;
116+
}
117+
}
118+
if (!options.vars && !process.stdin.isTTY && !stdinUsedFor) {
119+
options.vars = "@-";
120+
}
121+
const unparsedVars = await literalOrFile(options.vars, "--vars");
122+
const response = await executeGraphQL(apiClient, serviceName, {
123+
query,
124+
operationName,
125+
variables: parseJsonObject(unparsedVars, "--vars"),
126+
});
127+
128+
// If the status code isn't OK or the top-level `error` field is set, this
129+
// is an HTTP / gRPC error, not a GQL-compatible error response.
130+
let err = responseToError(response, response.body);
131+
if (isGraphQLResponseError(response.body)) {
132+
const { status, message } = response.body.error;
133+
if (!err) {
134+
err = new FirebaseError(message, {
135+
context: {
136+
body: response.body,
137+
response: response,
138+
},
139+
status: response.status,
140+
});
141+
}
142+
if (status === "INVALID_ARGUMENT" && message.includes("operationName is required")) {
143+
throw new FirebaseError(
144+
err.message + `\nHint: Append <operationName> as an argument to disambiguate.`,
145+
{ ...err, original: err },
146+
);
147+
}
148+
}
149+
if (err) {
150+
throw err;
151+
}
152+
153+
// If we reach here, we should have a GraphQL response with `data` and/or
154+
// `errors` (note the plural). First let's double check that's the case.
155+
if (!isGraphQLResponse(response.body)) {
156+
throw new FirebaseError("Got invalid response body with neither .data or .errors", {
157+
context: {
158+
body: response.body,
159+
response: response,
160+
},
161+
status: response.status,
162+
});
163+
}
164+
165+
// Log the body to stdout to allow pipe processing (even with .errors).
166+
logger.info(JSON.stringify(response.body, null, 2));
167+
168+
// TODO: Pretty-print these errors by parsing the .errors array to extract
169+
// messages, line numbers, etc.
170+
if (!response.body.data) {
171+
// If `data` is absent, this is a request error (i.e. total failure):
172+
// https://spec.graphql.org/draft/#sec-Errors.Request-Errors
173+
throw new FirebaseError(
174+
"GraphQL request error(s). See response body (above) for details.",
175+
{
176+
context: {
177+
body: response.body,
178+
response: response,
179+
},
180+
status: response.status,
181+
},
182+
);
183+
}
184+
if (response.body.errors && response.body.errors.length > 0) {
185+
throw new FirebaseError(
186+
"Execution completed with error(s). See response body (above) for details.",
187+
{
188+
context: {
189+
body: response.body,
190+
response: response,
191+
},
192+
status: response.status,
193+
},
194+
);
195+
}
196+
return response.body;
197+
198+
async function readQueryFromDir(dir: string): Promise<string> {
199+
const opDisplay = operationName ? clc.bold(operationName) : "operation";
200+
process.stderr.write(`${clc.cyan(`Executing ${opDisplay} in ${clc.bold(dir)}`)}${EOL}`);
201+
const files = await readGQLFiles(dir);
202+
const query = squashGraphQL({ files });
203+
if (!query) {
204+
throw new FirebaseError(`${dir} contains no GQL files or only empty ones`);
205+
}
206+
return query;
207+
}
208+
209+
async function getServiceInfo(): Promise<ServiceInfo> {
210+
return pickService(projectId, options.config, serviceId || undefined).catch((e) => {
211+
if (!(e instanceof FirebaseError)) {
212+
return Promise.reject(e);
213+
}
214+
if (!serviceId) {
215+
e = new FirebaseError(
216+
e.message +
217+
`\nHint: Try specifying the ${clc.yellow("--service <serviceId>")} option.`,
218+
{ ...e, original: e },
219+
);
220+
}
221+
return Promise.reject(e);
222+
});
223+
}
224+
225+
async function pickConnectorDir(): Promise<string> {
226+
const serviceInfo = await getServiceInfo();
227+
serviceName = serviceInfo.serviceName;
228+
switch (serviceInfo.connectorInfo.length) {
229+
case 1: {
230+
const connector = serviceInfo.connectorInfo[0];
231+
return relative(process.cwd(), connector.directory);
232+
}
233+
case 0:
234+
throw new FirebaseError(
235+
`No connector found.\n` +
236+
"Hint: To execute an operation in a GraphQL file, run:\n" +
237+
` firebase dataconnect:execute ${clc.yellow("./path/to/file.gql OPERATION_NAME")}`,
238+
);
239+
default: {
240+
const example = relative(process.cwd(), serviceInfo.connectorInfo[0].directory);
241+
throw new FirebaseError(
242+
`A file or directory must be explicitly specified when there are multiple connectors.\n` +
243+
"Hint: To execute an operation within a connector, try e.g.:\n" +
244+
` firebase dataconnect:execute ${clc.yellow(`${example} OPERATION_NAME`)}`,
245+
);
246+
}
247+
}
248+
}
249+
},
250+
);
251+
252+
function parseJsonObject(json: string, subject: string): Record<string, any> {
253+
let obj: unknown;
254+
try {
255+
obj = JSON.parse(json || "{}") as unknown;
256+
} catch (e) {
257+
throw new FirebaseError(`expected ${subject} to be valid JSON string, got: ${json}`);
258+
}
259+
if (typeof obj !== "object" || obj == null)
260+
throw new FirebaseError(`Provided ${subject} is not an object`);
261+
return obj;
262+
}
263+
264+
async function literalOrFile(arg: any, subject: string): Promise<string> {
265+
let str = arg as string | undefined;
266+
if (!str) {
267+
return "";
268+
}
269+
if (str.startsWith("@")) {
270+
if (str === "@-") {
271+
if (stdinUsedFor) {
272+
throw new FirebaseError(
273+
`standard input can only be used for one of ${stdinUsedFor} and ${subject}.`,
274+
);
275+
}
276+
str = await text(process.stdin);
277+
} else {
278+
str = await readFile(str.substring(1), "utf-8");
279+
}
280+
}
281+
return str;
282+
}

src/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ export function load(client: any): any {
231231
client.setup.emulators.ui = loadCommand("setup-emulators-ui");
232232
client.dataconnect = {};
233233
client.setup.emulators.dataconnect = loadCommand("setup-emulators-dataconnect");
234+
client.dataconnect.execute = loadCommand("dataconnect-execute");
234235
client.dataconnect.services = {};
235236
client.dataconnect.services.list = loadCommand("dataconnect-services-list");
236237
client.dataconnect.sql = {};

src/dataconnect/dataplaneClient.spec.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ describe("dataplaneClient", () => {
2323
describe("executeGraphQL", () => {
2424
it("should make a POST request to the executeGraphql endpoint", async () => {
2525
const requestBody: types.ExecuteGraphqlRequest = {
26-
name: "test",
2726
query: "query { users { id } }",
2827
};
2928
const expectedResponse = { data: { users: [{ id: "1" }] } };
@@ -41,7 +40,6 @@ describe("dataplaneClient", () => {
4140
describe("executeGraphQLRead", () => {
4241
it("should make a POST request to the executeGraphqlRead endpoint", async () => {
4342
const requestBody: types.ExecuteGraphqlRequest = {
44-
name: "test",
4543
query: "query { users { id } }",
4644
};
4745
const expectedResponse = { data: { users: [{ id: "1" }] } };

src/dataconnect/load.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as path from "path";
22
import * as fs from "fs-extra";
33
import * as clc from "colorette";
44
import { glob } from "glob";
5+
56
import { Config } from "../config";
67
import { FirebaseError } from "../error";
78
import {
@@ -11,6 +12,7 @@ import {
1112
DataConnectYaml,
1213
File,
1314
ServiceInfo,
15+
Source,
1416
} from "./types";
1517
import { readFileFromDirectory, wrappedSafeLoad } from "../utils";
1618
import { DataConnectMultiple } from "../firebaseConfig";
@@ -161,7 +163,7 @@ function validateConnectorYaml(unvalidated: any): ConnectorYaml {
161163
return unvalidated as ConnectorYaml;
162164
}
163165

164-
async function readGQLFiles(sourceDir: string): Promise<File[]> {
166+
export async function readGQLFiles(sourceDir: string): Promise<File[]> {
165167
if (!fs.existsSync(sourceDir)) {
166168
return [];
167169
}
@@ -180,3 +182,26 @@ function toFile(sourceDir: string, fullPath: string): File {
180182
content,
181183
};
182184
}
185+
186+
/**
187+
* Combine the contents in all GQL files into a string.
188+
* @return combined file contents, possible deliminated by boundary comments.
189+
*/
190+
export function squashGraphQL(source: Source): string {
191+
if (!source.files || !source.files.length) {
192+
return "";
193+
}
194+
if (source.files.length === 1) {
195+
return source.files[0].content;
196+
}
197+
let query = "";
198+
for (const f of source.files) {
199+
if (!f.content || !/\S/.test(f.content)) {
200+
continue; // Empty or space-only file.
201+
}
202+
query += `### Begin file ${f.path}\n`;
203+
query += f.content;
204+
query += `### End file ${f.path}\n`;
205+
}
206+
return query;
207+
}

src/dataconnect/names.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,14 @@ export function parseCloudSQLInstanceName(cloudSQLInstanceName: string): cloudSQ
8989
toString,
9090
};
9191
}
92+
93+
// https://spec.graphql.org/September2025/#sec-Names
94+
const graphqlNameRegex = /^[A-Za-z_][A-Za-z0-9_]*$/;
95+
96+
/**
97+
* Returns whether the string is a valid GraphQL Name (a.k.a. identifier).
98+
* @param name the string to test
99+
*/
100+
export function isGraphqlName(name: string): boolean {
101+
return graphqlNameRegex.test(name);
102+
}

0 commit comments

Comments
 (0)