Skip to content

Commit c0c32bc

Browse files
authored
feat(feat-toggle): New simple feature toggles rule engine (WIP) (#494)
Main features: * Define global boolean feature toggles * Define boolean feature toggles per customer, email or any other key/value. Keys are always strings, values can be other valid json types. * Use get_configuration API to get the entire configuration dict. Basically, it's an easy way to get a JSON file, the same way the AppConfig utility did. * get_all_enabled_feature_toggles - get a list of strings - names of boolean feature toggles that are True according to the input context, i.e. all the rules that matched/True by default. * Current recommended default is to use AppConfig as the feature store but allows for extension with other services via the Schema Fetcher. Before releasing to prod we should fix: * Missing docstrings with examples on how to use it in public Classes and public methods * Document and explain the rules mechanism and rule match flow. * Review whether we have sufficient logger.debug coverage for future diagnostic * Docs: Extract key features for getting started vs advanced * Use mypy doc strings
1 parent c52a987 commit c0c32bc

File tree

10 files changed

+1056
-1
lines changed

10 files changed

+1056
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Advanced feature toggles utility
2+
"""
3+
from .appconfig_fetcher import AppConfigFetcher
4+
from .configuration_store import ConfigurationStore
5+
from .exceptions import ConfigurationException
6+
from .schema import ACTION, SchemaValidator
7+
from .schema_fetcher import SchemaFetcher
8+
9+
__all__ = [
10+
"ConfigurationException",
11+
"ConfigurationStore",
12+
"ACTION",
13+
"SchemaValidator",
14+
"AppConfigFetcher",
15+
"SchemaFetcher",
16+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import logging
2+
from typing import Any, Dict, Optional
3+
4+
from botocore.config import Config
5+
6+
from aws_lambda_powertools.utilities.parameters import AppConfigProvider, GetParameterError, TransformParameterError
7+
8+
from .exceptions import ConfigurationException
9+
from .schema_fetcher import SchemaFetcher
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
TRANSFORM_TYPE = "json"
15+
16+
17+
class AppConfigFetcher(SchemaFetcher):
18+
def __init__(
19+
self,
20+
environment: str,
21+
service: str,
22+
configuration_name: str,
23+
cache_seconds: int,
24+
config: Optional[Config] = None,
25+
):
26+
"""This class fetches JSON schemas from AWS AppConfig
27+
28+
Args:
29+
environment (str): what appconfig environment to use 'dev/test' etc.
30+
service (str): what service name to use from the supplied environment
31+
configuration_name (str): what configuration to take from the environment & service combination
32+
cache_seconds (int): cache expiration time, how often to call AppConfig to fetch latest configuration
33+
config (Optional[Config]): boto3 client configuration
34+
"""
35+
super().__init__(configuration_name, cache_seconds)
36+
self._logger = logger
37+
self._conf_store = AppConfigProvider(environment=environment, application=service, config=config)
38+
39+
def get_json_configuration(self) -> Dict[str, Any]:
40+
"""Get configuration string from AWs AppConfig and return the parsed JSON dictionary
41+
42+
Raises:
43+
ConfigurationException: Any validation error or appconfig error that can occur
44+
45+
Returns:
46+
Dict[str, Any]: parsed JSON dictionary
47+
"""
48+
try:
49+
return self._conf_store.get(
50+
name=self.configuration_name,
51+
transform=TRANSFORM_TYPE,
52+
max_age=self._cache_seconds,
53+
) # parse result conf as JSON, keep in cache for self.max_age seconds
54+
except (GetParameterError, TransformParameterError) as exc:
55+
error_str = f"unable to get AWS AppConfig configuration file, exception={str(exc)}"
56+
self._logger.error(error_str)
57+
raise ConfigurationException(error_str)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import logging
2+
from typing import Any, Dict, List, Optional
3+
4+
from . import schema
5+
from .exceptions import ConfigurationException
6+
from .schema_fetcher import SchemaFetcher
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
class ConfigurationStore:
12+
def __init__(self, schema_fetcher: SchemaFetcher):
13+
"""constructor
14+
15+
Args:
16+
schema_fetcher (SchemaFetcher): A schema JSON fetcher, can be AWS AppConfig, Hashicorp Consul etc.
17+
"""
18+
self._logger = logger
19+
self._schema_fetcher = schema_fetcher
20+
self._schema_validator = schema.SchemaValidator(self._logger)
21+
22+
def _match_by_action(self, action: str, condition_value: Any, context_value: Any) -> bool:
23+
if not context_value:
24+
return False
25+
mapping_by_action = {
26+
schema.ACTION.EQUALS.value: lambda a, b: a == b,
27+
schema.ACTION.STARTSWITH.value: lambda a, b: a.startswith(b),
28+
schema.ACTION.ENDSWITH.value: lambda a, b: a.endswith(b),
29+
schema.ACTION.CONTAINS.value: lambda a, b: a in b,
30+
}
31+
32+
try:
33+
func = mapping_by_action.get(action, lambda a, b: False)
34+
return func(context_value, condition_value)
35+
except Exception as exc:
36+
self._logger.error(f"caught exception while matching action, action={action}, exception={str(exc)}")
37+
return False
38+
39+
def _is_rule_matched(self, feature_name: str, rule: Dict[str, Any], rules_context: Dict[str, Any]) -> bool:
40+
rule_name = rule.get(schema.RULE_NAME_KEY, "")
41+
rule_default_value = rule.get(schema.RULE_DEFAULT_VALUE)
42+
conditions: Dict[str, str] = rule.get(schema.CONDITIONS_KEY)
43+
44+
for condition in conditions:
45+
context_value = rules_context.get(condition.get(schema.CONDITION_KEY))
46+
if not self._match_by_action(
47+
condition.get(schema.CONDITION_ACTION),
48+
condition.get(schema.CONDITION_VALUE),
49+
context_value,
50+
):
51+
logger.debug(
52+
f"rule did not match action, rule_name={rule_name}, rule_default_value={rule_default_value}, feature_name={feature_name}, context_value={str(context_value)}" # noqa: E501
53+
)
54+
# context doesn't match condition
55+
return False
56+
# if we got here, all conditions match
57+
logger.debug(
58+
f"rule matched, rule_name={rule_name}, rule_default_value={rule_default_value}, feature_name={feature_name}" # noqa: E501
59+
)
60+
return True
61+
62+
def _handle_rules(
63+
self,
64+
*,
65+
feature_name: str,
66+
rules_context: Dict[str, Any],
67+
feature_default_value: bool,
68+
rules: List[Dict[str, Any]],
69+
) -> bool:
70+
for rule in rules:
71+
rule_default_value = rule.get(schema.RULE_DEFAULT_VALUE)
72+
if self._is_rule_matched(feature_name, rule, rules_context):
73+
return rule_default_value
74+
# no rule matched, return default value of feature
75+
logger.debug(
76+
f"no rule matched, returning default value of feature, feature_default_value={feature_default_value}, feature_name={feature_name}" # noqa: E501
77+
)
78+
return feature_default_value
79+
80+
def get_configuration(self) -> Dict[str, Any]:
81+
"""Get configuration string from AWs AppConfig and returned the parsed JSON dictionary
82+
83+
Raises:
84+
ConfigurationException: Any validation error or appconfig error that can occur
85+
86+
Returns:
87+
Dict[str, Any]: parsed JSON dictionary
88+
"""
89+
schema: Dict[
90+
str, Any
91+
] = (
92+
self._schema_fetcher.get_json_configuration()
93+
) # parse result conf as JSON, keep in cache for self.max_age seconds
94+
# validate schema
95+
self._schema_validator.validate_json_schema(schema)
96+
return schema
97+
98+
def get_feature_toggle(
99+
self, *, feature_name: str, rules_context: Optional[Dict[str, Any]] = None, value_if_missing: bool
100+
) -> bool:
101+
"""get a feature toggle boolean value. Value is calculated according to a set of rules and conditions.
102+
see below for explanation.
103+
104+
Args:
105+
feature_name (str): feature name that you wish to fetch
106+
rules_context (Optional[Dict[str, Any]]): dict of attributes that you would like to match the rules
107+
against, can be {'tenant_id: 'X', 'username':' 'Y', 'region': 'Z'} etc.
108+
value_if_missing (bool): this will be the returned value in case the feature toggle doesn't exist in
109+
the schema or there has been an error while fetching the
110+
configuration from appconfig
111+
112+
Returns:
113+
bool: calculated feature toggle value. several possibilities:
114+
1. if the feature doesn't appear in the schema or there has been an error fetching the
115+
configuration -> error/warning log would appear and value_if_missing is returned
116+
2. feature exists and has no rules or no rules have matched -> return feature_default_value of
117+
the defined feature
118+
3. feature exists and a rule matches -> rule_default_value of rule is returned
119+
"""
120+
if rules_context is None:
121+
rules_context = {}
122+
123+
try:
124+
toggles_dict: Dict[str, Any] = self.get_configuration()
125+
except ConfigurationException:
126+
logger.error("unable to get feature toggles JSON, returning provided value_if_missing value") # noqa: E501
127+
return value_if_missing
128+
129+
feature: Dict[str, Dict] = toggles_dict.get(schema.FEATURES_KEY, {}).get(feature_name, None)
130+
if feature is None:
131+
logger.warning(
132+
f"feature does not appear in configuration, using provided value_if_missing, feature_name={feature_name}, value_if_missing={value_if_missing}" # noqa: E501
133+
)
134+
return value_if_missing
135+
136+
rules_list = feature.get(schema.RULES_KEY)
137+
feature_default_value = feature.get(schema.FEATURE_DEFAULT_VAL_KEY)
138+
if not rules_list:
139+
# not rules but has a value
140+
logger.debug(
141+
f"no rules found, returning feature default value, feature_name={feature_name}, default_value={feature_default_value}" # noqa: E501
142+
)
143+
return feature_default_value
144+
# look for first rule match
145+
logger.debug(
146+
f"looking for rule match, feature_name={feature_name}, feature_default_value={feature_default_value}"
147+
) # noqa: E501
148+
return self._handle_rules(
149+
feature_name=feature_name,
150+
rules_context=rules_context,
151+
feature_default_value=feature_default_value,
152+
rules=rules_list,
153+
)
154+
155+
def get_all_enabled_feature_toggles(self, *, rules_context: Optional[Dict[str, Any]] = None) -> List[str]:
156+
"""Get all enabled feature toggles while also taking into account rule_context (when a feature has defined rules)
157+
158+
Args:
159+
rules_context (Optional[Dict[str, Any]]): dict of attributes that you would like to match the rules
160+
against, can be {'tenant_id: 'X', 'username':' 'Y', 'region': 'Z'} etc.
161+
162+
Returns:
163+
List[str]: a list of all features name that are enabled by also taking into account
164+
rule_context (when a feature has defined rules)
165+
"""
166+
if rules_context is None:
167+
rules_context = {}
168+
try:
169+
toggles_dict: Dict[str, Any] = self.get_configuration()
170+
except ConfigurationException:
171+
logger.error("unable to get feature toggles JSON") # noqa: E501
172+
return []
173+
ret_list = []
174+
features: Dict[str, Any] = toggles_dict.get(schema.FEATURES_KEY, {})
175+
for feature_name, feature_dict_def in features.items():
176+
rules_list = feature_dict_def.get(schema.RULES_KEY, [])
177+
feature_default_value = feature_dict_def.get(schema.FEATURE_DEFAULT_VAL_KEY)
178+
if feature_default_value and not rules_list:
179+
self._logger.debug(
180+
f"feature is enabled by default and has no defined rules, feature_name={feature_name}"
181+
)
182+
ret_list.append(feature_name)
183+
elif self._handle_rules(
184+
feature_name=feature_name,
185+
rules_context=rules_context,
186+
feature_default_value=feature_default_value,
187+
rules=rules_list,
188+
):
189+
self._logger.debug(f"feature's calculated value is True, feature_name={feature_name}")
190+
ret_list.append(feature_name)
191+
return ret_list
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class ConfigurationException(Exception):
2+
"""When a a configuration store raises an exception on config retrieval or parsing"""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from enum import Enum
2+
from typing import Any, Dict
3+
4+
from .exceptions import ConfigurationException
5+
6+
FEATURES_KEY = "features"
7+
RULES_KEY = "rules"
8+
FEATURE_DEFAULT_VAL_KEY = "feature_default_value"
9+
CONDITIONS_KEY = "conditions"
10+
RULE_NAME_KEY = "rule_name"
11+
RULE_DEFAULT_VALUE = "value_when_applies"
12+
CONDITION_KEY = "key"
13+
CONDITION_VALUE = "value"
14+
CONDITION_ACTION = "action"
15+
16+
17+
class ACTION(str, Enum):
18+
EQUALS = "EQUALS"
19+
STARTSWITH = "STARTSWITH"
20+
ENDSWITH = "ENDSWITH"
21+
CONTAINS = "CONTAINS"
22+
23+
24+
class SchemaValidator:
25+
def __init__(self, logger: object):
26+
self._logger = logger
27+
28+
def _raise_conf_exc(self, error_str: str) -> None:
29+
self._logger.error(error_str)
30+
raise ConfigurationException(error_str)
31+
32+
def _validate_condition(self, rule_name: str, condition: Dict[str, str]) -> None:
33+
if not condition or not isinstance(condition, dict):
34+
self._raise_conf_exc(f"invalid condition type, not a dictionary, rule_name={rule_name}")
35+
action = condition.get(CONDITION_ACTION, "")
36+
if action not in [ACTION.EQUALS.value, ACTION.STARTSWITH.value, ACTION.ENDSWITH.value, ACTION.CONTAINS.value]:
37+
self._raise_conf_exc(f"invalid action value, rule_name={rule_name}, action={action}")
38+
key = condition.get(CONDITION_KEY, "")
39+
if not key or not isinstance(key, str):
40+
self._raise_conf_exc(f"invalid key value, key has to be a non empty string, rule_name={rule_name}")
41+
value = condition.get(CONDITION_VALUE, "")
42+
if not value:
43+
self._raise_conf_exc(f"missing condition value, rule_name={rule_name}")
44+
45+
def _validate_rule(self, feature_name: str, rule: Dict[str, Any]) -> None:
46+
if not rule or not isinstance(rule, dict):
47+
self._raise_conf_exc(f"feature rule is not a dictionary, feature_name={feature_name}")
48+
rule_name = rule.get(RULE_NAME_KEY)
49+
if not rule_name or rule_name is None or not isinstance(rule_name, str):
50+
self._raise_conf_exc(f"invalid rule_name, feature_name={feature_name}")
51+
rule_default_value = rule.get(RULE_DEFAULT_VALUE)
52+
if rule_default_value is None or not isinstance(rule_default_value, bool):
53+
self._raise_conf_exc(f"invalid rule_default_value, rule_name={rule_name}")
54+
conditions = rule.get(CONDITIONS_KEY, {})
55+
if not conditions or not isinstance(conditions, list):
56+
self._raise_conf_exc(f"invalid condition, rule_name={rule_name}")
57+
# validate conditions
58+
for condition in conditions:
59+
self._validate_condition(rule_name, condition)
60+
61+
def _validate_feature(self, feature_name: str, feature_dict_def: Dict[str, Any]) -> None:
62+
if not feature_dict_def or not isinstance(feature_dict_def, dict):
63+
self._raise_conf_exc(f"invalid AWS AppConfig JSON schema detected, feature {feature_name} is invalid")
64+
feature_default_value = feature_dict_def.get(FEATURE_DEFAULT_VAL_KEY)
65+
if feature_default_value is None or not isinstance(feature_default_value, bool):
66+
self._raise_conf_exc(f"missing feature_default_value for feature, feature_name={feature_name}")
67+
# validate rules
68+
rules = feature_dict_def.get(RULES_KEY, [])
69+
if not rules:
70+
return
71+
if not isinstance(rules, list):
72+
self._raise_conf_exc(f"feature rules is not a list, feature_name={feature_name}")
73+
for rule in rules:
74+
self._validate_rule(feature_name, rule)
75+
76+
def validate_json_schema(self, schema: Dict[str, Any]) -> None:
77+
if not isinstance(schema, dict):
78+
self._raise_conf_exc("invalid AWS AppConfig JSON schema detected, root schema is not a dictionary")
79+
features_dict: Dict = schema.get(FEATURES_KEY)
80+
if not isinstance(features_dict, dict):
81+
self._raise_conf_exc("invalid AWS AppConfig JSON schema detected, missing features dictionary")
82+
for feature_name, feature_dict_def in features_dict.items():
83+
self._validate_feature(feature_name, feature_dict_def)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from abc import ABC, abstractclassmethod
2+
from typing import Any, Dict
3+
4+
5+
class SchemaFetcher(ABC):
6+
def __init__(self, configuration_name: str, cache_seconds: int):
7+
self.configuration_name = configuration_name
8+
self._cache_seconds = cache_seconds
9+
10+
@abstractclassmethod
11+
def get_json_configuration(self) -> Dict[str, Any]:
12+
"""Get configuration string from any configuration storing service and return the parsed JSON dictionary
13+
14+
Raises:
15+
ConfigurationException: Any error that can occur during schema fetch or JSON parse
16+
17+
Returns:
18+
Dict[str, Any]: parsed JSON dictionary
19+
"""
20+
return None

aws_lambda_powertools/utilities/parameters/appconfig.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ def get_app_config(
149149
>>> print(value)
150150
My configuration value
151151
152-
**Retrieves a confiugration value and decodes it using a JSON decoder**
152+
**Retrieves a configuration value and decodes it using a JSON decoder**
153153
154154
>>> from aws_lambda_powertools.utilities.parameters import get_parameter
155155
>>>

tests/functional/feature_toggles/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)