|
| 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 | +} |
0 commit comments