Skip to content

feat(remote_config): add Firebase Remote Config support - #249

Open
long1eu wants to merge 1 commit into
firebase:mainfrom
long1eu:main
Open

feat(remote_config): add Firebase Remote Config support#249
long1eu wants to merge 1 commit into
firebase:mainfrom
long1eu:main

Conversation

@long1eu

@long1eu long1eu commented Apr 26, 2026

Copy link
Copy Markdown

Summary

Adds Firebase Remote Config support to the Dart Admin SDK: template management (read, validate, publish with force, rollback, list versions, parse from JSON) and server-side template evaluation (fetch + in-process rules engine for percent rollouts and string/numeric/semver custom signals).

Notable design choices

  • Direct REST calls instead of googleapis-generated client — the generated client doesn't expose response headers (no ETag) and doesn't accept If-Match, both of which are required for optimistic concurrency and force-publish.
  • Server-template publishing is not exposed — the REST API doesn't accept writes on the firebase-server namespace; server templates are Console-managed only.
  • Field shapes and required/output-only annotations match the v1 discovery schema.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

2 similar comments
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

* feat: add Firebase Remote Config support

Implements Firebase Remote Config for the Dart Admin SDK with full
template management plus server-side template evaluation.
@long1eu

long1eu commented Apr 29, 2026

Copy link
Copy Markdown
Author

@kevmoon @Lyokone can you have a look? :D

@demolaf

demolaf commented Apr 29, 2026

Copy link
Copy Markdown
Member

Hi @long1eu , thanks for the PR!

At the moment there's no support for Remote Config in the googleapis generated package and all our Firebase services are routed through it.

I'll get some more details on what the plan is there and keep this PR up to date.

@long1eu

long1eu commented Apr 29, 2026

Copy link
Copy Markdown
Author

Thanks for taking a look @demolaf!

Quick context that might be useful while you check on the googleapis plan:

  • firebaseremoteconfig was in package:googleapis up through 13.2.0; it was dropped between 13.2.0 and 14.0.0 and isn't in 16.0.0 (the version this repo currently depends on). So even if I had wanted to route through it, the generated client isn't shipped today.

  • More importantly, four of the five admin endpoints (getTemplate, publishTemplate, validateTemplate, rollback) require reading and setting the ETag / If-Match headers — that's how Remote Config does optimistic concurrency, and how force=true is expressed (If-Match: *). The googleapis-generated clients don't surface request/response headers, so even when firebaseremoteconfig was available in googleapis, those four endpoints would still need a raw-REST path. Only listVersions is a clean GET that could go through the generated client.

Happy to take this in any direction that fits the project's plans:

  1. Leave as-is — raw REST throughout, consistent within the new remote_config library.
  2. Hybrid — route listVersions through googleapis if/when firebaseremoteconfig is re-added; keep raw REST for the four ETag-bound endpoints.
  3. Wait for the broader googleapis-package decision before merging, and refactor accordingly.

Just let me know which you'd prefer once you have more details on the plan.

@Ortes

Ortes commented Sep 7, 2026

Copy link
Copy Markdown

Production user data point, in case it helps this get unblocked.

We run Dart Cloud Functions in production alongside a legacy Node codebase, and migrate onCall endpoints to Dart as capabilities land. Missing server-side Remote Config is the one capability gap blocking our next migration: the endpoint reads a kill switch through

const template = await getRemoteConfig().getServerTemplate({defaultConfig: {[key]: false}});
return template.evaluate().getBoolean(key);

which maps 1:1 onto RemoteConfig.getServerTemplateServerTemplate.evaluateServerConfig.getBoolean in this PR. I read through the branch; the shape follows the Node SDK closely enough that it would slot in behind our existing helper without touching call sites.

On the googleapis question from @demolaf: firebaseremoteconfig isn't in the generated set today — zero occurrences in config.yaml on google/googleapis.dart, across the stable, beta and skipped_apis lists alike. The changelog puts its removal in 14.0.0, which lines up with what @long1eu described, so there's nothing to route through as things stand. His ETag / If-Match point also reads as correct to me for getTemplate / publishTemplate / validateTemplate / rollback: those need request and response headers that the generated clients don't surface, which would leave only listVersions as a candidate even if the API were regenerated.

Would a maintainer be able to pick between the three options @long1eu offered in April? Even "option 3, wait for the googleapis decision" would help — the PR has gone CONFLICTING, and nobody can tell whether rebasing it is worth the contributor's time. maintainerCanModify is true on it if that makes things easier.

