Skip to content
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
- Add a confirmation in `firebase init dataconnect` before asking for app idea description. (#9282)
- Add a command `firebase dataconnect:execute` to run queries and mutations (#9274).
6 changes: 4 additions & 2 deletions firebase-vscode/src/data-connect/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,10 @@ export class DataConnectService {
return {
impersonate:
userMock.kind === UserMockKind.AUTHENTICATED
? { authClaims: JSON.parse(userMock.claims), includeDebugDetails: true }
? {
authClaims: JSON.parse(userMock.claims),
includeDebugDetails: true,
}
: { unauthenticated: true, includeDebugDetails: true },
};
}
Expand Down Expand Up @@ -212,7 +215,6 @@ export class DataConnectService {
operationName: params.operationName,
variables: parseVariableString(params.variables),
query: params.query,
name: `${servicePath}`,
extensions: this._auth(),
};

Expand Down
282 changes: 282 additions & 0 deletions src/commands/dataconnect-execute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
import * as clc from "colorette";
import { Command } from "../command";
import { Options } from "../options";
import { getProjectId, needProjectId } from "../projectUtils";
import { pickService, readGQLFiles, squashGraphQL } from "../dataconnect/load";
import { requireAuth } from "../requireAuth";
import { Constants } from "../emulator/constants";
import { Client } from "../apiv2";
import { DATACONNECT_API_VERSION, executeGraphQL } from "../dataconnect/dataplaneClient";
import { dataconnectDataplaneClient } from "../dataconnect/dataplaneClient";
import { isGraphqlName } from "../dataconnect/names";
import { FirebaseError } from "../error";
import { statSync } from "node:fs";
import { isGraphQLResponse, isGraphQLResponseError, ServiceInfo } from "../dataconnect/types";
import { EmulatorHub } from "../emulator/hub";
import { readFile } from "node:fs/promises";
import { EOL } from "node:os";
import { relative } from "node:path";
import { text } from "node:stream/consumers";
import { logger } from "../logger";
import { responseToError } from "../responseToError";

let stdinUsedFor: string | undefined = undefined;

export const command = new Command("dataconnect:execute [file] [operationName]")
.description(
"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.",
)
.option(
"--service <serviceId>",
"The service ID to execute against (optional if there's only one service)",
)
.option(
"--location <locationId>",
"The location ID to execute against (optional if there's only one service). Ignored by the emulator.",
)
.option(
"--vars, --variables <vars>",
"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.",
)
.option(
"--no-debug-details",
"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.",
)
.action(
// eslint-disable-next-line @typescript-eslint/no-inferrable-types
async (file: string = "", operationName: string | undefined, options: Options) => {
const emulatorHost = process.env[Constants.FIREBASE_DATACONNECT_EMULATOR_HOST];
let projectId: string;
if (emulatorHost) {
projectId = getProjectId(options) || EmulatorHub.MISSING_PROJECT_PLACEHOLDER;
} else {
projectId = needProjectId(options);
}
let serviceName: string | undefined = undefined;
const serviceId = options.service as string | undefined;
const locationId = options.location as string | undefined;

if (!file && !operationName) {
if (process.stdin.isTTY) {
throw new FirebaseError(
"At least one of the [file] [operationName] arguments is required.",
);
}
file = "-";
}
let query: string;
if (file === "-") {
stdinUsedFor = "operation source code";
if (process.stdin.isTTY) {
process.stderr.write(
`${clc.cyan("Reading GraphQL operation from stdin. EOF (CTRL+D) to finish and execute.")}${EOL}`,
);
}
query = await text(process.stdin);
} else {
const stat = statSync(file, { throwIfNoEntry: false });
if (stat?.isFile()) {
const opDisplay = operationName ? clc.bold(operationName) : "operation";
process.stderr.write(`${clc.cyan(`Executing ${opDisplay} in ${clc.bold(file)}`)}${EOL}`);
query = await readFile(file, "utf-8");
} else if (stat?.isDirectory()) {
query = await readQueryFromDir(file);
} else {
if (operationName === undefined /* but not an empty string */ && isGraphqlName(file)) {
// Command invoked with one single arg that looks like an operationName.
operationName = file;
file = "";
}
if (file) {
throw new FirebaseError(`${file}: no such file or directory`);
}
file = await pickConnectorDir();
query = await readQueryFromDir(file);
}
}

let apiClient: Client;
if (emulatorHost) {
const url = new URL("http://placeholder");
url.host = emulatorHost;
apiClient = new Client({
urlPrefix: url.toString(),
apiVersion: DATACONNECT_API_VERSION,
});
} else {
await requireAuth(options);
apiClient = dataconnectDataplaneClient();
}

if (!serviceName) {
if (serviceId && (locationId || emulatorHost)) {
serviceName = `projects/${projectId}/locations/${locationId || "unused"}/services/${serviceId}`;
} else {
serviceName = (await getServiceInfo()).serviceName;
}
}
if (!options.vars && !process.stdin.isTTY && !stdinUsedFor) {
options.vars = "@-";
}
const unparsedVars = await literalOrFile(options.vars, "--vars");
const response = await executeGraphQL(apiClient, serviceName, {
query,
operationName,
variables: parseJsonObject(unparsedVars, "--vars"),
});

// If the status code isn't OK or the top-level `error` field is set, this
// is an HTTP / gRPC error, not a GQL-compatible error response.
let err = responseToError(response, response.body);
if (isGraphQLResponseError(response.body)) {
const { status, message } = response.body.error;
if (!err) {
err = new FirebaseError(message, {
context: {
body: response.body,
response: response,
},
status: response.status,
});
}
if (status === "INVALID_ARGUMENT" && message.includes("operationName is required")) {
throw new FirebaseError(
err.message + `\nHint: Append <operationName> as an argument to disambiguate.`,
{ ...err, original: err },
);
}
}
if (err) {
throw err;
}

// If we reach here, we should have a GraphQL response with `data` and/or
// `errors` (note the plural). First let's double check that's the case.
if (!isGraphQLResponse(response.body)) {
throw new FirebaseError("Got invalid response body with neither .data or .errors", {
context: {
body: response.body,
response: response,
},
status: response.status,
});
}

// Log the body to stdout to allow pipe processing (even with .errors).
Copy link
Member Author

Choose a reason for hiding this comment

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

This doesn't work quite nicely since errors thrown (below, if any) are also logged to stdout (globally handled). Piping to jq for example can choke on the extra output

logger.info(JSON.stringify(response.body, null, 2));

// TODO: Pretty-print these errors by parsing the .errors array to extract
// messages, line numbers, etc.
if (!response.body.data) {
// If `data` is absent, this is a request error (i.e. total failure):
// https://spec.graphql.org/draft/#sec-Errors.Request-Errors
throw new FirebaseError(
"GraphQL request error(s). See response body (above) for details.",
{
context: {
body: response.body,
response: response,
},
status: response.status,
},
);
}
if (response.body.errors && response.body.errors.length > 0) {
throw new FirebaseError(
"Execution completed with error(s). See response body (above) for details.",
{
context: {
body: response.body,
response: response,
},
status: response.status,
},
);
}
return response.body;

async function readQueryFromDir(dir: string): Promise<string> {
const opDisplay = operationName ? clc.bold(operationName) : "operation";
process.stderr.write(`${clc.cyan(`Executing ${opDisplay} in ${clc.bold(dir)}`)}${EOL}`);
const files = await readGQLFiles(dir);
const query = squashGraphQL({ files });
if (!query) {
throw new FirebaseError(`${dir} contains no GQL files or only empty ones`);
}
return query;
}

async function getServiceInfo(): Promise<ServiceInfo> {
return pickService(projectId, options.config, serviceId || undefined).catch((e) => {
if (!(e instanceof FirebaseError)) {
return Promise.reject(e);
}
if (!serviceId) {
e = new FirebaseError(
e.message +
`\nHint: Try specifying the ${clc.yellow("--service <serviceId>")} option.`,
{ ...e, original: e },
);
}
return Promise.reject(e);
});
}

async function pickConnectorDir(): Promise<string> {
const serviceInfo = await getServiceInfo();
serviceName = serviceInfo.serviceName;
switch (serviceInfo.connectorInfo.length) {
case 1: {
const connector = serviceInfo.connectorInfo[0];
return relative(process.cwd(), connector.directory);
}
case 0:
throw new FirebaseError(
`No connector found.\n` +
"Hint: To execute an operation in a GraphQL file, run:\n" +
` firebase dataconnect:execute ${clc.yellow("./path/to/file.gql OPERATION_NAME")}`,
);
default: {
const example = relative(process.cwd(), serviceInfo.connectorInfo[0].directory);
throw new FirebaseError(
`A file or directory must be explicitly specified when there are multiple connectors.\n` +
"Hint: To execute an operation within a connector, try e.g.:\n" +
` firebase dataconnect:execute ${clc.yellow(`${example} OPERATION_NAME`)}`,
);
}
}
}
},
);

function parseJsonObject(json: string, subject: string): Record<string, any> {

Check warning on line 252 in src/commands/dataconnect-execute.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
let obj: unknown;
try {
obj = JSON.parse(json || "{}") as unknown;
} catch (e) {
throw new FirebaseError(`expected ${subject} to be valid JSON string, got: ${json}`);
}
if (typeof obj !== "object" || obj == null)
throw new FirebaseError(`Provided ${subject} is not an object`);
return obj;
}

async function literalOrFile(arg: any, subject: string): Promise<string> {

Check warning on line 264 in src/commands/dataconnect-execute.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
let str = arg as string | undefined;
if (!str) {
return "";
}
if (str.startsWith("@")) {
if (str === "@-") {
if (stdinUsedFor) {
throw new FirebaseError(
`standard input can only be used for one of ${stdinUsedFor} and ${subject}.`,
);
}
str = await text(process.stdin);
} else {
str = await readFile(str.substring(1), "utf-8");
}
}
return str;
}
1 change: 1 addition & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
/**
* Loads all commands for our parser.
*/
export function load(client: any): any {

Check warning on line 5 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type

Check warning on line 5 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
function loadCommand(name: string) {

Check warning on line 6 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
const t0 = process.hrtime.bigint();
const { command: cmd } = require(`./${name}`);

Check warning on line 8 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Require statement not part of import statement

Check warning on line 8 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
cmd.register(client);

Check warning on line 9 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe call of an `any` typed value

Check warning on line 9 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .register on an `any` value
const t1 = process.hrtime.bigint();
const diffMS = (t1 - t0) / BigInt(1e6);
if (diffMS > 75) {
Expand All @@ -14,7 +14,7 @@
// console.error(`Loading ${name} took ${diffMS}ms`);
}

return cmd.runner();

Check warning on line 17 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe return of an `any` typed value
}

const t0 = process.hrtime.bigint();
Expand Down Expand Up @@ -231,6 +231,7 @@
client.setup.emulators.ui = loadCommand("setup-emulators-ui");
client.dataconnect = {};
client.setup.emulators.dataconnect = loadCommand("setup-emulators-dataconnect");
client.dataconnect.execute = loadCommand("dataconnect-execute");
client.dataconnect.services = {};
client.dataconnect.services.list = loadCommand("dataconnect-services-list");
client.dataconnect.sql = {};
Expand Down
2 changes: 0 additions & 2 deletions src/dataconnect/dataplaneClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ describe("dataplaneClient", () => {
describe("executeGraphQL", () => {
it("should make a POST request to the executeGraphql endpoint", async () => {
const requestBody: types.ExecuteGraphqlRequest = {
name: "test",
query: "query { users { id } }",
};
const expectedResponse = { data: { users: [{ id: "1" }] } };
Expand All @@ -41,7 +40,6 @@ describe("dataplaneClient", () => {
describe("executeGraphQLRead", () => {
it("should make a POST request to the executeGraphqlRead endpoint", async () => {
const requestBody: types.ExecuteGraphqlRequest = {
name: "test",
query: "query { users { id } }",
};
const expectedResponse = { data: { users: [{ id: "1" }] } };
Expand Down
27 changes: 26 additions & 1 deletion src/dataconnect/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as path from "path";
import * as fs from "fs-extra";
import * as clc from "colorette";
import { glob } from "glob";

import { Config } from "../config";
import { FirebaseError } from "../error";
import {
Expand All @@ -11,6 +12,7 @@ import {
DataConnectYaml,
File,
ServiceInfo,
Source,
} from "./types";
import { readFileFromDirectory, wrappedSafeLoad } from "../utils";
import { DataConnectMultiple } from "../firebaseConfig";
Expand Down Expand Up @@ -161,7 +163,7 @@ function validateConnectorYaml(unvalidated: any): ConnectorYaml {
return unvalidated as ConnectorYaml;
}

async function readGQLFiles(sourceDir: string): Promise<File[]> {
export async function readGQLFiles(sourceDir: string): Promise<File[]> {
if (!fs.existsSync(sourceDir)) {
return [];
}
Expand All @@ -180,3 +182,26 @@ function toFile(sourceDir: string, fullPath: string): File {
content,
};
}

/**
* Combine the contents in all GQL files into a string.
* @return combined file contents, possible deliminated by boundary comments.
*/
export function squashGraphQL(source: Source): string {
if (!source.files || !source.files.length) {
return "";
}
if (source.files.length === 1) {
return source.files[0].content;
}
let query = "";
for (const f of source.files) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Highly tempted to do this with functional programming as .filter(...).map(...).reduce(...), but this probably reads cleaner as is.

if (!f.content || !/\S/.test(f.content)) {
continue; // Empty or space-only file.
}
query += `### Begin file ${f.path}\n`;
query += f.content;
query += `### End file ${f.path}\n`;
}
return query;
}
11 changes: 11 additions & 0 deletions src/dataconnect/names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,14 @@ export function parseCloudSQLInstanceName(cloudSQLInstanceName: string): cloudSQ
toString,
};
}

// https://spec.graphql.org/September2025/#sec-Names
const graphqlNameRegex = /^[A-Za-z_][A-Za-z0-9_]*$/;

/**
* Returns whether the string is a valid GraphQL Name (a.k.a. identifier).
* @param name the string to test
*/
export function isGraphqlName(name: string): boolean {
return graphqlNameRegex.test(name);
}
Loading
Loading