Skip to content
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

refactor(feature-flags): optimize UX and maintenance #563

Merged
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
9890170
docs(feature_flags): initial skeleton
heitorlessa Jul 26, 2021
85f4b9d
Merge branch 'develop' into docs/dynamic-feature-toggles
heitorlessa Jul 30, 2021
0b2abc7
docs(feature_flags): initial sections
heitorlessa Jul 30, 2021
566a2f9
docs: add terminology section
heitorlessa Jul 30, 2021
e9f87dc
docs: add IAM permission
heitorlessa Jul 30, 2021
619c547
refactor: rules_context to context
heitorlessa Jul 30, 2021
b274eed
refactor: AppConfig params to match Parameters
heitorlessa Jul 30, 2021
4d397a7
refactor: rename ConfigurationStore, SchemaFetcher
heitorlessa Jul 30, 2021
a70bcd8
refactor: rename schema_fetcher param
heitorlessa Jul 30, 2021
649d987
refactor: rename get_feature_toggle to evaluate
heitorlessa Jul 30, 2021
38c7bd1
chore: improve all_enabled feature tests
heitorlessa Jul 30, 2021
d902018
refactor: get_all_enabled_feature_toggles to get_enabled_features
heitorlessa Jul 30, 2021
ce16548
refactor: remove redundant logger, add newlines
heitorlessa Jul 30, 2021
2f5ccd3
chore: make method static
heitorlessa Jul 30, 2021
0d44e4d
refactor(tests): simplify with pytest raise match str
heitorlessa Aug 1, 2021
6100d38
refactor: rename feature_name to name
heitorlessa Aug 1, 2021
7370464
refactor: remove redundant logger; rename method to validate
heitorlessa Aug 2, 2021
d50650c
refactor: rename to feature_flags
heitorlessa Aug 2, 2021
0c0a4f7
refactor(schema-validator): accept schema in constructor
heitorlessa Aug 2, 2021
0e15b4b
refactor(schema-validator): private fn signature name
heitorlessa Aug 2, 2021
7eb8a67
revert: re-add root schema validation under validate
heitorlessa Aug 2, 2021
9e757fa
fix: regression on empty schemas
heitorlessa Aug 2, 2021
3a71fdf
fix: regression on empty schemas
heitorlessa Aug 2, 2021
bf4bdd6
refactor(schema-validator): initial change to classes
heitorlessa Aug 2, 2021
33f30d2
Merge branch 'docs/dynamic-feature-toggles' of https://github.com/hei…
heitorlessa Aug 2, 2021
7fa8ec2
refactor(schema-validator): rename each Validator
heitorlessa Aug 2, 2021
3b50664
refactor(schema-validator): rename Action to RuleAction
heitorlessa Aug 2, 2021
d22226f
refactor(tests): use public methods
heitorlessa Aug 2, 2021
cd561a1
michaelbrewer Aug 2, 2021
dd2c1f8
test(feature-flags): review issues with pr
michaelbrewer Aug 2, 2021
6ed29ea
Merge pull request #36 from gyft/docs/dynamic-feature-toggles-patch
heitorlessa Aug 3, 2021
b500acb
refactor(tests): use public static methods
heitorlessa Aug 3, 2021
327a4ae
refactor(schema): rename feature_default_value to default
heitorlessa Aug 3, 2021
4bfe2a8
refactor(schema): rename value_when_applies to when_match
heitorlessa Aug 3, 2021
4aba507
refactor(schema): use new rules dict over list
heitorlessa Aug 3, 2021
387da38
refactor(tests): conf_store to feature flags
heitorlessa Aug 3, 2021
a872814
refactor: remove RULE_KEY, exception & rule test consistency
heitorlessa Aug 3, 2021
1f62696
refactor(schema): remove 'features' key
heitorlessa Aug 3, 2021
f63a5da
feat: support JMESPath envelope/options to extract features
heitorlessa Aug 3, 2021
9c714ab
refactor(store): use get_configuration over get_json_configuration
heitorlessa Aug 3, 2021
f89d572
refactor: add docstrings and logging to FeatureFlags, Store
heitorlessa Aug 3, 2021
b61cafa
docs: remove changes; add in PR description
heitorlessa Aug 3, 2021
e9bb190
refactor(schema): add docstrings and schema specification
heitorlessa Aug 3, 2021
a55131f
refactor: add SchemaValidationError
heitorlessa Aug 3, 2021
e0ab7a1
refactor: rename ConfigurationError to ConfigurationStoreError, impro…
heitorlessa Aug 3, 2021
d8125c7
test: conditional dict values
heitorlessa Aug 4, 2021
1b202be
refactor: rename toggles to flags
heitorlessa Aug 4, 2021
a0ae9da
fix: propagate access denied errors
heitorlessa Aug 4, 2021
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
12 changes: 6 additions & 6 deletions aws_lambda_powertools/utilities/feature_toggles/__init__.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
"""Advanced feature toggles utility
"""
from .appconfig_fetcher import AppConfigFetcher
from .configuration_store import ConfigurationStore
from .appconfig import AppConfigStore
from .base import StoreProvider
from .exceptions import ConfigurationError
from .feature_flags import FeatureFlags
from .schema import ACTION, SchemaValidator
from .schema_fetcher import SchemaFetcher

__all__ = [
"ConfigurationError",
"ConfigurationStore",
"FeatureFlags",
"ACTION",
"SchemaValidator",
"AppConfigFetcher",
"SchemaFetcher",
"AppConfigStore",
"StoreProvider",
]
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,20 @@

from aws_lambda_powertools.utilities.parameters import AppConfigProvider, GetParameterError, TransformParameterError

from .base import StoreProvider
from .exceptions import ConfigurationError
from .schema_fetcher import SchemaFetcher

logger = logging.getLogger(__name__)


TRANSFORM_TYPE = "json"


class AppConfigFetcher(SchemaFetcher):
class AppConfigStore(StoreProvider):
def __init__(
self,
environment: str,
service: str,
configuration_name: str,
application: str,
name: str,
cache_seconds: int,
config: Optional[Config] = None,
):
Expand All @@ -28,19 +27,19 @@ def __init__(
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
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
config: Optional[Config]
boto3 client configuration
"""
super().__init__(configuration_name, cache_seconds)
super().__init__(name, cache_seconds)
self._logger = logger
self._conf_store = AppConfigProvider(environment=environment, application=service, config=config)
self._conf_store = AppConfigProvider(environment=environment, application=application, config=config)

def get_json_configuration(self) -> Dict[str, Any]:
"""Get configuration string from AWs AppConfig and return the parsed JSON dictionary
Expand All @@ -60,12 +59,10 @@ def get_json_configuration(self) -> Dict[str, Any]:
return cast(
dict,
self._conf_store.get(
name=self.configuration_name,
name=self.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)
raise ConfigurationError("Unable to get AWS AppConfig configuration file") from exc
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
from typing import Any, Dict


class SchemaFetcher(ABC):
class StoreProvider(ABC):
def __init__(self, configuration_name: str, cache_seconds: int):
self.configuration_name = configuration_name
self.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
"""Get configuration string from any configuration storing application and return the parsed JSON dictionary

Raises
------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,23 @@
from typing import Any, Dict, List, Optional, cast

from . import schema
from .base import StoreProvider
from .exceptions import ConfigurationError
from .schema_fetcher import SchemaFetcher

logger = logging.getLogger(__name__)


class ConfigurationStore:
def __init__(self, schema_fetcher: SchemaFetcher):
class FeatureFlags:
def __init__(self, store: StoreProvider):
"""constructor

Parameters
----------
schema_fetcher: SchemaFetcher
store: StoreProvider
A schema JSON fetcher, can be AWS AppConfig, Hashicorp Consul etc.
"""
self._logger = logger
self._schema_fetcher = schema_fetcher
self._store = store
self._schema_validator = schema.SchemaValidator(self._logger)

def _match_by_action(self, action: str, condition_value: Any, context_value: Any) -> bool:
Expand All @@ -35,16 +35,16 @@ def _match_by_action(self, action: str, condition_value: Any, context_value: Any
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)}")
self._logger.debug(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:
def _is_rule_matched(self, feature_name: str, rule: Dict[str, Any], 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)))
context_value = context.get(str(condition.get(schema.CONDITION_KEY)))
if not self._match_by_action(
condition.get(schema.CONDITION_ACTION, ""),
condition.get(schema.CONDITION_VALUE),
Expand All @@ -68,13 +68,13 @@ def _handle_rules(
self,
*,
feature_name: str,
rules_context: Dict[str, Any],
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):
if self._is_rule_matched(feature_name, rule, context):
return bool(rule_default_value)
# no rule matched, return default value of feature
logger.debug(
Expand All @@ -98,14 +98,12 @@ def get_configuration(self) -> 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()
config = self._store.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:
def evaluate(self, *, feature_name: str, context: Optional[Dict[str, Any]] = None, default: bool) -> bool:
"""Get a feature toggle boolean value. Value is calculated according to a set of rules and conditions.

See below for explanation.
Expand All @@ -114,13 +112,12 @@ def get_feature_toggle(
----------
feature_name: str
feature name that you wish to fetch
rules_context: Optional[Dict[str, Any]]
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
default: bool
default value if feature flag doesn't exist in the schema,
or there has been an error while fetching the configuration from appconfig

Returns
------
Expand All @@ -132,27 +129,27 @@ def get_feature_toggle(
the defined feature
3. feature exists and a rule matches -> rule_default_value of rule is returned
"""
if rules_context is None:
rules_context = {}
if context is None:
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
logger.debug("Unable to get feature toggles JSON, returning provided default value")
return default

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}"
logger.debug(
f"feature does not appear in configuration, using provided default, "
f"feature_name={feature_name}, default={default}"
)
return value_if_missing
return default

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
# no rules but value
logger.debug(
f"no rules found, returning feature default value, feature_name={feature_name}, "
f"default_value={feature_default_value}"
Expand All @@ -164,18 +161,18 @@ def get_feature_toggle(
)
return self._handle_rules(
feature_name=feature_name,
rules_context=rules_context,
context=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]:
def get_enabled_features(self, *, 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]]
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.

Expand All @@ -185,16 +182,17 @@ def get_all_enabled_feature_toggles(self, *, rules_context: Optional[Dict[str, A
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 = {}
if context is None:
context = {}

features_enabled: List[str] = []

try:
toggles_dict: Dict[str, Any] = self.get_configuration()
except ConfigurationError:
logger.error("unable to get feature toggles JSON")
return []
logger.debug("unable to get feature toggles JSON")
return features_enabled

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, [])
Expand All @@ -203,14 +201,14 @@ def get_all_enabled_feature_toggles(self, *, rules_context: Optional[Dict[str, A
self._logger.debug(
f"feature is enabled by default and has no defined rules, feature_name={feature_name}"
)
ret_list.append(feature_name)
features_enabled.append(feature_name)
elif self._handle_rules(
feature_name=feature_name,
rules_context=rules_context,
context=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)
features_enabled.append(feature_name)

return ret_list
return features_enabled
46 changes: 28 additions & 18 deletions aws_lambda_powertools/utilities/feature_toggles/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,59 +26,69 @@ class SchemaValidator:
def __init__(self, logger: Logger):
self._logger = logger
heitorlessa marked this conversation as resolved.
Show resolved Hide resolved

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:
@staticmethod
def _validate_condition(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}")
raise ConfigurationError(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}")
raise ConfigurationError(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}")
raise ConfigurationError(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}")
raise ConfigurationError(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}")
raise ConfigurationError(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}")
raise ConfigurationError(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}")
raise ConfigurationError(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}")
raise ConfigurationError(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")
raise ConfigurationError(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}")
raise ConfigurationError(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}")
raise ConfigurationError(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")
raise ConfigurationError("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")
raise ConfigurationError("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)
Loading