Skip to content

Commit

Permalink
resolve merge conflict
Browse files Browse the repository at this point in the history
  • Loading branch information
Varun Rathore committed Nov 15, 2024
2 parents 1c27d54 + fa8b1b0 commit c6423af
Show file tree
Hide file tree
Showing 2 changed files with 201 additions and 37 deletions.
95 changes: 62 additions & 33 deletions firebase_admin/remote_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@
This module has required APIs for the clients to use Firebase Remote Config with python.
"""

import json
import asyncio
import logging
from typing import Dict, Optional, Literal, Union
from typing import Dict, Optional, Literal, Union, Any
from enum import Enum
import re
import hashlib
import requests
from firebase_admin import App, _http_client, _utils
import firebase_admin

Expand All @@ -32,6 +33,7 @@
_REMOTE_CONFIG_ATTRIBUTE = '_remoteconfig'
MAX_CONDITION_RECURSION_DEPTH = 10
ValueSource = Literal['default', 'remote', 'static'] # Define the ValueSource type

class PercentConditionOperator(Enum):
"""Enum representing the available operators for percent conditions.
"""
Expand Down Expand Up @@ -62,19 +64,40 @@ class CustomSignalOperator(Enum):
UNKNOWN = "UNKNOWN"

class ServerTemplateData:
"""Represents a Server Template Data class."""
"""Parses, validates and encapsulates template data and metadata."""
def __init__(self, etag, 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:
ValueError: If the template data is not valid.
"""
self._parameters = template_data['parameters']
self._conditions = template_data['conditions']
self._version = template_data['version']
self._parameter_groups = template_data['parameterGroups']
self._etag = etag
if 'parameters' in template_data:
if template_data['parameters'] is not None:
self._parameters = template_data['parameters']
else:
raise ValueError('Remote Config parameters must be a non-null object')
else:
self._parameters = {}

if 'conditions' in template_data:
if template_data['conditions'] is not None:
self._conditions = template_data['conditions']
else:
raise ValueError('Remote Config conditions must be a non-null object')
else:
self._conditions = []

self._version = ''
if 'version' in template_data:
self._version = template_data['version']

self._etag = ''
if etag is not None and isinstance(etag, str):
self._etag = etag

@property
def parameters(self):
Expand All @@ -92,13 +115,9 @@ def version(self):
def conditions(self):
return self._conditions

@property
def parameter_groups(self):
return self._parameter_groups


class ServerTemplate:
"""Represents a Server Template with implementations for loading and evaluting the tempalte."""
"""Represents a Server Template with implementations for loading and evaluting the template."""
def __init__(self, app: App = None, default_config: Optional[Dict[str, str]] = None):
"""Initializes a ServerTemplate instance.
Expand All @@ -112,14 +131,18 @@ def __init__(self, app: App = None, default_config: Optional[Dict[str, str]] = N
# This gets set when the template is
# fetched from RC servers via the load API, or via the set API.
self._cache = None
self._stringified_default_config: Dict[str, str] = {}

# RC stores all remote values as string, but it's more intuitive
# to declare default values with specific types, so this converts
# the external declaration to an internal string representation.
if default_config is not None:
self._stringified_default_config = json.dumps(default_config)
else:
self._stringified_default_config = None
for key in default_config:
self._stringified_default_config[key] = str(default_config[key])

async def load(self):
"""Fetches the server template and caches the data."""
self._cache = await self._rc_service.getServerTemplate()
self._cache = await self._rc_service.get_server_template()

def evaluate(self, context: Optional[Dict[str, Union[str, int]]] = None) -> 'ServerConfig':
"""Evaluates the cached server template to produce a ServerConfig.
Expand All @@ -140,21 +163,20 @@ def evaluate(self, context: Optional[Dict[str, Union[str, int]]] = None) -> 'Ser
config_values = {}
# Initializes config Value objects with default values.
if self._stringified_default_config is not None:
for key, value in json.loads(self._stringified_default_config).items():
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,
config_values)
return ServerConfig(config_values=self._evaluator.evaluate())

def set(self, template):
def set(self, template: ServerTemplateData):
"""Updates the cache to store the given template is of type ServerTemplateData.
Args:
template: An object of type ServerTemplateData to be cached.
"""
if isinstance(template, ServerTemplateData):
self._cache = template
self._cache = template


class ServerConfig:
Expand Down Expand Up @@ -202,20 +224,28 @@ def __init__(self, app):
base_url=remote_config_base_url,
headers=rc_headers, timeout=timeout)


def get_server_template(self):
async def get_server_template(self):
"""Requests for a server template and converts the response to an instance of
ServerTemplateData for storing the template parameters and conditions."""
url_prefix = self._get_url_prefix()
headers, response_json = self._client.headers_and_body('get',
url=url_prefix+'/namespaces/ \
firebase-server/serverRemoteConfig')
return ServerTemplateData(headers.get('ETag'), response_json)
try:
loop = asyncio.get_event_loop()
headers, template_data = await loop.run_in_executor(None,
self._client.headers_and_body,
'get', self._get_url())
except requests.exceptions.RequestException as error:
raise self._handle_remote_config_error(error)
else:
return ServerTemplateData(headers.get('etag'), template_data)

def _get_url_prefix(self):
# Returns project prefix for url, in the format of
# /v1/projects/${projectId}
return "/v1/projects/{0}".format(self._project_id)
def _get_url(self):
"""Returns project prefix for url, in the format of /v1/projects/${projectId}"""
return "/v1/projects/{0}/namespaces/firebase-server/serverRemoteConfig".format(
self._project_id)

@classmethod
def _handle_remote_config_error(cls, error: Any):
"""Handles errors received from the Cloud Functions API."""
return _utils.handle_platform_error_from_requests(error)


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


async def get_server_template(app: App = None, default_config: Optional[Dict[str, str]] = None):
"""Initializes a new ServerTemplate instance and fetches the server template.
Expand Down
143 changes: 139 additions & 4 deletions tests/test_remote_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@
# limitations under the License.

"""Tests for firebase_admin.remote_config."""
import json
import uuid
import pytest
import firebase_admin
from firebase_admin.remote_config import (
PercentConditionOperator,
_REMOTE_CONFIG_ATTRIBUTE,
_RemoteConfigService,
ServerTemplateData)
from firebase_admin import remote_config
from firebase_admin import remote_config, _utils
from tests import testutils

VERSION_INFO = {
Expand Down Expand Up @@ -258,7 +262,7 @@ def test_evaluate_return_conditional_values_honor_order(self):

def test_evaluate_default_when_no_param(self):
app = firebase_admin.get_app()
default_config = {'promo_enabled': False, 'promo_discount': 20,}
default_config = {'promo_enabled': False, 'promo_discount': '20',}
template_data = SERVER_REMOTE_CONFIG_RESPONSE
template_data['parameters'] = {}
server_template = remote_config.init_server_template(
Expand Down Expand Up @@ -322,15 +326,15 @@ def test_evaluate_return_numeric_value(self):
app = firebase_admin.get_app()
template_data = SERVER_REMOTE_CONFIG_RESPONSE
default_config = {
'dog_age': 12
'dog_age': '12'
}
server_template = remote_config.init_server_template(
app=app,
default_config=default_config,
template_data=ServerTemplateData('etag', template_data)
)
server_config = server_template.evaluate()
assert server_config.get_int('dog_age') == 12
assert server_config.get_int('dog_age') == default_config.get('dog_age')

def test_evaluate_return_boolean_value(self):
app = firebase_admin.get_app()
Expand Down Expand Up @@ -745,3 +749,134 @@ def evaluate_random_assignments(self, condition, num_of_assignments, mock_app, d
eval_true_count += 1

return eval_true_count


class MockAdapter(testutils.MockAdapter):
"""A Mock HTTP Adapter that Firebase Remote Config with ETag in header."""

ETAG = 'etag'

def __init__(self, data, status, recorder, etag=ETAG):
testutils.MockAdapter.__init__(self, data, status, recorder)
self._etag = etag

def send(self, request, **kwargs):
resp = super(MockAdapter, self).send(request, **kwargs)
resp.headers = {'etag': self._etag}
return resp


class TestRemoteConfigService:
"""Tests methods on _RemoteConfigService"""
@classmethod
def setup_class(cls):
cred = testutils.MockCredential()
firebase_admin.initialize_app(cred, {'projectId': 'project-id'})

@classmethod
def teardown_class(cls):
testutils.cleanup_apps()

@pytest.mark.asyncio
async def test_rc_instance_get_server_template(self):
recorder = []
response = json.dumps({
'parameters': {
'test_key': 'test_value'
},
'conditions': [],
'version': 'test'
})

rc_instance = _utils.get_app_service(firebase_admin.get_app(),
_REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService)
rc_instance._client.session.mount(
'https://firebaseremoteconfig.googleapis.com',
MockAdapter(response, 200, recorder))

template = await rc_instance.get_server_template()

assert template.parameters == dict(test_key="test_value")
assert str(template.version) == 'test'
assert str(template.etag) == 'etag'

@pytest.mark.asyncio
async def test_rc_instance_get_server_template_empty_params(self):
recorder = []
response = json.dumps({
'conditions': [],
'version': 'test'
})

rc_instance = _utils.get_app_service(firebase_admin.get_app(),
_REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService)
rc_instance._client.session.mount(
'https://firebaseremoteconfig.googleapis.com',
MockAdapter(response, 200, recorder))

template = await rc_instance.get_server_template()

assert template.parameters == {}
assert str(template.version) == 'test'
assert str(template.etag) == 'etag'


class TestRemoteConfigModule:
"""Tests methods on firebase_admin.remote_config"""
@classmethod
def setup_class(cls):
cred = testutils.MockCredential()
firebase_admin.initialize_app(cred, {'projectId': 'project-id'})

@classmethod
def teardown_class(cls):
testutils.cleanup_apps()

def test_init_server_template(self):
app = firebase_admin.get_app()
template_data = {
'conditions': [],
'parameters': {
'test_key': {
'defaultValue': {'value': 'test_value'},
'conditionalValues': {}
}
},
'version': '',
}

template = remote_config.init_server_template(
app=app,
default_config={'default_test': 'default_value'},
template_data=ServerTemplateData('etag', template_data)
)

config = template.evaluate()
assert config.get_string('test_key') == 'test_value'

@pytest.mark.asyncio
async def test_get_server_template(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'
})

rc_instance._client.session.mount(
'https://firebaseremoteconfig.googleapis.com',
MockAdapter(response, 200, recorder))

template = await remote_config.get_server_template(app=app)

config = template.evaluate()
assert config.get_string('test_key') == 'test_value'

0 comments on commit c6423af

Please sign in to comment.