|  | 
|  | 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