From c52a98768c28a51f53f2f24dfd947e80b6431170 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 13 Jul 2021 09:00:13 +0000
Subject: [PATCH 1/2] chore(deps-dev): bump mkdocs-material from 7.1.9 to
7.1.10 (#522)
Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 7.1.9 to 7.1.10.
Release notes
Sourced from mkdocs-material's releases.
mkdocs-material-7.1.10
- Refactored appearance of back-to-top button
- Fixed graceful handling of search when browsing locally
Changelog
Sourced from mkdocs-material's changelog.
7.1.10 _ July 10, 2021
- Refactored appearance of back-to-top button
- Fixed graceful handling of search when browsing locally
Commits
714a9cc
Prepare 7.1.10 release
30a4d58
Added documentation for cookie consent
580f118
Improved graceful handling of broken search when browsing locally
52d773b
Added distribution files
4fb80ea
Fixed back-to-top button overlaying search
7abeb68
Fixed rendering of back-to-top button when used with sticky tabs
7ca4ca1
Fixed transitions on back-to-top button
f6efb78
Updated dependencies
e4d6207
Updated dependencies
68de4db
Removed back-to-top button in print view
- Additional commits viewable in compare view
[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=mkdocs-material&package-manager=pip&previous-version=7.1.9&new-version=7.1.10)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
---
poetry.lock | 8 ++++----
pyproject.toml | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/poetry.lock b/poetry.lock
index f1616f1373..9973d2cc45 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -592,7 +592,7 @@ mkdocs = ">=0.17"
[[package]]
name = "mkdocs-material"
-version = "7.1.9"
+version = "7.1.10"
description = "A Material Design theme for MkDocs"
category = "dev"
optional = false
@@ -1084,7 +1084,7 @@ pydantic = ["pydantic", "email-validator"]
[metadata]
lock-version = "1.1"
python-versions = "^3.6.1"
-content-hash = "280bed1df43e61ee78cc81ad974e54ce98b2a3f0fa8246acb98da86a4c6c320e"
+content-hash = "97a8aca56202d6047233c32cf8960b85b1fbee1218663c1654288fa40c3268ef"
[metadata.files]
appdirs = [
@@ -1361,8 +1361,8 @@ mkdocs-git-revision-date-plugin = [
{file = "mkdocs_git_revision_date_plugin-0.3.1-py3-none-any.whl", hash = "sha256:8ae50b45eb75d07b150a69726041860801615aae5f4adbd6b1cf4d51abaa03d5"},
]
mkdocs-material = [
- {file = "mkdocs-material-7.1.9.tar.gz", hash = "sha256:5a2fd487f769f382a7c979e869e4eab1372af58d7dec44c4365dd97ef5268cb5"},
- {file = "mkdocs_material-7.1.9-py2.py3-none-any.whl", hash = "sha256:92c8a2bd3bd44d5948eefc46ba138e2d3285cac658900112b6bf5722c7d067a5"},
+ {file = "mkdocs-material-7.1.10.tar.gz", hash = "sha256:890e9be00bfbe4d22ccccbcde1bf9bad67a3ba495f2a7d2422ea4acb5099f014"},
+ {file = "mkdocs_material-7.1.10-py2.py3-none-any.whl", hash = "sha256:92ff8c4a8e78555ef7b7ed0ba3043421d18971b48d066ea2cefb50e889fc66db"},
]
mkdocs-material-extensions = [
{file = "mkdocs-material-extensions-1.0.1.tar.gz", hash = "sha256:6947fb7f5e4291e3c61405bad3539d81e0b3cd62ae0d66ced018128af509c68f"},
diff --git a/pyproject.toml b/pyproject.toml
index 6385da9d2d..f65572f7b3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -49,7 +49,7 @@ radon = "^4.5.0"
xenon = "^0.7.3"
flake8-eradicate = "^1.1.0"
flake8-bugbear = "^21.3.2"
-mkdocs-material = "^7.1.9"
+mkdocs-material = "^7.1.10"
mkdocs-git-revision-date-plugin = "^0.3.1"
mike = "^0.6.0"
mypy = "^0.910"
From c0c32bc5cab5b8e7b1cec00cef0e6cb791cea4e3 Mon Sep 17 00:00:00 2001
From: Ran Isenberg <60175085+risenberg-cyberark@users.noreply.github.com>
Date: Wed, 14 Jul 2021 13:02:13 +0300
Subject: [PATCH 2/2] 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
---
.../utilities/feature_toggles/__init__.py | 16 +
.../feature_toggles/appconfig_fetcher.py | 57 +++
.../feature_toggles/configuration_store.py | 191 ++++++++
.../utilities/feature_toggles/exceptions.py | 2 +
.../utilities/feature_toggles/schema.py | 83 ++++
.../feature_toggles/schema_fetcher.py | 20 +
.../utilities/parameters/appconfig.py | 2 +-
tests/functional/feature_toggles/__init__.py | 0
.../feature_toggles/test_feature_toggles.py | 422 ++++++++++++++++++
.../feature_toggles/test_schema_validation.py | 264 +++++++++++
10 files changed, 1056 insertions(+), 1 deletion(-)
create mode 100644 aws_lambda_powertools/utilities/feature_toggles/__init__.py
create mode 100644 aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py
create mode 100644 aws_lambda_powertools/utilities/feature_toggles/configuration_store.py
create mode 100644 aws_lambda_powertools/utilities/feature_toggles/exceptions.py
create mode 100644 aws_lambda_powertools/utilities/feature_toggles/schema.py
create mode 100644 aws_lambda_powertools/utilities/feature_toggles/schema_fetcher.py
create mode 100644 tests/functional/feature_toggles/__init__.py
create mode 100644 tests/functional/feature_toggles/test_feature_toggles.py
create mode 100644 tests/functional/feature_toggles/test_schema_validation.py
diff --git a/aws_lambda_powertools/utilities/feature_toggles/__init__.py b/aws_lambda_powertools/utilities/feature_toggles/__init__.py
new file mode 100644
index 0000000000..378f7e23f4
--- /dev/null
+++ b/aws_lambda_powertools/utilities/feature_toggles/__init__.py
@@ -0,0 +1,16 @@
+"""Advanced feature toggles utility
+"""
+from .appconfig_fetcher import AppConfigFetcher
+from .configuration_store import ConfigurationStore
+from .exceptions import ConfigurationException
+from .schema import ACTION, SchemaValidator
+from .schema_fetcher import SchemaFetcher
+
+__all__ = [
+ "ConfigurationException",
+ "ConfigurationStore",
+ "ACTION",
+ "SchemaValidator",
+ "AppConfigFetcher",
+ "SchemaFetcher",
+]
diff --git a/aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py b/aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py
new file mode 100644
index 0000000000..177d4ed0ae
--- /dev/null
+++ b/aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py
@@ -0,0 +1,57 @@
+import logging
+from typing import Any, Dict, Optional
+
+from botocore.config import Config
+
+from aws_lambda_powertools.utilities.parameters import AppConfigProvider, GetParameterError, TransformParameterError
+
+from .exceptions import ConfigurationException
+from .schema_fetcher import SchemaFetcher
+
+logger = logging.getLogger(__name__)
+
+
+TRANSFORM_TYPE = "json"
+
+
+class AppConfigFetcher(SchemaFetcher):
+ def __init__(
+ self,
+ environment: str,
+ service: str,
+ configuration_name: str,
+ cache_seconds: int,
+ config: Optional[Config] = None,
+ ):
+ """This class fetches JSON schemas from AWS AppConfig
+
+ Args:
+ 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
+ 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)
+ self._logger = logger
+ self._conf_store = AppConfigProvider(environment=environment, application=service, config=config)
+
+ def get_json_configuration(self) -> Dict[str, Any]:
+ """Get configuration string from AWs AppConfig and return the parsed JSON dictionary
+
+ Raises:
+ ConfigurationException: Any validation error or appconfig error that can occur
+
+ Returns:
+ Dict[str, Any]: parsed JSON dictionary
+ """
+ try:
+ return self._conf_store.get(
+ name=self.configuration_name,
+ transform=TRANSFORM_TYPE,
+ max_age=self._cache_seconds,
+ ) # parse result conf as JSON, keep in cache for self.max_age 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 ConfigurationException(error_str)
diff --git a/aws_lambda_powertools/utilities/feature_toggles/configuration_store.py b/aws_lambda_powertools/utilities/feature_toggles/configuration_store.py
new file mode 100644
index 0000000000..e540447737
--- /dev/null
+++ b/aws_lambda_powertools/utilities/feature_toggles/configuration_store.py
@@ -0,0 +1,191 @@
+import logging
+from typing import Any, Dict, List, Optional
+
+from . import schema
+from .exceptions import ConfigurationException
+from .schema_fetcher import SchemaFetcher
+
+logger = logging.getLogger(__name__)
+
+
+class ConfigurationStore:
+ def __init__(self, schema_fetcher: SchemaFetcher):
+ """constructor
+
+ Args:
+ schema_fetcher (SchemaFetcher): A schema JSON fetcher, can be AWS AppConfig, Hashicorp Consul etc.
+ """
+ self._logger = logger
+ self._schema_fetcher = schema_fetcher
+ self._schema_validator = schema.SchemaValidator(self._logger)
+
+ def _match_by_action(self, action: str, condition_value: Any, context_value: Any) -> bool:
+ if not context_value:
+ return False
+ mapping_by_action = {
+ schema.ACTION.EQUALS.value: lambda a, b: a == b,
+ schema.ACTION.STARTSWITH.value: lambda a, b: a.startswith(b),
+ schema.ACTION.ENDSWITH.value: lambda a, b: a.endswith(b),
+ schema.ACTION.CONTAINS.value: lambda a, b: a in b,
+ }
+
+ try:
+ 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)}")
+ return False
+
+ def _is_rule_matched(self, feature_name: str, rule: Dict[str, Any], rules_context: Dict[str, Any]) -> bool:
+ rule_name = rule.get(schema.RULE_NAME_KEY, "")
+ rule_default_value = rule.get(schema.RULE_DEFAULT_VALUE)
+ conditions: Dict[str, str] = rule.get(schema.CONDITIONS_KEY)
+
+ for condition in conditions:
+ context_value = rules_context.get(condition.get(schema.CONDITION_KEY))
+ if not self._match_by_action(
+ condition.get(schema.CONDITION_ACTION),
+ condition.get(schema.CONDITION_VALUE),
+ context_value,
+ ):
+ logger.debug(
+ 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
+ )
+ # context doesn't match condition
+ return False
+ # if we got here, all conditions match
+ logger.debug(
+ f"rule matched, rule_name={rule_name}, rule_default_value={rule_default_value}, feature_name={feature_name}" # noqa: E501
+ )
+ return True
+
+ def _handle_rules(
+ self,
+ *,
+ feature_name: str,
+ rules_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):
+ return rule_default_value
+ # no rule matched, return default value of feature
+ logger.debug(
+ f"no rule matched, returning default value of feature, feature_default_value={feature_default_value}, feature_name={feature_name}" # noqa: E501
+ )
+ return feature_default_value
+
+ def get_configuration(self) -> Dict[str, Any]:
+ """Get configuration string from AWs AppConfig and returned the parsed JSON dictionary
+
+ Raises:
+ ConfigurationException: Any validation error or appconfig error that can occur
+
+ Returns:
+ Dict[str, Any]: parsed JSON dictionary
+ """
+ schema: Dict[
+ str, Any
+ ] = (
+ self._schema_fetcher.get_json_configuration()
+ ) # parse result conf as JSON, keep in cache for self.max_age seconds
+ # validate schema
+ self._schema_validator.validate_json_schema(schema)
+ return schema
+
+ def get_feature_toggle(
+ self, *, feature_name: str, rules_context: Optional[Dict[str, Any]] = None, value_if_missing: bool
+ ) -> bool:
+ """get a feature toggle boolean value. Value is calculated according to a set of rules and conditions.
+ see below for explanation.
+
+ Args:
+ feature_name (str): feature name that you wish to fetch
+ rules_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
+
+ Returns:
+ bool: calculated feature toggle value. several possibilities:
+ 1. if the feature doesn't appear in the schema or there has been an error fetching the
+ configuration -> error/warning log would appear and value_if_missing is returned
+ 2. feature exists and has no rules or no rules have matched -> return feature_default_value of
+ the defined feature
+ 3. feature exists and a rule matches -> rule_default_value of rule is returned
+ """
+ if rules_context is None:
+ rules_context = {}
+
+ try:
+ toggles_dict: Dict[str, Any] = self.get_configuration()
+ except ConfigurationException:
+ logger.error("unable to get feature toggles JSON, returning provided value_if_missing value") # noqa: E501
+ return value_if_missing
+
+ 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, feature_name={feature_name}, value_if_missing={value_if_missing}" # noqa: E501
+ )
+ return value_if_missing
+
+ 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
+ logger.debug(
+ f"no rules found, returning feature default value, feature_name={feature_name}, default_value={feature_default_value}" # noqa: E501
+ )
+ return feature_default_value
+ # look for first rule match
+ logger.debug(
+ f"looking for rule match, feature_name={feature_name}, feature_default_value={feature_default_value}"
+ ) # noqa: E501
+ return self._handle_rules(
+ feature_name=feature_name,
+ rules_context=rules_context,
+ feature_default_value=feature_default_value,
+ rules=rules_list,
+ )
+
+ def get_all_enabled_feature_toggles(self, *, rules_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)
+
+ Args:
+ rules_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.
+
+ Returns:
+ List[str]: 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 = {}
+ try:
+ toggles_dict: Dict[str, Any] = self.get_configuration()
+ except ConfigurationException:
+ logger.error("unable to get feature toggles JSON") # noqa: E501
+ return []
+ 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, [])
+ feature_default_value = feature_dict_def.get(schema.FEATURE_DEFAULT_VAL_KEY)
+ if feature_default_value and not rules_list:
+ self._logger.debug(
+ f"feature is enabled by default and has no defined rules, feature_name={feature_name}"
+ )
+ ret_list.append(feature_name)
+ elif self._handle_rules(
+ feature_name=feature_name,
+ rules_context=rules_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)
+ return ret_list
diff --git a/aws_lambda_powertools/utilities/feature_toggles/exceptions.py b/aws_lambda_powertools/utilities/feature_toggles/exceptions.py
new file mode 100644
index 0000000000..9bbb5f200b
--- /dev/null
+++ b/aws_lambda_powertools/utilities/feature_toggles/exceptions.py
@@ -0,0 +1,2 @@
+class ConfigurationException(Exception):
+ """When a a configuration store raises an exception on config retrieval or parsing"""
diff --git a/aws_lambda_powertools/utilities/feature_toggles/schema.py b/aws_lambda_powertools/utilities/feature_toggles/schema.py
new file mode 100644
index 0000000000..58e75fabfc
--- /dev/null
+++ b/aws_lambda_powertools/utilities/feature_toggles/schema.py
@@ -0,0 +1,83 @@
+from enum import Enum
+from typing import Any, Dict
+
+from .exceptions import ConfigurationException
+
+FEATURES_KEY = "features"
+RULES_KEY = "rules"
+FEATURE_DEFAULT_VAL_KEY = "feature_default_value"
+CONDITIONS_KEY = "conditions"
+RULE_NAME_KEY = "rule_name"
+RULE_DEFAULT_VALUE = "value_when_applies"
+CONDITION_KEY = "key"
+CONDITION_VALUE = "value"
+CONDITION_ACTION = "action"
+
+
+class ACTION(str, Enum):
+ EQUALS = "EQUALS"
+ STARTSWITH = "STARTSWITH"
+ ENDSWITH = "ENDSWITH"
+ CONTAINS = "CONTAINS"
+
+
+class SchemaValidator:
+ def __init__(self, logger: object):
+ self._logger = logger
+
+ def _raise_conf_exc(self, error_str: str) -> None:
+ self._logger.error(error_str)
+ raise ConfigurationException(error_str)
+
+ def _validate_condition(self, 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}")
+ 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}")
+ 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}")
+ value = condition.get(CONDITION_VALUE, "")
+ if not value:
+ self._raise_conf_exc(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}")
+ rule_name = rule.get(RULE_NAME_KEY)
+ if not rule_name or rule_name is None or not isinstance(rule_name, str):
+ self._raise_conf_exc(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}")
+ conditions = rule.get(CONDITIONS_KEY, {})
+ if not conditions or not isinstance(conditions, list):
+ self._raise_conf_exc(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")
+ 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}")
+ # 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}")
+ 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")
+ features_dict: Dict = schema.get(FEATURES_KEY)
+ if not isinstance(features_dict, dict):
+ self._raise_conf_exc("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)
diff --git a/aws_lambda_powertools/utilities/feature_toggles/schema_fetcher.py b/aws_lambda_powertools/utilities/feature_toggles/schema_fetcher.py
new file mode 100644
index 0000000000..37dee63f7f
--- /dev/null
+++ b/aws_lambda_powertools/utilities/feature_toggles/schema_fetcher.py
@@ -0,0 +1,20 @@
+from abc import ABC, abstractclassmethod
+from typing import Any, Dict
+
+
+class SchemaFetcher(ABC):
+ def __init__(self, configuration_name: str, cache_seconds: int):
+ self.configuration_name = configuration_name
+ self._cache_seconds = cache_seconds
+
+ @abstractclassmethod
+ def get_json_configuration(self) -> Dict[str, Any]:
+ """Get configuration string from any configuration storing service and return the parsed JSON dictionary
+
+ Raises:
+ ConfigurationException: Any error that can occur during schema fetch or JSON parse
+
+ Returns:
+ Dict[str, Any]: parsed JSON dictionary
+ """
+ return None
diff --git a/aws_lambda_powertools/utilities/parameters/appconfig.py b/aws_lambda_powertools/utilities/parameters/appconfig.py
index 4490e26036..63a8415f1e 100644
--- a/aws_lambda_powertools/utilities/parameters/appconfig.py
+++ b/aws_lambda_powertools/utilities/parameters/appconfig.py
@@ -149,7 +149,7 @@ def get_app_config(
>>> print(value)
My configuration value
- **Retrieves a confiugration value and decodes it using a JSON decoder**
+ **Retrieves a configuration value and decodes it using a JSON decoder**
>>> from aws_lambda_powertools.utilities.parameters import get_parameter
>>>
diff --git a/tests/functional/feature_toggles/__init__.py b/tests/functional/feature_toggles/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/functional/feature_toggles/test_feature_toggles.py b/tests/functional/feature_toggles/test_feature_toggles.py
new file mode 100644
index 0000000000..27f89eb151
--- /dev/null
+++ b/tests/functional/feature_toggles/test_feature_toggles.py
@@ -0,0 +1,422 @@
+from typing import Dict, List
+
+import pytest # noqa: F401
+from botocore.config import Config
+
+from aws_lambda_powertools.utilities.feature_toggles.appconfig_fetcher import AppConfigFetcher
+from aws_lambda_powertools.utilities.feature_toggles.configuration_store import ConfigurationStore
+from aws_lambda_powertools.utilities.feature_toggles.schema import ACTION
+
+
+@pytest.fixture(scope="module")
+def config():
+ return Config(region_name="us-east-1")
+
+
+def init_configuration_store(mocker, mock_schema: Dict, config: Config) -> ConfigurationStore:
+ mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get")
+ mocked_get_conf.return_value = mock_schema
+
+ app_conf_fetcher = AppConfigFetcher(
+ environment="test_env",
+ service="test_app",
+ configuration_name="test_conf_name",
+ cache_seconds=600,
+ config=config,
+ )
+ conf_store: ConfigurationStore = ConfigurationStore(schema_fetcher=app_conf_fetcher)
+ return conf_store
+
+
+# this test checks that we get correct value of feature that exists in the schema.
+# we also don't send an empty rules_context dict in this case
+def test_toggles_rule_does_not_match(mocker, config):
+ expected_value = True
+ mocked_app_config_schema = {
+ "features": {
+ "my_feature": {
+ "feature_default_value": expected_value,
+ "rules": [
+ {
+ "rule_name": "tenant id equals 345345435",
+ "value_when_applies": False,
+ "conditions": [
+ {
+ "action": ACTION.EQUALS.value,
+ "key": "tenant_id",
+ "value": "345345435",
+ }
+ ],
+ },
+ ],
+ }
+ },
+ }
+
+ conf_store = init_configuration_store(mocker, mocked_app_config_schema, config)
+ toggle = conf_store.get_feature_toggle(feature_name="my_feature", rules_context={}, value_if_missing=False)
+ assert toggle == expected_value
+
+
+# this test checks that if you try to get a feature that doesn't exist in the schema,
+# you get the default value of False that was sent to the get_feature_toggle API
+def test_toggles_no_conditions_feature_does_not_exist(mocker, config):
+ expected_value = False
+ mocked_app_config_schema = {"features": {"my_fake_feature": {"feature_default_value": True}}}
+
+ conf_store = init_configuration_store(mocker, mocked_app_config_schema, config)
+ toggle = conf_store.get_feature_toggle(feature_name="my_feature", rules_context={}, value_if_missing=expected_value)
+ assert toggle == expected_value
+
+
+# check that feature match works when they are no rules and we send rules_context.
+# default value is False but the feature has a True default_value.
+def test_toggles_no_rules(mocker, config):
+ expected_value = True
+ mocked_app_config_schema = {"features": {"my_feature": {"feature_default_value": expected_value}}}
+ conf_store = init_configuration_store(mocker, mocked_app_config_schema, config)
+ toggle = conf_store.get_feature_toggle(
+ feature_name="my_feature", rules_context={"tenant_id": "6", "username": "a"}, value_if_missing=False
+ )
+ assert toggle == expected_value
+
+
+# check a case where the feature exists but the rule doesn't match so we revert to the default value of the feature
+def test_toggles_conditions_no_match(mocker, config):
+ expected_value = True
+ mocked_app_config_schema = {
+ "features": {
+ "my_feature": {
+ "feature_default_value": expected_value,
+ "rules": [
+ {
+ "rule_name": "tenant id equals 345345435",
+ "value_when_applies": False,
+ "conditions": [
+ {
+ "action": ACTION.EQUALS.value,
+ "key": "tenant_id",
+ "value": "345345435",
+ }
+ ],
+ },
+ ],
+ }
+ },
+ }
+ conf_store = init_configuration_store(mocker, mocked_app_config_schema, config)
+ toggle = conf_store.get_feature_toggle(
+ feature_name="my_feature",
+ rules_context={"tenant_id": "6", "username": "a"}, # rule will not match
+ value_if_missing=False,
+ )
+ assert toggle == expected_value
+
+
+# check that a rule can match when it has multiple conditions, see rule name for further explanation
+def test_toggles_conditions_rule_match_equal_multiple_conditions(mocker, config):
+ expected_value = False
+ tenant_id_val = "6"
+ username_val = "a"
+ mocked_app_config_schema = {
+ "features": {
+ "my_feature": {
+ "feature_default_value": True,
+ "rules": [
+ {
+ "rule_name": "tenant id equals 6 and username is a",
+ "value_when_applies": expected_value,
+ "conditions": [
+ {
+ "action": ACTION.EQUALS.value, # this rule will match, it has multiple conditions
+ "key": "tenant_id",
+ "value": tenant_id_val,
+ },
+ {
+ "action": ACTION.EQUALS.value,
+ "key": "username",
+ "value": username_val,
+ },
+ ],
+ },
+ ],
+ }
+ },
+ }
+ conf_store = init_configuration_store(mocker, mocked_app_config_schema, config)
+ toggle = conf_store.get_feature_toggle(
+ feature_name="my_feature",
+ rules_context={
+ "tenant_id": tenant_id_val,
+ "username": username_val,
+ },
+ value_if_missing=True,
+ )
+ assert toggle == expected_value
+
+
+# check a case when rule doesn't match and it has multiple conditions,
+# different tenant id causes the rule to not match.
+# default value of the feature in this case is True
+def test_toggles_conditions_no_rule_match_equal_multiple_conditions(mocker, config):
+ expected_val = True
+ mocked_app_config_schema = {
+ "features": {
+ "my_feature": {
+ "feature_default_value": expected_val,
+ "rules": [
+ {
+ "rule_name": "tenant id equals 645654 and username is a", # rule will not match
+ "value_when_applies": False,
+ "conditions": [
+ {
+ "action": ACTION.EQUALS.value,
+ "key": "tenant_id",
+ "value": "645654",
+ },
+ {
+ "action": ACTION.EQUALS.value,
+ "key": "username",
+ "value": "a",
+ },
+ ],
+ },
+ ],
+ }
+ },
+ }
+ conf_store = init_configuration_store(mocker, mocked_app_config_schema, config)
+ toggle = conf_store.get_feature_toggle(
+ feature_name="my_feature", rules_context={"tenant_id": "6", "username": "a"}, value_if_missing=False
+ )
+ assert toggle == expected_val
+
+
+# check rule match for multiple of action types
+def test_toggles_conditions_rule_match_multiple_actions_multiple_rules_multiple_conditions(mocker, config):
+ expected_value_first_check = True
+ expected_value_second_check = False
+ expected_value_third_check = False
+ expected_value_fourth_case = False
+ mocked_app_config_schema = {
+ "features": {
+ "my_feature": {
+ "feature_default_value": expected_value_third_check,
+ "rules": [
+ {
+ "rule_name": "tenant id equals 6 and username startswith a",
+ "value_when_applies": expected_value_first_check,
+ "conditions": [
+ {
+ "action": ACTION.EQUALS.value,
+ "key": "tenant_id",
+ "value": "6",
+ },
+ {
+ "action": ACTION.STARTSWITH.value,
+ "key": "username",
+ "value": "a",
+ },
+ ],
+ },
+ {
+ "rule_name": "tenant id equals 4446 and username startswith a and endswith z",
+ "value_when_applies": expected_value_second_check,
+ "conditions": [
+ {
+ "action": ACTION.EQUALS.value,
+ "key": "tenant_id",
+ "value": "4446",
+ },
+ {
+ "action": ACTION.STARTSWITH.value,
+ "key": "username",
+ "value": "a",
+ },
+ {
+ "action": ACTION.ENDSWITH.value,
+ "key": "username",
+ "value": "z",
+ },
+ ],
+ },
+ ],
+ }
+ },
+ }
+
+ conf_store = init_configuration_store(mocker, mocked_app_config_schema, config)
+ # match first rule
+ toggle = conf_store.get_feature_toggle(
+ feature_name="my_feature",
+ rules_context={"tenant_id": "6", "username": "abcd"},
+ value_if_missing=False,
+ )
+ assert toggle == expected_value_first_check
+ # match second rule
+ toggle = conf_store.get_feature_toggle(
+ feature_name="my_feature",
+ rules_context={"tenant_id": "4446", "username": "az"},
+ value_if_missing=False,
+ )
+ assert toggle == expected_value_second_check
+ # match no rule
+ toggle = conf_store.get_feature_toggle(
+ feature_name="my_feature",
+ rules_context={"tenant_id": "11114446", "username": "ab"},
+ value_if_missing=False,
+ )
+ assert toggle == expected_value_third_check
+ # feature doesn't exist
+ toggle = conf_store.get_feature_toggle(
+ feature_name="my_fake_feature",
+ rules_context={"tenant_id": "11114446", "username": "ab"},
+ value_if_missing=expected_value_fourth_case,
+ )
+ assert toggle == expected_value_fourth_case
+
+
+# check a case where the feature exists but the rule doesn't match so we revert to the default value of the feature
+def test_toggles_match_rule_with_contains_action(mocker, config):
+ expected_value = True
+ mocked_app_config_schema = {
+ "features": {
+ "my_feature": {
+ "feature_default_value": False,
+ "rules": [
+ {
+ "rule_name": "tenant id is contained in [6,2] ",
+ "value_when_applies": expected_value,
+ "conditions": [
+ {
+ "action": ACTION.CONTAINS.value,
+ "key": "tenant_id",
+ "value": ["6", "2"],
+ }
+ ],
+ },
+ ],
+ }
+ },
+ }
+ conf_store = init_configuration_store(mocker, mocked_app_config_schema, config)
+ toggle = conf_store.get_feature_toggle(
+ feature_name="my_feature",
+ rules_context={"tenant_id": "6", "username": "a"}, # rule will match
+ value_if_missing=False,
+ )
+ assert toggle == expected_value
+
+
+def test_toggles_no_match_rule_with_contains_action(mocker, config):
+ expected_value = False
+ mocked_app_config_schema = {
+ "features": {
+ "my_feature": {
+ "feature_default_value": expected_value,
+ "rules": [
+ {
+ "rule_name": "tenant id is contained in [6,2] ",
+ "value_when_applies": True,
+ "conditions": [
+ {
+ "action": ACTION.CONTAINS.value,
+ "key": "tenant_id",
+ "value": ["8", "2"],
+ }
+ ],
+ },
+ ],
+ }
+ },
+ }
+ conf_store = init_configuration_store(mocker, mocked_app_config_schema, config)
+ toggle = conf_store.get_feature_toggle(
+ feature_name="my_feature",
+ rules_context={"tenant_id": "6", "username": "a"}, # rule will not match
+ value_if_missing=False,
+ )
+ assert toggle == expected_value
+
+
+def test_multiple_features_enabled(mocker, config):
+ expected_value = ["my_feature", "my_feature2"]
+ mocked_app_config_schema = {
+ "features": {
+ "my_feature": {
+ "feature_default_value": False,
+ "rules": [
+ {
+ "rule_name": "tenant id is contained in [6,2] ",
+ "value_when_applies": True,
+ "conditions": [
+ {
+ "action": ACTION.CONTAINS.value,
+ "key": "tenant_id",
+ "value": ["6", "2"],
+ }
+ ],
+ },
+ ],
+ },
+ "my_feature2": {
+ "feature_default_value": True,
+ },
+ },
+ }
+ conf_store = init_configuration_store(mocker, mocked_app_config_schema, config)
+ enabled_list: List[str] = conf_store.get_all_enabled_feature_toggles(
+ rules_context={"tenant_id": "6", "username": "a"}
+ )
+ assert enabled_list == expected_value
+
+
+def test_multiple_features_only_some_enabled(mocker, config):
+ expected_value = ["my_feature", "my_feature2", "my_feature4"]
+ mocked_app_config_schema = {
+ "features": {
+ "my_feature": { # rule will match here, feature is enabled due to rule match
+ "feature_default_value": False,
+ "rules": [
+ {
+ "rule_name": "tenant id is contained in [6,2] ",
+ "value_when_applies": True,
+ "conditions": [
+ {
+ "action": ACTION.CONTAINS.value,
+ "key": "tenant_id",
+ "value": ["6", "2"],
+ }
+ ],
+ },
+ ],
+ },
+ "my_feature2": {
+ "feature_default_value": True,
+ },
+ "my_feature3": {
+ "feature_default_value": False,
+ },
+ "my_feature4": { # rule will not match here, feature is enabled by default
+ "feature_default_value": True,
+ "rules": [
+ {
+ "rule_name": "tenant id equals 7",
+ "value_when_applies": False,
+ "conditions": [
+ {
+ "action": ACTION.EQUALS.value,
+ "key": "tenant_id",
+ "value": "7",
+ }
+ ],
+ },
+ ],
+ },
+ },
+ }
+ conf_store = init_configuration_store(mocker, mocked_app_config_schema, config)
+ enabled_list: List[str] = conf_store.get_all_enabled_feature_toggles(
+ rules_context={"tenant_id": "6", "username": "a"}
+ )
+ assert enabled_list == expected_value
diff --git a/tests/functional/feature_toggles/test_schema_validation.py b/tests/functional/feature_toggles/test_schema_validation.py
new file mode 100644
index 0000000000..3b024c854b
--- /dev/null
+++ b/tests/functional/feature_toggles/test_schema_validation.py
@@ -0,0 +1,264 @@
+import logging
+
+import pytest # noqa: F401
+
+from aws_lambda_powertools.utilities.feature_toggles.exceptions import ConfigurationException
+from aws_lambda_powertools.utilities.feature_toggles.schema import (
+ ACTION,
+ CONDITION_ACTION,
+ CONDITION_KEY,
+ CONDITION_VALUE,
+ CONDITIONS_KEY,
+ FEATURE_DEFAULT_VAL_KEY,
+ FEATURES_KEY,
+ RULE_DEFAULT_VALUE,
+ RULE_NAME_KEY,
+ RULES_KEY,
+ SchemaValidator,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def test_invalid_features_dict():
+ schema = {}
+ # empty dict
+ validator = SchemaValidator(logger)
+ with pytest.raises(ConfigurationException):
+ validator.validate_json_schema(schema)
+
+ schema = []
+ # invalid type
+ with pytest.raises(ConfigurationException):
+ validator.validate_json_schema(schema)
+
+ # invalid features key
+ schema = {FEATURES_KEY: []}
+ with pytest.raises(ConfigurationException):
+ validator.validate_json_schema(schema)
+
+
+def test_empty_features_not_fail():
+ schema = {FEATURES_KEY: {}}
+ validator = SchemaValidator(logger)
+ validator.validate_json_schema(schema)
+
+
+def test_invalid_feature_dict():
+ # invalid feature type, not dict
+ schema = {FEATURES_KEY: {"my_feature": []}}
+ validator = SchemaValidator(logger)
+ with pytest.raises(ConfigurationException):
+ validator.validate_json_schema(schema)
+
+ # empty feature dict
+ schema = {FEATURES_KEY: {"my_feature": {}}}
+ with pytest.raises(ConfigurationException):
+ validator.validate_json_schema(schema)
+
+ # invalid FEATURE_DEFAULT_VAL_KEY type, not boolean
+ schema = {FEATURES_KEY: {"my_feature": {FEATURE_DEFAULT_VAL_KEY: "False"}}}
+ with pytest.raises(ConfigurationException):
+ validator.validate_json_schema(schema)
+
+ # invalid FEATURE_DEFAULT_VAL_KEY type, not boolean #2
+ schema = {FEATURES_KEY: {"my_feature": {FEATURE_DEFAULT_VAL_KEY: 5}}}
+ with pytest.raises(ConfigurationException):
+ validator.validate_json_schema(schema)
+
+ # invalid rules type, not list
+ schema = {FEATURES_KEY: {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False, RULES_KEY: "4"}}}
+ with pytest.raises(ConfigurationException):
+ validator.validate_json_schema(schema)
+
+
+def test_valid_feature_dict():
+ # no rules list at all
+ schema = {FEATURES_KEY: {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False}}}
+ validator = SchemaValidator(logger)
+ validator.validate_json_schema(schema)
+
+ # empty rules list
+ schema = {FEATURES_KEY: {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False, RULES_KEY: []}}}
+ validator.validate_json_schema(schema)
+
+
+def test_invalid_rule():
+ # rules list is not a list of dict
+ schema = {
+ FEATURES_KEY: {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: [
+ "a",
+ "b",
+ ],
+ }
+ }
+ }
+ validator = SchemaValidator(logger)
+ with pytest.raises(ConfigurationException):
+ validator.validate_json_schema(schema)
+
+ # rules RULE_DEFAULT_VALUE is not bool
+ schema = {
+ FEATURES_KEY: {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: [
+ {
+ RULE_NAME_KEY: "tenant id equals 345345435",
+ RULE_DEFAULT_VALUE: "False",
+ },
+ ],
+ }
+ }
+ }
+ with pytest.raises(ConfigurationException):
+ validator.validate_json_schema(schema)
+
+ # missing conditions list
+ schema = {
+ FEATURES_KEY: {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: [
+ {
+ RULE_NAME_KEY: "tenant id equals 345345435",
+ RULE_DEFAULT_VALUE: False,
+ },
+ ],
+ }
+ }
+ }
+ with pytest.raises(ConfigurationException):
+ validator.validate_json_schema(schema)
+
+ # condition list is empty
+ schema = {
+ FEATURES_KEY: {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: [
+ {RULE_NAME_KEY: "tenant id equals 345345435", RULE_DEFAULT_VALUE: False, CONDITIONS_KEY: []},
+ ],
+ }
+ }
+ }
+ with pytest.raises(ConfigurationException):
+ validator.validate_json_schema(schema)
+
+ # condition is invalid type, not list
+ schema = {
+ FEATURES_KEY: {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: [
+ {RULE_NAME_KEY: "tenant id equals 345345435", RULE_DEFAULT_VALUE: False, CONDITIONS_KEY: {}},
+ ],
+ }
+ }
+ }
+ with pytest.raises(ConfigurationException):
+ validator.validate_json_schema(schema)
+
+
+def test_invalid_condition():
+ # invalid condition action
+ schema = {
+ FEATURES_KEY: {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: [
+ {
+ RULE_NAME_KEY: "tenant id equals 345345435",
+ RULE_DEFAULT_VALUE: False,
+ CONDITIONS_KEY: {CONDITION_ACTION: "stuff", CONDITION_KEY: "a", CONDITION_VALUE: "a"},
+ },
+ ],
+ }
+ }
+ }
+ validator = SchemaValidator(logger)
+ with pytest.raises(ConfigurationException):
+ validator.validate_json_schema(schema)
+
+ # missing condition key and value
+ schema = {
+ FEATURES_KEY: {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: [
+ {
+ RULE_NAME_KEY: "tenant id equals 345345435",
+ RULE_DEFAULT_VALUE: False,
+ CONDITIONS_KEY: {CONDITION_ACTION: ACTION.EQUALS.value},
+ },
+ ],
+ }
+ }
+ }
+ with pytest.raises(ConfigurationException):
+ validator.validate_json_schema(schema)
+
+ # invalid condition key type, not string
+ schema = {
+ FEATURES_KEY: {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: [
+ {
+ RULE_NAME_KEY: "tenant id equals 345345435",
+ RULE_DEFAULT_VALUE: False,
+ CONDITIONS_KEY: {
+ CONDITION_ACTION: ACTION.EQUALS.value,
+ CONDITION_KEY: 5,
+ CONDITION_VALUE: "a",
+ },
+ },
+ ],
+ }
+ }
+ }
+ with pytest.raises(ConfigurationException):
+ validator.validate_json_schema(schema)
+
+
+def test_valid_condition_all_actions():
+ validator = SchemaValidator(logger)
+ schema = {
+ FEATURES_KEY: {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: [
+ {
+ RULE_NAME_KEY: "tenant id equals 645654 and username is a",
+ RULE_DEFAULT_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: ACTION.EQUALS.value,
+ CONDITION_KEY: "tenant_id",
+ CONDITION_VALUE: "645654",
+ },
+ {
+ CONDITION_ACTION: ACTION.STARTSWITH.value,
+ CONDITION_KEY: "username",
+ CONDITION_VALUE: "a",
+ },
+ {
+ CONDITION_ACTION: ACTION.ENDSWITH.value,
+ CONDITION_KEY: "username",
+ CONDITION_VALUE: "a",
+ },
+ {
+ CONDITION_ACTION: ACTION.CONTAINS.value,
+ CONDITION_KEY: "username",
+ CONDITION_VALUE: ["a", "b"],
+ },
+ ],
+ },
+ ],
+ }
+ },
+ }
+ validator.validate_json_schema(schema)