Skip to content

Commit 9ca0f08

Browse files
pijushcsPijush Chakraborty
and
Pijush Chakraborty
committed
Implementation for Fetching and Caching Server Side Remote Config (#825)
* Initial Skeleton for SSRC Implementation * Adding Implementation for RemoteConfigApiClient and ServerTemplate APIs * Updating API signature * Minor update to API signature * Adding comments and unit tests * Updating init params for ServerTemplateData * Adding validation errors and test * Adding unit tests for init_server_template and get_server_template * Removing parameter groups * Addressing PR comments and fixing async flow during fetch call * Fixing lint issues --------- Co-authored-by: Pijush Chakraborty <pijushc@google.com>
1 parent e6c95e7 commit 9ca0f08

File tree

2 files changed

+379
-0
lines changed

2 files changed

+379
-0
lines changed

firebase_admin/remote_config.py

+231
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
# Copyright 2017 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Firebase Remote Config Module.
16+
This module has required APIs for the clients to use Firebase Remote Config with python.
17+
"""
18+
19+
import asyncio
20+
from typing import Any, Dict, Optional
21+
import requests
22+
from firebase_admin import App, _http_client, _utils
23+
import firebase_admin
24+
25+
_REMOTE_CONFIG_ATTRIBUTE = '_remoteconfig'
26+
27+
class ServerTemplateData:
28+
"""Parses, validates and encapsulates template data and metadata."""
29+
def __init__(self, etag, template_data):
30+
"""Initializes a new ServerTemplateData instance.
31+
32+
Args:
33+
etag: The string to be used for initialize the ETag property.
34+
template_data: The data to be parsed for getting the parameters and conditions.
35+
36+
Raises:
37+
ValueError: If the template data is not valid.
38+
"""
39+
if 'parameters' in template_data:
40+
if template_data['parameters'] is not None:
41+
self._parameters = template_data['parameters']
42+
else:
43+
raise ValueError('Remote Config parameters must be a non-null object')
44+
else:
45+
self._parameters = {}
46+
47+
if 'conditions' in template_data:
48+
if template_data['conditions'] is not None:
49+
self._conditions = template_data['conditions']
50+
else:
51+
raise ValueError('Remote Config conditions must be a non-null object')
52+
else:
53+
self._conditions = []
54+
55+
self._version = ''
56+
if 'version' in template_data:
57+
self._version = template_data['version']
58+
59+
self._etag = ''
60+
if etag is not None and isinstance(etag, str):
61+
self._etag = etag
62+
63+
@property
64+
def parameters(self):
65+
return self._parameters
66+
67+
@property
68+
def etag(self):
69+
return self._etag
70+
71+
@property
72+
def version(self):
73+
return self._version
74+
75+
@property
76+
def conditions(self):
77+
return self._conditions
78+
79+
80+
class ServerTemplate:
81+
"""Represents a Server Template with implementations for loading and evaluting the template."""
82+
def __init__(self, app: App = None, default_config: Optional[Dict[str, str]] = None):
83+
"""Initializes a ServerTemplate instance.
84+
85+
Args:
86+
app: App instance to be used. This is optional and the default app instance will
87+
be used if not present.
88+
default_config: The default config to be used in the evaluated config.
89+
"""
90+
self._rc_service = _utils.get_app_service(app,
91+
_REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService)
92+
93+
# This gets set when the template is
94+
# fetched from RC servers via the load API, or via the set API.
95+
self._cache = None
96+
self._stringified_default_config: Dict[str, str] = {}
97+
98+
# RC stores all remote values as string, but it's more intuitive
99+
# to declare default values with specific types, so this converts
100+
# the external declaration to an internal string representation.
101+
if default_config is not None:
102+
for key in default_config:
103+
self._stringified_default_config[key] = str(default_config[key])
104+
105+
async def load(self):
106+
"""Fetches the server template and caches the data."""
107+
self._cache = await self._rc_service.get_server_template()
108+
109+
def evaluate(self):
110+
# Logic to process the cached template into a ServerConfig here.
111+
# TODO: Add and validate Condition evaluator.
112+
self._evaluator = _ConditionEvaluator(self._cache.parameters)
113+
return ServerConfig(config_values=self._evaluator.evaluate())
114+
115+
def set(self, template: ServerTemplateData):
116+
"""Updates the cache to store the given template is of type ServerTemplateData.
117+
118+
Args:
119+
template: An object of type ServerTemplateData to be cached.
120+
"""
121+
self._cache = template
122+
123+
124+
class ServerConfig:
125+
"""Represents a Remote Config Server Side Config."""
126+
def __init__(self, config_values):
127+
self._config_values = config_values # dictionary of param key to values
128+
129+
def get_boolean(self, key):
130+
return bool(self.get_value(key))
131+
132+
def get_string(self, key):
133+
return str(self.get_value(key))
134+
135+
def get_int(self, key):
136+
return int(self.get_value(key))
137+
138+
def get_value(self, key):
139+
return self._config_values[key]
140+
141+
142+
class _RemoteConfigService:
143+
"""Internal class that facilitates sending requests to the Firebase Remote
144+
Config backend API.
145+
"""
146+
def __init__(self, app):
147+
"""Initialize a JsonHttpClient with necessary inputs.
148+
149+
Args:
150+
app: App instance to be used for fetching app specific details required
151+
for initializing the http client.
152+
"""
153+
remote_config_base_url = 'https://firebaseremoteconfig.googleapis.com'
154+
self._project_id = app.project_id
155+
app_credential = app.credential.get_credential()
156+
rc_headers = {
157+
'X-FIREBASE-CLIENT': 'fire-admin-python/{0}'.format(firebase_admin.__version__), }
158+
timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS)
159+
160+
self._client = _http_client.JsonHttpClient(credential=app_credential,
161+
base_url=remote_config_base_url,
162+
headers=rc_headers, timeout=timeout)
163+
164+
async def get_server_template(self):
165+
"""Requests for a server template and converts the response to an instance of
166+
ServerTemplateData for storing the template parameters and conditions."""
167+
try:
168+
loop = asyncio.get_event_loop()
169+
headers, template_data = await loop.run_in_executor(None,
170+
self._client.headers_and_body,
171+
'get', self._get_url())
172+
except requests.exceptions.RequestException as error:
173+
raise self._handle_remote_config_error(error)
174+
else:
175+
return ServerTemplateData(headers.get('etag'), template_data)
176+
177+
def _get_url(self):
178+
"""Returns project prefix for url, in the format of /v1/projects/${projectId}"""
179+
return "/v1/projects/{0}/namespaces/firebase-server/serverRemoteConfig".format(
180+
self._project_id)
181+
182+
@classmethod
183+
def _handle_remote_config_error(cls, error: Any):
184+
"""Handles errors received from the Cloud Functions API."""
185+
return _utils.handle_platform_error_from_requests(error)
186+
187+
188+
class _ConditionEvaluator:
189+
"""Internal class that facilitates sending requests to the Firebase Remote
190+
Config backend API."""
191+
def __init__(self, parameters):
192+
self._parameters = parameters
193+
194+
def evaluate(self):
195+
# TODO: Write logic for evaluator
196+
return self._parameters
197+
198+
199+
async def get_server_template(app: App = None, default_config: Optional[Dict[str, str]] = None):
200+
"""Initializes a new ServerTemplate instance and fetches the server template.
201+
202+
Args:
203+
app: App instance to be used. This is optional and the default app instance will
204+
be used if not present.
205+
default_config: The default config to be used in the evaluated config.
206+
207+
Returns:
208+
ServerTemplate: An object having the cached server template to be used for evaluation.
209+
"""
210+
template = init_server_template(app=app, default_config=default_config)
211+
await template.load()
212+
return template
213+
214+
def init_server_template(app: App = None, default_config: Optional[Dict[str, str]] = None,
215+
template_data: Optional[ServerTemplateData] = None):
216+
"""Initializes a new ServerTemplate instance.
217+
218+
Args:
219+
app: App instance to be used. This is optional and the default app instance will
220+
be used if not present.
221+
default_config: The default config to be used in the evaluated config.
222+
template_data: An optional template data to be set on initialization.
223+
224+
Returns:
225+
ServerTemplate: A new ServerTemplate instance initialized with an optional
226+
template and config.
227+
"""
228+
template = ServerTemplate(app=app, default_config=default_config)
229+
if template_data is not None:
230+
template.set(template_data)
231+
return template

tests/test_remote_config.py

+148
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# Copyright 2017 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for firebase_admin.remote_config."""
16+
import json
17+
import pytest
18+
import firebase_admin
19+
from firebase_admin import remote_config
20+
from firebase_admin.remote_config import _REMOTE_CONFIG_ATTRIBUTE
21+
from firebase_admin.remote_config import _RemoteConfigService, ServerTemplateData
22+
23+
from firebase_admin import _utils
24+
from tests import testutils
25+
26+
class MockAdapter(testutils.MockAdapter):
27+
"""A Mock HTTP Adapter that Firebase Remote Config with ETag in header."""
28+
29+
ETAG = 'etag'
30+
31+
def __init__(self, data, status, recorder, etag=ETAG):
32+
testutils.MockAdapter.__init__(self, data, status, recorder)
33+
self._etag = etag
34+
35+
def send(self, request, **kwargs):
36+
resp = super(MockAdapter, self).send(request, **kwargs)
37+
resp.headers = {'etag': self._etag}
38+
return resp
39+
40+
41+
class TestRemoteConfigService:
42+
"""Tests methods on _RemoteConfigService"""
43+
@classmethod
44+
def setup_class(cls):
45+
cred = testutils.MockCredential()
46+
firebase_admin.initialize_app(cred, {'projectId': 'project-id'})
47+
48+
@classmethod
49+
def teardown_class(cls):
50+
testutils.cleanup_apps()
51+
52+
@pytest.mark.asyncio
53+
async def test_rc_instance_get_server_template(self):
54+
recorder = []
55+
response = json.dumps({
56+
'parameters': {
57+
'test_key': 'test_value'
58+
},
59+
'conditions': [],
60+
'version': 'test'
61+
})
62+
63+
rc_instance = _utils.get_app_service(firebase_admin.get_app(),
64+
_REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService)
65+
rc_instance._client.session.mount(
66+
'https://firebaseremoteconfig.googleapis.com',
67+
MockAdapter(response, 200, recorder))
68+
69+
template = await rc_instance.get_server_template()
70+
71+
assert template.parameters == dict(test_key="test_value")
72+
assert str(template.version) == 'test'
73+
assert str(template.etag) == 'etag'
74+
75+
@pytest.mark.asyncio
76+
async def test_rc_instance_get_server_template_empty_params(self):
77+
recorder = []
78+
response = json.dumps({
79+
'conditions': [],
80+
'version': 'test'
81+
})
82+
83+
rc_instance = _utils.get_app_service(firebase_admin.get_app(),
84+
_REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService)
85+
rc_instance._client.session.mount(
86+
'https://firebaseremoteconfig.googleapis.com',
87+
MockAdapter(response, 200, recorder))
88+
89+
template = await rc_instance.get_server_template()
90+
91+
assert template.parameters == {}
92+
assert str(template.version) == 'test'
93+
assert str(template.etag) == 'etag'
94+
95+
96+
class TestRemoteConfigModule:
97+
"""Tests methods on firebase_admin.remote_config"""
98+
@classmethod
99+
def setup_class(cls):
100+
cred = testutils.MockCredential()
101+
firebase_admin.initialize_app(cred, {'projectId': 'project-id'})
102+
103+
@classmethod
104+
def teardown_class(cls):
105+
testutils.cleanup_apps()
106+
107+
def test_init_server_template(self):
108+
app = firebase_admin.get_app()
109+
template_data = {
110+
'conditions': [],
111+
'parameters': {
112+
'test_key': 'test_value'
113+
},
114+
'version': '',
115+
}
116+
117+
template = remote_config.init_server_template(
118+
app=app,
119+
default_config={'default_test': 'default_value'},
120+
template_data=ServerTemplateData('etag', template_data)
121+
)
122+
123+
config = template.evaluate()
124+
assert config.get_string('test_key') == 'test_value'
125+
126+
@pytest.mark.asyncio
127+
async def test_get_server_template(self):
128+
app = firebase_admin.get_app()
129+
rc_instance = _utils.get_app_service(app,
130+
_REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService)
131+
132+
recorder = []
133+
response = json.dumps({
134+
'parameters': {
135+
'test_key': 'test_value'
136+
},
137+
'conditions': [],
138+
'version': 'test'
139+
})
140+
141+
rc_instance._client.session.mount(
142+
'https://firebaseremoteconfig.googleapis.com',
143+
MockAdapter(response, 200, recorder))
144+
145+
template = await remote_config.get_server_template(app=app)
146+
147+
config = template.evaluate()
148+
assert config.get_string('test_key') == 'test_value'

0 commit comments

Comments
 (0)