Skip to content

feat(feature_flags): support beyond boolean values (JSON values) #804

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Dec 31, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 41 additions & 12 deletions aws_lambda_powertools/utilities/feature_flags/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,21 +97,29 @@ def _evaluate_conditions(
return True

def _evaluate_rules(
self, *, feature_name: str, context: Dict[str, Any], feat_default: bool, rules: Dict[str, Any]
self,
*,
feature_name: str,
context: Dict[str, Any],
feat_default: Any,
rules: Dict[str, Any],
boolean_feature: bool,
) -> bool:
"""Evaluates whether context matches rules and conditions, otherwise return feature default"""
for rule_name, rule in rules.items():
rule_match_value = rule.get(schema.RULE_MATCH_VALUE)

# Context might contain PII data; do not log its value
self.logger.debug(
f"Evaluating rule matching, rule={rule_name}, feature={feature_name}, default={feat_default}"
f"Evaluating rule matching, rule={rule_name}, feature={feature_name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # type: ignore # noqa: E501
)
if self._evaluate_conditions(rule_name=rule_name, feature_name=feature_name, rule=rule, context=context):
return bool(rule_match_value)
return bool(rule_match_value) if boolean_feature else rule_match_value

# no rule matched, return default value of feature
self.logger.debug(f"no rule matched, returning feature default, default={feat_default}, name={feature_name}")
self.logger.debug(
f"no rule matched, returning feature default, default={str(feat_default)}, name={feature_name}, boolean_feature={boolean_feature}" # type: ignore # noqa: E501
)
return feat_default

def get_configuration(self) -> Dict:
Expand Down Expand Up @@ -164,7 +172,7 @@ def get_configuration(self) -> Dict:

return config

def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, default: bool) -> bool:
def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, default: Any) -> Any:
"""Evaluate whether a feature flag should be enabled according to stored schema and input context

**Logic when evaluating a feature flag**
Expand All @@ -181,14 +189,15 @@ def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, defau
Attributes that should be evaluated against the stored schema.

for example: `{"tenant_id": "X", "username": "Y", "region": "Z"}`
default: bool
default: Any
default value if feature flag doesn't exist in the schema,
or there has been an error when fetching the configuration from the store
Can be boolean (the default for feature flags) or any JSON values for advanced features

Returns
------
bool
whether feature should be enabled or not
whether feature should be enabled or not for boolean feature flag or any other JSON type

Raises
------
Expand All @@ -211,12 +220,21 @@ def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, defau

rules = feature.get(schema.RULES_KEY)
feat_default = feature.get(schema.FEATURE_DEFAULT_VAL_KEY)
boolean_feature = feature.get(
schema.FEATURE_DEFAULT_VAL_TYPE_KEY, True
) # backwards compatability ,assume feature flag
if not rules:
self.logger.debug(f"no rules found, returning feature default, name={name}, default={feat_default}")
return bool(feat_default)
self.logger.debug(
f"no rules found, returning feature default, name={name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # type: ignore # noqa: E501
)
return bool(feat_default) if boolean_feature else feat_default

self.logger.debug(f"looking for rule match, name={name}, default={feat_default}")
return self._evaluate_rules(feature_name=name, context=context, feat_default=bool(feat_default), rules=rules)
self.logger.debug(
f"looking for rule match, name={name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # type: ignore # noqa: E501
)
return self._evaluate_rules(
feature_name=name, context=context, feat_default=feat_default, rules=rules, boolean_feature=boolean_feature
)

def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> List[str]:
"""Get all enabled feature flags while also taking into account context
Expand Down Expand Up @@ -259,11 +277,22 @@ def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> L
for name, feature in features.items():
rules = feature.get(schema.RULES_KEY, {})
feature_default_value = feature.get(schema.FEATURE_DEFAULT_VAL_KEY)
boolean_feature = feature.get(
schema.FEATURE_DEFAULT_VAL_TYPE_KEY, True
) # backwards compatability ,assume feature flag
if not boolean_feature:
self.logger.debug(f"skipping feature because it is not a boolean feature flag, name={name}")
continue

if feature_default_value and not rules:
self.logger.debug(f"feature is enabled by default and has no defined rules, name={name}")
features_enabled.append(name)
elif self._evaluate_rules(
feature_name=name, context=context, feat_default=feature_default_value, rules=rules
feature_name=name,
context=context,
feat_default=feature_default_value,
rules=rules,
boolean_feature=boolean_feature,
):
self.logger.debug(f"feature's calculated value is True, name={name}")
features_enabled.append(name)
Expand Down
33 changes: 22 additions & 11 deletions aws_lambda_powertools/utilities/feature_flags/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
CONDITION_KEY = "key"
CONDITION_VALUE = "value"
CONDITION_ACTION = "action"
FEATURE_DEFAULT_VAL_TYPE_KEY = "boolean_type"


