diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py index 119f41e3..4b5d4611 100644 --- a/firebase_admin/remote_config.py +++ b/firebase_admin/remote_config.py @@ -17,7 +17,9 @@ """ import asyncio +import json import logging +import threading from typing import Dict, Optional, Literal, Union, Any from enum import Enum import re @@ -63,13 +65,12 @@ class CustomSignalOperator(Enum): SEMANTIC_VERSION_GREATER_EQUAL = "SEMANTIC_VERSION_GREATER_EQUAL" UNKNOWN = "UNKNOWN" -class ServerTemplateData: +class _ServerTemplateData: """Parses, validates and encapsulates template data and metadata.""" - def __init__(self, etag, template_data): + def __init__(self, template_data): """Initializes a new ServerTemplateData instance. Args: - etag: The string to be used for initialize the ETag property. template_data: The data to be parsed for getting the parameters and conditions. Raises: @@ -96,8 +97,10 @@ def __init__(self, etag, template_data): self._version = template_data['version'] self._etag = '' - if etag is not None and isinstance(etag, str): - self._etag = etag + if 'etag' in template_data and isinstance(template_data['etag'], str): + self._etag = template_data['etag'] + + self._template_data_json = json.dumps(template_data) @property def parameters(self): @@ -115,6 +118,10 @@ def version(self): def conditions(self): return self._conditions + @property + def template_data_json(self): + return self._template_data_json + class ServerTemplate: """Represents a Server Template with implementations for loading and evaluting the template.""" @@ -132,6 +139,7 @@ def __init__(self, app: App = None, default_config: Optional[Dict[str, str]] = N # fetched from RC servers via the load API, or via the set API. self._cache = None self._stringified_default_config: Dict[str, str] = {} + self._lock = threading.RLock() # RC stores all remote values as string, but it's more intuitive # to declare default values with specific types, so this converts @@ -142,7 +150,9 @@ def __init__(self, app: App = None, default_config: Optional[Dict[str, str]] = N async def load(self): """Fetches the server template and caches the data.""" - self._cache = await self._rc_service.get_server_template() + rc_server_template = await self._rc_service.get_server_template() + with self._lock: + self._cache = rc_server_template def evaluate(self, context: Optional[Dict[str, Union[str, int]]] = None) -> 'ServerConfig': """Evaluates the cached server template to produce a ServerConfig. @@ -161,22 +171,40 @@ def evaluate(self, context: Optional[Dict[str, Union[str, int]]] = None) -> 'Ser Call load() before calling evaluate().""") context = context or {} config_values = {} + + with self._lock: + template_conditions = self._cache.conditions + template_parameters = self._cache.parameters + # Initializes config Value objects with default values. if self._stringified_default_config is not None: for key, value in self._stringified_default_config.items(): config_values[key] = _Value('default', value) - self._evaluator = _ConditionEvaluator(self._cache.conditions, - self._cache.parameters, context, + self._evaluator = _ConditionEvaluator(template_conditions, + template_parameters, context, config_values) return ServerConfig(config_values=self._evaluator.evaluate()) - def set(self, template: ServerTemplateData): + def set(self, template_data_json: str): """Updates the cache to store the given template is of type ServerTemplateData. Args: - template: An object of type ServerTemplateData to be cached. + template_data_json: A json string representing ServerTemplateData to be cached. """ - self._cache = template + template_data_map = json.loads(template_data_json) + template_data = _ServerTemplateData(template_data_map) + + with self._lock: + self._cache = template_data + + def to_json(self): + """Provides the server template in a JSON format to be used for initialization later.""" + if not self._cache: + raise ValueError("""No Remote Config Server template in cache. + Call load() before calling toJSON().""") + with self._lock: + template_json = self._cache.template_data_json + return template_json class ServerConfig: @@ -185,17 +213,25 @@ def __init__(self, config_values): self._config_values = config_values # dictionary of param key to values def get_boolean(self, key): + """Returns the value as a boolean.""" return self._get_value(key).as_boolean() def get_string(self, key): + """Returns the value as a string.""" return self._get_value(key).as_string() def get_int(self, key): + """Returns the value as an integer.""" return self._get_value(key).as_int() def get_float(self, key): + """Returns the value as a float.""" return self._get_value(key).as_float() + def get_value_source(self, key): + """Returns the source of the value.""" + return self._get_value(key).get_source() + def _get_value(self, key): return self._config_values.get(key, _Value('static')) @@ -233,7 +269,8 @@ async def get_server_template(self): except requests.exceptions.RequestException as error: raise self._handle_remote_config_error(error) else: - return ServerTemplateData(headers.get('etag'), template_data) + template_data['etag'] = headers.get('etag') + return _ServerTemplateData(template_data) def _get_url(self): """Returns project prefix for url, in the format of /v1/projects/${projectId}""" @@ -633,22 +670,22 @@ async def get_server_template(app: App = None, default_config: Optional[Dict[str return template def init_server_template(app: App = None, default_config: Optional[Dict[str, str]] = None, - template_data: Optional[ServerTemplateData] = None): + template_data_json: Optional[str] = None): """Initializes a new ServerTemplate instance. Args: app: App instance to be used. This is optional and the default app instance will be used if not present. default_config: The default config to be used in the evaluated config. - template_data: An optional template data to be set on initialization. + template_data_json: An optional template data JSON to be set on initialization. Returns: ServerTemplate: A new ServerTemplate instance initialized with an optional template and config. """ template = ServerTemplate(app=app, default_config=default_config) - if template_data is not None: - template.set(template_data) + if template_data_json is not None: + template.set(template_data_json) return template class _Value: diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py index 2eabe15e..8c6248e1 100644 --- a/tests/test_remote_config.py +++ b/tests/test_remote_config.py @@ -21,8 +21,7 @@ CustomSignalOperator, PercentConditionOperator, _REMOTE_CONFIG_ATTRIBUTE, - _RemoteConfigService, - ServerTemplateData) + _RemoteConfigService) from firebase_admin import remote_config, _utils from tests import testutils @@ -121,16 +120,17 @@ def test_evaluate_or_and_true_condition_true(self): }, 'parameterGroups': '', 'version': '', - 'etag': '123' + 'etag': 'etag' } server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() assert server_config.get_boolean('is_enabled') + assert server_config.get_value_source('is_enabled') == 'remote' def test_evaluate_or_and_false_condition_false(self): app = firebase_admin.get_app() @@ -165,12 +165,12 @@ def test_evaluate_or_and_false_condition_false(self): }, 'parameterGroups': '', 'version': '', - 'etag': '123' + 'etag': 'etag' } server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() @@ -196,12 +196,12 @@ def test_evaluate_non_or_condition(self): }, 'parameterGroups': '', 'version': '', - 'etag': '123' + 'etag': 'etag' } server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() @@ -262,12 +262,12 @@ def test_evaluate_return_conditional_values_honor_order(self): }, 'parameterGroups':'', 'version':'', - 'etag': '123' + 'etag': 'etag' } server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() assert server_config.get_string('dog_type') == 'corgi' @@ -280,7 +280,7 @@ def test_evaluate_default_when_no_param(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() assert server_config.get_boolean('promo_enabled') == default_config.get('promo_enabled') @@ -296,7 +296,7 @@ def test_evaluate_default_when_no_default_value(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() assert server_config.get_string('default_value') == default_config.get('default_value') @@ -313,7 +313,7 @@ def test_evaluate_default_when_in_default(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() assert server_config.get_string('inapp_default') == default_config.get('inapp_default') @@ -328,7 +328,7 @@ def test_evaluate_default_when_defined(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() assert server_config.get_string('dog_type') == 'shiba' @@ -342,7 +342,7 @@ def test_evaluate_return_numeric_value(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() assert server_config.get_int('dog_age') == int(default_config.get('dog_age')) @@ -356,7 +356,7 @@ def test_evaluate_return_boolean_value(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() assert server_config.get_boolean('dog_is_cute') @@ -398,7 +398,7 @@ def test_evaluate_unknown_operator_to_false(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') @@ -442,7 +442,7 @@ def test_evaluate_less_or_equal_to_max_to_true(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate(context) assert server_config.get_boolean('is_enabled') @@ -485,7 +485,7 @@ def test_evaluate_undefined_micropercent_to_false(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') @@ -528,7 +528,7 @@ def test_evaluate_undefined_micropercentrange_to_false(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') @@ -575,7 +575,7 @@ def test_evaluate_between_min_max_to_true(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate(context) assert server_config.get_boolean('is_enabled') @@ -622,7 +622,7 @@ def test_evaluate_between_equal_bounds_to_false(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') @@ -750,7 +750,7 @@ def evaluate_random_assignments(self, condition, num_of_assignments, mock_app, d server_template = remote_config.init_server_template( app=mock_app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) for _ in range(num_of_assignments): @@ -814,7 +814,7 @@ def test_evaluate_custom_signal_semantic_version(self, server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate(context) assert server_config.get_boolean('is_enabled') == parameter_value @@ -917,7 +917,7 @@ def test_init_server_template(self): template = remote_config.init_server_template( app=app, default_config={'default_test': 'default_value'}, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) config = template.evaluate() @@ -949,3 +949,36 @@ async def test_get_server_template(self): config = template.evaluate() assert config.get_string('test_key') == 'test_value' + + @pytest.mark.asyncio + async def test_server_template_to_json(self): + app = firebase_admin.get_app() + rc_instance = _utils.get_app_service(app, + _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService) + + recorder = [] + response = json.dumps({ + 'parameters': { + 'test_key': { + 'defaultValue': {'value': 'test_value'}, + 'conditionalValues': {} + } + }, + 'conditions': [], + 'version': 'test' + }) + + expected_template_json = '{"parameters": {' \ + '"test_key": {' \ + '"defaultValue": {' \ + '"value": "test_value"}, ' \ + '"conditionalValues": {}}}, "conditions": [], ' \ + '"version": "test", "etag": "etag"}' + + rc_instance._client.session.mount( + 'https://firebaseremoteconfig.googleapis.com', + MockAdapter(response, 200, recorder)) + template = await remote_config.get_server_template(app=app) + + template_json = template.to_json() + assert template_json == expected_template_json