diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eabc7a65a10..f42337d5c5b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,7 @@ repos: types: [python] - id: isort name: formatting::isort - entry: poetry run isort -rc + entry: poetry run isort language: system types: [python] - repo: local diff --git a/aws_lambda_powertools/shared/jmespath_functions.py b/aws_lambda_powertools/shared/jmespath_functions.py deleted file mode 100644 index b23ab477d6b..00000000000 --- a/aws_lambda_powertools/shared/jmespath_functions.py +++ /dev/null @@ -1,22 +0,0 @@ -import base64 -import gzip -import json - -import jmespath - - -class PowertoolsFunctions(jmespath.functions.Functions): - @jmespath.functions.signature({"types": ["string"]}) - def _func_powertools_json(self, value): - return json.loads(value) - - @jmespath.functions.signature({"types": ["string"]}) - def _func_powertools_base64(self, value): - return base64.b64decode(value).decode() - - @jmespath.functions.signature({"types": ["string"]}) - def _func_powertools_base64_gzip(self, value): - encoded = base64.b64decode(value) - uncompressed = gzip.decompress(encoded) - - return uncompressed.decode() diff --git a/aws_lambda_powertools/shared/jmespath_utils.py b/aws_lambda_powertools/shared/jmespath_utils.py new file mode 100644 index 00000000000..f2a865d4807 --- /dev/null +++ b/aws_lambda_powertools/shared/jmespath_utils.py @@ -0,0 +1,55 @@ +import base64 +import gzip +import json +from typing import Any, Dict, Optional, Union + +import jmespath +from jmespath.exceptions import LexerError + +from aws_lambda_powertools.utilities.validation import InvalidEnvelopeExpressionError +from aws_lambda_powertools.utilities.validation.base import logger + + +class PowertoolsFunctions(jmespath.functions.Functions): + @jmespath.functions.signature({"types": ["string"]}) + def _func_powertools_json(self, value): + return json.loads(value) + + @jmespath.functions.signature({"types": ["string"]}) + def _func_powertools_base64(self, value): + return base64.b64decode(value).decode() + + @jmespath.functions.signature({"types": ["string"]}) + def _func_powertools_base64_gzip(self, value): + encoded = base64.b64decode(value) + uncompressed = gzip.decompress(encoded) + + return uncompressed.decode() + + +def unwrap_event_from_envelope(data: Union[Dict, str], envelope: str, jmespath_options: Optional[Dict]) -> Any: + """Searches data using JMESPath expression + + Parameters + ---------- + data : Dict + Data set to be filtered + envelope : str + JMESPath expression to filter data against + jmespath_options : Dict + Alternative JMESPath options to be included when filtering expr + + Returns + ------- + Any + Data found using JMESPath expression given in envelope + """ + if not jmespath_options: + jmespath_options = {"custom_functions": PowertoolsFunctions()} + + try: + logger.debug(f"Envelope detected: {envelope}. JMESPath options: {jmespath_options}") + return jmespath.search(envelope, data, options=jmespath.Options(**jmespath_options)) + except (LexerError, TypeError, UnicodeError) as e: + message = f"Failed to unwrap event from envelope using expression. Error: {e} Exp: {envelope}, Data: {data}" # noqa: B306, E501 + raise InvalidEnvelopeExpressionError(message) diff --git a/aws_lambda_powertools/utilities/feature_flags/__init__.py b/aws_lambda_powertools/utilities/feature_flags/__init__.py new file mode 100644 index 00000000000..db7dfca5b57 --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/__init__.py @@ -0,0 +1,15 @@ +"""Advanced feature flags utility""" +from .appconfig import AppConfigStore +from .base import StoreProvider +from .exceptions import ConfigurationStoreError +from .feature_flags import FeatureFlags +from .schema import RuleAction, SchemaValidator + +__all__ = [ + "ConfigurationStoreError", + "FeatureFlags", + "RuleAction", + "SchemaValidator", + "AppConfigStore", + "StoreProvider", +] diff --git a/aws_lambda_powertools/utilities/feature_flags/appconfig.py b/aws_lambda_powertools/utilities/feature_flags/appconfig.py new file mode 100644 index 00000000000..6c075eac1a1 --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/appconfig.py @@ -0,0 +1,92 @@ +import logging +import traceback +from typing import Any, Dict, Optional, cast + +from botocore.config import Config + +from aws_lambda_powertools.utilities.parameters import AppConfigProvider, GetParameterError, TransformParameterError + +from ...shared import jmespath_utils +from .base import StoreProvider +from .exceptions import ConfigurationStoreError, StoreClientError + +logger = logging.getLogger(__name__) + +TRANSFORM_TYPE = "json" + + +class AppConfigStore(StoreProvider): + def __init__( + self, + environment: str, + application: str, + name: str, + cache_seconds: int, + sdk_config: Optional[Config] = None, + envelope: str = "", + jmespath_options: Optional[Dict] = None, + ): + """This class fetches JSON schemas from AWS AppConfig + + Parameters + ---------- + environment: str + Appconfig environment, e.g. 'dev/test' etc. + application: str + AppConfig application name, e.g. 'powertools' + name: str + AppConfig configuration name e.g. `my_conf` + cache_seconds: int + cache expiration time, how often to call AppConfig to fetch latest configuration + sdk_config: Optional[Config] + Botocore Config object to pass during client initialization + envelope : str + JMESPath expression to pluck feature flags data from config + jmespath_options : Dict + Alternative JMESPath options to be included when filtering expr + """ + super().__init__() + self.environment = environment + self.application = application + self.name = name + self.cache_seconds = cache_seconds + self.config = sdk_config + self.envelope = envelope + self.jmespath_options = jmespath_options + self._conf_store = AppConfigProvider(environment=environment, application=application, config=sdk_config) + + def get_configuration(self) -> Dict[str, Any]: + """Fetch feature schema configuration from AWS AppConfig + + Raises + ------ + ConfigurationStoreError + Any validation error or AppConfig error that can occur + + Returns + ------- + Dict[str, Any] + parsed JSON dictionary + """ + try: + # parse result conf as JSON, keep in cache for self.max_age seconds + config = cast( + dict, + self._conf_store.get( + name=self.name, + transform=TRANSFORM_TYPE, + max_age=self.cache_seconds, + ), + ) + + if self.envelope: + config = jmespath_utils.unwrap_event_from_envelope( + data=config, envelope=self.envelope, jmespath_options=self.jmespath_options + ) + + return config + except (GetParameterError, TransformParameterError) as exc: + err_msg = traceback.format_exc() + if "AccessDenied" in err_msg: + raise StoreClientError(err_msg) from exc + raise ConfigurationStoreError("Unable to get AWS AppConfig configuration file") from exc diff --git a/aws_lambda_powertools/utilities/feature_flags/base.py b/aws_lambda_powertools/utilities/feature_flags/base.py new file mode 100644 index 00000000000..1df90f19ac8 --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/base.py @@ -0,0 +1,50 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict + + +class StoreProvider(ABC): + @abstractmethod + def get_configuration(self) -> Dict[str, Any]: + """Get configuration from any store and return the parsed JSON dictionary + + Raises + ------ + ConfigurationStoreError + Any error that can occur during schema fetch or JSON parse + + Returns + ------- + Dict[str, Any] + parsed JSON dictionary + + **Example** + + ```python + { + "premium_features": { + "default": False, + "rules": { + "customer tier equals premium": { + "when_match": True, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium", + } + ], + } + }, + }, + "feature_two": { + "default": False + } + } + """ + return NotImplemented # pragma: no cover + + +class BaseValidator(ABC): + @abstractmethod + def validate(self): + return NotImplemented # pragma: no cover diff --git a/aws_lambda_powertools/utilities/feature_flags/exceptions.py b/aws_lambda_powertools/utilities/feature_flags/exceptions.py new file mode 100644 index 00000000000..eaea6c61cca --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/exceptions.py @@ -0,0 +1,13 @@ +class ConfigurationStoreError(Exception): + """When a configuration store raises an exception on config retrieval or parsing""" + + +class SchemaValidationError(Exception): + """When feature flag schema fails validation""" + + +class StoreClientError(Exception): + """When a store raises an exception that should be propagated to the client to fix + + For example, Access Denied errors when the client doesn't permissions to fetch config + """ diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py new file mode 100644 index 00000000000..a862baf61c2 --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -0,0 +1,252 @@ +import logging +from typing import Any, Dict, List, Optional, Union, cast + +from . import schema +from .base import StoreProvider +from .exceptions import ConfigurationStoreError + +logger = logging.getLogger(__name__) + + +class FeatureFlags: + def __init__(self, store: StoreProvider): + """Evaluates whether feature flags should be enabled based on a given context. + + It uses the provided store to fetch feature flag rules before evaluating them. + + Examples + -------- + + ```python + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + + app_config = AppConfigStore( + environment="test", + application="powertools", + name="test_conf_name", + cache_seconds=300, + envelope="features" + ) + + feature_flags: FeatureFlags = FeatureFlags(store=app_config) + ``` + + Parameters + ---------- + store: StoreProvider + Store to use to fetch feature flag schema configuration. + """ + self._store = store + + @staticmethod + def _match_by_action(action: str, condition_value: Any, context_value: Any) -> bool: + if not context_value: + return False + mapping_by_action = { + schema.RuleAction.EQUALS.value: lambda a, b: a == b, + schema.RuleAction.STARTSWITH.value: lambda a, b: a.startswith(b), + schema.RuleAction.ENDSWITH.value: lambda a, b: a.endswith(b), + schema.RuleAction.CONTAINS.value: lambda a, b: a in b, + } + + try: + func = mapping_by_action.get(action, lambda a, b: False) + return func(context_value, condition_value) + except Exception as exc: + logger.debug(f"caught exception while matching action: action={action}, exception={str(exc)}") + return False + + def _evaluate_conditions( + self, rule_name: str, feature_name: str, rule: Dict[str, Any], context: Dict[str, Any] + ) -> bool: + """Evaluates whether context matches conditions, return False otherwise""" + rule_match_value = rule.get(schema.RULE_MATCH_VALUE) + conditions = cast(List[Dict], rule.get(schema.CONDITIONS_KEY)) + + for condition in conditions: + context_value = context.get(str(condition.get(schema.CONDITION_KEY))) + cond_action = condition.get(schema.CONDITION_ACTION, "") + cond_value = condition.get(schema.CONDITION_VALUE) + + if not self._match_by_action(action=cond_action, condition_value=cond_value, context_value=context_value): + logger.debug( + f"rule did not match action, rule_name={rule_name}, rule_value={rule_match_value}, " + f"name={feature_name}, context_value={str(context_value)} " + ) + return False # context doesn't match condition + + logger.debug(f"rule matched, rule_name={rule_name}, rule_value={rule_match_value}, name={feature_name}") + return True + return False + + def _evaluate_rules( + self, *, feature_name: str, context: Dict[str, Any], feat_default: bool, rules: Dict[str, Any] + ) -> 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 + logger.debug(f"Evaluating rule matching, rule={rule_name}, feature={feature_name}, default={feat_default}") + if self._evaluate_conditions(rule_name=rule_name, feature_name=feature_name, rule=rule, context=context): + return bool(rule_match_value) + + # no rule matched, return default value of feature + logger.debug(f"no rule matched, returning feature default, default={feat_default}, name={feature_name}") + return feat_default + return False + + def get_configuration(self) -> Union[Dict[str, Dict], Dict]: + """Get validated feature flag schema from configured store. + + Largely used to aid testing, since it's called by `evaluate` and `get_enabled_features` methods. + + Raises + ------ + ConfigurationStoreError + Any propagated error from store + SchemaValidationError + When schema doesn't conform with feature flag schema + + Returns + ------ + Dict[str, Dict] + parsed JSON dictionary + + **Example** + + ```python + { + "premium_features": { + "default": False, + "rules": { + "customer tier equals premium": { + "when_match": True, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium", + } + ], + } + }, + }, + "feature_two": { + "default": False + } + } + ``` + """ + # parse result conf as JSON, keep in cache for max age defined in store + logger.debug(f"Fetching schema from registered store, store={self._store}") + config = self._store.get_configuration() + validator = schema.SchemaValidator(schema=config) + validator.validate() + + return config + + def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, default: bool) -> bool: + """Evaluate whether a feature flag should be enabled according to stored schema and input context + + **Logic when evaluating a feature flag** + + 1. Feature exists and a rule matches, returns when_match value + 2. Feature exists but has either no rules or no match, return feature default value + 3. Feature doesn't exist in stored schema, encountered an error when fetching -> return default value provided + + Parameters + ---------- + name: str + feature name to evaluate + context: Optional[Dict[str, Any]] + Attributes that should be evaluated against the stored schema. + + for example: `{"tenant_id": "X", "username": "Y", "region": "Z"}` + default: bool + default value if feature flag doesn't exist in the schema, + or there has been an error when fetching the configuration from the store + + Returns + ------ + bool + whether feature should be enabled or not + + Raises + ------ + SchemaValidationError + When schema doesn't conform with feature flag schema + """ + if context is None: + context = {} + + try: + features = self.get_configuration() + except ConfigurationStoreError as err: + logger.debug(f"Failed to fetch feature flags from store, returning default provided, reason={err}") + return default + + feature = features.get(name) + if feature is None: + logger.debug(f"Feature not found; returning default provided, name={name}, default={default}") + return default + + rules = feature.get(schema.RULES_KEY) + feat_default = feature.get(schema.FEATURE_DEFAULT_VAL_KEY) + if not rules: + logger.debug(f"no rules found, returning feature default, name={name}, default={feat_default}") + return bool(feat_default) + + 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) + + def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> List[str]: + """Get all enabled feature flags while also taking into account context + (when a feature has defined rules) + + Parameters + ---------- + context: Optional[Dict[str, Any]] + dict of attributes that you would like to match the rules + against, can be `{'tenant_id: 'X', 'username':' 'Y', 'region': 'Z'}` etc. + + Returns + ---------- + List[str] + list of all feature names that either matches context or have True as default + + **Example** + + ```python + ["premium_features", "my_feature_two", "always_true_feature"] + ``` + + Raises + ------ + SchemaValidationError + When schema doesn't conform with feature flag schema + """ + if context is None: + context = {} + + features_enabled: List[str] = [] + + try: + features: Dict[str, Any] = self.get_configuration() + except ConfigurationStoreError as err: + logger.debug(f"Failed to fetch feature flags from store, returning empty list, reason={err}") + return features_enabled + + for name, feature in features.items(): + rules = feature.get(schema.RULES_KEY, {}) + feature_default_value = feature.get(schema.FEATURE_DEFAULT_VAL_KEY) + if feature_default_value and not rules: + 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 + ): + logger.debug(f"feature's calculated value is True, name={name}") + features_enabled.append(name) + + return features_enabled diff --git a/aws_lambda_powertools/utilities/feature_flags/schema.py b/aws_lambda_powertools/utilities/feature_flags/schema.py new file mode 100644 index 00000000000..3de7ac22363 --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/schema.py @@ -0,0 +1,226 @@ +import logging +from enum import Enum +from typing import Any, Dict, List, Optional + +from .base import BaseValidator +from .exceptions import SchemaValidationError + +logger = logging.getLogger(__name__) + +RULES_KEY = "rules" +FEATURE_DEFAULT_VAL_KEY = "default" +CONDITIONS_KEY = "conditions" +RULE_MATCH_VALUE = "when_match" +CONDITION_KEY = "key" +CONDITION_VALUE = "value" +CONDITION_ACTION = "action" + + +class RuleAction(str, Enum): + EQUALS = "EQUALS" + STARTSWITH = "STARTSWITH" + ENDSWITH = "ENDSWITH" + CONTAINS = "CONTAINS" + + +class SchemaValidator(BaseValidator): + """Validates feature flag schema configuration + + Raises + ------ + SchemaValidationError + When schema doesn't conform with feature flag schema + + Schema + ------ + + **Feature object** + + 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 + * **rules**: `Dict[str, Dict]`. Rules object. This MIGHT be present + + ```json + { + "my_feature": { + "default": True, + "rules": {} + } + } + ``` + + **Rules object** + + 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 + * **conditions**: `List[Dict]`. Conditions object. This MUST be present + + ```json + { + "my_feature": { + "default": True, + "rules": { + "tenant id equals 345345435": { + "when_match": False, + "conditions": [] + } + } + } + } + ``` + + **Conditions object** + + A list of dictionaries containing conditions for a given rule. + The value MUST contain the following members: + + * **action**: `str`. Operation to perform to match a key and value. + The value MUST be either EQUALS, STARTSWITH, ENDSWITH, CONTAINS + * **key**: `str`. Key in given context to perform operation + * **value**: `Any`. Value in given context that should match action operation. + + ```json + { + "my_feature": { + "default": True, + "rules": { + "tenant id equals 345345435": { + "when_match": False, + "conditions": [ + { + "action": "EQUALS", + "key": "tenant_id", + "value": "345345435", + } + ] + } + } + } + } + ``` + """ + + def __init__(self, schema: Dict[str, Any]): + self.schema = schema + + def validate(self) -> None: + logger.debug("Validating schema") + if not isinstance(self.schema, dict): + raise SchemaValidationError(f"Features must be a dictionary, schema={str(self.schema)}") + + features = FeaturesValidator(schema=self.schema) + features.validate() + + +class FeaturesValidator(BaseValidator): + """Validates each feature and calls RulesValidator to validate its rules""" + + def __init__(self, schema: Dict): + self.schema = schema + + def validate(self): + for name, feature in self.schema.items(): + logger.debug(f"Attempting to validate feature '{name}'") + self.validate_feature(name, feature) + rules = RulesValidator(feature=feature) + rules.validate() + + @staticmethod + def validate_feature(name, feature): + 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): + raise SchemaValidationError(f"feature 'default' boolean key must be present, feature={name}") + + +class RulesValidator(BaseValidator): + """Validates each rule and calls ConditionsValidator to validate each rule's conditions""" + + def __init__(self, feature: Dict[str, Any]): + self.feature = feature + self.feature_name = next(iter(self.feature)) + self.rules: Optional[Dict] = self.feature.get(RULES_KEY) + + def validate(self): + if not self.rules: + logger.debug("Rules are empty, ignoring validation") + return + + if not isinstance(self.rules, dict): + raise SchemaValidationError(f"Feature rules must be a dictionary, feature={self.feature_name}") + + for rule_name, rule in self.rules.items(): + logger.debug(f"Attempting to validate rule '{rule_name}'") + self.validate_rule(rule=rule, rule_name=rule_name, feature_name=self.feature_name) + conditions = ConditionsValidator(rule=rule, rule_name=rule_name) + conditions.validate() + + @staticmethod + def validate_rule(rule, rule_name, feature_name): + 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) + + @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): + rule_default_value = rule.get(RULE_MATCH_VALUE) + if not isinstance(rule_default_value, bool): + raise SchemaValidationError(f"'rule_default_value' key must have be bool, rule={rule_name}") + + +class ConditionsValidator(BaseValidator): + def __init__(self, rule: Dict[str, Any], rule_name: str): + self.conditions: List[Dict[str, Any]] = rule.get(CONDITIONS_KEY, {}) + self.rule_name = rule_name + + def validate(self): + if not self.conditions or not isinstance(self.conditions, list): + raise SchemaValidationError(f"Invalid condition, rule={self.rule_name}") + + for condition in self.conditions: + self.validate_condition(rule_name=self.rule_name, condition=condition) + + @staticmethod + def validate_condition(rule_name: str, condition: Dict[str, str]) -> None: + if not condition or not isinstance(condition, dict): + raise SchemaValidationError(f"Feature rule condition must be a dictionary, rule={rule_name}") + + # Condition can contain PII data; do not log condition value + logger.debug(f"Attempting to validate condition for '{rule_name}'") + ConditionsValidator.validate_condition_action(condition=condition, rule_name=rule_name) + ConditionsValidator.validate_condition_key(condition=condition, rule_name=rule_name) + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + @staticmethod + def validate_condition_action(condition: Dict[str, Any], rule_name: str): + action = condition.get(CONDITION_ACTION, "") + if action not in RuleAction.__members__: + allowed_values = [_action.value for _action in RuleAction] + raise SchemaValidationError( + f"'action' value must be either {allowed_values}, rule_name={rule_name}, action={action}" + ) + + @staticmethod + def validate_condition_key(condition: Dict[str, Any], rule_name: str): + key = condition.get(CONDITION_KEY, "") + if not key or not isinstance(key, str): + raise SchemaValidationError(f"'key' value must be a non empty string, rule={rule_name}") + + @staticmethod + def validate_condition_value(condition: Dict[str, Any], rule_name: str): + value = condition.get(CONDITION_VALUE, "") + if not value: + raise SchemaValidationError(f"'value' key must not be empty, rule={rule_name}") diff --git a/aws_lambda_powertools/utilities/feature_toggles/__init__.py b/aws_lambda_powertools/utilities/feature_toggles/__init__.py deleted file mode 100644 index 04237d63812..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Advanced feature toggles utility -""" -from .appconfig_fetcher import AppConfigFetcher -from .configuration_store import ConfigurationStore -from .exceptions import ConfigurationError -from .schema import ACTION, SchemaValidator -from .schema_fetcher import SchemaFetcher - -__all__ = [ - "ConfigurationError", - "ConfigurationStore", - "ACTION", - "SchemaValidator", - "AppConfigFetcher", - "SchemaFetcher", -] diff --git a/aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py b/aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py deleted file mode 100644 index 3501edfd0d3..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py +++ /dev/null @@ -1,71 +0,0 @@ -import logging -from typing import Any, Dict, Optional, cast - -from botocore.config import Config - -from aws_lambda_powertools.utilities.parameters import AppConfigProvider, GetParameterError, TransformParameterError - -from .exceptions import ConfigurationError -from .schema_fetcher import SchemaFetcher - -logger = logging.getLogger(__name__) - - -TRANSFORM_TYPE = "json" - - -class AppConfigFetcher(SchemaFetcher): - def __init__( - self, - environment: str, - service: str, - configuration_name: str, - cache_seconds: int, - config: Optional[Config] = None, - ): - """This class fetches JSON schemas from AWS AppConfig - - Parameters - ---------- - environment: str - what appconfig environment to use 'dev/test' etc. - service: str - what service name to use from the supplied environment - configuration_name: str - what configuration to take from the environment & service combination - cache_seconds: int - cache expiration time, how often to call AppConfig to fetch latest configuration - config: Optional[Config] - boto3 client configuration - """ - super().__init__(configuration_name, cache_seconds) - self._logger = logger - self._conf_store = AppConfigProvider(environment=environment, application=service, config=config) - - def get_json_configuration(self) -> Dict[str, Any]: - """Get configuration string from AWs AppConfig and return the parsed JSON dictionary - - Raises - ------ - ConfigurationError - Any validation error or appconfig error that can occur - - Returns - ------- - Dict[str, Any] - parsed JSON dictionary - """ - try: - # parse result conf as JSON, keep in cache for self.max_age seconds - return cast( - dict, - self._conf_store.get( - name=self.configuration_name, - transform=TRANSFORM_TYPE, - max_age=self._cache_seconds, - ), - ) - except (GetParameterError, TransformParameterError) as exc: - error_str = f"unable to get AWS AppConfig configuration file, exception={str(exc)}" - self._logger.error(error_str) - raise ConfigurationError(error_str) diff --git a/aws_lambda_powertools/utilities/feature_toggles/configuration_store.py b/aws_lambda_powertools/utilities/feature_toggles/configuration_store.py deleted file mode 100644 index 72d00bb9c03..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/configuration_store.py +++ /dev/null @@ -1,216 +0,0 @@ -import logging -from typing import Any, Dict, List, Optional, cast - -from . import schema -from .exceptions import ConfigurationError -from .schema_fetcher import SchemaFetcher - -logger = logging.getLogger(__name__) - - -class ConfigurationStore: - def __init__(self, schema_fetcher: SchemaFetcher): - """constructor - - Parameters - ---------- - schema_fetcher: SchemaFetcher - A schema JSON fetcher, can be AWS AppConfig, Hashicorp Consul etc. - """ - self._logger = logger - self._schema_fetcher = schema_fetcher - self._schema_validator = schema.SchemaValidator(self._logger) - - def _match_by_action(self, action: str, condition_value: Any, context_value: Any) -> bool: - if not context_value: - return False - mapping_by_action = { - schema.ACTION.EQUALS.value: lambda a, b: a == b, - schema.ACTION.STARTSWITH.value: lambda a, b: a.startswith(b), - schema.ACTION.ENDSWITH.value: lambda a, b: a.endswith(b), - schema.ACTION.CONTAINS.value: lambda a, b: a in b, - } - - try: - func = mapping_by_action.get(action, lambda a, b: False) - return func(context_value, condition_value) - except Exception as exc: - self._logger.error(f"caught exception while matching action, action={action}, exception={str(exc)}") - return False - - def _is_rule_matched(self, feature_name: str, rule: Dict[str, Any], rules_context: Dict[str, Any]) -> bool: - rule_name = rule.get(schema.RULE_NAME_KEY, "") - rule_default_value = rule.get(schema.RULE_DEFAULT_VALUE) - conditions = cast(List[Dict], rule.get(schema.CONDITIONS_KEY)) - - for condition in conditions: - context_value = rules_context.get(str(condition.get(schema.CONDITION_KEY))) - if not self._match_by_action( - condition.get(schema.CONDITION_ACTION, ""), - condition.get(schema.CONDITION_VALUE), - context_value, - ): - logger.debug( - f"rule did not match action, rule_name={rule_name}, rule_default_value={rule_default_value}, " - f"feature_name={feature_name}, context_value={str(context_value)} " - ) - # context doesn't match condition - return False - # if we got here, all conditions match - logger.debug( - f"rule matched, rule_name={rule_name}, rule_default_value={rule_default_value}, " - f"feature_name={feature_name}" - ) - return True - return False - - def _handle_rules( - self, - *, - feature_name: str, - rules_context: Dict[str, Any], - feature_default_value: bool, - rules: List[Dict[str, Any]], - ) -> bool: - for rule in rules: - rule_default_value = rule.get(schema.RULE_DEFAULT_VALUE) - if self._is_rule_matched(feature_name, rule, rules_context): - return bool(rule_default_value) - # no rule matched, return default value of feature - logger.debug( - f"no rule matched, returning default value of feature, feature_default_value={feature_default_value}, " - f"feature_name={feature_name}" - ) - return feature_default_value - return False - - def get_configuration(self) -> Dict[str, Any]: - """Get configuration string from AWs AppConfig and returned the parsed JSON dictionary - - Raises - ------ - ConfigurationError - Any validation error or appconfig error that can occur - - Returns - ------ - Dict[str, Any] - parsed JSON dictionary - """ - # parse result conf as JSON, keep in cache for self.max_age seconds - config = self._schema_fetcher.get_json_configuration() - # validate schema - self._schema_validator.validate_json_schema(config) - return config - - def get_feature_toggle( - self, *, feature_name: str, rules_context: Optional[Dict[str, Any]] = None, value_if_missing: bool - ) -> bool: - """Get a feature toggle boolean value. Value is calculated according to a set of rules and conditions. - - See below for explanation. - - Parameters - ---------- - feature_name: str - feature name that you wish to fetch - rules_context: Optional[Dict[str, Any]] - dict of attributes that you would like to match the rules - against, can be {'tenant_id: 'X', 'username':' 'Y', 'region': 'Z'} etc. - value_if_missing: bool - this will be the returned value in case the feature toggle doesn't exist in - the schema or there has been an error while fetching the - configuration from appconfig - - Returns - ------ - bool - calculated feature toggle value. several possibilities: - 1. if the feature doesn't appear in the schema or there has been an error fetching the - configuration -> error/warning log would appear and value_if_missing is returned - 2. feature exists and has no rules or no rules have matched -> return feature_default_value of - the defined feature - 3. feature exists and a rule matches -> rule_default_value of rule is returned - """ - if rules_context is None: - rules_context = {} - - try: - toggles_dict: Dict[str, Any] = self.get_configuration() - except ConfigurationError: - logger.error("unable to get feature toggles JSON, returning provided value_if_missing value") - return value_if_missing - - feature: Dict[str, Dict] = toggles_dict.get(schema.FEATURES_KEY, {}).get(feature_name, None) - if feature is None: - logger.warning( - f"feature does not appear in configuration, using provided value_if_missing, " - f"feature_name={feature_name}, value_if_missing={value_if_missing}" - ) - return value_if_missing - - rules_list = feature.get(schema.RULES_KEY) - feature_default_value = feature.get(schema.FEATURE_DEFAULT_VAL_KEY) - if not rules_list: - # not rules but has a value - logger.debug( - f"no rules found, returning feature default value, feature_name={feature_name}, " - f"default_value={feature_default_value}" - ) - return bool(feature_default_value) - # look for first rule match - logger.debug( - f"looking for rule match, feature_name={feature_name}, feature_default_value={feature_default_value}" - ) - return self._handle_rules( - feature_name=feature_name, - rules_context=rules_context, - feature_default_value=bool(feature_default_value), - rules=cast(List, rules_list), - ) - - def get_all_enabled_feature_toggles(self, *, rules_context: Optional[Dict[str, Any]] = None) -> List[str]: - """Get all enabled feature toggles while also taking into account rule_context - (when a feature has defined rules) - - Parameters - ---------- - rules_context: Optional[Dict[str, Any]] - dict of attributes that you would like to match the rules - against, can be `{'tenant_id: 'X', 'username':' 'Y', 'region': 'Z'}` etc. - - Returns - ---------- - List[str] - a list of all features name that are enabled by also taking into account - rule_context (when a feature has defined rules) - """ - if rules_context is None: - rules_context = {} - - try: - toggles_dict: Dict[str, Any] = self.get_configuration() - except ConfigurationError: - logger.error("unable to get feature toggles JSON") - return [] - - ret_list = [] - features: Dict[str, Any] = toggles_dict.get(schema.FEATURES_KEY, {}) - for feature_name, feature_dict_def in features.items(): - rules_list = feature_dict_def.get(schema.RULES_KEY, []) - feature_default_value = feature_dict_def.get(schema.FEATURE_DEFAULT_VAL_KEY) - if feature_default_value and not rules_list: - self._logger.debug( - f"feature is enabled by default and has no defined rules, feature_name={feature_name}" - ) - ret_list.append(feature_name) - elif self._handle_rules( - feature_name=feature_name, - rules_context=rules_context, - feature_default_value=feature_default_value, - rules=rules_list, - ): - self._logger.debug(f"feature's calculated value is True, feature_name={feature_name}") - ret_list.append(feature_name) - - return ret_list diff --git a/aws_lambda_powertools/utilities/feature_toggles/exceptions.py b/aws_lambda_powertools/utilities/feature_toggles/exceptions.py deleted file mode 100644 index d87f9a39dec..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/exceptions.py +++ /dev/null @@ -1,2 +0,0 @@ -class ConfigurationError(Exception): - """When a a configuration store raises an exception on config retrieval or parsing""" diff --git a/aws_lambda_powertools/utilities/feature_toggles/schema.py b/aws_lambda_powertools/utilities/feature_toggles/schema.py deleted file mode 100644 index 9d995ab59e4..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/schema.py +++ /dev/null @@ -1,84 +0,0 @@ -from enum import Enum -from logging import Logger -from typing import Any, Dict - -from .exceptions import ConfigurationError - -FEATURES_KEY = "features" -RULES_KEY = "rules" -FEATURE_DEFAULT_VAL_KEY = "feature_default_value" -CONDITIONS_KEY = "conditions" -RULE_NAME_KEY = "rule_name" -RULE_DEFAULT_VALUE = "value_when_applies" -CONDITION_KEY = "key" -CONDITION_VALUE = "value" -CONDITION_ACTION = "action" - - -class ACTION(str, Enum): - EQUALS = "EQUALS" - STARTSWITH = "STARTSWITH" - ENDSWITH = "ENDSWITH" - CONTAINS = "CONTAINS" - - -class SchemaValidator: - def __init__(self, logger: Logger): - self._logger = logger - - def _raise_conf_exc(self, error_str: str) -> None: - self._logger.error(error_str) - raise ConfigurationError(error_str) - - def _validate_condition(self, rule_name: str, condition: Dict[str, str]) -> None: - if not condition or not isinstance(condition, dict): - self._raise_conf_exc(f"invalid condition type, not a dictionary, rule_name={rule_name}") - action = condition.get(CONDITION_ACTION, "") - if action not in [ACTION.EQUALS.value, ACTION.STARTSWITH.value, ACTION.ENDSWITH.value, ACTION.CONTAINS.value]: - self._raise_conf_exc(f"invalid action value, rule_name={rule_name}, action={action}") - key = condition.get(CONDITION_KEY, "") - if not key or not isinstance(key, str): - self._raise_conf_exc(f"invalid key value, key has to be a non empty string, rule_name={rule_name}") - value = condition.get(CONDITION_VALUE, "") - if not value: - self._raise_conf_exc(f"missing condition value, rule_name={rule_name}") - - def _validate_rule(self, feature_name: str, rule: Dict[str, Any]) -> None: - if not rule or not isinstance(rule, dict): - self._raise_conf_exc(f"feature rule is not a dictionary, feature_name={feature_name}") - rule_name = rule.get(RULE_NAME_KEY) - if not rule_name or rule_name is None or not isinstance(rule_name, str): - return self._raise_conf_exc(f"invalid rule_name, feature_name={feature_name}") - rule_default_value = rule.get(RULE_DEFAULT_VALUE) - if rule_default_value is None or not isinstance(rule_default_value, bool): - self._raise_conf_exc(f"invalid rule_default_value, rule_name={rule_name}") - conditions = rule.get(CONDITIONS_KEY, {}) - if not conditions or not isinstance(conditions, list): - self._raise_conf_exc(f"invalid condition, rule_name={rule_name}") - # validate conditions - for condition in conditions: - self._validate_condition(rule_name, condition) - - def _validate_feature(self, feature_name: str, feature_dict_def: Dict[str, Any]) -> None: - if not feature_dict_def or not isinstance(feature_dict_def, dict): - self._raise_conf_exc(f"invalid AWS AppConfig JSON schema detected, feature {feature_name} is invalid") - feature_default_value = feature_dict_def.get(FEATURE_DEFAULT_VAL_KEY) - if feature_default_value is None or not isinstance(feature_default_value, bool): - self._raise_conf_exc(f"missing feature_default_value for feature, feature_name={feature_name}") - # validate rules - rules = feature_dict_def.get(RULES_KEY, []) - if not rules: - return - if not isinstance(rules, list): - self._raise_conf_exc(f"feature rules is not a list, feature_name={feature_name}") - for rule in rules: - self._validate_rule(feature_name, rule) - - def validate_json_schema(self, schema: Dict[str, Any]) -> None: - if not isinstance(schema, dict): - self._raise_conf_exc("invalid AWS AppConfig JSON schema detected, root schema is not a dictionary") - features_dict = schema.get(FEATURES_KEY) - if not isinstance(features_dict, dict): - return self._raise_conf_exc("invalid AWS AppConfig JSON schema detected, missing features dictionary") - for feature_name, feature_dict_def in features_dict.items(): - self._validate_feature(feature_name, feature_dict_def) diff --git a/aws_lambda_powertools/utilities/feature_toggles/schema_fetcher.py b/aws_lambda_powertools/utilities/feature_toggles/schema_fetcher.py deleted file mode 100644 index 89fffe1221d..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/schema_fetcher.py +++ /dev/null @@ -1,24 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Dict - - -class SchemaFetcher(ABC): - def __init__(self, configuration_name: str, cache_seconds: int): - self.configuration_name = configuration_name - self._cache_seconds = cache_seconds - - @abstractmethod - def get_json_configuration(self) -> Dict[str, Any]: - """Get configuration string from any configuration storing service and return the parsed JSON dictionary - - Raises - ------ - ConfigurationError - Any error that can occur during schema fetch or JSON parse - - Returns - ------- - Dict[str, Any] - parsed JSON dictionary - """ - return NotImplemented # pragma: no cover diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index eb43a8b30c5..0388adfbf55 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -14,7 +14,7 @@ import jmespath from aws_lambda_powertools.shared.cache_dict import LRUDict -from aws_lambda_powertools.shared.jmespath_functions import PowertoolsFunctions +from aws_lambda_powertools.shared.jmespath_utils import PowertoolsFunctions from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.idempotency.config import IdempotencyConfig from aws_lambda_powertools.utilities.idempotency.exceptions import ( diff --git a/aws_lambda_powertools/utilities/validation/base.py b/aws_lambda_powertools/utilities/validation/base.py index b818f11a40e..13deb4d24e2 100644 --- a/aws_lambda_powertools/utilities/validation/base.py +++ b/aws_lambda_powertools/utilities/validation/base.py @@ -1,13 +1,9 @@ import logging -from typing import Any, Dict, Optional, Union +from typing import Dict, Optional, Union import fastjsonschema # type: ignore -import jmespath -from jmespath.exceptions import LexerError # type: ignore -from aws_lambda_powertools.shared.jmespath_functions import PowertoolsFunctions - -from .exceptions import InvalidEnvelopeExpressionError, InvalidSchemaFormatError, SchemaValidationError +from .exceptions import InvalidSchemaFormatError, SchemaValidationError logger = logging.getLogger(__name__) @@ -39,31 +35,3 @@ def validate_data_against_schema(data: Union[Dict, str], schema: Dict, formats: except fastjsonschema.JsonSchemaException as e: message = f"Failed schema validation. Error: {e.message}, Path: {e.path}, Data: {e.value}" # noqa: B306, E501 raise SchemaValidationError(message) - - -def unwrap_event_from_envelope(data: Union[Dict, str], envelope: str, jmespath_options: Optional[Dict]) -> Any: - """Searches data using JMESPath expression - - Parameters - ---------- - data : Dict - Data set to be filtered - envelope : str - JMESPath expression to filter data against - jmespath_options : Dict - Alternative JMESPath options to be included when filtering expr - - Returns - ------- - Any - Data found using JMESPath expression given in envelope - """ - if not jmespath_options: - jmespath_options = {"custom_functions": PowertoolsFunctions()} - - try: - logger.debug(f"Envelope detected: {envelope}. JMESPath options: {jmespath_options}") - return jmespath.search(envelope, data, options=jmespath.Options(**jmespath_options)) - except (LexerError, TypeError, UnicodeError) as e: - message = f"Failed to unwrap event from envelope using expression. Error: {e} Exp: {envelope}, Data: {data}" # noqa: B306, E501 - raise InvalidEnvelopeExpressionError(message) diff --git a/aws_lambda_powertools/utilities/validation/validator.py b/aws_lambda_powertools/utilities/validation/validator.py index 0497a49a714..02a685a1565 100644 --- a/aws_lambda_powertools/utilities/validation/validator.py +++ b/aws_lambda_powertools/utilities/validation/validator.py @@ -2,7 +2,8 @@ from typing import Any, Callable, Dict, Optional, Union from ...middleware_factory import lambda_handler_decorator -from .base import unwrap_event_from_envelope, validate_data_against_schema +from ...shared import jmespath_utils +from .base import validate_data_against_schema logger = logging.getLogger(__name__) @@ -16,7 +17,7 @@ def validator( inbound_formats: Optional[Dict] = None, outbound_schema: Optional[Dict] = None, outbound_formats: Optional[Dict] = None, - envelope: Optional[str] = None, + envelope: str = "", jmespath_options: Optional[Dict] = None, ) -> Any: """Lambda handler decorator to validate incoming/outbound data using a JSON Schema @@ -116,7 +117,9 @@ def handler(event, context): When JMESPath expression to unwrap event is invalid """ if envelope: - event = unwrap_event_from_envelope(data=event, envelope=envelope, jmespath_options=jmespath_options) + event = jmespath_utils.unwrap_event_from_envelope( + data=event, envelope=envelope, jmespath_options=jmespath_options + ) if inbound_schema: logger.debug("Validating inbound event") @@ -216,6 +219,8 @@ def handler(event, context): When JMESPath expression to unwrap event is invalid """ if envelope: - event = unwrap_event_from_envelope(data=event, envelope=envelope, jmespath_options=jmespath_options) + event = jmespath_utils.unwrap_event_from_envelope( + data=event, envelope=envelope, jmespath_options=jmespath_options + ) validate_data_against_schema(data=event, schema=schema, formats=formats) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md new file mode 100644 index 00000000000..b450d45806c --- /dev/null +++ b/docs/utilities/feature_flags.md @@ -0,0 +1,57 @@ +--- +title: Feature flags +description: Utility +--- + +The feature flags utility provides a simple rule engine to define when one or multiple features should be enabled depending on the input. + +!!! tip "For simpler use cases where a feature is simply on or off for all users, use [Parameters](parameters.md) utility instead." + +## Terminology + +Feature flags are used to modify a system behaviour without having to change their code. These flags can be static or dynamic. + +**Static feature flags** are commonly used for long-lived behaviours that will rarely change, for example `TRACER_ENABLED=True`. These are better suited for [Parameters utility](parameters.md). + +**Dynamic feature flags** are typically used for experiments where you'd want to enable a feature for a limited set of customers, for example A/B testing and Canary releases. These are better suited for this utility, as you can create multiple conditions on whether a feature flag should be `True` or `False`. + +That being said, be mindful that feature flags can increase your application complexity over time if you're not careful; use them sparingly. + +!!! tip "Read [this article](https://martinfowler.com/articles/feature-toggles.html){target="_blank"} for more details on different types of feature flags and trade-offs" + +## Key features + +> TODO: Revisit once getting started and advanced sections are complete + +* Define simple feature flags to dynamically decide when to enable a feature +* Fetch one or all feature flags enabled for a given application context +* Bring your own configuration store + +## Getting started +### IAM Permissions + +By default, this utility provides AWS AppConfig as a configuration store. As such, you IAM Role needs permission - `appconfig:GetConfiguration` - to fetch feature flags from AppConfig. + +### Creating feature flags + +> NOTE: Explain schema, provide sample boto3 script and CFN to create one + +#### Rules + + + +### Fetching a single feature flag + +### Fetching all feature flags + +### Advanced + +#### Adjusting cache TTL + +### Partially enabling features + +### Bring your own store provider + +## Testing your code + +> NOTE: Share example on how customers can unit test their feature flags diff --git a/mkdocs.yml b/mkdocs.yml index 7ee0fd56236..94dc9980cf1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -25,6 +25,7 @@ nav: - utilities/data_classes.md - utilities/parser.md - utilities/idempotency.md + - utilities/feature_flags.md theme: name: material diff --git a/tests/functional/feature_toggles/__init__.py b/tests/functional/feature_flags/__init__.py similarity index 100% rename from tests/functional/feature_toggles/__init__.py rename to tests/functional/feature_flags/__init__.py diff --git a/tests/functional/feature_flags/test_feature_flags.py b/tests/functional/feature_flags/test_feature_flags.py new file mode 100644 index 00000000000..d2150268062 --- /dev/null +++ b/tests/functional/feature_flags/test_feature_flags.py @@ -0,0 +1,503 @@ +from typing import Dict, List, Optional + +import pytest +from botocore.config import Config + +from aws_lambda_powertools.utilities.feature_flags import ConfigurationStoreError, schema +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.parameters import GetParameterError + + +@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", + cache_seconds=600, + sdk_config=config, + envelope=envelope, + jmespath_options=jmespath_options, + ) + feature_flags: FeatureFlags = FeatureFlags(store=app_conf_fetcher) + return feature_flags + + +def init_fetcher_side_effect(mocker, config: Config, side_effect) -> AppConfigStore: + mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get") + mocked_get_conf.side_effect = side_effect + return AppConfigStore( + environment="env", + application="application", + name="conf", + cache_seconds=1, + sdk_config=config, + ) + + +# this test checks that we get correct value of feature that exists in the schema. +# we also don't send an empty context dict in this case +def test_flags_rule_does_not_match(mocker, config): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant id equals 345345435": { + "when_match": False, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "345345435", + } + ], + } + }, + } + } + + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={}, default=False) + assert toggle == expected_value + + +# this test checks that if you try to get a feature that doesn't exist in the schema, +# you get the default value of False that was sent to the evaluate API +def test_flags_no_conditions_feature_does_not_exist(mocker, config): + expected_value = False + mocked_app_config_schema = {"my_fake_feature": {"default": True}} + + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={}, default=expected_value) + assert toggle == expected_value + + +# check that feature match works when they are no rules and we send context. +# default value is False but the feature has a True default_value. +def test_flags_no_rules(mocker, config): + expected_value = True + mocked_app_config_schema = {"my_feature": {"default": expected_value}} + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_value + + +# check a case where the feature exists but the rule doesn't match so we revert to the default value of the feature +def test_flags_conditions_no_match(mocker, config): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant id equals 345345435": { + "when_match": False, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "345345435", + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_value + + +# check that a rule can match when it has multiple conditions, see rule name for further explanation +def test_flags_conditions_rule_match_equal_multiple_conditions(mocker, config): + expected_value = False + tenant_id_val = "6" + username_val = "a" + mocked_app_config_schema = { + "my_feature": { + "default": True, + "rules": { + "tenant id equals 6 and username is a": { + "when_match": expected_value, + "conditions": [ + { + "action": RuleAction.EQUALS.value, # this rule will match, it has multiple conditions + "key": "tenant_id", + "value": tenant_id_val, + }, + { + "action": RuleAction.EQUALS.value, + "key": "username", + "value": username_val, + }, + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate( + name="my_feature", + context={ + "tenant_id": tenant_id_val, + "username": username_val, + }, + default=True, + ) + assert toggle == expected_value + + +# check a case when rule doesn't match and it has multiple conditions, +# different tenant id causes the rule to not match. +# default value of the feature in this case is True +def test_flags_conditions_no_rule_match_equal_multiple_conditions(mocker, config): + expected_val = True + mocked_app_config_schema = { + "my_feature": { + "default": expected_val, + "rules": { + # rule will not match + "tenant id equals 645654 and username is a": { + "when_match": False, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "645654", + }, + { + "action": RuleAction.EQUALS.value, + "key": "username", + "value": "a", + }, + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_val + + +# check rule match for multiple of action types +def test_flags_conditions_rule_match_multiple_actions_multiple_rules_multiple_conditions(mocker, config): + expected_value_first_check = True + expected_value_second_check = False + expected_value_third_check = False + expected_value_fourth_case = False + mocked_app_config_schema = { + "my_feature": { + "default": expected_value_third_check, + "rules": { + "tenant id equals 6 and username startswith a": { + "when_match": expected_value_first_check, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "6", + }, + { + "action": RuleAction.STARTSWITH.value, + "key": "username", + "value": "a", + }, + ], + }, + "tenant id equals 4446 and username startswith a and endswith z": { + "when_match": expected_value_second_check, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "4446", + }, + { + "action": RuleAction.STARTSWITH.value, + "key": "username", + "value": "a", + }, + { + "action": RuleAction.ENDSWITH.value, + "key": "username", + "value": "z", + }, + ], + }, + }, + } + } + + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + # match first rule + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "abcd"}, default=False) + assert toggle == expected_value_first_check + # match second rule + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "4446", "username": "az"}, default=False) + assert toggle == expected_value_second_check + # match no rule + toggle = feature_flags.evaluate( + name="my_feature", context={"tenant_id": "11114446", "username": "ab"}, default=False + ) + assert toggle == expected_value_third_check + # feature doesn't exist + toggle = feature_flags.evaluate( + name="my_fake_feature", + context={"tenant_id": "11114446", "username": "ab"}, + default=expected_value_fourth_case, + ) + assert toggle == expected_value_fourth_case + + +# check a case where the feature exists but the rule doesn't match so we revert to the default value of the feature +def test_flags_match_rule_with_contains_action(mocker, config): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "tenant id is contained in [6, 2]": { + "when_match": expected_value, + "conditions": [ + { + "action": RuleAction.CONTAINS.value, + "key": "tenant_id", + "value": ["6", "2"], + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_value + + +def test_flags_no_match_rule_with_contains_action(mocker, config): + expected_value = False + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant id is contained in [8, 2]": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.CONTAINS.value, + "key": "tenant_id", + "value": ["8", "2"], + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_value + + +def test_multiple_features_enabled(mocker, config): + expected_value = ["my_feature", "my_feature2"] + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "tenant id is contained in [6, 2]": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.CONTAINS.value, + "key": "tenant_id", + "value": ["6", "2"], + } + ], + } + }, + }, + "my_feature2": { + "default": True, + }, + "my_feature3": { + "default": 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_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.CONTAINS.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()) + feature_flags = FeatureFlags(schema_fetcher) + + # WHEN calling evaluate + toggle = feature_flags.evaluate(name="Foo", default=False) + + # THEN handle the error and return the default + assert toggle is False + + +def test_get_all_enabled_feature_flags_handles_error(mocker, config): + # GIVEN a schema fetch that raises a ConfigurationStoreError + schema_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) + feature_flags = FeatureFlags(schema_fetcher) + + # WHEN calling get_enabled_features + flags = feature_flags.get_enabled_features(context=None) + + # THEN handle the error and return an empty list + assert flags == [] + + +def test_app_config_get_parameter_err(mocker, config): + # GIVEN an appconfig with a missing config + app_conf_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) + + # WHEN calling get_configuration + with pytest.raises(ConfigurationStoreError) as err: + app_conf_fetcher.get_configuration() + + # THEN raise ConfigurationStoreError error + assert "AWS AppConfig configuration" in str(err.value) + + +def test_match_by_action_no_matching_action(mocker, config): + # GIVEN an unsupported action + feature_flags = init_feature_flags(mocker, {}, config) + # WHEN calling _match_by_action + result = feature_flags._match_by_action("Foo", None, "foo") + # THEN default to False + assert result is False + + +def test_match_by_action_attribute_error(mocker, config): + # GIVEN a startswith action and 2 integer + feature_flags = init_feature_flags(mocker, {}, config) + # WHEN calling _match_by_action + result = feature_flags._match_by_action(RuleAction.STARTSWITH.value, 1, 100) + # THEN swallow the AttributeError and return False + assert result is False + + +def test_is_rule_matched_no_matches(mocker, config): + # GIVEN an empty list of conditions + rule = {schema.CONDITIONS_KEY: []} + rules_context = {} + feature_flags = init_feature_flags(mocker, {}, config) + + # WHEN calling _evaluate_conditions + result = feature_flags._evaluate_conditions( + rule_name="dummy", feature_name="dummy", rule=rule, context=rules_context + ) + + # THEN return False + assert result is False + + +def test_features_jmespath_envelope(mocker, config): + expected_value = True + mocked_app_config_schema = {"features": {"my_feature": {"default": expected_value}}} + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config, envelope="features") + toggle = feature_flags.evaluate(name="my_feature", context={}, default=False) + assert toggle == expected_value + + +# test_match_rule_with_contains_action +def test_match_condition_with_dict_value(mocker, config): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "tenant id is 6 and username is lessa": { + "when_match": expected_value, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant", + "value": {"tenant_id": "6", "username": "lessa"}, + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + ctx = {"tenant": {"tenant_id": "6", "username": "lessa"}} + toggle = feature_flags.evaluate(name="my_feature", context=ctx, default=False) + assert toggle == expected_value + + +def test_get_feature_toggle_propagates_access_denied_error(mocker, config): + # GIVEN a schema fetch that raises a StoreClientError + # due to client invalid permissions to fetch from the store + err = "An error occurred (AccessDeniedException) when calling the GetConfiguration operation" + schema_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError(err)) + feature_flags = FeatureFlags(schema_fetcher) + + # WHEN calling evaluate + # THEN raise StoreClientError error + with pytest.raises(StoreClientError, match="AccessDeniedException") as err: + feature_flags.evaluate(name="Foo", default=False) diff --git a/tests/functional/feature_flags/test_schema_validation.py b/tests/functional/feature_flags/test_schema_validation.py new file mode 100644 index 00000000000..2c33d3c61cc --- /dev/null +++ b/tests/functional/feature_flags/test_schema_validation.py @@ -0,0 +1,282 @@ +import logging + +import pytest # noqa: F401 + +from aws_lambda_powertools.utilities.feature_flags.exceptions import SchemaValidationError +from aws_lambda_powertools.utilities.feature_flags.schema import ( + CONDITION_ACTION, + CONDITION_KEY, + CONDITION_VALUE, + CONDITIONS_KEY, + FEATURE_DEFAULT_VAL_KEY, + RULE_MATCH_VALUE, + RULES_KEY, + ConditionsValidator, + RuleAction, + RulesValidator, + SchemaValidator, +) + +logger = logging.getLogger(__name__) + +EMPTY_SCHEMA = {"": ""} + + +def test_invalid_features_dict(): + validator = SchemaValidator(schema=[]) + with pytest.raises(SchemaValidationError): + validator.validate() + + +def test_empty_features_not_fail(): + validator = SchemaValidator(schema={}) + validator.validate() + + +@pytest.mark.parametrize( + "schema", + [ + pytest.param({"my_feature": []}, id="feat_as_list"), + pytest.param({"my_feature": {}}, id="feat_empty_dict"), + pytest.param({"my_feature": {FEATURE_DEFAULT_VAL_KEY: "False"}}, id="feat_default_non_bool"), + pytest.param({"my_feature": {FEATURE_DEFAULT_VAL_KEY: False, RULES_KEY: "4"}}, id="feat_rules_non_dict"), + pytest.param("%<>[]{}|^", id="unsafe-rfc3986"), + ], +) +def test_invalid_feature(schema): + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + +def test_valid_feature_dict(): + # empty rules list + schema = {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False, RULES_KEY: []}} + validator = SchemaValidator(schema) + validator.validate() + + # no rules list at all + schema = {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False}} + validator = SchemaValidator(schema) + validator.validate() + + +def test_invalid_rule(): + # rules list is not a list of dict + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: [ + "a", + "b", + ], + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # rules RULE_MATCH_VALUE is not bool + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": { + RULE_MATCH_VALUE: "False", + } + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # missing conditions list + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": { + RULE_MATCH_VALUE: False, + } + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # condition list is empty + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": {RULE_MATCH_VALUE: False, CONDITIONS_KEY: []}, + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # condition is invalid type, not list + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": {RULE_MATCH_VALUE: False, CONDITIONS_KEY: {}}, + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + +def test_invalid_condition(): + # invalid condition action + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": { + RULE_MATCH_VALUE: False, + CONDITIONS_KEY: {CONDITION_ACTION: "stuff", CONDITION_KEY: "a", CONDITION_VALUE: "a"}, + } + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # missing condition key and value + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": { + RULE_MATCH_VALUE: False, + CONDITIONS_KEY: {CONDITION_ACTION: RuleAction.EQUALS.value}, + } + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # invalid condition key type, not string + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": { + RULE_MATCH_VALUE: False, + CONDITIONS_KEY: { + CONDITION_ACTION: RuleAction.EQUALS.value, + CONDITION_KEY: 5, + CONDITION_VALUE: "a", + }, + } + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + +def test_valid_condition_all_actions(): + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 645654 and username is a": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.EQUALS.value, + CONDITION_KEY: "tenant_id", + CONDITION_VALUE: "645654", + }, + { + CONDITION_ACTION: RuleAction.STARTSWITH.value, + CONDITION_KEY: "username", + CONDITION_VALUE: "a", + }, + { + CONDITION_ACTION: RuleAction.ENDSWITH.value, + CONDITION_KEY: "username", + CONDITION_VALUE: "a", + }, + { + CONDITION_ACTION: RuleAction.CONTAINS.value, + CONDITION_KEY: "username", + CONDITION_VALUE: ["a", "b"], + }, + ], + } + }, + } + } + validator = SchemaValidator(schema) + validator.validate() + + +def test_validate_condition_invalid_condition_type(): + # GIVEN an invalid condition type of empty dict + condition = {} + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises(SchemaValidationError, match="Feature rule condition must be a dictionary"): + ConditionsValidator.validate_condition(condition=condition, rule_name="dummy") + + +def test_validate_condition_invalid_condition_action(): + # GIVEN an invalid condition action of foo + condition = {"action": "INVALID", "key": "tenant_id", "value": "12345"} + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises(SchemaValidationError, match="'action' value must be either"): + ConditionsValidator.validate_condition_action(condition=condition, rule_name="dummy") + + +def test_validate_condition_invalid_condition_key(): + # GIVEN a configuration with a missing "key" + condition = {"action": RuleAction.EQUALS.value, "value": "12345"} + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises(SchemaValidationError, match="'key' value must be a non empty string"): + ConditionsValidator.validate_condition_key(condition=condition, rule_name="dummy") + + +def test_validate_condition_missing_condition_value(): + # GIVEN a configuration with a missing condition value + condition = { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + } + + # WHEN calling validate_condition + with pytest.raises(SchemaValidationError, match="'value' key must not be empty"): + ConditionsValidator.validate_condition_value(condition=condition, rule_name="dummy") + + +def test_validate_rule_invalid_rule_type(): + # GIVEN an invalid rule type of empty list + # WHEN calling validate_rule + # THEN raise SchemaValidationError + with pytest.raises(SchemaValidationError, match="Feature rule must be a dictionary"): + RulesValidator.validate_rule(rule=[], rule_name="dummy", feature_name="dummy") + + +def test_validate_rule_invalid_rule_name(): + # GIVEN a rule name is empty + # WHEN calling validate_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") diff --git a/tests/functional/feature_toggles/test_feature_toggles.py b/tests/functional/feature_toggles/test_feature_toggles.py deleted file mode 100644 index bb4b8f24dfc..00000000000 --- a/tests/functional/feature_toggles/test_feature_toggles.py +++ /dev/null @@ -1,503 +0,0 @@ -from typing import Dict, List - -import pytest -from botocore.config import Config - -from aws_lambda_powertools.utilities.feature_toggles import ConfigurationError, schema -from aws_lambda_powertools.utilities.feature_toggles.appconfig_fetcher import AppConfigFetcher -from aws_lambda_powertools.utilities.feature_toggles.configuration_store import ConfigurationStore -from aws_lambda_powertools.utilities.feature_toggles.schema import ACTION -from aws_lambda_powertools.utilities.parameters import GetParameterError - - -@pytest.fixture(scope="module") -def config(): - return Config(region_name="us-east-1") - - -def init_configuration_store(mocker, mock_schema: Dict, config: Config) -> ConfigurationStore: - mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get") - mocked_get_conf.return_value = mock_schema - - app_conf_fetcher = AppConfigFetcher( - environment="test_env", - service="test_app", - configuration_name="test_conf_name", - cache_seconds=600, - config=config, - ) - conf_store: ConfigurationStore = ConfigurationStore(schema_fetcher=app_conf_fetcher) - return conf_store - - -def init_fetcher_side_effect(mocker, config: Config, side_effect) -> AppConfigFetcher: - mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get") - mocked_get_conf.side_effect = side_effect - return AppConfigFetcher( - environment="env", - service="service", - configuration_name="conf", - cache_seconds=1, - config=config, - ) - - -# this test checks that we get correct value of feature that exists in the schema. -# we also don't send an empty rules_context dict in this case -def test_toggles_rule_does_not_match(mocker, config): - expected_value = True - mocked_app_config_schema = { - "features": { - "my_feature": { - "feature_default_value": expected_value, - "rules": [ - { - "rule_name": "tenant id equals 345345435", - "value_when_applies": False, - "conditions": [ - { - "action": ACTION.EQUALS.value, - "key": "tenant_id", - "value": "345345435", - } - ], - }, - ], - } - }, - } - - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - toggle = conf_store.get_feature_toggle(feature_name="my_feature", rules_context={}, value_if_missing=False) - assert toggle == expected_value - - -# this test checks that if you try to get a feature that doesn't exist in the schema, -# you get the default value of False that was sent to the get_feature_toggle API -def test_toggles_no_conditions_feature_does_not_exist(mocker, config): - expected_value = False - mocked_app_config_schema = {"features": {"my_fake_feature": {"feature_default_value": True}}} - - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - toggle = conf_store.get_feature_toggle(feature_name="my_feature", rules_context={}, value_if_missing=expected_value) - assert toggle == expected_value - - -# check that feature match works when they are no rules and we send rules_context. -# default value is False but the feature has a True default_value. -def test_toggles_no_rules(mocker, config): - expected_value = True - mocked_app_config_schema = {"features": {"my_feature": {"feature_default_value": expected_value}}} - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", rules_context={"tenant_id": "6", "username": "a"}, value_if_missing=False - ) - assert toggle == expected_value - - -# check a case where the feature exists but the rule doesn't match so we revert to the default value of the feature -def test_toggles_conditions_no_match(mocker, config): - expected_value = True - mocked_app_config_schema = { - "features": { - "my_feature": { - "feature_default_value": expected_value, - "rules": [ - { - "rule_name": "tenant id equals 345345435", - "value_when_applies": False, - "conditions": [ - { - "action": ACTION.EQUALS.value, - "key": "tenant_id", - "value": "345345435", - } - ], - }, - ], - } - }, - } - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", - rules_context={"tenant_id": "6", "username": "a"}, # rule will not match - value_if_missing=False, - ) - assert toggle == expected_value - - -# check that a rule can match when it has multiple conditions, see rule name for further explanation -def test_toggles_conditions_rule_match_equal_multiple_conditions(mocker, config): - expected_value = False - tenant_id_val = "6" - username_val = "a" - mocked_app_config_schema = { - "features": { - "my_feature": { - "feature_default_value": True, - "rules": [ - { - "rule_name": "tenant id equals 6 and username is a", - "value_when_applies": expected_value, - "conditions": [ - { - "action": ACTION.EQUALS.value, # this rule will match, it has multiple conditions - "key": "tenant_id", - "value": tenant_id_val, - }, - { - "action": ACTION.EQUALS.value, - "key": "username", - "value": username_val, - }, - ], - }, - ], - } - }, - } - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", - rules_context={ - "tenant_id": tenant_id_val, - "username": username_val, - }, - value_if_missing=True, - ) - assert toggle == expected_value - - -# check a case when rule doesn't match and it has multiple conditions, -# different tenant id causes the rule to not match. -# default value of the feature in this case is True -def test_toggles_conditions_no_rule_match_equal_multiple_conditions(mocker, config): - expected_val = True - mocked_app_config_schema = { - "features": { - "my_feature": { - "feature_default_value": expected_val, - "rules": [ - { - "rule_name": "tenant id equals 645654 and username is a", # rule will not match - "value_when_applies": False, - "conditions": [ - { - "action": ACTION.EQUALS.value, - "key": "tenant_id", - "value": "645654", - }, - { - "action": ACTION.EQUALS.value, - "key": "username", - "value": "a", - }, - ], - }, - ], - } - }, - } - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", rules_context={"tenant_id": "6", "username": "a"}, value_if_missing=False - ) - assert toggle == expected_val - - -# check rule match for multiple of action types -def test_toggles_conditions_rule_match_multiple_actions_multiple_rules_multiple_conditions(mocker, config): - expected_value_first_check = True - expected_value_second_check = False - expected_value_third_check = False - expected_value_fourth_case = False - mocked_app_config_schema = { - "features": { - "my_feature": { - "feature_default_value": expected_value_third_check, - "rules": [ - { - "rule_name": "tenant id equals 6 and username startswith a", - "value_when_applies": expected_value_first_check, - "conditions": [ - { - "action": ACTION.EQUALS.value, - "key": "tenant_id", - "value": "6", - }, - { - "action": ACTION.STARTSWITH.value, - "key": "username", - "value": "a", - }, - ], - }, - { - "rule_name": "tenant id equals 4446 and username startswith a and endswith z", - "value_when_applies": expected_value_second_check, - "conditions": [ - { - "action": ACTION.EQUALS.value, - "key": "tenant_id", - "value": "4446", - }, - { - "action": ACTION.STARTSWITH.value, - "key": "username", - "value": "a", - }, - { - "action": ACTION.ENDSWITH.value, - "key": "username", - "value": "z", - }, - ], - }, - ], - } - }, - } - - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - # match first rule - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", - rules_context={"tenant_id": "6", "username": "abcd"}, - value_if_missing=False, - ) - assert toggle == expected_value_first_check - # match second rule - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", - rules_context={"tenant_id": "4446", "username": "az"}, - value_if_missing=False, - ) - assert toggle == expected_value_second_check - # match no rule - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", - rules_context={"tenant_id": "11114446", "username": "ab"}, - value_if_missing=False, - ) - assert toggle == expected_value_third_check - # feature doesn't exist - toggle = conf_store.get_feature_toggle( - feature_name="my_fake_feature", - rules_context={"tenant_id": "11114446", "username": "ab"}, - value_if_missing=expected_value_fourth_case, - ) - assert toggle == expected_value_fourth_case - - -# check a case where the feature exists but the rule doesn't match so we revert to the default value of the feature -def test_toggles_match_rule_with_contains_action(mocker, config): - expected_value = True - mocked_app_config_schema = { - "features": { - "my_feature": { - "feature_default_value": False, - "rules": [ - { - "rule_name": "tenant id is contained in [6,2] ", - "value_when_applies": expected_value, - "conditions": [ - { - "action": ACTION.CONTAINS.value, - "key": "tenant_id", - "value": ["6", "2"], - } - ], - }, - ], - } - }, - } - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", - rules_context={"tenant_id": "6", "username": "a"}, # rule will match - value_if_missing=False, - ) - assert toggle == expected_value - - -def test_toggles_no_match_rule_with_contains_action(mocker, config): - expected_value = False - mocked_app_config_schema = { - "features": { - "my_feature": { - "feature_default_value": expected_value, - "rules": [ - { - "rule_name": "tenant id is contained in [6,2] ", - "value_when_applies": True, - "conditions": [ - { - "action": ACTION.CONTAINS.value, - "key": "tenant_id", - "value": ["8", "2"], - } - ], - }, - ], - } - }, - } - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", - rules_context={"tenant_id": "6", "username": "a"}, # rule will not match - value_if_missing=False, - ) - assert toggle == expected_value - - -def test_multiple_features_enabled(mocker, config): - expected_value = ["my_feature", "my_feature2"] - mocked_app_config_schema = { - "features": { - "my_feature": { - "feature_default_value": False, - "rules": [ - { - "rule_name": "tenant id is contained in [6,2] ", - "value_when_applies": True, - "conditions": [ - { - "action": ACTION.CONTAINS.value, - "key": "tenant_id", - "value": ["6", "2"], - } - ], - }, - ], - }, - "my_feature2": { - "feature_default_value": True, - }, - }, - } - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - enabled_list: List[str] = conf_store.get_all_enabled_feature_toggles( - rules_context={"tenant_id": "6", "username": "a"} - ) - 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 = { - "features": { - "my_feature": { # rule will match here, feature is enabled due to rule match - "feature_default_value": False, - "rules": [ - { - "rule_name": "tenant id is contained in [6,2] ", - "value_when_applies": True, - "conditions": [ - { - "action": ACTION.CONTAINS.value, - "key": "tenant_id", - "value": ["6", "2"], - } - ], - }, - ], - }, - "my_feature2": { - "feature_default_value": True, - }, - "my_feature3": { - "feature_default_value": False, - }, - "my_feature4": { # rule will not match here, feature is enabled by default - "feature_default_value": True, - "rules": [ - { - "rule_name": "tenant id equals 7", - "value_when_applies": False, - "conditions": [ - { - "action": ACTION.EQUALS.value, - "key": "tenant_id", - "value": "7", - } - ], - }, - ], - }, - }, - } - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - enabled_list: List[str] = conf_store.get_all_enabled_feature_toggles( - rules_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 ConfigurationError - schema_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) - conf_store = ConfigurationStore(schema_fetcher) - - # WHEN calling get_feature_toggle - toggle = conf_store.get_feature_toggle(feature_name="Foo", value_if_missing=False) - - # THEN handle the error and return the value_if_missing - assert toggle is False - - -def test_get_all_enabled_feature_toggles_handles_error(mocker, config): - # GIVEN a schema fetch that raises a ConfigurationError - schema_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) - conf_store = ConfigurationStore(schema_fetcher) - - # WHEN calling get_all_enabled_feature_toggles - toggles = conf_store.get_all_enabled_feature_toggles(rules_context=None) - - # THEN handle the error and return an empty list - assert toggles == [] - - -def test_app_config_get_parameter_err(mocker, config): - # GIVEN an appconfig with a missing config - app_conf_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) - - # WHEN calling get_json_configuration - with pytest.raises(ConfigurationError) as err: - app_conf_fetcher.get_json_configuration() - - # THEN raise ConfigurationError error - assert "AWS AppConfig configuration" in str(err.value) - - -def test_match_by_action_no_matching_action(mocker, config): - # GIVEN an unsupported action - conf_store = init_configuration_store(mocker, {}, config) - # WHEN calling _match_by_action - result = conf_store._match_by_action("Foo", None, "foo") - # THEN default to False - assert result is False - - -def test_match_by_action_attribute_error(mocker, config): - # GIVEN a startswith action and 2 integer - conf_store = init_configuration_store(mocker, {}, config) - # WHEN calling _match_by_action - result = conf_store._match_by_action(ACTION.STARTSWITH.value, 1, 100) - # THEN swallow the AttributeError and return False - assert result is False - - -def test_is_rule_matched_no_matches(mocker, config): - # GIVEN an empty list of conditions - rule = {schema.CONDITIONS_KEY: []} - rules_context = {} - conf_store = init_configuration_store(mocker, {}, config) - - # WHEN calling _is_rule_matched - result = conf_store._is_rule_matched("feature_name", rule, rules_context) - - # THEN return False - assert result is False diff --git a/tests/functional/feature_toggles/test_schema_validation.py b/tests/functional/feature_toggles/test_schema_validation.py deleted file mode 100644 index 184f448322a..00000000000 --- a/tests/functional/feature_toggles/test_schema_validation.py +++ /dev/null @@ -1,330 +0,0 @@ -import logging - -import pytest # noqa: F401 - -from aws_lambda_powertools.utilities.feature_toggles.exceptions import ConfigurationError -from aws_lambda_powertools.utilities.feature_toggles.schema import ( - ACTION, - CONDITION_ACTION, - CONDITION_KEY, - CONDITION_VALUE, - CONDITIONS_KEY, - FEATURE_DEFAULT_VAL_KEY, - FEATURES_KEY, - RULE_DEFAULT_VALUE, - RULE_NAME_KEY, - RULES_KEY, - SchemaValidator, -) - -logger = logging.getLogger(__name__) - - -def test_invalid_features_dict(): - schema = {} - # empty dict - validator = SchemaValidator(logger) - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - schema = [] - # invalid type - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # invalid features key - schema = {FEATURES_KEY: []} - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - -def test_empty_features_not_fail(): - schema = {FEATURES_KEY: {}} - validator = SchemaValidator(logger) - validator.validate_json_schema(schema) - - -def test_invalid_feature_dict(): - # invalid feature type, not dict - schema = {FEATURES_KEY: {"my_feature": []}} - validator = SchemaValidator(logger) - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # empty feature dict - schema = {FEATURES_KEY: {"my_feature": {}}} - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # invalid FEATURE_DEFAULT_VAL_KEY type, not boolean - schema = {FEATURES_KEY: {"my_feature": {FEATURE_DEFAULT_VAL_KEY: "False"}}} - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # invalid FEATURE_DEFAULT_VAL_KEY type, not boolean #2 - schema = {FEATURES_KEY: {"my_feature": {FEATURE_DEFAULT_VAL_KEY: 5}}} - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # invalid rules type, not list - schema = {FEATURES_KEY: {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False, RULES_KEY: "4"}}} - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - -def test_valid_feature_dict(): - # no rules list at all - schema = {FEATURES_KEY: {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False}}} - validator = SchemaValidator(logger) - validator.validate_json_schema(schema) - - # empty rules list - schema = {FEATURES_KEY: {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False, RULES_KEY: []}}} - validator.validate_json_schema(schema) - - -def test_invalid_rule(): - # rules list is not a list of dict - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - "a", - "b", - ], - } - } - } - validator = SchemaValidator(logger) - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # rules RULE_DEFAULT_VALUE is not bool - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - { - RULE_NAME_KEY: "tenant id equals 345345435", - RULE_DEFAULT_VALUE: "False", - }, - ], - } - } - } - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # missing conditions list - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - { - RULE_NAME_KEY: "tenant id equals 345345435", - RULE_DEFAULT_VALUE: False, - }, - ], - } - } - } - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # condition list is empty - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - {RULE_NAME_KEY: "tenant id equals 345345435", RULE_DEFAULT_VALUE: False, CONDITIONS_KEY: []}, - ], - } - } - } - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # condition is invalid type, not list - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - {RULE_NAME_KEY: "tenant id equals 345345435", RULE_DEFAULT_VALUE: False, CONDITIONS_KEY: {}}, - ], - } - } - } - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - -def test_invalid_condition(): - # invalid condition action - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - { - RULE_NAME_KEY: "tenant id equals 345345435", - RULE_DEFAULT_VALUE: False, - CONDITIONS_KEY: {CONDITION_ACTION: "stuff", CONDITION_KEY: "a", CONDITION_VALUE: "a"}, - }, - ], - } - } - } - validator = SchemaValidator(logger) - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # missing condition key and value - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - { - RULE_NAME_KEY: "tenant id equals 345345435", - RULE_DEFAULT_VALUE: False, - CONDITIONS_KEY: {CONDITION_ACTION: ACTION.EQUALS.value}, - }, - ], - } - } - } - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # invalid condition key type, not string - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - { - RULE_NAME_KEY: "tenant id equals 345345435", - RULE_DEFAULT_VALUE: False, - CONDITIONS_KEY: { - CONDITION_ACTION: ACTION.EQUALS.value, - CONDITION_KEY: 5, - CONDITION_VALUE: "a", - }, - }, - ], - } - } - } - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - -def test_valid_condition_all_actions(): - validator = SchemaValidator(logger) - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - { - RULE_NAME_KEY: "tenant id equals 645654 and username is a", - RULE_DEFAULT_VALUE: True, - CONDITIONS_KEY: [ - { - CONDITION_ACTION: ACTION.EQUALS.value, - CONDITION_KEY: "tenant_id", - CONDITION_VALUE: "645654", - }, - { - CONDITION_ACTION: ACTION.STARTSWITH.value, - CONDITION_KEY: "username", - CONDITION_VALUE: "a", - }, - { - CONDITION_ACTION: ACTION.ENDSWITH.value, - CONDITION_KEY: "username", - CONDITION_VALUE: "a", - }, - { - CONDITION_ACTION: ACTION.CONTAINS.value, - CONDITION_KEY: "username", - CONDITION_VALUE: ["a", "b"], - }, - ], - }, - ], - } - }, - } - validator.validate_json_schema(schema) - - -def test_validate_condition_invalid_condition_type(): - # GIVEN an invalid condition type of empty dict - validator = SchemaValidator(logger) - condition = {} - - # WHEN calling _validate_condition - with pytest.raises(ConfigurationError) as err: - validator._validate_condition("foo", condition) - - # THEN raise ConfigurationError - assert "invalid condition type" in str(err) - - -def test_validate_condition_invalid_condition_action(): - # GIVEN an invalid condition action of foo - validator = SchemaValidator(logger) - condition = {"action": "foo"} - - # WHEN calling _validate_condition - with pytest.raises(ConfigurationError) as err: - validator._validate_condition("foo", condition) - - # THEN raise ConfigurationError - assert "invalid action value" in str(err) - - -def test_validate_condition_invalid_condition_key(): - # GIVEN a configuration with a missing "key" - validator = SchemaValidator(logger) - condition = {"action": ACTION.EQUALS.value} - - # WHEN calling _validate_condition - with pytest.raises(ConfigurationError) as err: - validator._validate_condition("foo", condition) - - # THEN raise ConfigurationError - assert "invalid key value" in str(err) - - -def test_validate_condition_missing_condition_value(): - # GIVEN a configuration with a missing condition value - validator = SchemaValidator(logger) - condition = {"action": ACTION.EQUALS.value, "key": "Foo"} - - # WHEN calling _validate_condition - with pytest.raises(ConfigurationError) as err: - validator._validate_condition("foo", condition) - - # THEN raise ConfigurationError - assert "missing condition value" in str(err) - - -def test_validate_rule_invalid_rule_name(): - # GIVEN a rule_name not in the rule dict - validator = SchemaValidator(logger) - rule_name = "invalid_rule_name" - rule = {"missing": ""} - - # WHEN calling _validate_rule - with pytest.raises(ConfigurationError) as err: - validator._validate_rule(rule_name, rule) - - # THEN raise ConfigurationError - assert "invalid rule_name" in str(err) diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py index e100957dee7..9f61d50d656 100644 --- a/tests/functional/idempotency/conftest.py +++ b/tests/functional/idempotency/conftest.py @@ -11,11 +11,11 @@ from botocore.config import Config from jmespath import functions +from aws_lambda_powertools.shared.jmespath_utils import unwrap_event_from_envelope from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer from aws_lambda_powertools.utilities.idempotency.idempotency import IdempotencyConfig from aws_lambda_powertools.utilities.validation import envelopes -from aws_lambda_powertools.utilities.validation.base import unwrap_event_from_envelope from tests.functional.utils import load_event TABLE_NAME = "TEST_TABLE"