Skip to content

feat(feature-flags): improve "IN/NOT_IN"; new rule actions #710

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Oct 1, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ 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.KEY_IN_VALUE.value: lambda a, b: a in b,
schema.RuleAction.KEY_NOT_IN_VALUE.value: lambda a, b: a not in b,
schema.RuleAction.VALUE_IN_KEY.value: lambda a, b: b in a,
schema.RuleAction.VALUE_NOT_IN_KEY.value: lambda a, b: b not in a,
}

try:
Expand Down
8 changes: 7 additions & 1 deletion aws_lambda_powertools/utilities/feature_flags/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ class RuleAction(str, Enum):
ENDSWITH = "ENDSWITH"
IN = "IN"
NOT_IN = "NOT_IN"
KEY_IN_VALUE = "KEY_IN_VALUE"
KEY_NOT_IN_VALUE = "KEY_NOT_IN_VALUE"
VALUE_IN_KEY = "VALUE_IN_KEY"
VALUE_NOT_IN_KEY = "VALUE_NOT_IN_KEY"


class SchemaValidator(BaseValidator):
Expand Down Expand Up @@ -80,7 +84,9 @@ class SchemaValidator(BaseValidator):
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, IN, NOT_IN
The value MUST be either EQUALS, STARTSWITH, ENDSWITH,
KEY_IN_VALUE KEY_NOT_IN_VALUE VALUE_IN_KEY VALUE_NOT_IN_KEY

* **key**: `str`. Key in given context to perform operation
* **value**: `Any`. Value in given context that should match action operation.

Expand Down
25 changes: 22 additions & 3 deletions docs/utilities/feature_flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ You can use `get_enabled_features` method for scenarios where you need a list of
"when_match": true,
"conditions": [
{
"action": "IN",
"action": "KEY_IN_VALUE",
"key": "CloudFront-Viewer-Country",
"value": ["NL", "IE", "UK", "PL", "PT"]
}
Expand Down Expand Up @@ -450,9 +450,20 @@ 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 the following values, where the expressions **`a`** is the `key` and **`b`** is the `value` above:

The `key` and `value` will be compared to the input from the context parameter.
Action | Equivalent expression
------------------------------------------------- | ---------------------------------------------------------------------------------
**EQUALS** | `lambda a, b: a == b`
**STARTSWITH** | `lambda a, b: a.startswith(b)`
**ENDSWITH** | `lambda a, b: a.endswith(b)`
**KEY_IN_VALUE** | `lambda a, b: a in b`
**KEY_NOT_IN_VALUE** | `lambda a, b: a not in b`
**VALUE_IN_KEY** | `lambda a, b: b in a`
**VALUE_NOT_IN_KEY** | `lambda a, b: b not in a`


!!! info "The `**key**` and `**value**` will be compared to the input from the `**context**` parameter."

**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.

Expand Down Expand Up @@ -650,3 +661,11 @@ Method | When to use | Requires new deployment on changes | Supported services
**[Environment variables](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html){target="_blank"}** | Simple configuration that will rarely if ever change, because changing it requires a Lambda function deployment. | Yes | Lambda
**[Parameters utility](parameters.md)** | Access to secrets, or fetch parameters in different formats from AWS System Manager Parameter Store or Amazon DynamoDB. | No | Parameter Store, DynamoDB, Secrets Manager, AppConfig
**Feature flags utility** | Rule engine to define when one or multiple features should be enabled depending on the input. | No | AppConfig


## Deprecation list when GA

Breaking change | Recommendation
------------------------------------------------- | ---------------------------------------------------------------------------------
`IN` RuleAction | Use `KEY_IN_VALUE` instead
`NOT_IN` RuleAction | Use `KEY_NOT_IN_VALUE` instead
203 changes: 203 additions & 0 deletions tests/functional/feature_flags/test_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,8 @@ def test_flags_conditions_rule_match_multiple_actions_multiple_rules_multiple_co


# check a case where the feature exists but the rule doesn't match so we revert to the default value of the feature

# Check IN/NOT_IN/KEY_IN_VALUE/KEY_NOT_IN_VALUE/VALUE_IN_KEY/VALUE_NOT_IN_KEY conditions
def test_flags_match_rule_with_in_action(mocker, config):
expected_value = True
mocked_app_config_schema = {
Expand Down Expand Up @@ -397,6 +399,207 @@ def test_flags_no_match_rule_with_not_in_action(mocker, config):
assert toggle == expected_value


def test_flags_match_rule_with_key_in_value_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.KEY_IN_VALUE.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_key_in_value_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.KEY_IN_VALUE.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_flags_match_rule_with_key_not_in_value_action(mocker, config):
expected_value = True
mocked_app_config_schema = {
"my_feature": {
"default": False,
"rules": {
"tenant id is contained in [8, 2]": {
"when_match": expected_value,
"conditions": [
{
"action": RuleAction.KEY_NOT_IN_VALUE.value,
"key": "tenant_id",
"value": ["10", "4"],
}
],
}
},
}
}
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_key_not_in_value_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.KEY_NOT_IN_VALUE.value,
"key": "tenant_id",
"value": ["6", "4"],
}
],
}
},
}
}
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_match_rule_with_value_in_key_action(mocker, config):
expected_value = True
mocked_app_config_schema = {
"my_feature": {
"default": False,
"rules": {
"user is in the SYSADMIN group": {
"when_match": expected_value,
"conditions": [
{
"action": RuleAction.VALUE_IN_KEY.value,
"key": "groups",
"value": "SYSADMIN",
}
],
}
},
}
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
name="my_feature", context={"tenant_id": "6", "username": "a", "groups": ["SYSADMIN", "IT"]}, default=False
)
assert toggle == expected_value