class RuleAction(str, Enum):
Expand Down Expand Up @@ -138,28 +139,36 @@ def __init__(self, schema: Dict, logger: Optional[Union[logging.Logger, Logger]]
def validate(self):
for name, feature in self.schema.items():
self.logger.debug(f"Attempting to validate feature '{name}'")
self.validate_feature(name, feature)
rules = RulesValidator(feature=feature)
boolean_feature: bool = self.validate_feature(name, feature)
rules = RulesValidator(feature=feature, boolean_feature=boolean_feature)
rules.validate()

# returns True in case the feature is a regular feature flag with a boolean default value
@staticmethod
def validate_feature(name, feature):
def validate_feature(name, feature) -> bool:
if not feature or not isinstance(feature, dict):
raise SchemaValidationError(f"Feature must be a non-empty dictionary, feature={name}")

default_value = feature.get(FEATURE_DEFAULT_VAL_KEY)
if default_value is None or not isinstance(default_value, bool):
default_value: Any = feature.get(FEATURE_DEFAULT_VAL_KEY)
boolean_feature: bool = feature.get(FEATURE_DEFAULT_VAL_TYPE_KEY, True)
# if feature is boolean_feature, default_value must be a boolean type.
# default_value must exist
if default_value is None or (not isinstance(default_value, bool) and boolean_feature):
raise SchemaValidationError(f"feature 'default' boolean key must be present, feature={name}")
return boolean_feature


class RulesValidator(BaseValidator):
"""Validates each rule and calls ConditionsValidator to validate each rule's conditions"""

def __init__(self, feature: Dict[str, Any], logger: Optional[Union[logging.Logger, Logger]] = None):
def __init__(
self, feature: Dict[str, Any], boolean_feature: bool, logger: Optional[Union[logging.Logger, Logger]] = None
):
self.feature = feature
self.feature_name = next(iter(self.feature))
self.rules: Optional[Dict] = self.feature.get(RULES_KEY)
self.logger = logger or logging.getLogger(__name__)
self.boolean_feature = boolean_feature

def validate(self):
if not self.rules:
Expand All @@ -171,27 +180,29 @@ def validate(self):

for rule_name, rule in self.rules.items():
self.logger.debug(f"Attempting to validate rule '{rule_name}'")
self.validate_rule(rule=rule, rule_name=rule_name, feature_name=self.feature_name)
self.validate_rule(
rule=rule, rule_name=rule_name, feature_name=self.feature_name, boolean_feature=self.boolean_feature
)
conditions = ConditionsValidator(rule=rule, rule_name=rule_name)
conditions.validate()

@staticmethod
def validate_rule(rule, rule_name, feature_name):
def validate_rule(rule: Dict, rule_name: str, feature_name: str, boolean_feature: Optional[bool] = True):
if not rule or not isinstance(rule, dict):
raise SchemaValidationError(f"Feature rule must be a dictionary, feature={feature_name}")

RulesValidator.validate_rule_name(rule_name=rule_name, feature_name=feature_name)
RulesValidator.validate_rule_default_value(rule=rule, rule_name=rule_name)
RulesValidator.validate_rule_default_value(rule=rule, rule_name=rule_name, boolean_feature=boolean_feature)

@staticmethod
def validate_rule_name(rule_name: str, feature_name: str):
if not rule_name or not isinstance(rule_name, str):
raise SchemaValidationError(f"Rule name key must have a non-empty string, feature={feature_name}")

@staticmethod
def validate_rule_default_value(rule: Dict, rule_name: str):
def validate_rule_default_value(rule: Dict, rule_name: str, boolean_feature: bool):
rule_default_value = rule.get(RULE_MATCH_VALUE)
if not isinstance(rule_default_value, bool):
if boolean_feature and not isinstance(rule_default_value, bool):
raise SchemaValidationError(f"'rule_default_value' key must have be bool, rule={rule_name}")


Expand Down
Binary file added docs/media/feature_flags_diagram.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
155 changes: 155 additions & 0 deletions tests/functional/feature_flags/test_complex_rule_values.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
from typing import Dict, List, Optional

import pytest
from botocore.config import Config

from aws_lambda_powertools.utilities.feature_flags.appconfig import AppConfigStore
from aws_lambda_powertools.utilities.feature_flags.feature_flags import FeatureFlags
from aws_lambda_powertools.utilities.feature_flags.schema import (
CONDITION_ACTION,
CONDITION_KEY,
CONDITION_VALUE,
CONDITIONS_KEY,
FEATURE_DEFAULT_VAL_KEY,
FEATURE_DEFAULT_VAL_TYPE_KEY,
RULE_MATCH_VALUE,
RULES_KEY,
RuleAction,
)


@pytest.fixture(scope="module")
def config():
return Config(region_name="us-east-1")


def init_feature_flags(
mocker, mock_schema: Dict, config: Config, envelope: str = "", jmespath_options: Optional[Dict] = None
) -> FeatureFlags:
mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get")
mocked_get_conf.return_value = mock_schema

app_conf_fetcher = AppConfigStore(
environment="test_env",
application="test_app",
name="test_conf_name",
max_age=600,
sdk_config=config,
envelope=envelope,
jmespath_options=jmespath_options,
)
feature_flags: FeatureFlags = FeatureFlags(store=app_conf_fetcher)
return feature_flags


# default return value is an empty list, when rule matches return a non empty list
def test_feature_rule_match(mocker, config):
expected_value = ["value1"]
mocked_app_config_schema = {
"my_feature": {
FEATURE_DEFAULT_VAL_KEY: [],
FEATURE_DEFAULT_VAL_TYPE_KEY: False,
RULES_KEY: {
"tenant id equals 345345435": {
RULE_MATCH_VALUE: expected_value,
CONDITIONS_KEY: [
{
CONDITION_ACTION: RuleAction.EQUALS.value,
CONDITION_KEY: "tenant_id",
CONDITION_VALUE: "345345435",
}
],
}
},
}
}

features = init_feature_flags(mocker, mocked_app_config_schema, config)
feature_value = features.evaluate(name="my_feature", context={"tenant_id": "345345435"}, default=[])
assert feature_value == expected_value


def test_complex_feature_no_rules(mocker, config):
expected_value = ["value1"]
mocked_app_config_schema = {
"my_feature": {FEATURE_DEFAULT_VAL_KEY: expected_value, FEATURE_DEFAULT_VAL_TYPE_KEY: False}
}

features = init_feature_flags(mocker, mocked_app_config_schema, config)
feature_value = features.evaluate(name="my_feature", context={"tenant_id": "345345435"}, default=[])
assert feature_value == expected_value


def test_feature_no_rule_match(mocker, config):
expected_value = []
mocked_app_config_schema = {
"my_feature": {
FEATURE_DEFAULT_VAL_KEY: expected_value,
FEATURE_DEFAULT_VAL_TYPE_KEY: False,
RULES_KEY: {
"tenant id equals 345345435": {
RULE_MATCH_VALUE: ["value1"],
CONDITIONS_KEY: [
{
CONDITION_ACTION: RuleAction.EQUALS.value,
CONDITION_KEY: "tenant_id",
CONDITION_VALUE: "345345435",
}
],
}
},
}
}

features = init_feature_flags(mocker, mocked_app_config_schema, config)
feature_value = features.evaluate(name="my_feature", context={}, default=[])
assert feature_value == expected_value


# Check multiple features
def test_multiple_features_enabled_with_complex_toggles_and_boolean_toggles(mocker, config):
expected_value = ["my_feature", "my_feature2"]
mocked_app_config_schema = {
"my_feature": {
FEATURE_DEFAULT_VAL_KEY: False,
RULES_KEY: {
"tenant id is contained in [6, 2]": {
RULE_MATCH_VALUE: True,
CONDITIONS_KEY: [
{
CONDITION_ACTION: RuleAction.IN.value,
CONDITION_KEY: "tenant_id",
CONDITION_VALUE: ["6", "2"],
}
],
}
},
},
"my_complex_feature": {
FEATURE_DEFAULT_VAL_KEY: {},
FEATURE_DEFAULT_VAL_TYPE_KEY: False,
RULES_KEY: {
"tenant id equals 345345435": {
RULE_MATCH_VALUE: {"b": 4},
CONDITIONS_KEY: [
{
CONDITION_ACTION: RuleAction.EQUALS.value,
CONDITION_KEY: "tenant_id",
CONDITION_VALUE: "345345435",
}
],
},
},
},
"my_feature2": {
FEATURE_DEFAULT_VAL_KEY: True,
},
"my_feature3": {
FEATURE_DEFAULT_VAL_KEY: False,
},
"my_feature4": {FEATURE_DEFAULT_VAL_KEY: {"a": "b"}, FEATURE_DEFAULT_VAL_TYPE_KEY: False},
}

feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
enabled_list: List[str] = feature_flags.get_enabled_features(context={"tenant_id": "6", "username": "a"})
assert enabled_list == expected_value
Loading