Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@
- name: Upload coverage to codecov
if: matrix.dart-version == 'stable'
continue-on-error: true
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f
uses: codecov/codecov-action@v4

Check failure on line 237 in .github/workflows/build.yml

View workflow job for this annotation

GitHub Actions / zizmor-output

zizmor/unpinned-uses

unpinned action reference: action is not pinned to a hash (required by blanket policy)

Check failure on line 237 in .github/workflows/build.yml

View workflow job for this annotation

GitHub Actions / zizmor-output

unpinned-uses

build.yml:237: unpinned action reference: action is not pinned to a hash (required by blanket policy)
with:
files: coverage.lcov
flags: unittests
Expand Down
2 changes: 2 additions & 0 deletions packages/firebase_admin_sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## 0.5.4-wip

- Fixed `Firestore` requests not carrying the SDK's usage-tracking headers
(`X-Firebase-Client`, `X-Goog-Api-Client`).
- Update dependency `googleapis_auth: ^2.3.3` to fix `auth/insufficient-permission`
errors with Application Default Credentials that have no quota project set.

Expand Down
1 change: 0 additions & 1 deletion packages/firebase_admin_sdk/lib/src/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import '../messaging.dart';
import '../security_rules.dart';
import '../storage.dart';
import 'utils/utils.dart';
import 'version.g.dart';

part 'app/app_exception.dart';
part 'app/app_options.dart';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ class FirebaseUserAgentClient extends BaseClient

@override
Future<StreamedResponse> send(BaseRequest request) {
request.headers['X-Firebase-Client'] = 'fire-admin-dart/$packageVersion';
request.headers['X-Goog-Api-Client'] =
'gl-dart/$dartVersion fire-admin/$packageVersion';
request.headers.addAll(firebaseUserAgentHeaders);
return _client.send(request);
}

Expand Down
5 changes: 5 additions & 0 deletions packages/firebase_admin_sdk/lib/src/firestore/firestore.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import 'package:google_cloud_firestore/google_cloud_firestore.dart'
import 'package:meta/meta.dart';

import '../app.dart';
import '../utils/utils.dart';

/// Default database ID used by Firestore
const String kDefaultDatabaseId = '(default)';
Expand Down Expand Up @@ -152,6 +153,10 @@ class Firestore implements FirebaseService {
settings = settings.copyWith(projectId: projectId);
}

settings = settings.copyWith(
headers: {...firestoreUsageTrackingHeaders, ...?settings.headers},
);

return settings;
}

Expand Down
32 changes: 32 additions & 0 deletions packages/firebase_admin_sdk/lib/src/utils/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,42 @@

import 'dart:io';

import '../version.g.dart';

/// The current Dart SDK version in semver format (e.g. "3.3.0").
String get dartVersion =>
Platform.version.split(RegExp('[^0-9]')).take(3).join('.');

/// The Firebase Admin SDK's `X-Firebase-Client` identity.
const String _fireAdminFirebaseClientId = 'fire-admin-dart/$packageVersion';

/// The Firebase Admin SDK's tag within `X-Goog-Api-Client`.
const String _fireAdminApiClientTag = 'fire-admin/$packageVersion';

/// Headers that identify a request as originating from this SDK, for
/// Firebase backend usage tracking.
///
/// Used by `FirebaseUserAgentClient`, which wraps HTTP clients (Auth,
/// Messaging, and other services that go through `FirebaseApp.client`) that
/// don't already set a client-identification header of their own.
final Map<String, String> firebaseUserAgentHeaders = Map.unmodifiable({
'X-Firebase-Client': _fireAdminFirebaseClientId,
'X-Goog-Api-Client': 'gl-dart/$dartVersion $_fireAdminApiClientTag',
});

/// Headers to attach to Firestore requests to identify them as originating
/// from this SDK, for Firebase backend usage tracking.
///
/// Unlike [firebaseUserAgentHeaders], `X-Goog-Api-Client` here omits the
/// `gl-dart/{version}` runtime tag: `google_cloud_firestore`'s
/// `FirestoreRequestClient` appends this value onto one that
/// `package:google_cloud_rpc` already set (which already carries that tag),
/// so repeating it here would duplicate it.
const Map<String, String> firestoreUsageTrackingHeaders = {
'X-Firebase-Client': _fireAdminFirebaseClientId,
'X-Goog-Api-Client': _fireAdminApiClientTag,
};

/// Generates the update mask for the provided object.
/// Note this will ignore the last key with value `null`.
List<String> _generateUpdateMask(Object? obj, String root) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import 'dart:io';

