diff --git a/packages/firebase/firestore/pipelines/index.cdn.ts b/packages/firebase/firestore/pipelines/index.cdn.ts new file mode 100644 index 00000000000..81e81b39d81 --- /dev/null +++ b/packages/firebase/firestore/pipelines/index.cdn.ts @@ -0,0 +1,21 @@ +/** + * @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. + */ + +export * from '@firebase/firestore'; + +import * as pipelines from '@firebase/firestore/pipelines'; +export { pipelines }; diff --git a/packages/firebase/gulpfile.js b/packages/firebase/gulpfile.js index 4ea24182af2..e05345fbdf7 100644 --- a/packages/firebase/gulpfile.js +++ b/packages/firebase/gulpfile.js @@ -21,7 +21,7 @@ const replace = require('gulp-replace'); const pkgJson = require('./package.json'); const files = pkgJson.components.map(component => { - const componentName = component.replace('/', '-'); + const componentName = component.replaceAll('/', '-'); return `firebase-${componentName}.js`; }); const FIREBASE_APP_URL = `https://www.gstatic.com/firebasejs/${pkgJson.version}/firebase-app.js`; diff --git a/packages/firebase/package.json b/packages/firebase/package.json index 72eebabc315..c16f37642c4 100644 --- a/packages/firebase/package.json +++ b/packages/firebase/package.json @@ -476,8 +476,8 @@ "auth/web-extension", "functions", "firestore", - "firestore/pipelines", "firestore/lite", + "firestore/pipelines", "firestore/lite/pipelines", "installations", "storage", diff --git a/packages/firebase/rollup.config.js b/packages/firebase/rollup.config.js index f96ff01666c..87b9f7c834d 100644 --- a/packages/firebase/rollup.config.js +++ b/packages/firebase/rollup.config.js @@ -20,6 +20,7 @@ import commonjs from '@rollup/plugin-commonjs'; import json from '@rollup/plugin-json'; import pkg from './package.json'; import { resolve } from 'path'; +import { existsSync } from 'fs'; import resolveModule from '@rollup/plugin-node-resolve'; import rollupTypescriptPlugin from 'rollup-plugin-typescript2'; import sourcemaps from 'rollup-plugin-sourcemaps'; @@ -149,10 +150,12 @@ const cdnBuilds = [ .map(component => { // It is needed for handling sub modules, for example firestore/lite which should produce firebase-firestore-lite.js // Otherwise, we will create a directory with '/' in the name. - const componentName = component.replace('/', '-'); + const componentName = component.replaceAll('/', '-'); return { - input: `${component}/index.ts`, + input: existsSync(`${component}/index.cdn.ts`) + ? `${component}/index.cdn.ts` + : `${component}/index.ts`, output: { file: `firebase-${componentName}.js`, sourcemap: true, diff --git a/packages/firestore/rollup.config.js b/packages/firestore/rollup.config.js index c9222f69ab4..b9db3922098 100644 --- a/packages/firestore/rollup.config.js +++ b/packages/firestore/rollup.config.js @@ -59,6 +59,35 @@ const browserPlugins = [ terser(util.manglePrivatePropertiesOptions) ]; +// TODO - update the implementation to match all content in the declare module block. +function declareModuleReplacePlugin() { + // The regex we created earlier + const moduleToReplace = + /declare module '\.\/\S+' \{\s+interface Firestore \{\s+pipeline\(\): PipelineSource;\s+}\s*}/gm; + + // What to replace it with (an empty string to remove it) + const replacement = + 'interface Firestore {pipeline(): PipelineSource;}'; + + return { + name: 'declare-module-replace', + generateBundle(options, bundle) { + const outputFileName = 'global_index.d.ts'; + if (!bundle[outputFileName]) { + console.warn( + `[regexReplacePlugin] File not found in bundle: ${outputFileName}` + ); + return; + } + + const chunk = bundle[outputFileName]; + if (chunk.type === 'chunk') { + chunk.code = chunk.code.replace(moduleToReplace, replacement); + } + } + }; +} + const allBuilds = [ // Intermediate Node ESM build without build target reporting // this is an intermediate build used to generate the actual esm and cjs builds @@ -214,7 +243,7 @@ const allBuilds = [ } }, { - input: 'dist/firestore/src/index.d.ts', + input: 'dist/firestore/src/global.d.ts', output: { file: 'dist/firestore/src/global_index.d.ts', format: 'es' @@ -222,7 +251,9 @@ const allBuilds = [ plugins: [ dts({ respectExternal: true - }) + }), + + declareModuleReplacePlugin() ] } ]; 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/global.ts b/packages/firestore/src/global.ts new file mode 100644 index 00000000000..529f43e022b --- /dev/null +++ b/packages/firestore/src/global.ts @@ -0,0 +1,23 @@ +/** + * @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. + */ + +// This file supports a special internal build that includes the entire +// Firestore classic and pipeline api surface in one bundle. + +import * as pipelines from './api_pipelines'; +export * from './api'; +export { pipelines }; 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/internal_serializer.ts b/packages/firestore/src/remote/internal_serializer.ts index 29a68620efc..1e759dab814 100644 --- a/packages/firestore/src/remote/internal_serializer.ts +++ b/packages/firestore/src/remote/internal_serializer.ts @@ -18,14 +18,23 @@ import { ensureFirestoreConfigured, Firestore } from '../api/database'; import { AggregateImpl } from '../core/aggregate'; import { queryToAggregateTarget, queryToTarget } from '../core/query'; +import { + StructuredPipeline, + StructuredPipelineOptions +} from '../core/structured_pipeline'; import { AggregateSpec } from '../lite-api/aggregate_types'; import { getDatastore } from '../lite-api/components'; import { Pipeline } from '../lite-api/pipeline'; import { Query } from '../lite-api/reference'; +import { ExecutePipelineRequest as ProtoExecutePipelineRequest } from '../protos/firestore_proto_api'; import { cast } from '../util/input_validation'; import { mapToArray } from '../util/obj'; -import { toQueryTarget, toRunAggregationQueryRequest } from './serializer'; +import { + getEncodedDatabaseId, + toQueryTarget, + toRunAggregationQueryRequest +} from './serializer'; /** * @internal @@ -112,5 +121,15 @@ export function _internalPipelineToExecutePipelineRequestProto( if (serializer === undefined) { return null; } - return pipeline._toProto(serializer); + + const structuredPipeline = new StructuredPipeline( + pipeline, + new StructuredPipelineOptions() + ); + const executePipelineRequest: ProtoExecutePipelineRequest = { + database: getEncodedDatabaseId(serializer), + structuredPipeline: structuredPipeline._toProto(serializer) + }; + + return executePipelineRequest; } 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/pipeline.test.ts b/packages/firestore/test/integration/api/pipeline.test.ts index aab69008254..9ebf9ced4b5 100644 --- a/packages/firestore/test/integration/api/pipeline.test.ts +++ b/packages/firestore/test/integration/api/pipeline.test.ts @@ -346,18 +346,19 @@ const timestampDeltaMS = 1000; describe('console support', () => { it('supports internal serialization to proto', async () => { + // Perform the same test as the console const pipeline = firestore .pipeline() - .collection('books') - .where(equal('awards.hugo', true)) - .select( - 'title', - field('nestedField.level.1'), - mapGet('nestedField', 'level.1').mapGet('level.2').as('nested') - ); + .collection('customers') + .where(field('country').equal('United Kingdom')); const proto = _internalPipelineToExecutePipelineRequestProto(pipeline); - expect(proto).not.to.be.null; + + const expectedStructuredPipelineProto = + '{"pipeline":{"stages":[{"name":"collection","options":{},"args":[{"referenceValue":"/customers"}]},{"name":"where","options":{},"args":[{"functionValue":{"name":"equal","args":[{"fieldReferenceValue":"country"},{"stringValue":"United Kingdom"}]}}]}]}}'; + expect(JSON.stringify(proto.structuredPipeline)).to.equal( + expectedStructuredPipelineProto + ); }); }); 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.node.test.ts b/packages/firestore/test/unit/remote/grpc_connection.node.test.ts new file mode 100644 index 00000000000..8364cbbd5e6 --- /dev/null +++ b/packages/firestore/test/unit/remote/grpc_connection.node.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 diff --git a/scripts/size_report/report_binary_size.ts b/scripts/size_report/report_binary_size.ts index da1ad166702..34dce01924e 100644 --- a/scripts/size_report/report_binary_size.ts +++ b/scripts/size_report/report_binary_size.ts @@ -57,7 +57,7 @@ function generateReportForCDNScripts(): Report[] { ...special_files.map((file: string) => `${firebaseRoot}/${file}`), ...pkgJson.components.map( (component: string) => - `${firebaseRoot}/firebase-${component.replace('/', '-')}.js` + `${firebaseRoot}/firebase-${component.replaceAll('/', '-')}.js` ), ...compatPkgJson.components.map( (component: string) => `${firebaseRoot}/firebase-${component}-compat.js`