From c87c84f09802da98fdbe4de2d3fa89a512d9d8c3 Mon Sep 17 00:00:00 2001 From: Ran Isenberg Date: Sun, 7 Nov 2021 16:24:53 +0200 Subject: [PATCH 01/12] feat: enhance feature flags to a generic engine with non boolean values support --- .../utilities/feature_flags/feature_flags.py | 53 ++++++++--- .../utilities/feature_flags/schema.py | 33 ++++--- .../feature_flags/test_complex_rule_values.py | 95 +++++++++++++++++++ 3 files changed, 158 insertions(+), 23 deletions(-) create mode 100644 tests/functional/feature_flags/test_complex_rule_values.py diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py index c66feee0536..72303ec0d71 100644 --- a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -97,7 +97,13 @@ 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(): @@ -105,13 +111,15 @@ def _evaluate_rules( # 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: @@ -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: bool) -> Any: """Evaluate whether a feature flag should be enabled according to stored schema and input context **Logic when evaluating a feature flag** @@ -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 ------ @@ -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 @@ -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) diff --git a/aws_lambda_powertools/utilities/feature_flags/schema.py b/aws_lambda_powertools/utilities/feature_flags/schema.py index 6a92508676e..40de7a4f421 100644 --- a/aws_lambda_powertools/utilities/feature_flags/schema.py +++ b/aws_lambda_powertools/utilities/feature_flags/schema.py @@ -13,6 +13,7 @@ CONDITION_KEY = "key" CONDITION_VALUE = "value" CONDITION_ACTION = "action" +FEATURE_DEFAULT_VAL_TYPE_KEY = "boolean_type" class RuleAction(str, Enum): @@ -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: @@ -171,17 +180,19 @@ 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): @@ -189,9 +200,9 @@ def validate_rule_name(rule_name: str, feature_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}") diff --git a/tests/functional/feature_flags/test_complex_rule_values.py b/tests/functional/feature_flags/test_complex_rule_values.py new file mode 100644 index 00000000000..8e5b1bda36d --- /dev/null +++ b/tests/functional/feature_flags/test_complex_rule_values.py @@ -0,0 +1,95 @@ +from typing import Dict, 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_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 From 77cb862d3589176f685c99a1728678ecf1dc76a2 Mon Sep 17 00:00:00 2001 From: Ran Isenberg Date: Thu, 9 Dec 2021 21:24:23 +0200 Subject: [PATCH 02/12] feat: add feature flags diagram --- docs/media/feature_flags_diagram.png | Bin 0 -> 30812 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/media/feature_flags_diagram.png diff --git a/docs/media/feature_flags_diagram.png b/docs/media/feature_flags_diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..5262c115e025a53f4bc34be504fb0819a93b5b45 GIT binary patch literal 30812 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 Date: Thu, 9 Dec 2021 21:25:19 +0200 Subject: [PATCH 03/12] chore: add missing tests for complex feature flags --- .../utilities/feature_flags/feature_flags.py | 2 +- .../feature_flags/test_complex_rule_values.py | 62 ++++++++++++++++++- .../feature_flags/test_schema_validation.py | 59 ++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py index 72303ec0d71..69f24c32b21 100644 --- a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -172,7 +172,7 @@ def get_configuration(self) -> Dict: return config - def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, default: bool) -> Any: + 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** diff --git a/tests/functional/feature_flags/test_complex_rule_values.py b/tests/functional/feature_flags/test_complex_rule_values.py index 8e5b1bda36d..a5cd00f2fbc 100644 --- a/tests/functional/feature_flags/test_complex_rule_values.py +++ b/tests/functional/feature_flags/test_complex_rule_values.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional +from typing import Dict, List, Optional import pytest from botocore.config import Config @@ -69,6 +69,17 @@ def test_feature_rule_match(mocker, config): 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 = { @@ -93,3 +104,52 @@ def test_feature_no_rule_match(mocker, config): 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 diff --git a/tests/functional/feature_flags/test_schema_validation.py b/tests/functional/feature_flags/test_schema_validation.py index 1cd14aa4287..a82f9ecafa7 100644 --- a/tests/functional/feature_flags/test_schema_validation.py +++ b/tests/functional/feature_flags/test_schema_validation.py @@ -9,6 +9,7 @@ CONDITION_VALUE, CONDITIONS_KEY, FEATURE_DEFAULT_VAL_KEY, + FEATURE_DEFAULT_VAL_TYPE_KEY, RULE_MATCH_VALUE, RULES_KEY, ConditionsValidator, @@ -61,6 +62,14 @@ def test_valid_feature_dict(): validator.validate() +def test_invalid_feature_default_value_is_not_boolean(): + # feature is boolean but default value is a number, not a boolean + schema = {"my_feature": {FEATURE_DEFAULT_VAL_KEY: 3, FEATURE_DEFAULT_VAL_TYPE_KEY: True, RULES_KEY: []}} + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + def test_invalid_rule(): # rules list is not a list of dict schema = { @@ -305,3 +314,53 @@ def test_validate_rule_invalid_rule_name(): # THEN raise SchemaValidationError with pytest.raises(SchemaValidationError, match="Rule name key must have a non-empty string"): RulesValidator.validate_rule_name(rule_name="", feature_name="dummy") + + +def test_validate_rule_invalid_when_match_type_boolean_feature_is_set(): + # GIVEN an invalid rule with non boolean when_match but feature type boolean + # WHEN calling validate_rule + # THEN raise SchemaValidationError + rule_name = "dummy" + rule = { + RULE_MATCH_VALUE: ["matched_value"], + CONDITIONS_KEY: { + CONDITION_ACTION: RuleAction.EQUALS.value, + CONDITION_KEY: 5, + CONDITION_VALUE: "a", + }, + } + with pytest.raises(SchemaValidationError, match=f"rule_default_value' key must have be bool, rule={rule_name}"): + RulesValidator.validate_rule(rule=rule, rule_name=rule_name, feature_name="dummy", boolean_feature=True) + + +def test_validate_rule_invalid_when_match_type_boolean_feature_is_not_set(): + # GIVEN an invalid rule with non boolean when_match but feature type boolean. validate_rule is called without validate_rule=True # type: ignore # noqa: E501 + # WHEN calling validate_rule + # THEN raise SchemaValidationError + rule_name = "dummy" + rule = { + RULE_MATCH_VALUE: ["matched_value"], + CONDITIONS_KEY: { + CONDITION_ACTION: RuleAction.EQUALS.value, + CONDITION_KEY: 5, + CONDITION_VALUE: "a", + }, + } + with pytest.raises(SchemaValidationError, match=f"rule_default_value' key must have be bool, rule={rule_name}"): + RulesValidator.validate_rule(rule=rule, rule_name=rule_name, feature_name="dummy") + + +def test_validate_rule_boolean_feature_is_set(): + # GIVEN a rule with a boolean when_match and feature type boolean + # WHEN calling validate_rule + # THEN schema is validated and decalared as valid + rule_name = "dummy" + rule = { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: { + CONDITION_ACTION: RuleAction.EQUALS.value, + CONDITION_KEY: 5, + CONDITION_VALUE: "a", + }, + } + RulesValidator.validate_rule(rule=rule, rule_name=rule_name, feature_name="dummy", boolean_feature=True) From 0a44cf8ed0f0a41de76dbba9326d2684854a6178 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Fri, 31 Dec 2021 12:19:42 +0100 Subject: [PATCH 04/12] fix: mypy, JSONType annotation, GA notice --- aws_lambda_powertools/shared/types.py | 4 ++- .../utilities/feature_flags/feature_flags.py | 26 ++++++++++++------- .../utilities/feature_flags/schema.py | 4 ++- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/aws_lambda_powertools/shared/types.py b/aws_lambda_powertools/shared/types.py index c5c91535bd3..e4e10192e55 100644 --- a/aws_lambda_powertools/shared/types.py +++ b/aws_lambda_powertools/shared/types.py @@ -1,3 +1,5 @@ -from typing import Any, Callable, TypeVar +from typing import Any, Callable, Dict, List, TypeVar, Union AnyCallableT = TypeVar("AnyCallableT", bound=Callable[..., Any]) # noqa: VNE001 +# JSON primitives only, mypy doesn't support recursive tho +JSONType = Union[str, int, float, bool, None, Dict[str, Any], List[Any]] diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py index 69f24c32b21..8973b704acb 100644 --- a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional, Union, cast from ... import Logger +from ...shared.types import JSONType from . import schema from .base import StoreProvider from .exceptions import ConfigurationStoreError @@ -111,14 +112,15 @@ def _evaluate_rules( # Context might contain PII data; do not log its value self.logger.debug( - f"Evaluating rule matching, rule={rule_name}, feature={feature_name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # type: ignore # noqa: E501 + f"Evaluating rule matching, rule={rule_name}, feature={feature_name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # noqa: E501 ) if self._evaluate_conditions(rule_name=rule_name, feature_name=feature_name, rule=rule, context=context): + # Maintenance: Revisit before going GA. 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={str(feat_default)}, name={feature_name}, boolean_feature={boolean_feature}" # type: ignore # noqa: E501 + f"no rule matched, returning feature default, default={str(feat_default)}, name={feature_name}, boolean_feature={boolean_feature}" # noqa: E501 ) return feat_default @@ -172,7 +174,7 @@ def get_configuration(self) -> Dict: return config - def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, default: Any) -> Any: + def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, default: JSONType) -> JSONType: """Evaluate whether a feature flag should be enabled according to stored schema and input context **Logic when evaluating a feature flag** @@ -189,15 +191,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: Any + default: JSONType 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 + Can be boolean or any JSON values for non-boolean features. Returns ------ - bool - whether feature should be enabled or not for boolean feature flag or any other JSON type + JSONType + whether feature should be enabled (bool flags) or JSON value when non-bool feature matches Raises ------ @@ -220,17 +222,23 @@ 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) + # Maintenance: Revisit before going GA. We might to simplify customers on-boarding by not requiring it + # for non-boolean flags. It'll need minor implementation changes, docs changes, and maybe refactor + # get_enabled_features. We can minimize breaking change, despite Beta label, by having a new + # method `get_matching_features` returning Dict[feature_name, feature_value] 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={str(feat_default)}, boolean_feature={boolean_feature}" # type: ignore # noqa: E501 + f"no rules found, returning feature default, name={name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # noqa: E501 ) + # Maintenance: Revisit before going GA. We might to simplify customers on-boarding by not requiring it + # for non-boolean flags. return bool(feat_default) if boolean_feature else feat_default self.logger.debug( - f"looking for rule match, name={name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # type: ignore # noqa: E501 + f"looking for rule match, name={name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # noqa: E501 ) return self._evaluate_rules( feature_name=name, context=context, feat_default=feat_default, rules=rules, boolean_feature=boolean_feature diff --git a/aws_lambda_powertools/utilities/feature_flags/schema.py b/aws_lambda_powertools/utilities/feature_flags/schema.py index 40de7a4f421..777fbf3e36d 100644 --- a/aws_lambda_powertools/utilities/feature_flags/schema.py +++ b/aws_lambda_powertools/utilities/feature_flags/schema.py @@ -153,6 +153,8 @@ def validate_feature(name, feature) -> bool: 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 + # Maintenance: Revisit before going GA. We might to simplify customers on-boarding by not requiring it + # for non-boolean flags. 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 @@ -187,7 +189,7 @@ def validate(self): conditions.validate() @staticmethod - def validate_rule(rule: Dict, rule_name: str, feature_name: str, boolean_feature: Optional[bool] = True): + def validate_rule(rule: Dict, rule_name: str, feature_name: str, boolean_feature: bool = True): if not rule or not isinstance(rule, dict): raise SchemaValidationError(f"Feature rule must be a dictionary, feature={feature_name}") From 19bae979f9fb3cec3262a0999942409b64db01ef Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Fri, 31 Dec 2021 12:39:37 +0100 Subject: [PATCH 05/12] fix: non-boolean matching features should return enabled --- .../utilities/feature_flags/feature_flags.py | 3 -- .../feature_flags/test_complex_rule_values.py | 54 ++++++++++++++----- .../feature_flags/test_feature_flags.py | 46 ---------------- 3 files changed, 40 insertions(+), 63 deletions(-) diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py index 8973b704acb..36a74c4c58a 100644 --- a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -288,9 +288,6 @@ def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> L 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}") diff --git a/tests/functional/feature_flags/test_complex_rule_values.py b/tests/functional/feature_flags/test_complex_rule_values.py index a5cd00f2fbc..9e5c75a145a 100644 --- a/tests/functional/feature_flags/test_complex_rule_values.py +++ b/tests/functional/feature_flags/test_complex_rule_values.py @@ -106,9 +106,8 @@ def test_feature_no_rule_match(mocker, config): 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"] +def test_get_all_enabled_features_boolean_and_non_boolean(mocker, config): + expected_value = ["my_feature", "my_feature2", "my_non_boolean_feature"] mocked_app_config_schema = { "my_feature": { FEATURE_DEFAULT_VAL_KEY: False, @@ -125,29 +124,56 @@ def test_multiple_features_enabled_with_complex_toggles_and_boolean_toggles(mock } }, }, - "my_complex_feature": { + "my_feature2": { + FEATURE_DEFAULT_VAL_KEY: True, + }, + "my_feature3": { + FEATURE_DEFAULT_VAL_KEY: False, + }, + "my_non_boolean_feature": { FEATURE_DEFAULT_VAL_KEY: {}, FEATURE_DEFAULT_VAL_TYPE_KEY: False, RULES_KEY: { - "tenant id equals 345345435": { - RULE_MATCH_VALUE: {"b": 4}, + "username equals 'a'": { + RULE_MATCH_VALUE: {"group": "admin"}, CONDITIONS_KEY: [ { CONDITION_ACTION: RuleAction.EQUALS.value, - CONDITION_KEY: "tenant_id", - CONDITION_VALUE: "345345435", + CONDITION_KEY: "username", + CONDITION_VALUE: "a", } ], }, }, }, - "my_feature2": { - FEATURE_DEFAULT_VAL_KEY: True, - }, - "my_feature3": { - FEATURE_DEFAULT_VAL_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 + + +def test_get_all_enabled_feature_flags_non_boolean_truthy_defaults(mocker, config): + expected_value = ["my_feature", "my_truthy_feature"] + mocked_app_config_schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: {}, + FEATURE_DEFAULT_VAL_TYPE_KEY: False, + RULES_KEY: { + "username equals 'a'": { + RULE_MATCH_VALUE: {"group": "admin"}, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.EQUALS.value, + CONDITION_KEY: "username", + CONDITION_VALUE: "a", + } + ], + }, + }, }, - "my_feature4": {FEATURE_DEFAULT_VAL_KEY: {"a": "b"}, FEATURE_DEFAULT_VAL_TYPE_KEY: False}, + "my_truthy_feature": {FEATURE_DEFAULT_VAL_KEY: {"a": "b"}, FEATURE_DEFAULT_VAL_TYPE_KEY: False}, + "my_falsy_feature": {FEATURE_DEFAULT_VAL_KEY: {}, FEATURE_DEFAULT_VAL_TYPE_KEY: False}, } feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) diff --git a/tests/functional/feature_flags/test_feature_flags.py b/tests/functional/feature_flags/test_feature_flags.py index 8381dc6bf1d..d10dce63f1e 100644 --- a/tests/functional/feature_flags/test_feature_flags.py +++ b/tests/functional/feature_flags/test_feature_flags.py @@ -630,52 +630,6 @@ def test_multiple_features_enabled(mocker, config): assert enabled_list == expected_value -def test_multiple_features_only_some_enabled(mocker, config): - expected_value = ["my_feature", "my_feature2", "my_feature4"] - mocked_app_config_schema = { - "my_feature": { # rule will match here, feature is enabled due to rule match - "default": False, - "rules": { - "tenant id is contained in [6, 2]": { - "when_match": True, - "conditions": [ - { - "action": RuleAction.IN.value, - "key": "tenant_id", - "value": ["6", "2"], - } - ], - } - }, - }, - "my_feature2": { - "default": True, - }, - "my_feature3": { - "default": False, - }, - # rule will not match here, feature is enabled by default - "my_feature4": { - "default": True, - "rules": { - "tenant id equals 7": { - "when_match": False, - "conditions": [ - { - "action": RuleAction.EQUALS.value, - "key": "tenant_id", - "value": "7", - } - ], - } - }, - }, - } - 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 - - def test_get_feature_toggle_handles_error(mocker, config): # GIVEN a schema fetch that raises a ConfigurationStoreError schema_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) From 6b2a5c6c639621b4a35c6bf2d2b23c444914af54 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Fri, 31 Dec 2021 12:40:11 +0100 Subject: [PATCH 06/12] docs: update deprecation list --- docs/utilities/feature_flags.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 816aac8b817..f206fc8f884 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -696,3 +696,5 @@ Breaking change | Recommendation ------------------------------------------------- | --------------------------------------------------------------------------------- `IN` RuleAction | Use `KEY_IN_VALUE` instead `NOT_IN` RuleAction | Use `KEY_NOT_IN_VALUE` instead +`get_enabled_features` | Return type changes from `List[str]` to `Dict[str, Any]`. New return will contain a list of features enabled and their values. List of enabled features will be in `enabled_features` key to keep ease of assertion we have in Beta. +`boolean_type` Schema | This **might** not be necessary anymore before we go GA. We will return either the `default` value when there are no rules as well as `when_match` value. This will simplify on-boarding if we can keep the same set of validations already offered. From 5dd6eaaee6f8f48d6cc8b81d6cd68642f1af4173 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Fri, 31 Dec 2021 14:13:43 +0100 Subject: [PATCH 07/12] refactor: merge tests, make test name explicit to behaviour --- .../feature_flags/test_complex_rule_values.py | 181 ------------------ .../feature_flags/test_feature_flags.py | 139 +++++++++++++- 2 files changed, 138 insertions(+), 182 deletions(-) delete mode 100644 tests/functional/feature_flags/test_complex_rule_values.py diff --git a/tests/functional/feature_flags/test_complex_rule_values.py b/tests/functional/feature_flags/test_complex_rule_values.py deleted file mode 100644 index 9e5c75a145a..00000000000 --- a/tests/functional/feature_flags/test_complex_rule_values.py +++ /dev/null @@ -1,181 +0,0 @@ -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 - - -def test_get_all_enabled_features_boolean_and_non_boolean(mocker, config): - expected_value = ["my_feature", "my_feature2", "my_non_boolean_feature"] - 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_feature2": { - FEATURE_DEFAULT_VAL_KEY: True, - }, - "my_feature3": { - FEATURE_DEFAULT_VAL_KEY: False, - }, - "my_non_boolean_feature": { - FEATURE_DEFAULT_VAL_KEY: {}, - FEATURE_DEFAULT_VAL_TYPE_KEY: False, - RULES_KEY: { - "username equals 'a'": { - RULE_MATCH_VALUE: {"group": "admin"}, - CONDITIONS_KEY: [ - { - CONDITION_ACTION: RuleAction.EQUALS.value, - CONDITION_KEY: "username", - CONDITION_VALUE: "a", - } - ], - }, - }, - }, - } - - 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 - - -def test_get_all_enabled_feature_flags_non_boolean_truthy_defaults(mocker, config): - expected_value = ["my_feature", "my_truthy_feature"] - mocked_app_config_schema = { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: {}, - FEATURE_DEFAULT_VAL_TYPE_KEY: False, - RULES_KEY: { - "username equals 'a'": { - RULE_MATCH_VALUE: {"group": "admin"}, - CONDITIONS_KEY: [ - { - CONDITION_ACTION: RuleAction.EQUALS.value, - CONDITION_KEY: "username", - CONDITION_VALUE: "a", - } - ], - }, - }, - }, - "my_truthy_feature": {FEATURE_DEFAULT_VAL_KEY: {"a": "b"}, FEATURE_DEFAULT_VAL_TYPE_KEY: False}, - "my_falsy_feature": {FEATURE_DEFAULT_VAL_KEY: {}, 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 diff --git a/tests/functional/feature_flags/test_feature_flags.py b/tests/functional/feature_flags/test_feature_flags.py index d10dce63f1e..32d6143ba9a 100644 --- a/tests/functional/feature_flags/test_feature_flags.py +++ b/tests/functional/feature_flags/test_feature_flags.py @@ -7,7 +7,17 @@ from aws_lambda_powertools.utilities.feature_flags.appconfig import AppConfigStore from aws_lambda_powertools.utilities.feature_flags.exceptions import StoreClientError from aws_lambda_powertools.utilities.feature_flags.feature_flags import FeatureFlags -from aws_lambda_powertools.utilities.feature_flags.schema import RuleAction +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, +) from aws_lambda_powertools.utilities.parameters import GetParameterError @@ -1151,3 +1161,130 @@ def test_flags_greater_than_or_equal_match_2(mocker, config): default=False, ) assert toggle == expected_value + + +def test_non_boolean_feature_match(mocker, config): + expected_value = ["value1"] + # GIVEN + 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", + } + ], + } + }, + } + } + + # WHEN + features = init_feature_flags(mocker, mocked_app_config_schema, config) + feature_value = features.evaluate(name="my_feature", context={"tenant_id": "345345435"}, default=[]) + # THEN + assert feature_value == expected_value + + +def test_non_boolean_feature_with_no_rules(mocker, config): + expected_value = ["value1"] + # GIVEN + mocked_app_config_schema = { + "my_feature": {FEATURE_DEFAULT_VAL_KEY: expected_value, FEATURE_DEFAULT_VAL_TYPE_KEY: False} + } + # WHEN + features = init_feature_flags(mocker, mocked_app_config_schema, config) + feature_value = features.evaluate(name="my_feature", context={"tenant_id": "345345435"}, default=[]) + # THEN + assert feature_value == expected_value + + +def test_non_boolean_feature_with_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 + + +def test_get_all_enabled_features_boolean_and_non_boolean(mocker, config): + expected_value = ["my_feature", "my_feature2", "my_non_boolean_feature"] + 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_feature2": { + FEATURE_DEFAULT_VAL_KEY: True, + }, + "my_feature3": { + FEATURE_DEFAULT_VAL_KEY: False, + }, + "my_non_boolean_feature": { + FEATURE_DEFAULT_VAL_KEY: {}, + FEATURE_DEFAULT_VAL_TYPE_KEY: False, + RULES_KEY: { + "username equals 'a'": { + RULE_MATCH_VALUE: {"group": "admin"}, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.EQUALS.value, + CONDITION_KEY: "username", + CONDITION_VALUE: "a", + } + ], + }, + }, + }, + } + + 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 + + +def test_get_all_enabled_features_non_boolean_truthy_defaults(mocker, config): + expected_value = ["my_truthy_feature"] + mocked_app_config_schema = { + "my_truthy_feature": {FEATURE_DEFAULT_VAL_KEY: {"a": "b"}, FEATURE_DEFAULT_VAL_TYPE_KEY: False}, + "my_falsy_feature": {FEATURE_DEFAULT_VAL_KEY: {}, 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 From c34b7ff4660ce17cbb5559a681361ee3cf5c8afa Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Fri, 31 Dec 2021 14:22:55 +0100 Subject: [PATCH 08/12] fix: update schema validation docstring --- .../utilities/feature_flags/schema.py | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/aws_lambda_powertools/utilities/feature_flags/schema.py b/aws_lambda_powertools/utilities/feature_flags/schema.py index 777fbf3e36d..373eb26a9e1 100644 --- a/aws_lambda_powertools/utilities/feature_flags/schema.py +++ b/aws_lambda_powertools/utilities/feature_flags/schema.py @@ -49,14 +49,22 @@ class SchemaValidator(BaseValidator): A dictionary containing default value and rules for matching. The value MUST be an object and MIGHT contain the following members: - * **default**: `bool`. Defines default feature value. This MUST be present + * **default**: Union[`bool`, `JSONType`]. Defines default feature value. This MUST be present + * **boolean_type**: bool. Defines whether feature has non-boolean value (`JSONType`). This MIGHT be present * **rules**: `Dict[str, Dict]`. Rules object. This MIGHT be present + `JSONType` being any JSON primitive value: `Union[str, int, float, bool, None, Dict[str, Any], List[Any]]` + ```python { "my_feature": { "default": True, "rules": {} + }, + "my_non_boolean_feature": { + "default": {"group": "read-only"}, + "boolean_type": False, + "rules": {} } } ``` @@ -66,7 +74,7 @@ class SchemaValidator(BaseValidator): A dictionary with each rule and their conditions that a feature might have. The value MIGHT be present, and when defined it MUST contain the following members: - * **when_match**: `bool`. Defines value to return when context matches conditions + * **when_match**: Union[`bool`, `JSONType`]. Defines value to return when context matches conditions * **conditions**: `List[Dict]`. Conditions object. This MUST be present ```python @@ -79,6 +87,16 @@ class SchemaValidator(BaseValidator): "conditions": [] } } + }, + "my_non_boolean_feature": { + "default": {"group": "read-only"}, + "boolean_type": False, + "rules": { + "tenant id equals 345345435": { + "when_match": {"group": "admin"}, + "conditions": [] + } + } } } ``` From 801c6b92363b3e9409a6350ccae8d12af6dceb00 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Fri, 31 Dec 2021 14:34:09 +0100 Subject: [PATCH 09/12] docs(schema): update rules to include JSON values; update image --- .../utilities/feature_flags/schema.py | 4 +-- docs/media/feat_flags_evaluation_workflow.png | Bin 70609 -> 0 bytes docs/utilities/feature_flags.md | 31 ++++++++++++++---- 3 files changed, 27 insertions(+), 8 deletions(-) delete mode 100644 docs/media/feat_flags_evaluation_workflow.png diff --git a/aws_lambda_powertools/utilities/feature_flags/schema.py b/aws_lambda_powertools/utilities/feature_flags/schema.py index 373eb26a9e1..8cf9bd12ebf 100644 --- a/aws_lambda_powertools/utilities/feature_flags/schema.py +++ b/aws_lambda_powertools/utilities/feature_flags/schema.py @@ -49,7 +49,7 @@ class SchemaValidator(BaseValidator): A dictionary containing default value and rules for matching. The value MUST be an object and MIGHT contain the following members: - * **default**: Union[`bool`, `JSONType`]. Defines default feature value. This MUST be present + * **default**: `Union[bool, JSONType]`. Defines default feature value. This MUST be present * **boolean_type**: bool. Defines whether feature has non-boolean value (`JSONType`). This MIGHT be present * **rules**: `Dict[str, Dict]`. Rules object. This MIGHT be present @@ -74,7 +74,7 @@ class SchemaValidator(BaseValidator): A dictionary with each rule and their conditions that a feature might have. The value MIGHT be present, and when defined it MUST contain the following members: - * **when_match**: Union[`bool`, `JSONType`]. Defines value to return when context matches conditions + * **when_match**: `Union[bool, JSONType]`. Defines value to return when context matches conditions * **conditions**: `List[Dict]`. Conditions object. This MUST be present ```python diff --git a/docs/media/feat_flags_evaluation_workflow.png b/docs/media/feat_flags_evaluation_workflow.png deleted file mode 100644 index deca3dfc297c0c748a0c61f36db7fcaccef07bfb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 70609 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 Date: Fri, 31 Dec 2021 14:39:04 +0100 Subject: [PATCH 10/12] docs: rearrange advanced sections based on popularity --- docs/utilities/feature_flags.md | 80 ++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 7ef8e5efb4e..47cc324f6ae 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -379,6 +379,49 @@ You can use `get_enabled_features` method for scenarios where you need a list of ## Advanced +### Adjusting in-memory cache + +By default, we cache configuration retrieved from the Store for 5 seconds for performance and reliability reasons. + +You can override `max_age` parameter when instantiating the store. + + ```python hl_lines="7" + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="features", + max_age=300 + ) + ``` + +### Getting fetched configuration + +???+ info "When is this useful?" + You might have application configuration in addition to feature flags in your store. + + This means you don't need to make another call only to fetch app configuration. + +You can access the configuration fetched from the store via `get_raw_configuration` property within the store instance. + +=== "app.py" + + ```python hl_lines="12" + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="configuration", + envelope = "feature_flags" + ) + + feature_flags = FeatureFlags(store=app_config) + + config = app_config.get_raw_configuration + ``` + ### Schema This utility expects a certain schema to be stored as JSON within AWS AppConfig. @@ -497,23 +540,6 @@ Now that you've seen all properties of a feature flag schema, this flowchart des ![Rule engine ](../media/feature_flags_diagram.png) -### Adjusting in-memory cache - -By default, we cache configuration retrieved from the Store for 5 seconds for performance and reliability reasons. - -You can override `max_age` parameter when instantiating the store. - - ```python hl_lines="7" - from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore - - app_config = AppConfigStore( - environment="dev", - application="product-catalogue", - name="features", - max_age=300 - ) - ``` - ### Envelope There are scenarios where you might want to include feature flags as part of an existing application configuration. @@ -564,26 +590,6 @@ For this to work, you need to use a JMESPath expression via the `envelope` param } ``` -### Getting fetched configuration - -You can access the configuration fetched from the store via `get_raw_configuration` property within the store instance. - -=== "app.py" - - ```python hl_lines="12" - from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore - - app_config = AppConfigStore( - environment="dev", - application="product-catalogue", - name="configuration", - envelope = "feature_flags" - ) - - feature_flags = FeatureFlags(store=app_config) - - config = app_config.get_raw_configuration - ``` ### Built-in store provider From 8ed3a6696d4090302703adf2c919dd8194cd31d2 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Fri, 31 Dec 2021 15:12:21 +0100 Subject: [PATCH 11/12] docs: new section for non boolean flags --- .../utilities/feature_flags/schema.py | 14 ++-- docs/utilities/feature_flags.md | 71 ++++++++++++++++++- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/aws_lambda_powertools/utilities/feature_flags/schema.py b/aws_lambda_powertools/utilities/feature_flags/schema.py index 8cf9bd12ebf..f0dd02516f2 100644 --- a/aws_lambda_powertools/utilities/feature_flags/schema.py +++ b/aws_lambda_powertools/utilities/feature_flags/schema.py @@ -55,7 +55,7 @@ class SchemaValidator(BaseValidator): `JSONType` being any JSON primitive value: `Union[str, int, float, bool, None, Dict[str, Any], List[Any]]` - ```python + ```json { "my_feature": { "default": True, @@ -77,10 +77,10 @@ class SchemaValidator(BaseValidator): * **when_match**: `Union[bool, JSONType]`. Defines value to return when context matches conditions * **conditions**: `List[Dict]`. Conditions object. This MUST be present - ```python + ```json { "my_feature": { - "default": True, + "default": true, "rules": { "tenant id equals 345345435": { "when_match": False, @@ -90,7 +90,7 @@ class SchemaValidator(BaseValidator): }, "my_non_boolean_feature": { "default": {"group": "read-only"}, - "boolean_type": False, + "boolean_type": false, "rules": { "tenant id equals 345345435": { "when_match": {"group": "admin"}, @@ -113,13 +113,13 @@ class SchemaValidator(BaseValidator): * **key**: `str`. Key in given context to perform operation * **value**: `Any`. Value in given context that should match action operation. - ```python + ```json { "my_feature": { - "default": True, + "default": true, "rules": { "tenant id equals 345345435": { - "when_match": False, + "when_match": false, "conditions": [ { "action": "EQUALS", diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 540521569a2..9fb79ac1eec 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -14,7 +14,7 @@ Feature flags are used to modify behaviour without changing the application's co **Static flags**. Indicates something is simply `on` or `off`, for example `TRACER_ENABLED=True`. -**Dynamic flags**. Indicates something can have varying states, for example enable a premium feature for customer X not Y. +**Dynamic flags**. Indicates something can have varying states, for example enable a list of premium features for customer X not Y. ???+ tip You can use [Parameters utility](parameters.md) for static flags while this utility can do both static and dynamic feature flags. @@ -380,6 +380,71 @@ You can use `get_enabled_features` method for scenarios where you need a list of } ``` +### Beyond boolean feature flags + +???+ info "When is this useful?" + You might have a list of features to unlock for premium customers, unlock a specific set of features for admin users, etc. + +Feature flags can return any JSON values when `boolean_type` parameter is set to `False`. These can be dictionaries, list, string, integers, etc. + + +=== "app.py" + + ```python hl_lines="3 9 13 16 18" + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="features" + ) + + feature_flags = FeatureFlags(store=app_config) + + def lambda_handler(event, context): + # Get customer's tier from incoming request + ctx = { "tier": event.get("tier", "standard") } + + # Evaluate `has_premium_features` base don customer's tier + premium_features: list[str] = feature_flags.evaluate(name="premium_features", + context=ctx, default=False) + for feature in premium_features: + # enable premium features + ... + ``` + +=== "event.json" + + ```json hl_lines="3" + { + "username": "lessa", + "tier": "premium", + "basked_id": "random_id" + } + ``` +=== "features.json" + + ```json hl_lines="3-4 7" + { + "premium_features": { + "boolean_type": false, + "default": [], + "rules": { + "customer tier equals premium": { + "when_match": ["no_ads", "no_limits", "chat"], + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + } + } + ``` + ## Advanced ### Adjusting in-memory cache @@ -436,11 +501,11 @@ A feature can simply have its name and a `default` value. This is either on or o ```json hl_lines="2-3 5-7" title="minimal_schema.json" { "global_feature": { - "default": True + "default": true }, "non_boolean_global_feature": { "default": {"group": "read-only"}, - "boolean_type": False + "boolean_type": false }, } ``` From c5bde49c72015e09846f95fa268377c03cbc5c7f Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Fri, 31 Dec 2021 15:21:18 +0100 Subject: [PATCH 12/12] chore: typo on json bool --- aws_lambda_powertools/utilities/feature_flags/schema.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aws_lambda_powertools/utilities/feature_flags/schema.py b/aws_lambda_powertools/utilities/feature_flags/schema.py index f0dd02516f2..2fa3140b15e 100644 --- a/aws_lambda_powertools/utilities/feature_flags/schema.py +++ b/aws_lambda_powertools/utilities/feature_flags/schema.py @@ -58,12 +58,12 @@ class SchemaValidator(BaseValidator): ```json { "my_feature": { - "default": True, + "default": true, "rules": {} }, "my_non_boolean_feature": { "default": {"group": "read-only"}, - "boolean_type": False, + "boolean_type": false, "rules": {} } } @@ -83,7 +83,7 @@ class SchemaValidator(BaseValidator): "default": true, "rules": { "tenant id equals 345345435": { - "when_match": False, + "when_match": false, "conditions": [] } }