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 c66feee0536..36a74c4c58a 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 @@ -97,7 +98,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 +112,16 @@ 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}" # noqa: E501 ) if self._evaluate_conditions(rule_name=rule_name, feature_name=feature_name, rule=rule, context=context): - return bool(rule_match_value) + # 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={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}" # noqa: E501 + ) return feat_default def get_configuration(self) -> Dict: @@ -164,7 +174,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: JSONType) -> JSONType: """Evaluate whether a feature flag should be enabled according to stored schema and input context **Logic when evaluating a feature flag** @@ -181,14 +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: bool + 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 or any JSON values for non-boolean features. Returns ------ - bool - whether feature should be enabled or not + JSONType + whether feature should be enabled (bool flags) or JSON value when non-bool feature matches Raises ------ @@ -211,12 +222,27 @@ 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={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}" # 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={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}" # 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 +285,19 @@ 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 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..2fa3140b15e 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): @@ -48,13 +49,21 @@ 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 - ```python + `JSONType` being any JSON primitive value: `Union[str, int, float, bool, None, Dict[str, Any], List[Any]]` + + ```json { "my_feature": { - "default": True, + "default": true, + "rules": {} + }, + "my_non_boolean_feature": { + "default": {"group": "read-only"}, + "boolean_type": false, "rules": {} } } @@ -65,16 +74,26 @@ 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 + ```json { "my_feature": { - "default": True, + "default": true, + "rules": { + "tenant id equals 345345435": { + "when_match": false, + "conditions": [] + } + } + }, + "my_non_boolean_feature": { + "default": {"group": "read-only"}, + "boolean_type": false, "rules": { "tenant id equals 345345435": { - "when_match": False, + "when_match": {"group": "admin"}, "conditions": [] } } @@ -94,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", @@ -138,28 +157,38 @@ 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 + # 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 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 +200,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: 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 +220,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/docs/media/feat_flags_evaluation_workflow.png b/docs/media/feat_flags_evaluation_workflow.png deleted file mode 100644 index deca3dfc297..00000000000 Binary files a/docs/media/feat_flags_evaluation_workflow.png and /dev/null differ diff --git a/docs/media/feature_flags_diagram.png b/docs/media/feature_flags_diagram.png new file mode 100644 index 00000000000..5262c115e02 Binary files /dev/null and b/docs/media/feature_flags_diagram.png differ diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index a2567276a0d..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,8 +380,116 @@ 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 + +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. @@ -390,11 +498,15 @@ This utility expects a certain schema to be stored as JSON within AWS AppConfig. A feature can simply have its name and a `default` value. This is either on or off, also known as a [static flag](#static-flags). -```json hl_lines="2-3" title="minimal_schema.json" +```json hl_lines="2-3 5-7" title="minimal_schema.json" { - "global_feature": { - "default": true - } + "global_feature": { + "default": true + }, + "non_boolean_global_feature": { + "default": {"group": "read-only"}, + "boolean_type": false + }, } ``` @@ -405,28 +517,43 @@ If you need more control and want to provide context such as user group, permiss When adding `rules` to a feature, they must contain: 1. A rule name as a key -2. `when_match` boolean value that should be used when conditions match +2. `when_match` boolean or JSON value that should be used when conditions match 3. A list of `conditions` for evaluation -```json hl_lines="4-11" title="feature_with_rules.json" -{ - "premium_feature": { - "default": false, - "rules": { - "customer tier equals premium": { - "when_match": true, - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] - } - } - } -} -``` + ```json hl_lines="4-11 19-26" title="feature_with_rules.json" + { + "premium_feature": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + }, + "non_boolean_premium_feature": { + "default": [], + "rules": { + "customer tier equals premium": { + "when_match": ["remove_limits", "remove_ads"], + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + } + } + ``` You can have multiple rules with different names. The rule engine will return the first result `when_match` of the matching rule configuration, or `default` value when none of the rules apply. @@ -472,26 +599,9 @@ Action | Equivalent expression #### Rule engine flowchart -Now that you've seen all properties of a feature flag schema, this flowchart describes how the rule engines makes a decision on when to return `True` or `False`. +Now that you've seen all properties of a feature flag schema, this flowchart describes how the rule engine decides what value to return. -![Rule engine ](../media/feat_flags_evaluation_workflow.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" title="Adjusting TTL" -from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore - -app_config = AppConfigStore( - environment="dev", - application="product-catalogue", - name="features", - max_age=300 -) -``` +![Rule engine ](../media/feature_flags_diagram.png) ### Envelope @@ -543,24 +653,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. - -```python hl_lines="12" title="Accessing entire configuration pulled from the store" -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 @@ -691,3 +783,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. diff --git a/tests/functional/feature_flags/test_feature_flags.py b/tests/functional/feature_flags/test_feature_flags.py index 8381dc6bf1d..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 @@ -630,52 +640,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()) @@ -1197,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 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)