Skip to content

Commit c6423af

Browse files
author
Varun Rathore
committed
resolve merge conflict
2 parents 1c27d54 + fa8b1b0 commit c6423af

File tree

2 files changed

+201
-37
lines changed

2 files changed

+201
-37
lines changed

firebase_admin/remote_config.py

Lines changed: 62 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@
1616
This module has required APIs for the clients to use Firebase Remote Config with python.
1717
"""
1818

19-
import json
19+
import asyncio
2020
import logging
21-
from typing import Dict, Optional, Literal, Union
21+
from typing import Dict, Optional, Literal, Union, Any
2222
from enum import Enum
2323
import re
2424
import hashlib
25+
import requests
2526
from firebase_admin import App, _http_client, _utils
2627
import firebase_admin
2728

@@ -32,6 +33,7 @@
3233
_REMOTE_CONFIG_ATTRIBUTE = '_remoteconfig'
3334
MAX_CONDITION_RECURSION_DEPTH = 10
3435
ValueSource = Literal['default', 'remote', 'static'] # Define the ValueSource type
36+
3537
class PercentConditionOperator(Enum):
3638
"""Enum representing the available operators for percent conditions.
3739
"""
@@ -62,19 +64,40 @@ class CustomSignalOperator(Enum):
6264
UNKNOWN = "UNKNOWN"
6365

6466
class ServerTemplateData:
65-
"""Represents a Server Template Data class."""
67+
"""Parses, validates and encapsulates template data and metadata."""
6668
def __init__(self, etag, template_data):
6769
"""Initializes a new ServerTemplateData instance.
6870
6971
Args:
7072
etag: The string to be used for initialize the ETag property.
7173
template_data: The data to be parsed for getting the parameters and conditions.
74+
75+
Raises:
76+
ValueError: If the template data is not valid.
7277
"""
73-
self._parameters = template_data['parameters']
74-
self._conditions = template_data['conditions']
75-
self._version = template_data['version']
76-
self._parameter_groups = template_data['parameterGroups']
77-
self._etag = etag
78+
if 'parameters' in template_data:
79+
if template_data['parameters'] is not None:
80+
self._parameters = template_data['parameters']
81+
else:
82+
raise ValueError('Remote Config parameters must be a non-null object')
83+
else:
84+
self._parameters = {}
85+
86+
if 'conditions' in template_data:
87+
if template_data['conditions'] is not None:
88+
self._conditions = template_data['conditions']
89+
else:
90+
raise ValueError('Remote Config conditions must be a non-null object')
91+
else:
92+
self._conditions = []
93+
94+
self._version = ''
95+
if 'version' in template_data:
96+
self._version = template_data['version']
97+
98+
self._etag = ''
99+
if etag is not None and isinstance(etag, str):
100+
self._etag = etag
78101

79102
@property
80103
def parameters(self):
@@ -92,13 +115,9 @@ def version(self):
92115
def conditions(self):
93116
return self._conditions
94117

95-
@property
96-
def parameter_groups(self):
97-
return self._parameter_groups
98-
99118

100119
class ServerTemplate:
101-
"""Represents a Server Template with implementations for loading and evaluting the tempalte."""
120+
"""Represents a Server Template with implementations for loading and evaluting the template."""
102121
def __init__(self, app: App = None, default_config: Optional[Dict[str, str]] = None):
103122
"""Initializes a ServerTemplate instance.
104123
@@ -112,14 +131,18 @@ def __init__(self, app: App = None, default_config: Optional[Dict[str, str]] = N
112131
# This gets set when the template is
113132
# fetched from RC servers via the load API, or via the set API.
114133
self._cache = None
134+
self._stringified_default_config: Dict[str, str] = {}
135+
136+
# RC stores all remote values as string, but it's more intuitive
137+
# to declare default values with specific types, so this converts
138+
# the external declaration to an internal string representation.
115139
if default_config is not None:
116-
self._stringified_default_config = json.dumps(default_config)
117-
else:
118-
self._stringified_default_config = None
140+
for key in default_config:
141+
self._stringified_default_config[key] = str(default_config[key])
119142

120143
async def load(self):
121144
"""Fetches the server template and caches the data."""
122-
self._cache = await self._rc_service.getServerTemplate()
145+
self._cache = await self._rc_service.get_server_template()
123146

124147
def evaluate(self, context: Optional[Dict[str, Union[str, int]]] = None) -> 'ServerConfig':
125148
"""Evaluates the cached server template to produce a ServerConfig.
@@ -140,21 +163,20 @@ def evaluate(self, context: Optional[Dict[str, Union[str, int]]] = None) -> 'Ser
140163
config_values = {}
141164
# Initializes config Value objects with default values.
142165
if self._stringified_default_config is not None:
143-
for key, value in json.loads(self._stringified_default_config).items():
166+
for key, value in self._stringified_default_config.items():
144167
config_values[key] = _Value('default', value)
145168
self._evaluator = _ConditionEvaluator(self._cache.conditions,
146169
self._cache.parameters, context,
147170
config_values)
148171
return ServerConfig(config_values=self._evaluator.evaluate())
149172

150-
def set(self, template):
173+
def set(self, template: ServerTemplateData):
151174
"""Updates the cache to store the given template is of type ServerTemplateData.
152175
153176
Args:
154177
template: An object of type ServerTemplateData to be cached.
155178
"""
156-
if isinstance(template, ServerTemplateData):
157-
self._cache = template
179+
self._cache = template
158180

159181

160182
class ServerConfig:
@@ -202,20 +224,28 @@ def __init__(self, app):
202224
base_url=remote_config_base_url,
203225
headers=rc_headers, timeout=timeout)
204226

205-
206-
def get_server_template(self):
227+
async def get_server_template(self):
207228
"""Requests for a server template and converts the response to an instance of
208229
ServerTemplateData for storing the template parameters and conditions."""
209-
url_prefix = self._get_url_prefix()
210-
headers, response_json = self._client.headers_and_body('get',
211-
url=url_prefix+'/namespaces/ \
212-
firebase-server/serverRemoteConfig')
213-
return ServerTemplateData(headers.get('ETag'), response_json)
230+
try:
231+
loop = asyncio.get_event_loop()
232+
headers, template_data = await loop.run_in_executor(None,
233+
self._client.headers_and_body,
234+
'get', self._get_url())
235+
except requests.exceptions.RequestException as error:
236+
raise self._handle_remote_config_error(error)
237+
else:
238+
return ServerTemplateData(headers.get('etag'), template_data)
214239

215-
def _get_url_prefix(self):
216-
# Returns project prefix for url, in the format of
217-
# /v1/projects/${projectId}
218-
return "/v1/projects/{0}".format(self._project_id)
240+
def _get_url(self):
241+
"""Returns project prefix for url, in the format of /v1/projects/${projectId}"""
242+
return "/v1/projects/{0}/namespaces/firebase-server/serverRemoteConfig".format(
243+
self._project_id)
244+
245+
@classmethod
246+
def _handle_remote_config_error(cls, error: Any):
247+
"""Handles errors received from the Cloud Functions API."""
248+
return _utils.handle_platform_error_from_requests(error)
219249

220250

221251
class _ConditionEvaluator:
@@ -587,7 +617,6 @@ def _compare_versions(self, version1, version2, predicate_fn) -> bool:
587617
logger.warning("Invalid semantic version format for comparison.")
588618
return False
589619

590-
591620
async def get_server_template(app: App = None, default_config: Optional[Dict[str, str]] = None):
592621
"""Initializes a new ServerTemplate instance and fetches the server template.
593622

tests/test_remote_config.py

Lines changed: 139 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,16 @@
1313
# limitations under the License.
1414

1515
"""Tests for firebase_admin.remote_config."""
16+
import json
1617
import uuid
18+
import pytest
1719
import firebase_admin
1820
from firebase_admin.remote_config import (
1921
PercentConditionOperator,
22+
_REMOTE_CONFIG_ATTRIBUTE,
23+
_RemoteConfigService,
2024
ServerTemplateData)
21-
from firebase_admin import remote_config
25+
from firebase_admin import remote_config, _utils
2226
from tests import testutils
2327

2428
VERSION_INFO = {
@@ -258,7 +262,7 @@ def test_evaluate_return_conditional_values_honor_order(self):
258262

259263
def test_evaluate_default_when_no_param(self):
260264
app = firebase_admin.get_app()
261-
default_config = {'promo_enabled': False, 'promo_discount': 20,}
265+
default_config = {'promo_enabled': False, 'promo_discount': '20',}
262266
template_data = SERVER_REMOTE_CONFIG_RESPONSE
263267
template_data['parameters'] = {}
264268
server_template = remote_config.init_server_template(
@@ -322,15 +326,15 @@ def test_evaluate_return_numeric_value(self):
322326
app = firebase_admin.get_app()
323327
template_data = SERVER_REMOTE_CONFIG_RESPONSE
324328
default_config = {
325-
'dog_age': 12
329+
'dog_age': '12'
326330
}
327331
server_template = remote_config.init_server_template(
328332
app=app,
329333
default_config=default_config,
330334
template_data=ServerTemplateData('etag', template_data)
331335
)
332336
server_config = server_template.evaluate()
333-
assert server_config.get_int('dog_age') == 12
337+
assert server_config.get_int('dog_age') == default_config.get('dog_age')
334338

335339
def test_evaluate_return_boolean_value(self):
336340
app = firebase_admin.get_app()
@@ -745,3 +749,134 @@ def evaluate_random_assignments(self, condition, num_of_assignments, mock_app, d
745749
eval_true_count += 1
746750

747751
return eval_true_count
752+
753+
754+
class MockAdapter(testutils.MockAdapter):
755+
"""A Mock HTTP Adapter that Firebase Remote Config with ETag in header."""
756+
757+
ETAG = 'etag'
758+
759+
def __init__(self, data, status, recorder, etag=ETAG):
760+
testutils.MockAdapter.__init__(self, data, status, recorder)
761+
self._etag = etag
762+
763+
def send(self, request, **kwargs):
764+
resp = super(MockAdapter, self).send(request, **kwargs)
765+
resp.headers = {'etag': self._etag}
766+
return resp
767+
768+
769+
class TestRemoteConfigService:
770+
"""Tests methods on _RemoteConfigService"""
771+
@classmethod
772+
def setup_class(cls):
773+
cred = testutils.MockCredential()
774+
firebase_admin.initialize_app(cred, {'projectId': 'project-id'})
775+
776+
@classmethod
777+
def teardown_class(cls):
778+
testutils.cleanup_apps()
779+
780+
@pytest.mark.asyncio
781+
async def test_rc_instance_get_server_template(self):
782+
recorder = []
783+
response = json.dumps({
784+
'parameters': {
785+
'test_key': 'test_value'
786+
},
787+
'conditions': [],
788+
'version': 'test'
789+
})
790+
791+
rc_instance = _utils.get_app_service(firebase_admin.get_app(),
792+
_REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService)
793+
rc_instance._client.session.mount(
794+
'https://firebaseremoteconfig.googleapis.com',
795+
MockAdapter(response, 200, recorder))
796+
797+
template = await rc_instance.get_server_template()
798+
799+
assert template.parameters == dict(test_key="test_value")
800+
assert str(template.version) == 'test'
801+
assert str(template.etag) == 'etag'
802+
803+
@pytest.mark.asyncio
804+
async def test_rc_instance_get_server_template_empty_params(self):
805+
recorder = []
806+
response = json.dumps({
807+
'conditions': [],
808+
'version': 'test'
809+
})
810+
811+
rc_instance = _utils.get_app_service(firebase_admin.get_app(),
812+
_REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService)
813+
rc_instance._client.session.mount(
814+
'https://firebaseremoteconfig.googleapis.com',
815+
MockAdapter(response, 200, recorder))
816+
817+
template = await rc_instance.get_server_template()
818+
819+
assert template.parameters == {}
820+
assert str(template.version) == 'test'
821+
assert str(template.etag) == 'etag'
822+
823+
824+
class TestRemoteConfigModule:
825+
"""Tests methods on firebase_admin.remote_config"""
826+
@classmethod
827+
def setup_class(cls):
828+
cred = testutils.MockCredential()
829+
firebase_admin.initialize_app(cred, {'projectId': 'project-id'})
830+
831+
@classmethod
832+
def teardown_class(cls):
833+
testutils.cleanup_apps()
834+
835+
def test_init_server_template(self):
836+
app = firebase_admin.get_app()
837+
template_data = {
838+
'conditions': [],
839+
'parameters': {
840+
'test_key': {
841+
'defaultValue': {'value': 'test_value'},
842+
'conditionalValues': {}
843+
}
844+
},
845+
'version': '',
846+
}
847+
848+
template = remote_config.init_server_template(
849+
app=app,
850+
default_config={'default_test': 'default_value'},
851+
template_data=ServerTemplateData('etag', template_data)
852+
)
853+
854+
config = template.evaluate()
855+
assert config.get_string('test_key') == 'test_value'
856+
857+
@pytest.mark.asyncio
858+
async def test_get_server_template(self):
859+
app = firebase_admin.get_app()
860+
rc_instance = _utils.get_app_service(app,
861+
_REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService)
862+
863+
recorder = []
864+
response = json.dumps({
865+
'parameters': {
866+
'test_key': {
867+
'defaultValue': {'value': 'test_value'},
868+
'conditionalValues': {}
869+
}
870+
},
871+
'conditions': [],
872+
'version': 'test'
873+
})
874+
875+
rc_instance._client.session.mount(
876+
'https://firebaseremoteconfig.googleapis.com',
877+
MockAdapter(response, 200, recorder))
878+
879+
template = await remote_config.get_server_template(app=app)
880+
881+
config = template.evaluate()
882+
assert config.get_string('test_key') == 'test_value'

0 commit comments

Comments
 (0)