import 'package:firebase_admin_sdk/firebase_admin_sdk.dart';
import 'package:firebase_admin_sdk/src/firestore/firestore.dart';
import 'package:firebase_admin_sdk/src/utils/utils.dart';
import 'package:google_cloud_firestore/google_cloud_firestore.dart' as gfs;
import 'package:googleapis_auth/auth_io.dart' as auth;
import 'package:mocktail/mocktail.dart';
Expand Down Expand Up @@ -254,6 +255,39 @@ void main() {
});
});

group('usage-tracking headers', () {
test('are attached when no user headers are provided', () {
final settings = firestoreService.buildSettingsForTesting('db', null);

expect(settings.headers, firestoreUsageTrackingHeaders);
});

test('are merged with, not replaced by, user-provided headers', () {
final settings = firestoreService.buildSettingsForTesting(
'db',
const gfs.Settings(headers: {'X-Trace-Id': 'abc123'}),
);

expect(settings.headers, {
...firestoreUsageTrackingHeaders,
'X-Trace-Id': 'abc123',
});
});

test('can be overridden by a user-provided header of the same name', () {
final settings = firestoreService.buildSettingsForTesting(
'db',
const gfs.Settings(headers: {'X-Firebase-Client': 'custom-value'}),
);

expect(settings.headers!['X-Firebase-Client'], 'custom-value');
expect(
settings.headers!['X-Goog-Api-Client'],
firestoreUsageTrackingHeaders['X-Goog-Api-Client'],
);
});
});

