diff --git a/packages/firestore/src/api/database.ts b/packages/firestore/src/api/database.ts index a2feb19507f..f894ddce03e 100644 --- a/packages/firestore/src/api/database.ts +++ b/packages/firestore/src/api/database.ts @@ -302,6 +302,7 @@ export function configureFirestore(firestore: Firestore): void { firestore._databaseId, firestore._app?.options.appId || '', firestore._persistenceKey, + firestore._app?.options.apiKey, settings ); if (!firestore._componentsProvider) { diff --git a/packages/firestore/src/core/database_info.ts b/packages/firestore/src/core/database_info.ts index a057516763f..ec75ba2486f 100644 --- a/packages/firestore/src/core/database_info.ts +++ b/packages/firestore/src/core/database_info.ts @@ -49,7 +49,8 @@ export class DatabaseInfo { readonly autoDetectLongPolling: boolean, readonly longPollingOptions: ExperimentalLongPollingOptions, readonly useFetchStreams: boolean, - readonly isUsingEmulator: boolean + readonly isUsingEmulator: boolean, + readonly apiKey: string | undefined ) {} } diff --git a/packages/firestore/src/core/firestore_client.ts b/packages/firestore/src/core/firestore_client.ts index 009e7b2aba2..124ab4eaa44 100644 --- a/packages/firestore/src/core/firestore_client.ts +++ b/packages/firestore/src/core/firestore_client.ts @@ -146,7 +146,11 @@ export class FirestoreClient { * an async I/O to complete). */ public asyncQueue: AsyncQueue, - private databaseInfo: DatabaseInfo, + /** + * @internal + * Exposed for testing + */ + public _databaseInfo: DatabaseInfo, componentProvider?: { _offline: OfflineComponentProvider; _online: OnlineComponentProvider; @@ -167,7 +171,7 @@ export class FirestoreClient { get configuration(): ComponentConfiguration { return { asyncQueue: this.asyncQueue, - databaseInfo: this.databaseInfo, + databaseInfo: this._databaseInfo, clientId: this.clientId, authCredentials: this.authCredentials, appCheckCredentials: this.appCheckCredentials, diff --git a/packages/firestore/src/lite-api/components.ts b/packages/firestore/src/lite-api/components.ts index 52c3b3729ee..d956fcd31f5 100644 --- a/packages/firestore/src/lite-api/components.ts +++ b/packages/firestore/src/lite-api/components.ts @@ -75,6 +75,7 @@ export function getDatastore(firestore: FirestoreService): Datastore { firestore._databaseId, firestore.app.options.appId || '', firestore._persistenceKey, + firestore.app.options.apiKey, firestore._freezeSettings() ); const connection = newConnection(databaseInfo); @@ -108,6 +109,7 @@ export function makeDatabaseInfo( databaseId: DatabaseId, appId: string, persistenceKey: string, + apiKey: string | undefined, settings: FirestoreSettingsImpl ): DatabaseInfo { return new DatabaseInfo( @@ -120,6 +122,7 @@ export function makeDatabaseInfo( settings.experimentalAutoDetectLongPolling, cloneLongPollingOptions(settings.experimentalLongPollingOptions), settings.useFetchStreams, - settings.isUsingEmulator + settings.isUsingEmulator, + apiKey ); } diff --git a/packages/firestore/src/platform/node/grpc_connection.ts b/packages/firestore/src/platform/node/grpc_connection.ts index d50a3149416..79ccb3ddf3c 100644 --- a/packages/firestore/src/platform/node/grpc_connection.ts +++ b/packages/firestore/src/platform/node/grpc_connection.ts @@ -44,7 +44,8 @@ function createMetadata( databasePath: string, authToken: Token | null, appCheckToken: Token | null, - appId: string + appId: string, + apiKey: string | undefined ): grpc.Metadata { hardAssert( authToken === null || authToken.type === 'OAuth', @@ -69,6 +70,9 @@ function createMetadata( // 11 from Google3. metadata.set('Google-Cloud-Resource-Prefix', databasePath); metadata.set('x-goog-request-params', databasePath); + if (apiKey) { + metadata.set('X-Goog-Api-Key', apiKey); + } return metadata; } @@ -100,7 +104,8 @@ export class GrpcConnection implements Connection { this.databasePath = `projects/${databaseInfo.databaseId.projectId}/databases/${databaseInfo.databaseId.database}`; } - private ensureActiveStub(): GeneratedGrpcStub { + /** made protected for testing */ + protected ensureActiveStub(): GeneratedGrpcStub { if (!this.cachedStub) { logDebug(LOG_TAG, 'Creating Firestore stub.'); const credentials = this.databaseInfo.ssl @@ -127,7 +132,8 @@ export class GrpcConnection implements Connection { this.databasePath, authToken, appCheckToken, - this.databaseInfo.appId + this.databaseInfo.appId, + this.databaseInfo.apiKey ); const jsonRequest = { database: this.databasePath, ...request }; @@ -187,7 +193,8 @@ export class GrpcConnection implements Connection { this.databasePath, authToken, appCheckToken, - this.databaseInfo.appId + this.databaseInfo.appId, + this.databaseInfo.apiKey ); const jsonRequest = { ...request, database: this.databasePath }; const stream = stub[rpcName](jsonRequest, metadata); @@ -239,7 +246,8 @@ export class GrpcConnection implements Connection { this.databasePath, authToken, appCheckToken, - this.databaseInfo.appId + this.databaseInfo.appId, + this.databaseInfo.apiKey ); const grpcStream = stub[rpcName](metadata); diff --git a/packages/firestore/src/remote/rest_connection.ts b/packages/firestore/src/remote/rest_connection.ts index 7469d8f45ff..d9446a733e5 100644 --- a/packages/firestore/src/remote/rest_connection.ts +++ b/packages/firestore/src/remote/rest_connection.ts @@ -64,6 +64,7 @@ export abstract class RestConnection implements Connection { protected readonly baseUrl: string; private readonly databasePath: string; private readonly requestParams: string; + private readonly apiKey: string | undefined; get shouldResourcePathBeIncludedInRequest(): boolean { // Both `invokeRPC()` and `invokeStreamingRPC()` use their `path` arguments to determine @@ -82,6 +83,7 @@ export abstract class RestConnection implements Connection { this.databaseId.database === DEFAULT_DATABASE_NAME ? `project_id=${projectId}` : `project_id=${projectId}&database_id=${databaseId}`; + this.apiKey = databaseInfo.apiKey; } invokeRPC( @@ -194,13 +196,17 @@ export abstract class RestConnection implements Connection { _forwardCredentials: boolean ): Promise; - private makeUrl(rpcName: string, path: string): string { + protected makeUrl(rpcName: string, path: string): string { const urlRpcName = RPC_NAME_URL_MAPPING[rpcName]; debugAssert( urlRpcName !== undefined, 'Unknown REST mapping for: ' + rpcName ); - return `${this.baseUrl}/${RPC_URL_VERSION}/${path}:${urlRpcName}`; + let url = `${this.baseUrl}/${RPC_URL_VERSION}/${path}:${urlRpcName}`; + if (this.apiKey) { + url = `${url}?key=${encodeURIComponent(this.apiKey)}`; + } + return url; } /** diff --git a/packages/firestore/test/integration/api/provider.test.ts b/packages/firestore/test/integration/api/provider.test.ts index cc7888a5385..95d825c2a9e 100644 --- a/packages/firestore/test/integration/api/provider.test.ts +++ b/packages/firestore/test/integration/api/provider.test.ts @@ -29,7 +29,8 @@ import { enableIndexedDbPersistence, setDoc, memoryLocalCache, - getDocFromCache + getDocFromCache, + ensureFirestoreConfigured } from '../util/firebase_export'; import { DEFAULT_SETTINGS } from '../util/settings'; @@ -200,4 +201,17 @@ describe('Firestore Provider', () => { return terminate(firestore).then(() => terminate(firestore)); }); + + it('passes API key to database info', () => { + const app = initializeApp( + { apiKey: 'fake-api-key-x', projectId: 'test-project' }, + 'test-app-getFirestore-x' + ); + const fs = getFirestore(app); + ensureFirestoreConfigured(fs); + + expect(fs._firestoreClient?._databaseInfo.apiKey).to.equal( + 'fake-api-key-x' + ); + }); }); diff --git a/packages/firestore/test/integration/util/internal_helpers.ts b/packages/firestore/test/integration/util/internal_helpers.ts index e5e64b5fbf4..b196599f408 100644 --- a/packages/firestore/test/integration/util/internal_helpers.ts +++ b/packages/firestore/test/integration/util/internal_helpers.ts @@ -62,7 +62,8 @@ export function getDefaultDatabaseInfo(): DatabaseInfo { DEFAULT_SETTINGS.experimentalLongPollingOptions ?? {} ), /*use FetchStreams= */ false, - /*isUsingEmulator=*/ false + /*isUsingEmulator=*/ false, + undefined ); } diff --git a/packages/firestore/test/unit/remote/fetch_connection.test.ts b/packages/firestore/test/unit/remote/fetch_connection.test.ts index 5a9aa67436f..4de0ba5a722 100644 --- a/packages/firestore/test/unit/remote/fetch_connection.test.ts +++ b/packages/firestore/test/unit/remote/fetch_connection.test.ts @@ -43,6 +43,7 @@ describe('Fetch Connection', () => { DatabaseId.empty(), '', '', + '', new FirestoreSettingsImpl({ host: 'abc.cloudworkstations.dev' }) diff --git a/packages/firestore/test/unit/remote/grpc_connection.test.ts b/packages/firestore/test/unit/remote/grpc_connection.test.ts new file mode 100644 index 00000000000..8364cbbd5e6 --- /dev/null +++ b/packages/firestore/test/unit/remote/grpc_connection.test.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2025 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. + */ + +import { Metadata } from '@grpc/grpc-js'; +import { expect } from 'chai'; + +import { DatabaseId, DatabaseInfo } from '../../../src/core/database_info'; +import { ResourcePath } from '../../../src/model/path'; +import { GrpcConnection } from '../../../src/platform/node/grpc_connection'; + +export class TestGrpcConnection extends GrpcConnection { + mockStub = { + lastMetadata: null, + mockRpc( + req: unknown, + metadata: Metadata, + callback: (err: unknown, resp: unknown) => void + ) { + this.lastMetadata = metadata; + callback(null, null); + } + } as { + lastMetadata: null | Metadata; + [index: string]: unknown; + }; + + protected ensureActiveStub(): unknown { + return this.mockStub; + } +} + +describe('GrpcConnection', () => { + const testDatabaseInfo = new DatabaseInfo( + new DatabaseId('testproject'), + 'test-app-id', + 'persistenceKey', + 'example.com', + /*ssl=*/ false, + /*forceLongPolling=*/ false, + /*autoDetectLongPolling=*/ false, + /*longPollingOptions=*/ {}, + /*useFetchStreams=*/ false, + /*isUsingEmulator=*/ false, + 'grpc-connection-test-api-key' + ); + const connection = new TestGrpcConnection( + { google: { firestore: { v1: {} } } }, + testDatabaseInfo + ); + + it('Passes the API Key from DatabaseInfo to the grpc stub', async () => { + const request = { + database: 'projects/testproject/databases/(default)', + writes: [] + }; + await connection.invokeRPC( + 'mockRpc', + ResourcePath.emptyPath(), + request, + null, + null + ); + expect( + connection.mockStub.lastMetadata?.get('x-goog-api-key') + ).to.deep.equal(['grpc-connection-test-api-key']); + }); +}); diff --git a/packages/firestore/test/unit/remote/rest_connection.test.ts b/packages/firestore/test/unit/remote/rest_connection.test.ts index 100b8b8368e..3501a910f66 100644 --- a/packages/firestore/test/unit/remote/rest_connection.test.ts +++ b/packages/firestore/test/unit/remote/rest_connection.test.ts @@ -68,7 +68,8 @@ describe('RestConnection', () => { /*autoDetectLongPolling=*/ false, /*longPollingOptions=*/ {}, /*useFetchStreams=*/ false, - /*isUsingEmulator=*/ false + /*isUsingEmulator=*/ false, + 'rest-connection-test-api-key' ); const connection = new TestRestConnection(testDatabaseInfo); @@ -83,7 +84,7 @@ describe('RestConnection', () => { null ); expect(connection.lastUrl).to.equal( - 'http://example.com/v1/projects/testproject/databases/(default)/documents:commit' + 'http://example.com/v1/projects/testproject/databases/(default)/documents:commit?key=rest-connection-test-api-key' ); }); diff --git a/packages/firestore/test/unit/specs/spec_test_runner.ts b/packages/firestore/test/unit/specs/spec_test_runner.ts index 51d2229b8a1..50806cb2a48 100644 --- a/packages/firestore/test/unit/specs/spec_test_runner.ts +++ b/packages/firestore/test/unit/specs/spec_test_runner.ts @@ -283,7 +283,8 @@ abstract class TestRunner { /*autoDetectLongPolling=*/ false, /*longPollingOptions=*/ {}, /*useFetchStreams=*/ false, - /*isUsingEmulator=*/ false + /*isUsingEmulator=*/ false, + 'test-api-key' ); // TODO(mrschmidt): During client startup in `firestore_client`, we block