Skip to content

Commit be15e3c

Browse files
ran-isenbergRan Isenbergheitorlessa
authored
feat(feature_flags): support beyond boolean values (JSON values) (#804)
Co-authored-by: Ran Isenberg <[email protected]> Co-authored-by: heitorlessa <[email protected]>
1 parent f985c40 commit be15e3c

File tree

8 files changed

+455
-144
lines changed

8 files changed

+455
-144
lines changed

Diff for: aws_lambda_powertools/shared/types.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
from typing import Any, Callable, TypeVar
1+
from typing import Any, Callable, Dict, List, TypeVar, Union
22

33
AnyCallableT = TypeVar("AnyCallableT", bound=Callable[..., Any]) # noqa: VNE001
4+
# JSON primitives only, mypy doesn't support recursive tho
5+
JSONType = Union[str, int, float, bool, None, Dict[str, Any], List[Any]]

Diff for: aws_lambda_powertools/utilities/feature_flags/feature_flags.py

+47-13
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import Any, Dict, List, Optional, Union, cast
33

44
from ... import Logger
5+
from ...shared.types import JSONType
56
from . import schema
67
from .base import StoreProvider
78
from .exceptions import ConfigurationStoreError
@@ -97,21 +98,30 @@ def _evaluate_conditions(
9798
return True
9899

99100
def _evaluate_rules(
100-
self, *, feature_name: str, context: Dict[str, Any], feat_default: bool, rules: Dict[str, Any]
101+
self,
102+
*,
103+
feature_name: str,
104+
context: Dict[str, Any],
105+
feat_default: Any,
106+
rules: Dict[str, Any],
107+
boolean_feature: bool,
101108
) -> bool:
102109
"""Evaluates whether context matches rules and conditions, otherwise return feature default"""
103110
for rule_name, rule in rules.items():
104111
rule_match_value = rule.get(schema.RULE_MATCH_VALUE)
105112

106113
# Context might contain PII data; do not log its value
107114
self.logger.debug(
108-
f"Evaluating rule matching, rule={rule_name}, feature={feature_name}, default={feat_default}"
115+
f"Evaluating rule matching, rule={rule_name}, feature={feature_name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # noqa: E501
109116
)
110117
if self._evaluate_conditions(rule_name=rule_name, feature_name=feature_name, rule=rule, context=context):
111-
return bool(rule_match_value)
118+
# Maintenance: Revisit before going GA.
119+
return bool(rule_match_value) if boolean_feature else rule_match_value
112120

113121
# no rule matched, return default value of feature
114-
self.logger.debug(f"no rule matched, returning feature default, default={feat_default}, name={feature_name}")
122+
self.logger.debug(
123+
f"no rule matched, returning feature default, default={str(feat_default)}, name={feature_name}, boolean_feature={boolean_feature}" # noqa: E501
124+
)
115125
return feat_default
116126

117127
def get_configuration(self) -> Dict:
@@ -164,7 +174,7 @@ def get_configuration(self) -> Dict:
164174

165175
return config
166176

167-
def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, default: bool) -> bool:
177+
def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, default: JSONType) -> JSONType:
168178
"""Evaluate whether a feature flag should be enabled according to stored schema and input context
169179
170180
**Logic when evaluating a feature flag**
@@ -181,14 +191,15 @@ def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, defau
181191
Attributes that should be evaluated against the stored schema.
182192
183193
for example: `{"tenant_id": "X", "username": "Y", "region": "Z"}`
184-
default: bool
194+
default: JSONType
185195
default value if feature flag doesn't exist in the schema,
186196
or there has been an error when fetching the configuration from the store
197+
Can be boolean or any JSON values for non-boolean features.
187198
188199
Returns
189200
------
190-
bool
191-
whether feature should be enabled or not
201+
JSONType
202+
whether feature should be enabled (bool flags) or JSON value when non-bool feature matches
192203
193204
Raises
194205
------
@@ -211,12 +222,27 @@ def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, defau
211222

212223
rules = feature.get(schema.RULES_KEY)
213224
feat_default = feature.get(schema.FEATURE_DEFAULT_VAL_KEY)
225+
# Maintenance: Revisit before going GA. We might to simplify customers on-boarding by not requiring it
226+
# for non-boolean flags. It'll need minor implementation changes, docs changes, and maybe refactor
227+
# get_enabled_features. We can minimize breaking change, despite Beta label, by having a new
228+
# method `get_matching_features` returning Dict[feature_name, feature_value]
229+
boolean_feature = feature.get(
230+
schema.FEATURE_DEFAULT_VAL_TYPE_KEY, True
231+
) # backwards compatability ,assume feature flag
214232
if not rules:
215-
self.logger.debug(f"no rules found, returning feature default, name={name}, default={feat_default}")
216-
return bool(feat_default)
233+
self.logger.debug(
234+
f"no rules found, returning feature default, name={name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # noqa: E501
235+
)
236+
# Maintenance: Revisit before going GA. We might to simplify customers on-boarding by not requiring it
237+
# for non-boolean flags.
238+
return bool(feat_default) if boolean_feature else feat_default
217239

218-
self.logger.debug(f"looking for rule match, name={name}, default={feat_default}")
219-
return self._evaluate_rules(feature_name=name, context=context, feat_default=bool(feat_default), rules=rules)
240+
self.logger.debug(
241+
f"looking for rule match, name={name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # noqa: E501
242+
)
243+
return self._evaluate_rules(
244+
feature_name=name, context=context, feat_default=feat_default, rules=rules, boolean_feature=boolean_feature
245+
)
220246

221247
def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> List[str]:
222248
"""Get all enabled feature flags while also taking into account context
@@ -259,11 +285,19 @@ def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> L
259285
for name, feature in features.items():
260286
rules = feature.get(schema.RULES_KEY, {})
261287
feature_default_value = feature.get(schema.FEATURE_DEFAULT_VAL_KEY)
288+
boolean_feature = feature.get(
289+
schema.FEATURE_DEFAULT_VAL_TYPE_KEY, True
290+
) # backwards compatability ,assume feature flag
291+
262292
if feature_default_value and not rules:
263293
self.logger.debug(f"feature is enabled by default and has no defined rules, name={name}")
264294
features_enabled.append(name)
265295
elif self._evaluate_rules(
266-
feature_name=name, context=context, feat_default=feature_default_value, rules=rules
296+
feature_name=name,
297+
context=context,
298+
feat_default=feature_default_value,
299+
rules=rules,
300+
boolean_feature=boolean_feature,
267301
):
268302
self.logger.debug(f"feature's calculated value is True, name={name}")
269303
features_enabled.append(name)