group('lifecycle', () {
test('should terminate all databases on delete', () async {
final db1 = firestoreService.getDatabase('lifecycle-1');
Expand Down
1 change: 1 addition & 0 deletions packages/google_cloud_firestore/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## Unreleased

- Added `Settings.headers` to attach custom HTTP headers to every outgoing Firestore request.
- Fixed intermittent `ClientException: Connection closed before full header was received` on queries and aggregations under high concurrency; these now retry with backoff.
- Fixed `Firestore.getAll()` retrying transient errors indefinitely; it now retries a bounded number of times before surfacing the error.
- Updated `Transaction.delete` and `Transaction.update` type constraints to accept `DocumentReference<Object?>`. (thanks to @Levin-Me)
Expand Down
14 changes: 13 additions & 1 deletion packages/google_cloud_firestore/lib/src/firestore.dart
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ class Settings {
this.ignoreUndefinedProperties = false,
this.useBigInt = false,
this.environmentOverride,
this.headers,
});

/// The project ID from the Google Developer's Console, e.g. 'grape-spaceship-123'.
Expand Down Expand Up @@ -186,6 +187,13 @@ class Settings {
/// ```
final Map<String, String>? environmentOverride;

/// Additional HTTP headers to send with every Firestore request.
///
/// Useful for attaching usage-tracking or tracing headers (e.g.
/// `X-Firebase-Client`, `X-Goog-Api-Client`) without needing to supply a
/// custom HTTP client.
final Map<String, String>? headers;
Comment thread
demolaf marked this conversation as resolved.

/// Creates a copy of this Settings with the given fields replaced.
Settings copyWith({
String? projectId,
Expand All @@ -196,6 +204,7 @@ class Settings {
bool? ignoreUndefinedProperties,
bool? useBigInt,
Map<String, String>? environmentOverride,
Map<String, String>? headers,
}) {
return Settings(
projectId: projectId ?? this.projectId,
Expand All @@ -207,6 +216,7 @@ class Settings {
ignoreUndefinedProperties ?? this.ignoreUndefinedProperties,
useBigInt: useBigInt ?? this.useBigInt,
environmentOverride: environmentOverride ?? this.environmentOverride,
headers: headers ?? this.headers,
);
}

Expand All @@ -221,7 +231,8 @@ class Settings {
ssl == other.ssl &&
credential == other.credential &&
ignoreUndefinedProperties == other.ignoreUndefinedProperties &&
useBigInt == other.useBigInt;
useBigInt == other.useBigInt &&
const MapEquality<String, String>().equals(headers, other.headers);

@override
int get hashCode => Object.hash(
Expand All @@ -230,6 +241,7 @@ class Settings {
host,
ssl,
credential,
const MapEquality<String, String>().hash(headers),
ignoreUndefinedProperties,
useBigInt,
);
Expand Down
47 changes: 47 additions & 0 deletions packages/google_cloud_firestore/lib/src/firestore_http_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,44 @@ class _RequestImpl extends BaseRequest {
}
}

/// HTTP client wrapper that adds custom headers to every outgoing Firestore
/// request.
///
/// Used to attach [Settings.headers] (e.g. usage-tracking headers set by a
/// caller like the Firebase Admin SDK) without altering how the underlying
/// client authenticates requests.
///
/// Values are appended (space-joined) onto any existing header of the same
/// name rather than replacing it, since the transport layer
/// (`package:google_cloud_rpc`) already sets its own `X-Goog-Api-Client`
/// value identifying this library — matching the multi-token convention
/// that header uses (e.g. `gl-dart/3.9 gax/1.0 gccl/0.5 fire-admin/0.5`).
@internal
class FirestoreRequestClient extends BaseClient
implements googleapis_auth.AuthClient {
FirestoreRequestClient(this._client, this._headers);

final googleapis_auth.AuthClient _client;
final Map<String, String> _headers;

@override
googleapis_auth.AccessCredentials get credentials => _client.credentials;

@override
Future<StreamedResponse> send(BaseRequest request) {
for (final entry in _headers.entries) {
final existing = request.headers[entry.key];
request.headers[entry.key] = existing == null
? entry.value
: '$existing ${entry.value}';
}
return _client.send(request);
}

@override
void close() => _client.close();
}

/// HTTP client wrapper that adds Firebase emulator authentication.
///
/// This client wraps another HTTP client and automatically adds the
Expand Down Expand Up @@ -156,6 +194,15 @@ class FirestoreHttpClient {

/// Creates the appropriate HTTP client based on emulator configuration.
Future<googleapis_auth.AuthClient> _createClient() async {
final client = await _createBaseClient();

final headers = _settings.headers;
if (headers == null || headers.isEmpty) return client;

return FirestoreRequestClient(client, Map.unmodifiable(headers));
}

Future<googleapis_auth.AuthClient> _createBaseClient() async {
if (_isUsingEmulator) {
// Emulator: Create unauthenticated client.
return EmulatorClient(Client());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright 2026 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 'package:google_cloud_firestore/src/firestore_http_client.dart';
import 'package:googleapis_auth/auth_io.dart' as googleapis_auth;
import 'package:http/http.dart';
import 'package:http/testing.dart';
import 'package:test/test.dart';

/// Wraps a plain [Client] (such as [MockClient]) so it satisfies the
/// [googleapis_auth.AuthClient] interface [FirestoreRequestClient] requires.
class _FakeAuthClient extends BaseClient implements googleapis_auth.AuthClient {
_FakeAuthClient(this._inner);

final Client _inner;
bool closed = false;

@override
googleapis_auth.AccessCredentials get credentials =>
throw UnimplementedError();

@override
Future<StreamedResponse> send(BaseRequest request) => _inner.send(request);

@override
void close() {
closed = true;
_inner.close();
}
}

void main() {
group('FirestoreRequestClient', () {
test('sets a header that has no existing value', () async {
late Request captured;
final mock = MockClient((request) async {
captured = request;
return Response('{}', 200);
});

final client = FirestoreRequestClient(_FakeAuthClient(mock), {
'X-Firebase-Client': 'fire-admin-dart/1.0.0',
});

await client.get(Uri.parse('https://firestore.googleapis.com/'));

expect(captured.headers['X-Firebase-Client'], 'fire-admin-dart/1.0.0');
});

test(
'appends to (rather than overwrites) an existing header value',
() async {
late Request captured;
final mock = MockClient((request) async {
captured = request;
return Response('{}', 200);
});

final client = FirestoreRequestClient(_FakeAuthClient(mock), {
'X-Goog-Api-Client': 'fire-admin/1.0.0',
});

final request = Request(
'GET',
Uri.parse('https://firestore.googleapis.com/'),
);
// Simulates the value package:google_cloud_rpc already sets.
request.headers['X-Goog-Api-Client'] =
'gl-dart/3.9 gax/1.0 rest/1.0 gapic/1.0';

await client.send(request);

expect(
captured.headers['X-Goog-Api-Client'],
'gl-dart/3.9 gax/1.0 rest/1.0 gapic/1.0 fire-admin/1.0.0',
);
},
);

test('applies multiple headers independently', () async {
late Request captured;
final mock = MockClient((request) async {
captured = request;
return Response('{}', 200);
});

final client = FirestoreRequestClient(_FakeAuthClient(mock), {
'X-Firebase-Client': 'fire-admin-dart/1.0.0',
'X-Goog-Api-Client': 'fire-admin/1.0.0',
});

await client.get(Uri.parse('https://firestore.googleapis.com/'));

expect(captured.headers['X-Firebase-Client'], 'fire-admin-dart/1.0.0');
expect(captured.headers['X-Goog-Api-Client'], 'fire-admin/1.0.0');
});

test('delegates close() to the inner client', () {
final mock = MockClient((request) async => Response('{}', 200));
final inner = _FakeAuthClient(mock);
final client = FirestoreRequestClient(inner, const {});

client.close();

expect(inner.closed, isTrue);
});

test('delegates credentials to the inner client', () {
final mock = MockClient((request) async => Response('{}', 200));
final inner = _FakeAuthClient(mock);
final client = FirestoreRequestClient(inner, const {});

expect(() => client.credentials, throwsUnimplementedError);
});
});
}
Loading