UNPKG

firebase-admin

Version:
488 lines (486 loc) 21.9 kB
/*! firebase-admin v13.6.1 */ "use strict"; /*! * @license * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.FirebaseDataConnectError = exports.DATA_CONNECT_ERROR_CODE_MAPPING = exports.DataConnectApiClient = void 0; exports.useEmulator = useEmulator; const api_request_1 = require("../utils/api-request"); const error_1 = require("../utils/error"); const utils = require("../utils/index"); const validator = require("../utils/validator"); const API_VERSION = 'v1'; const FIREBASE_DATA_CONNECT_PROD_URL = 'https://firebasedataconnect.googleapis.com'; /** The Firebase Data Connect backend service URL format. */ const FIREBASE_DATA_CONNECT_SERVICES_URL_FORMAT = FIREBASE_DATA_CONNECT_PROD_URL + '/{version}' + '/projects/{projectId}' + '/locations/{locationId}' + '/services/{serviceId}' + ':{endpointId}'; /** The Firebase Data Connect backend connector URL format. */ const FIREBASE_DATA_CONNECT_CONNECTORS_URL_FORMAT = FIREBASE_DATA_CONNECT_PROD_URL + '/{version}' + '/projects/{projectId}' + '/locations/{locationId}' + '/services/{serviceId}' + '/connectors/{connectorId}' + ':{endpointId}'; /** Firebase Data Connect service URL format when using the Data Connect emulator. */ const FIREBASE_DATA_CONNECT_EMULATOR_SERVICES_URL_FORMAT = 'http://{host}/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}:{endpointId}'; /** Firebase Data Connect connector URL format when using the Data Connect emulator. */ const FIREBASE_DATA_CONNECT_EMULATOR_CONNECTORS_URL_FORMAT = 'http://{host}/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}/connectors/{connectorId}:{endpointId}'; const EXECUTE_GRAPH_QL_ENDPOINT = 'executeGraphql'; const EXECUTE_GRAPH_QL_READ_ENDPOINT = 'executeGraphqlRead'; const IMPERSONATE_QUERY_ENDPOINT = 'impersonateQuery'; const IMPERSONATE_MUTATION_ENDPOINT = 'impersonateMutation'; function getHeaders(isUsingGen) { const headerValue = { 'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}`, 'X-Goog-Api-Client': utils.getMetricsHeader(), }; if (isUsingGen) { headerValue['X-Goog-Api-Client'] += ' admin-js/gen'; } return headerValue; } /** * Class that facilitates sending requests to the Firebase Data Connect backend API. * * @internal */ class DataConnectApiClient { constructor(connectorConfig, app) { this.connectorConfig = connectorConfig; this.app = app; this.isUsingGen = false; if (!validator.isNonNullObject(app) || !('options' in app)) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, 'First argument passed to getDataConnect() must be a valid Firebase app instance.'); } this.httpClient = new DataConnectHttpClient(app); } /** * Update whether the SDK is using a generated one or not. * @param isUsingGen */ setIsUsingGen(isUsingGen) { this.isUsingGen = isUsingGen; } /** * Execute arbitrary GraphQL, including both read and write queries * * @param query - The GraphQL string to be executed. * @param options - GraphQL Options * @returns A promise that fulfills with a `ExecuteGraphqlResponse`. */ async executeGraphql(query, options) { return this.executeGraphqlHelper(query, EXECUTE_GRAPH_QL_ENDPOINT, options); } /** * Execute arbitrary read-only GraphQL queries * * @param query - The GraphQL (read-only) string to be executed. * @param options - GraphQL Options * @returns A promise that fulfills with a `ExecuteGraphqlResponse`. * @throws FirebaseDataConnectError */ async executeGraphqlRead(query, options) { return this.executeGraphqlHelper(query, EXECUTE_GRAPH_QL_READ_ENDPOINT, options); } /** * A helper function to execute GraphQL queries. * * @param query - The arbitrary GraphQL query to execute. * @param endpoint - The endpoint to call. * @param options - The GraphQL options. * @returns A promise that fulfills with the GraphQL response, or throws an error. */ async executeGraphqlHelper(query, endpoint, options) { if (!validator.isNonEmptyString(query)) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, '`query` must be a non-empty string.'); } if (typeof options !== 'undefined') { if (!validator.isNonNullObject(options)) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, 'GraphqlOptions must be a non-null object'); } } const data = { query, ...(options?.variables && { variables: options?.variables }), ...(options?.operationName && { operationName: options?.operationName }), ...(options?.impersonate && { extensions: { impersonate: options?.impersonate } }), }; const url = await this.getServicesUrl(API_VERSION, this.connectorConfig.location, this.connectorConfig.serviceId, endpoint); try { const resp = await this.makeGqlRequest(url, data); return resp; } catch (err) { throw this.toFirebaseError(err); } } /** * Executes a GraphQL query with impersonation. * * @param options - The GraphQL options. Must include impersonation details. * @returns A promise that fulfills with the GraphQL response. */ async executeQuery(name, variables, options) { return this.executeOperationHelper(IMPERSONATE_QUERY_ENDPOINT, name, variables, options); } /** * Executes a GraphQL mutation with impersonation. * * @param options - The GraphQL options. Must include impersonation details. * @returns A promise that fulfills with the GraphQL response. */ async executeMutation(name, variables, options) { return this.executeOperationHelper(IMPERSONATE_MUTATION_ENDPOINT, name, variables, options); } /** * A helper function to execute operations by making requests to FDC's impersonate * operations endpoints. * * @param endpoint - The endpoint to call. * @param options - The GraphQL options, including impersonation details. * @returns A promise that fulfills with the GraphQL response. */ async executeOperationHelper(endpoint, name, variables, options) { if (typeof name === 'undefined' || !validator.isNonEmptyString(name)) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, '`name` must be a non-empty string.'); } if (this.connectorConfig.connector === undefined || this.connectorConfig.connector === '') { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, `The 'connectorConfig.connector' field used to instantiate your Data Connect instance must be a non-empty string (the connectorId) when calling executeQuery or executeMutation.`); } const data = { ...(variables && { variables: variables }), operationName: name, extensions: { impersonate: options?.impersonate }, }; const url = await this.getConnectorsUrl(API_VERSION, this.connectorConfig.location, this.connectorConfig.serviceId, this.connectorConfig.connector, endpoint); try { const resp = await this.makeGqlRequest(url, data); return resp; } catch (err) { throw this.toFirebaseError(err); } } /** * Constructs the URL for a Data Connect request to a service endpoint. * * @param version - The API version. * @param locationId - The location of the Data Connect service. * @param serviceId - The ID of the Data Connect service. * @param endpointId - The endpoint to call. * @returns A promise which resolves to the formatted URL string. */ async getServicesUrl(version, locationId, serviceId, endpointId) { const projectId = await this.getProjectId(); const params = { version, projectId, locationId, serviceId, endpointId, }; let urlFormat = FIREBASE_DATA_CONNECT_SERVICES_URL_FORMAT; if (useEmulator()) { urlFormat = FIREBASE_DATA_CONNECT_EMULATOR_SERVICES_URL_FORMAT; params.host = emulatorHost(); } return utils.formatString(urlFormat, params); } /** * Constructs the URL for a Data Connect request to a connector endpoint. * * @param version - The API version. * @param locationId - The location of the Data Connect service. * @param serviceId - The ID of the Data Connect service. * @param connectorId - The ID of the Connector. * @param endpointId - The endpoint to call. * @returns A promise which resolves to the formatted URL string. */ async getConnectorsUrl(version, locationId, serviceId, connectorId, endpointId) { const projectId = await this.getProjectId(); const params = { version, projectId, locationId, serviceId, connectorId, endpointId, }; let urlFormat = FIREBASE_DATA_CONNECT_CONNECTORS_URL_FORMAT; if (useEmulator()) { urlFormat = FIREBASE_DATA_CONNECT_EMULATOR_CONNECTORS_URL_FORMAT; params.host = emulatorHost(); } return utils.formatString(urlFormat, params); } getProjectId() { if (this.projectId) { return Promise.resolve(this.projectId); } return utils.findProjectId(this.app) .then((projectId) => { if (!validator.isNonEmptyString(projectId)) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.UNKNOWN, 'Failed to determine project ID. Initialize the ' + 'SDK with service account credentials or set project ID as an app option. ' + 'Alternatively, set the GOOGLE_CLOUD_PROJECT environment variable.'); } this.projectId = projectId; return projectId; }); } /** * Makes a GraphQL request to the specified url. * * @param url - The URL to send the request to. * @param data - The GraphQL request payload. * @returns A promise that fulfills with the GraphQL response, or throws an error. */ async makeGqlRequest(url, data) { const request = { method: 'POST', url, headers: getHeaders(this.isUsingGen), data, }; const resp = await this.httpClient.send(request); if (resp.data.errors && validator.isNonEmptyArray(resp.data.errors)) { const allMessages = resp.data.errors.map((error) => error.message).join(' '); throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR, allMessages); } return Promise.resolve({ data: resp.data.data, }); } toFirebaseError(err) { if (err instanceof error_1.PrefixedFirebaseError) { return err; } const response = err.response; if (!response.isJson()) { return new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.UNKNOWN, `Unexpected response with status: ${response.status} and body: ${response.text}`); } const error = response.data.error || {}; let code = exports.DATA_CONNECT_ERROR_CODE_MAPPING.UNKNOWN; if (error.status && error.status in exports.DATA_CONNECT_ERROR_CODE_MAPPING) { code = exports.DATA_CONNECT_ERROR_CODE_MAPPING[error.status]; } const message = error.message || `Unknown server error: ${response.text}`; return new FirebaseDataConnectError(code, message); } /** * Converts JSON data into a GraphQL literal string. * Handles nested objects, arrays, strings, numbers, and booleans. * Ensures strings are properly escaped. */ objectToString(data) { if (typeof data === 'string') { return JSON.stringify(data); } if (typeof data === 'number' || typeof data === 'boolean' || data === null) { return String(data); } if (validator.isArray(data)) { const elements = data.map(item => this.objectToString(item)).join(', '); return `[${elements}]`; } if (typeof data === 'object' && data !== null) { // Filter out properties where the value is undefined BEFORE mapping const kvPairs = Object.entries(data) .filter(([, val]) => val !== undefined) .map(([key, val]) => { // GraphQL object keys are typically unquoted. return `${key}: ${this.objectToString(val)}`; }); if (kvPairs.length === 0) { return '{}'; // Represent an object with no defined properties as {} } return `{ ${kvPairs.join(', ')} }`; } // If value is undefined (and not an object property, which is handled above, // e.g., if objectToString(undefined) is called directly or for an array element) // it should be represented as 'null'. if (typeof data === 'undefined') { return 'null'; } // Fallback for any other types (e.g., Symbol, BigInt - though less common in GQL contexts) // Consider how these should be handled or if an error should be thrown. // For now, simple string conversion. return String(data); } formatTableName(tableName) { // Format tableName: first character to lowercase if (tableName && tableName.length > 0) { return tableName.charAt(0).toLowerCase() + tableName.slice(1); } return tableName; } handleBulkImportErrors(err) { if (err.code === `data-connect/${exports.DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR}`) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR, `${err.message}. Make sure that your table name passed in matches the type name in your GraphQL schema file.`); } throw err; } /** * Insert a single row into the specified table. */ async insert(tableName, data) { if (!validator.isNonEmptyString(tableName)) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, '`tableName` must be a non-empty string.'); } if (validator.isArray(data)) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, '`data` must be an object, not an array, for single insert. For arrays, please use `insertMany` function.'); } if (!validator.isNonNullObject(data)) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, '`data` must be a non-null object.'); } try { tableName = this.formatTableName(tableName); const gqlDataString = this.objectToString(data); const mutation = `mutation { ${tableName}_insert(data: ${gqlDataString}) }`; // Use internal executeGraphql return this.executeGraphql(mutation).catch(this.handleBulkImportErrors); } catch (e) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL, `Failed to construct insert mutation: ${e.message}`); } } /** * Insert multiple rows into the specified table. */ async insertMany(tableName, data) { if (!validator.isNonEmptyString(tableName)) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, '`tableName` must be a non-empty string.'); } if (!validator.isNonEmptyArray(data)) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, '`data` must be a non-empty array for insertMany.'); } try { tableName = this.formatTableName(tableName); const gqlDataString = this.objectToString(data); const mutation = `mutation { ${tableName}_insertMany(data: ${gqlDataString}) }`; // Use internal executeGraphql return this.executeGraphql(mutation).catch(this.handleBulkImportErrors); } catch (e) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL, `Failed to construct insertMany mutation: ${e.message}`); } } /** * Insert a single row into the specified table, or update it if it already exists. */ async upsert(tableName, data) { if (!validator.isNonEmptyString(tableName)) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, '`tableName` must be a non-empty string.'); } if (validator.isArray(data)) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, '`data` must be an object, not an array, for single upsert. For arrays, please use `upsertMany` function.'); } if (!validator.isNonNullObject(data)) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, '`data` must be a non-null object.'); } try { tableName = this.formatTableName(tableName); const gqlDataString = this.objectToString(data); const mutation = `mutation { ${tableName}_upsert(data: ${gqlDataString}) }`; // Use internal executeGraphql return this.executeGraphql(mutation).catch(this.handleBulkImportErrors); } catch (e) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL, `Failed to construct upsert mutation: ${e.message}`); } } /** * Insert multiple rows into the specified table, or update them if they already exist. */ async upsertMany(tableName, data) { if (!validator.isNonEmptyString(tableName)) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, '`tableName` must be a non-empty string.'); } if (!validator.isNonEmptyArray(data)) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, '`data` must be a non-empty array for upsertMany.'); } try { tableName = this.formatTableName(tableName); const gqlDataString = this.objectToString(data); const mutation = `mutation { ${tableName}_upsertMany(data: ${gqlDataString}) }`; // Use internal executeGraphql return this.executeGraphql(mutation).catch(this.handleBulkImportErrors); } catch (e) { throw new FirebaseDataConnectError(exports.DATA_CONNECT_ERROR_CODE_MAPPING.INTERNAL, `Failed to construct upsertMany mutation: ${e.message}`); } } } exports.DataConnectApiClient = DataConnectApiClient; /** * Data Connect-specific HTTP client which uses the special "owner" token * when communicating with the Data Connect Emulator. */ class DataConnectHttpClient extends api_request_1.AuthorizedHttpClient { getToken() { if (useEmulator()) { return Promise.resolve('owner'); } return super.getToken(); } } function emulatorHost() { return process.env.DATA_CONNECT_EMULATOR_HOST; } /** * When true the SDK should communicate with the Data Connect Emulator for all API * calls and also produce unsigned tokens. */ function useEmulator() { return !!emulatorHost(); } exports.DATA_CONNECT_ERROR_CODE_MAPPING = { ABORTED: 'aborted', INVALID_ARGUMENT: 'invalid-argument', INVALID_CREDENTIAL: 'invalid-credential', INTERNAL: 'internal-error', PERMISSION_DENIED: 'permission-denied', UNAUTHENTICATED: 'unauthenticated', NOT_FOUND: 'not-found', UNKNOWN: 'unknown-error', QUERY_ERROR: 'query-error', }; /** * Firebase Data Connect error code structure. This extends PrefixedFirebaseError. * * @param code - The error code. * @param message - The error message. * @constructor */ class FirebaseDataConnectError extends error_1.PrefixedFirebaseError { constructor(code, message) { super('data-connect', code, message); /* tslint:disable:max-line-length */ // Set the prototype explicitly. See the following link for more details: // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work /* tslint:enable:max-line-length */ this.__proto__ = FirebaseDataConnectError.prototype; } } exports.FirebaseDataConnectError = FirebaseDataConnectError;