diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py index d04e74ff293..0bbbb0934e1 100644 --- a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -1,4 +1,5 @@ import logging +import re from typing import Any, Dict, List, Optional, Union, cast from . import schema @@ -48,6 +49,12 @@ def _match_by_action(action: str, condition_value: Any, context_value: Any) -> b schema.RuleAction.ENDSWITH.value: lambda a, b: a.endswith(b), schema.RuleAction.IN.value: lambda a, b: a in b, schema.RuleAction.NOT_IN.value: lambda a, b: a not in b, + schema.RuleAction.RE_MATCH.value: lambda a, b: bool(re.match(b, a)), + schema.RuleAction.RE_MATCH_IGNORECASE.value: lambda a, b: bool(re.match(b, a, flags=re.IGNORECASE)), + schema.RuleAction.RE_FULLMATCH.value: lambda a, b: bool(re.fullmatch(b, a)), + schema.RuleAction.RE_FULLMATCH_IGNORECASE.value: lambda a, b: bool(re.fullmatch(b, a, flags=re.IGNORECASE)), + schema.RuleAction.RE_SEARCH.value: lambda a, b: bool(re.search(b, a)), + schema.RuleAction.RE_SEARCH_IGNORECASE.value: lambda a, b: bool(re.search(b, a, flags=re.IGNORECASE)), } try: diff --git a/aws_lambda_powertools/utilities/feature_flags/schema.py b/aws_lambda_powertools/utilities/feature_flags/schema.py index efce82018db..5b75ba65757 100644 --- a/aws_lambda_powertools/utilities/feature_flags/schema.py +++ b/aws_lambda_powertools/utilities/feature_flags/schema.py @@ -22,6 +22,12 @@ class RuleAction(str, Enum): ENDSWITH = "ENDSWITH" IN = "IN" NOT_IN = "NOT_IN" + RE_MATCH = "RE_MATCH" + RE_MATCH_IGNORECASE = "RE_MATCH_IGNORECASE" + RE_FULLMATCH = "RE_FULLMATCH" + RE_FULLMATCH_IGNORECASE = "RE_FULLMATCH_IGNORECASE" + RE_SEARCH = "RE_SEARCH" + RE_SEARCH_IGNORECASE = "RE_SEARCH_IGNORECASE" class SchemaValidator(BaseValidator): diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index d22f9c03296..56858a1cf42 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -450,9 +450,9 @@ The `conditions` block is a list of conditions that contain `action`, `key`, and } ``` -The `action` configuration can have 5 different values: `EQUALS`, `STARTSWITH`, `ENDSWITH`, `IN`, `NOT_IN`. +The `action` configuration can have 5 different values: `EQUALS`, `STARTSWITH`, `ENDSWITH`, `IN`, `NOT_IN`, `RE_MATCH`, `RE_MATCH_IGNORECASE`, `RE_FULLMATCH`, `RE_FULLMATCH_IGNORECASE`, `RE_SEARCH`, `RE_SEARCH_IGNORECASE`. -The `key` and `value` will be compared to the input from the context parameter. +The `key` and `value` will be compared to the input from the context parameter. The `value` should be the regular expression pattern to use when the `action` is any of the `RE_*` actions listed above. **For multiple conditions**, we will evaluate the list of conditions as a logical `AND`, so all conditions needs to match to return `when_match` value. diff --git a/tests/functional/feature_flags/test_feature_flags.py b/tests/functional/feature_flags/test_feature_flags.py index 5342105da3d..1cfe05dc5a8 100644 --- a/tests/functional/feature_flags/test_feature_flags.py +++ b/tests/functional/feature_flags/test_feature_flags.py @@ -587,3 +587,102 @@ def test_get_feature_toggle_propagates_access_denied_error(mocker, config): # THEN raise StoreClientError error with pytest.raises(StoreClientError, match="AccessDeniedException") as err: feature_flags.evaluate(name="Foo", default=False) + + +def test_re_match_rule_does_match(mocker, config): + default_value = False + expected_value = True + email_address_val = "somebody+admin@COMPANY.COM" + email_address_re_val = ".*admin.*@company.com$" + mocked_app_config_schema = { + "my_feature": { + "default": default_value, + "rules": { + "email address ends with @company.com and has 'admin' in the mailbox (case insensitive)": { + "when_match": expected_value, + "conditions": [ + { + "action": RuleAction.RE_MATCH_IGNORECASE.value, + "key": "email_address", + "value": email_address_re_val, + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate( + name="my_feature", + context={ + "email_address": email_address_val, + }, + default=True, + ) + assert toggle == expected_value + + +def test_re_search_rule_does_match(mocker, config): + default_value = False + expected_value = True + email_address_val = "somebody+admin@COMPANY.COM" + email_address_re_val = "@company.com$" + mocked_app_config_schema = { + "my_feature": { + "default": default_value, + "rules": { + "email address ends with @company.com (case insensitive)": { + "when_match": expected_value, + "conditions": [ + { + "action": RuleAction.RE_SEARCH_IGNORECASE.value, + "key": "email_address", + "value": email_address_re_val, + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate( + name="my_feature", + context={ + "email_address": email_address_val, + }, + default=True, + ) + assert toggle == expected_value + + +def test_re_fullmatch_rule_does_not_match(mocker, config): + default_value = False + expected_value = True + email_address_val = "somebody+admin@COMPANY.COM" + email_address_re_val = ".*admin.*@competitor.com$" + mocked_app_config_schema = { + "my_feature": { + "default": default_value, + "rules": { + "email address ends with @company.com and has 'admin' in the mailbox (case insensitive)": { + "when_match": expected_value, + "conditions": [ + { + "action": RuleAction.RE_FULLMATCH_IGNORECASE.value, + "key": "email_address", + "value": email_address_re_val, + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate( + name="my_feature", + context={ + "email_address": email_address_val, + }, + default=True, + ) + assert toggle == default_value