feat(remote_config): add Firebase Remote Config support - #249
Conversation
|
Warning Gemini encountered an error creating the review. You can try again by commenting |
2 similar comments
|
Warning Gemini encountered an error creating the review. You can try again by commenting |
|
Warning Gemini encountered an error creating the review. You can try again by commenting |
* feat: add Firebase Remote Config support Implements Firebase Remote Config for the Dart Admin SDK with full template management plus server-side template evaluation.
|
Hi @long1eu , thanks for the PR! At the moment there's no support for Remote Config in the I'll get some more details on what the plan is there and keep this PR up to date. |
|
Thanks for taking a look @demolaf! Quick context that might be useful while you check on the
Happy to take this in any direction that fits the project's plans:
Just let me know which you'd prefer once you have more details on the plan. |
|
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 const template = await getRemoteConfig().getServerTemplate({defaultConfig: {[key]: false}});
return template.evaluate().getBoolean(key);which maps 1:1 onto On the 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 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. |
|
Hi @long1eu , can you rebase this on the latest version on |
demolaf
left a comment
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| 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, | |
| }; |
| 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); | ||
| } |
There was a problem hiding this comment.
Fold the bytes directly rather than building 64 hex chars and re-parsing them. Same value, one expression.
| 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)); | |
| } |
| 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, | ||
| }); | ||
| } |
There was a problem hiding this comment.
Can we extract the etag check? It is duplicated at :49 and :98, and each copy needs a ! right after proving non-null.
| 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), | |
| }); | |
| } |
| 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; |
There was a problem hiding this comment.
Use the shared _requireEtag here. This also drops the parsed local, which is only assigned and returned.
| 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.', |
There was a problem hiding this comment.
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.
| 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}); |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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. | |||
There was a problem hiding this comment.
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.
| - 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 | ||
|
|
There was a problem hiding this comment.
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.
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
googleapis-generated client — the generated client doesn't expose response headers (no ETag) and doesn't acceptIf-Match, both of which are required for optimistic concurrency and force-publish.firebase-servernamespace; server templates are Console-managed only.