Also worth noting there was no issue tracking Remote Config in this repo, so there's been nothing for users to find or subscribe to. I've opened #323 so the capability is trackable separately from the PR.

Happy to test a rebased branch against a real Firebase project (server template with percent conditions and custom signals) and report back if that's useful.

@demolaf
demolaf self-requested a review September 9, 2026 12:41
@demolaf

demolaf commented Sep 9, 2026

Copy link
Copy Markdown
Member

Hi @long1eu , can you rebase this on the latest version on main?

@demolaf demolaf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, a couple of changes though.

// - Credentials are available (CI WIF sets GOOGLE_APPLICATION_CREDENTIALS;
// local opt-in via RUN_PROD_TESTS=true).
final projectId = _rcProjectId;
final shouldRun = projectId != null && (hasWifEnv || hasProdEnv);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we tag this group prod and drop the shouldRun and skipReason guard? hasWifEnv and hasProdEnv were valid when this was written but #255 removed them two days later, and dart_test.yaml now excludes prod by default, which is what the guard was doing by hand.

Comment on lines +108 to +127
String? _serverErrorCode(Object? response) {
if (response is! Map || !response.containsKey('error')) return null;
final error = response['error'];
if (error is String) return error;
if (error is Map) {
if (error['status'] is String) return error['status'] as String;
if (error['code'] is String) return error['code'] as String;
}
return null;
}