Diff for: aws_lambda_powertools/utilities/feature_flags/schema.py

+52-21
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
CONDITION_KEY = "key"
1414
CONDITION_VALUE = "value"
1515
CONDITION_ACTION = "action"
16+
FEATURE_DEFAULT_VAL_TYPE_KEY = "boolean_type"
1617

1718

1819
class RuleAction(str, Enum):
@@ -48,13 +49,21 @@ class SchemaValidator(BaseValidator):
4849
A dictionary containing default value and rules for matching.
4950
The value MUST be an object and MIGHT contain the following members:
5051
51-
* **default**: `bool`. Defines default feature value. This MUST be present
52+
* **default**: `Union[bool, JSONType]`. Defines default feature value. This MUST be present
53+
* **boolean_type**: bool. Defines whether feature has non-boolean value (`JSONType`). This MIGHT be present
5254
* **rules**: `Dict[str, Dict]`. Rules object. This MIGHT be present
5355
54-
```python
56+
`JSONType` being any JSON primitive value: `Union[str, int, float, bool, None, Dict[str, Any], List[Any]]`
57+
58+
```json
5559
{
5660
"my_feature": {
57-
"default": True,
61+
"default": true,
62+
"rules": {}
63+
},
64+
"my_non_boolean_feature": {
65+
"default": {"group": "read-only"},
66+
"boolean_type": false,
5867
"rules": {}
5968
}
6069
}
@@ -65,16 +74,26 @@ class SchemaValidator(BaseValidator):
6574
A dictionary with each rule and their conditions that a feature might have.
6675
The value MIGHT be present, and when defined it MUST contain the following members:
6776
68-
* **when_match**: `bool`. Defines value to return when context matches conditions
77+
* **when_match**: `Union[bool, JSONType]`. Defines value to return when context matches conditions
6978
* **conditions**: `List[Dict]`. Conditions object. This MUST be present
7079
71-
```python
80+
```json
7281
{
7382
"my_feature": {
74-
"default": True,
83+
"default": true,
84+
"rules": {
85+
"tenant id equals 345345435": {
86+
"when_match": false,
87+
"conditions": []
88+
}
89+
}
90+
},
91+
"my_non_boolean_feature": {
92+
"default": {"group": "read-only"},
93+
"boolean_type": false,
7594
"rules": {
7695
"tenant id equals 345345435": {
77-
"when_match": False,
96+
"when_match": {"group": "admin"},
7897
"conditions": []
7998
}
8099
}
@@ -94,13 +113,13 @@ class SchemaValidator(BaseValidator):
94113
* **key**: `str`. Key in given context to perform operation
95114
* **value**: `Any`. Value in given context that should match action operation.
96115
97-
```python
116+
```json
98117
{
99118
"my_feature": {
100-
"default": True,
119+
"default": true,
101120
"rules": {
102121
"tenant id equals 345345435": {
103-
"when_match": False,
122+
"when_match": false,
104123
"conditions": [
105124
{
106125
"action": "EQUALS",
@@ -138,28 +157,38 @@ def __init__(self, schema: Dict, logger: Optional[Union[logging.Logger, Logger]]
138157
def validate(self):
139158
for name, feature in self.schema.items():
140159
self.logger.debug(f"Attempting to validate feature '{name}'")
141-
self.validate_feature(name, feature)
142-
rules = RulesValidator(feature=feature)
160+
boolean_feature: bool = self.validate_feature(name, feature)
161+
rules = RulesValidator(feature=feature, boolean_feature=boolean_feature)
143162
rules.validate()
144163

164+
# returns True in case the feature is a regular feature flag with a boolean default value
145165
@staticmethod
146-
def validate_feature(name, feature):
166+
def validate_feature(name, feature) -> bool:
147167
if not feature or not isinstance(feature, dict):
148168
raise SchemaValidationError(f"Feature must be a non-empty dictionary, feature={name}")
149169

150-
default_value = feature.get(FEATURE_DEFAULT_VAL_KEY)
151-
if default_value is None or not isinstance(default_value, bool):
170+
default_value: Any = feature.get(FEATURE_DEFAULT_VAL_KEY)
171+
boolean_feature: bool = feature.get(FEATURE_DEFAULT_VAL_TYPE_KEY, True)
172+
# if feature is boolean_feature, default_value must be a boolean type.
173+
# default_value must exist
174+
# Maintenance: Revisit before going GA. We might to simplify customers on-boarding by not requiring it
175+
# for non-boolean flags.
176+
if default_value is None or (not isinstance(default_value, bool) and boolean_feature):
152177
raise SchemaValidationError(f"feature 'default' boolean key must be present, feature={name}")
178+
return boolean_feature
153179

154180

155181
class RulesValidator(BaseValidator):
156182
"""Validates each rule and calls ConditionsValidator to validate each rule's conditions"""
157183

158-
def __init__(self, feature: Dict[str, Any], logger: Optional[Union[logging.Logger, Logger]] = None):
184+
def __init__(
185+
self, feature: Dict[str, Any], boolean_feature: bool, logger: Optional[Union[logging.Logger, Logger]] = None
186+
):
159187
self.feature = feature
160188
self.feature_name = next(iter(self.feature))
161189
self.rules: Optional[Dict] = self.feature.get(RULES_KEY)
162190
self.logger = logger or logging.getLogger(__name__)
191+
self.boolean_feature = boolean_feature
163192

164193
def validate(self):
165194
if not self.rules:
@@ -171,27 +200,29 @@ def validate(self):
171200

172201
for rule_name, rule in self.rules.items():
173202
self.logger.debug(f"Attempting to validate rule '{rule_name}'")
174-
self.validate_rule(rule=rule, rule_name=rule_name, feature_name=self.feature_name)
203+
self.validate_rule(
204+
rule=rule, rule_name=rule_name, feature_name=self.feature_name, boolean_feature=self.boolean_feature
205+
)
175206
conditions = ConditionsValidator(rule=rule, rule_name=rule_name)
176207
conditions.validate()
177208

178209
@staticmethod
179-
def validate_rule(rule, rule_name, feature_name):
210+
def validate_rule(rule: Dict, rule_name: str, feature_name: str, boolean_feature: bool = True):
180211
if not rule or not isinstance(rule, dict):
181212
raise SchemaValidationError(f"Feature rule must be a dictionary, feature={feature_name}")
182213

183214
RulesValidator.validate_rule_name(rule_name=rule_name, feature_name=feature_name)
184-
RulesValidator.validate_rule_default_value(rule=rule, rule_name=rule_name)
215+
RulesValidator.validate_rule_default_value(rule=rule, rule_name=rule_name, boolean_feature=boolean_feature)
185216

186217
@staticmethod
187218
def validate_rule_name(rule_name: str, feature_name: str):
188219
if not rule_name or not isinstance(rule_name, str):
189220
raise SchemaValidationError(f"Rule name key must have a non-empty string, feature={feature_name}")
190221

191222
@staticmethod
192-
def validate_rule_default_value(rule: Dict, rule_name: str):
223+
def validate_rule_default_value(rule: Dict, rule_name: str, boolean_feature: bool):
193224
rule_default_value = rule.get(RULE_MATCH_VALUE)
194-
if not isinstance(rule_default_value, bool):
225+
if boolean_feature and not isinstance(rule_default_value, bool):
195226
raise SchemaValidationError(f"'rule_default_value' key must have be bool, rule={rule_name}")
196227

197228

Diff for: docs/media/feat_flags_evaluation_workflow.png

-69 KB
Binary file not shown.

Diff for: docs/media/feature_flags_diagram.png

30.1 KB
Loading

0 commit comments

Comments
 (0)