def test_flags_no_match_rule_with_value_in_key_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.VALUE_IN_KEY.value,
"key": "groups",
"value": "GUEST",
}
],
}
},
}
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
name="my_feature", context={"tenant_id": "6", "username": "a", "groups": ["SYSADMIN", "IT"]}, default=False
)
assert toggle == expected_value


def test_flags_match_rule_with_value_not_in_key_action(mocker, config):
expected_value = True
mocked_app_config_schema = {
"my_feature": {
"default": False,
"rules": {
"user is in the GUEST group": {
"when_match": expected_value,
"conditions": [
{
"action": RuleAction.VALUE_NOT_IN_KEY.value,
"key": "groups",
"value": "GUEST",
}
],
}
},
}
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
name="my_feature", context={"tenant_id": "6", "username": "a", "groups": ["SYSADMIN", "IT"]}, default=False
)
assert toggle == expected_value


def test_flags_no_match_rule_with_value_not_in_key_action(mocker, config):
expected_value = False
mocked_app_config_schema = {
"my_feature": {
"default": expected_value,
"rules": {
"user is in the SYSADMIN group": {
"when_match": True,
"conditions": [
{
"action": RuleAction.VALUE_NOT_IN_KEY.value,
"key": "groups",
"value": "SYSADMIN",
}
],
}
},
}
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
name="my_feature", context={"tenant_id": "6", "username": "a", "groups": ["SYSADMIN", "IT"]}, default=False
)
assert toggle == expected_value


# Check multiple features
def test_multiple_features_enabled(mocker, config):
expected_value = ["my_feature", "my_feature2"]
mocked_app_config_schema = {
Expand Down
20 changes: 20 additions & 0 deletions tests/functional/feature_flags/test_schema_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,26 @@ def test_valid_condition_all_actions():
CONDITION_KEY: "username",
CONDITION_VALUE: ["c"],
},
{
CONDITION_ACTION: RuleAction.KEY_IN_VALUE.value,
CONDITION_KEY: "username",
CONDITION_VALUE: ["a", "b"],
},
{
CONDITION_ACTION: RuleAction.KEY_NOT_IN_VALUE.value,
CONDITION_KEY: "username",
CONDITION_VALUE: ["c"],
},
{
CONDITION_ACTION: RuleAction.VALUE_IN_KEY.value,
CONDITION_KEY: "groups",
CONDITION_VALUE: "SYSADMIN",
},
{
CONDITION_ACTION: RuleAction.VALUE_NOT_IN_KEY.value,
CONDITION_KEY: "groups",
CONDITION_VALUE: "GUEST",
},
],
}
},
Expand Down