String? _serverErrorMessage(Object? response) {
if (response is Map) {
final error = response['error'];
if (error is Map && error['message'] is String) {
return error['message'] as String;
}
}
return null;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we collapse these into map patterns? It removes both as casts and the is guards, and matches how the same extraction reads in firestore_exception.dart.

Suggested change
String? _serverErrorCode(Object? response) {
if (response is! Map || !response.containsKey('error')) return null;
final error = response['error'];
if (error is String) return error;
if (error is Map) {
if (error['status'] is String) return error['status'] as String;
if (error['code'] is String) return error['code'] as String;
}
return null;
}
String? _serverErrorMessage(Object? response) {
if (response is Map) {
final error = response['error'];
if (error is Map && error['message'] is String) {
return error['message'] as String;
}
}
return null;
}
String? _serverErrorCode(Object? response) => switch (response) {
{'error': final String code} => code,
{'error': {'status': final String status}} => status,
{'error': {'code': final String code}} => code,
_ => null,
};
String? _serverErrorMessage(Object? response) => switch (response) {
{'error': {'message': final String message}} => message,
_ => null,
};

Comment on lines +116 to +123
static BigInt _hashSeededRandomizationId(String input) {
final bytes = sha256.convert(utf8.encode(input)).bytes;
final hex = StringBuffer();
for (final b in bytes) {
hex.write(b.toRadixString(16).padLeft(2, '0'));
}
return BigInt.parse(hex.toString(), radix: 16);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fold the bytes directly rather than building 64 hex chars and re-parsing them. Same value, one expression.

Suggested change
static BigInt _hashSeededRandomizationId(String input) {
final bytes = sha256.convert(utf8.encode(input)).bytes;
final hex = StringBuffer();
for (final b in bytes) {
hex.write(b.toRadixString(16).padLeft(2, '0'));
}
return BigInt.parse(hex.toString(), radix: 16);
}
static BigInt _hashSeededRandomizationId(String input) {
return sha256
.convert(utf8.encode(input))
.bytes
.fold(BigInt.zero, (acc, b) => (acc << 8) | BigInt.from(b));
}

Comment on lines +110 to +121
RemoteConfigTemplate _parseTemplate(RemoteConfigHttpResult result) {
if (result.etag == null || result.etag!.isEmpty) {
throw FirebaseRemoteConfigException(
RemoteConfigErrorCode.invalidArgument,
'ETag header missing from response.',
);
}
return RemoteConfigTemplate.fromJson(<String, Object?>{
...result.body,
'etag': result.etag,
});
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we extract the etag check? It is duplicated at :49 and :98, and each copy needs a ! right after proving non-null.

Suggested change
RemoteConfigTemplate _parseTemplate(RemoteConfigHttpResult result) {
if (result.etag == null || result.etag!.isEmpty) {
throw FirebaseRemoteConfigException(
RemoteConfigErrorCode.invalidArgument,
'ETag header missing from response.',
);
}
return RemoteConfigTemplate.fromJson(<String, Object?>{
...result.body,
'etag': result.etag,
});
}
String _requireEtag(String? etag, [String source = 'response']) =>
switch (etag) {
final e? when e.isNotEmpty => e,
_ => throw FirebaseRemoteConfigException(
RemoteConfigErrorCode.invalidArgument,
'ETag header missing from $source.',
),
};
RemoteConfigTemplate _parseTemplate(RemoteConfigHttpResult result) {
return RemoteConfigTemplate.fromJson(<String, Object?>{
...result.body,
'etag': _requireEtag(result.etag),
});
}

Comment on lines +49 to +59
if (result.etag == null || result.etag!.isEmpty) {
throw FirebaseRemoteConfigException(
RemoteConfigErrorCode.invalidArgument,
'ETag header missing from validateTemplate response.',
);
}
final parsed = RemoteConfigTemplate.fromJson(<String, Object?>{
...result.body,
'etag': template.etag,
});
return parsed;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the shared _requireEtag here. This also drops the parsed local, which is only assigned and returned.

Suggested change
if (result.etag == null || result.etag!.isEmpty) {
throw FirebaseRemoteConfigException(
RemoteConfigErrorCode.invalidArgument,
'ETag header missing from validateTemplate response.',
);
}
final parsed = RemoteConfigTemplate.fromJson(<String, Object?>{
...result.body,
'etag': template.etag,
});
return parsed;
_requireEtag(result.etag, 'validateTemplate response');
return RemoteConfigTemplate.fromJson(<String, Object?>{
...result.body,
'etag': template.etag,
});

if (targetCustomSignalValues.isEmpty) {
throw FirebaseRemoteConfigException(
RemoteConfigErrorCode.invalidArgument,
'targetCustomSignalValues must contain at least one value.',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the fromJson paths skip these bounds checks and let the evaluator return false instead? An out-of-spec value from the server currently aborts the whole template parse, unlike OneOfCondition.fromJson which decodes unknown shapes to false.

Comment on lines +1332 to +1345
class GetServerTemplateOptions {
GetServerTemplateOptions({Map<String, Object>? defaultConfig})
: defaultConfig = defaultConfig == null
? null
: Map<String, Object>.unmodifiable(defaultConfig);

/// Default config values used by [ServerConfig] for keys not defined in the
/// evaluated template. Values must be `String`, `num`, or `bool`.
final Map<String, Object>? defaultConfig;
}

/// Options for [RemoteConfig.initServerTemplate].
class InitServerTemplateOptions extends GetServerTemplateOptions {
InitServerTemplateOptions({super.defaultConfig, this.template});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either wire GetServerTemplateOptions and InitServerTemplateOptions into the method signatures or drop them before release. They are exported but unreferenced, and they read as the intended call shape for anyone porting from the Node SDK.

CustomSignalOperator.stringContainsRegex => _compareStrings(
targets,
actual,
(target, actualString) => RegExp(target).hasMatch(actualString),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catch FormatException around this RegExp and treat an uncompilable pattern as a non-match. A malformed Console-authored target currently throws out of the synchronous evaluate() and keeps throwing for the life of the cached template.

@@ -1,5 +1,6 @@
## 0.5.2-wip

- Add Remote Config support: template management and server-side template evaluation.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this entry to 0.5.5-wip. Merging main as-is auto-resolves it into the released ## 0.5.2 section, three releases back, so Remote Config would be missing from the next release notes.

Comment on lines +290 to +298
- name: Run Remote Config integration tests
env:
SERVICE_ACCOUNT: ${{ secrets.SERVICE_ACCOUNT }}
run: |
project_id="${SERVICE_ACCOUNT##*@}"
project_id="${project_id%%.iam.gserviceaccount.com}"
RC_TEST_PROJECT_ID="$project_id" \
dart test test/integration/remote_config/ --concurrency=1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate this step on a single matrix entry, for example if: matrix.dart-version == 'stable'. test-wif runs stable and beta in parallel, so both jobs would publish and roll back the same live Remote Config project and race on ETags.

Run this step as dart test test/integration/remote_config -P prod and drop the SERVICE_ACCOUNT parsing. The prod preset arrives with main's dart_test.yaml, and helpers.dart already pins projectId to dart-firebase-admin, which is the project that tag documents, so RC_TEST_PROJECT_ID is not needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants