|
| 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 |
0 commit comments