diff --git a/.github/workflows/python_build.yml b/.github/workflows/python_build.yml index 48f8783a49e..7b4a1bdd77f 100644 --- a/.github/workflows/python_build.yml +++ b/.github/workflows/python_build.yml @@ -37,7 +37,7 @@ jobs: - name: Complexity baseline run: make complexity-baseline - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2.0.1 + uses: codecov/codecov-action@v2.0.2 with: file: ./coverage.xml # flags: unittests diff --git a/.github/workflows/python_docs.yml b/.github/workflows/python_docs.yml index dceee36b2f9..219b9381a8a 100644 --- a/.github/workflows/python_docs.yml +++ b/.github/workflows/python_docs.yml @@ -4,6 +4,10 @@ on: push: branches: - develop + paths: + - 'docs/**' + - 'CHANGELOG.md' + - 'mkdocs.yml' jobs: docs: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eabc7a65a10..f42337d5c5b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,7 @@ repos: types: [python] - id: isort name: formatting::isort - entry: poetry run isort -rc + entry: poetry run isort language: system types: [python] - repo: local diff --git a/CHANGELOG.md b/CHANGELOG.md index 49744f1b7d6..84ff76df5d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,51 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo ## [Unreleased] +## [1.19.0] - 2021-08-11 + +### Bug Fixes + +* **deps:** bump poetry to latest ([#592](https://github.com/awslabs/aws-lambda-powertools-python/issues/592)) +* **feature-flags:** bug handling multiple conditions ([#599](https://github.com/awslabs/aws-lambda-powertools-python/issues/599)) +* **parser:** API Gateway WebSocket validation under check_message_id; plus some housekeeping ([#553](https://github.com/awslabs/aws-lambda-powertools-python/issues/553)) +* **feature-toggles:** correct cdk example ([#601](https://github.com/awslabs/aws-lambda-powertools-python/issues/601)) + +### Code Refactoring + +* **feature-flags:** add debug statements for all feature evaluations ([#590](https://github.com/awslabs/aws-lambda-powertools-python/issues/590)) +* **feature-flags:** optimize UX and maintenance ([#563](https://github.com/awslabs/aws-lambda-powertools-python/issues/563)) + +### Documentation + +* **event-handler:** new custom serializer option +* **feature-flags:** create concrete documentation ([#594](https://github.com/awslabs/aws-lambda-powertools-python/issues/594)) +* **feature-flags:** correct docs and typing ([#588](https://github.com/awslabs/aws-lambda-powertools-python/issues/588)) +* **parameters:** auto-transforming values based on suffix ([#573](https://github.com/awslabs/aws-lambda-powertools-python/issues/573)) +* **readme:** add code coverage badge ([#577](https://github.com/awslabs/aws-lambda-powertools-python/issues/577)) +* **tracer:** update wording that it auto-disables on non-Lambda env +* **feature-flags:** fix SAM infra, convert CDK to Python +* **feature-flags:** fix sample feature name in evaluate method +* **feature-flags:** add guidance when to use vs env vars vs parameters +### Features + +* **api-gateway:** add support for custom serializer ([#568](https://github.com/awslabs/aws-lambda-powertools-python/issues/568)) +* **data-classes:** decode json_body if based64 encoded ([#560](https://github.com/awslabs/aws-lambda-powertools-python/issues/560)) +* **feature-flags:** Add not_in action and rename contains to in ([#589](https://github.com/awslabs/aws-lambda-powertools-python/issues/589)) +* **params:** expose params `max_age`, `raise_on_transform_error` to high level functions ([#567](https://github.com/awslabs/aws-lambda-powertools-python/issues/567)) +* **tracer:** auto-disable tracer for non-Lambda environments to ease testing ([#598](https://github.com/awslabs/aws-lambda-powertools-python/issues/598)) + +### Maintenance + +* **deps:** bump boto3 from 1.18.1 to 1.18.15 ([#591](https://github.com/awslabs/aws-lambda-powertools-python/issues/591)) +* **deps:** bump codecov/codecov-action from 2.0.1 to 2.0.2 ([#558](https://github.com/awslabs/aws-lambda-powertools-python/issues/558)) +* **deps:** bump boto3 from 1.18.15 to 1.18.17 ([#597](https://github.com/awslabs/aws-lambda-powertools-python/issues/597)) +* **deps-dev:** bump mkdocs-material from 7.2.2 to 7.2.3 ([#596](https://github.com/awslabs/aws-lambda-powertools-python/issues/596)) +* **deps-dev:** bump mkdocs-material from 7.2.1 to 7.2.2 ([#582](https://github.com/awslabs/aws-lambda-powertools-python/issues/582)) +* **deps-dev:** bump pdoc3 from 0.9.2 to 0.10.0 ([#584](https://github.com/awslabs/aws-lambda-powertools-python/issues/584)) +* **deps-dev:** bump isort from 5.9.2 to 5.9.3 ([#574](https://github.com/awslabs/aws-lambda-powertools-python/issues/574)) +* **deps-dev:** bump mkdocs-material from 7.2.0 to 7.2.1 ([#566](https://github.com/awslabs/aws-lambda-powertools-python/issues/566)) +* **deps-dev:** bump mkdocs-material from 7.1.11 to 7.2.0 ([#551](https://github.com/awslabs/aws-lambda-powertools-python/issues/551)) +* **deps-dev:** bump flake8-black from 0.2.1 to 0.2.3 ([#541](https://github.com/awslabs/aws-lambda-powertools-python/issues/541)) ## [1.18.1] - 2021-07-23 ### Bug Fixes diff --git a/Makefile b/Makefile index e098615b86c..6b9d6ef0963 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ target: @$(MAKE) pr dev: - pip install --upgrade pip pre-commit poetry==1.1.4 + pip install --upgrade pip pre-commit poetry poetry install --extras "pydantic" pre-commit install diff --git a/README.md b/README.md index 25e7b2e343d..89889bd3a92 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # AWS Lambda Powertools (Python) ![Build](https://github.com/awslabs/aws-lambda-powertools/workflows/Powertools%20Python/badge.svg?branch=master) +[![codecov.io](https://codecov.io/github/awslabs/aws-lambda-powertools-python/branch/develop/graphs/badge.svg)](https://app.codecov.io/gh/awslabs/aws-lambda-powertools-python) ![PythonSupport](https://img.shields.io/static/v1?label=python&message=3.6%20|%203.7|%203.8&color=blue?style=flat-square&logo=python) ![PyPI version](https://badge.fury.io/py/aws-lambda-powertools.svg) ![PyPi monthly downloads](https://img.shields.io/pypi/dm/aws-lambda-powertools) A suite of Python utilities for AWS Lambda functions to ease adopting best practices such as tracing, structured logging, custom metrics, and more. ([AWS Lambda Powertools Java](https://github.com/awslabs/aws-lambda-powertools-java) is also available). @@ -24,6 +25,7 @@ A suite of Python utilities for AWS Lambda functions to ease adopting best pract * **[Event source data classes](https://awslabs.github.io/aws-lambda-powertools-python/latest/utilities/data_classes/)** - Data classes describing the schema of common Lambda event triggers * **[Parser](https://awslabs.github.io/aws-lambda-powertools-python/latest/utilities/parser/)** - Data parsing and deep validation using Pydantic * **[Idempotency](https://awslabs.github.io/aws-lambda-powertools-python/latest/utilities/idempotency/)** - Convert your Lambda functions into idempotent operations which are safe to retry +* **[Feature Flags](./utilities/feature_flags.md)** - A simple rule engine to evaluate when one or multiple features should be enabled depending on the input ### Installation diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 44d3f2b07de..7bf364695da 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -6,6 +6,7 @@ import traceback import zlib from enum import Enum +from functools import partial from http import HTTPStatus from typing import Any, Callable, Dict, List, Optional, Set, Union @@ -263,6 +264,7 @@ def __init__( proxy_type: Enum = ProxyEventType.APIGatewayProxyEvent, cors: Optional[CORSConfig] = None, debug: Optional[bool] = None, + serializer: Optional[Callable[[Dict], str]] = None, ): """ Parameters @@ -284,6 +286,13 @@ def __init__( env=os.getenv(constants.EVENT_HANDLER_DEBUG_ENV, "false"), choice=debug ) + # Allow for a custom serializer or a concise json serialization + self._serializer = serializer or partial(json.dumps, separators=(",", ":"), cls=Encoder) + + if self._debug: + # Always does a pretty print when in debug mode + self._serializer = partial(json.dumps, indent=4, cls=Encoder) + def get(self, rule: str, cors: Optional[bool] = None, compress: bool = False, cache_control: Optional[str] = None): """Get route decorator with GET `method` @@ -592,8 +601,4 @@ def _to_response(self, result: Union[Dict, Response]) -> Response: ) def _json_dump(self, obj: Any) -> str: - """Does a concise json serialization or pretty print when in debug mode""" - if self._debug: - return json.dumps(obj, indent=4, cls=Encoder) - else: - return json.dumps(obj, separators=(",", ":"), cls=Encoder) + return self._serializer(obj) diff --git a/aws_lambda_powertools/shared/constants.py b/aws_lambda_powertools/shared/constants.py index 8388eded654..6061462a051 100644 --- a/aws_lambda_powertools/shared/constants.py +++ b/aws_lambda_powertools/shared/constants.py @@ -12,10 +12,9 @@ EVENT_HANDLER_DEBUG_ENV: str = "POWERTOOLS_EVENT_HANDLER_DEBUG" -SAM_LOCAL_ENV: str = "AWS_SAM_LOCAL" -CHALICE_LOCAL_ENV: str = "AWS_CHALICE_CLI_MODE" SERVICE_NAME_ENV: str = "POWERTOOLS_SERVICE_NAME" XRAY_TRACE_ID_ENV: str = "_X_AMZN_TRACE_ID" +LAMBDA_TASK_ROOT_ENV: str = "LAMBDA_TASK_ROOT" XRAY_SDK_MODULE: str = "aws_xray_sdk" XRAY_SDK_CORE_MODULE: str = "aws_xray_sdk.core" diff --git a/aws_lambda_powertools/shared/jmespath_functions.py b/aws_lambda_powertools/shared/jmespath_functions.py deleted file mode 100644 index b23ab477d6b..00000000000 --- a/aws_lambda_powertools/shared/jmespath_functions.py +++ /dev/null @@ -1,22 +0,0 @@ -import base64 -import gzip -import json - -import jmespath - - -class PowertoolsFunctions(jmespath.functions.Functions): - @jmespath.functions.signature({"types": ["string"]}) - def _func_powertools_json(self, value): - return json.loads(value) - - @jmespath.functions.signature({"types": ["string"]}) - def _func_powertools_base64(self, value): - return base64.b64decode(value).decode() - - @jmespath.functions.signature({"types": ["string"]}) - def _func_powertools_base64_gzip(self, value): - encoded = base64.b64decode(value) - uncompressed = gzip.decompress(encoded) - - return uncompressed.decode() diff --git a/aws_lambda_powertools/shared/jmespath_utils.py b/aws_lambda_powertools/shared/jmespath_utils.py new file mode 100644 index 00000000000..f2a865d4807 --- /dev/null +++ b/aws_lambda_powertools/shared/jmespath_utils.py @@ -0,0 +1,55 @@ +import base64 +import gzip +import json +from typing import Any, Dict, Optional, Union + +import jmespath +from jmespath.exceptions import LexerError + +from aws_lambda_powertools.utilities.validation import InvalidEnvelopeExpressionError +from aws_lambda_powertools.utilities.validation.base import logger + + +class PowertoolsFunctions(jmespath.functions.Functions): + @jmespath.functions.signature({"types": ["string"]}) + def _func_powertools_json(self, value): + return json.loads(value) + + @jmespath.functions.signature({"types": ["string"]}) + def _func_powertools_base64(self, value): + return base64.b64decode(value).decode() + + @jmespath.functions.signature({"types": ["string"]}) + def _func_powertools_base64_gzip(self, value): + encoded = base64.b64decode(value) + uncompressed = gzip.decompress(encoded) + + return uncompressed.decode() + + +def unwrap_event_from_envelope(data: Union[Dict, str], envelope: str, jmespath_options: Optional[Dict]) -> Any: + """Searches data using JMESPath expression + + Parameters + ---------- + data : Dict + Data set to be filtered + envelope : str + JMESPath expression to filter data against + jmespath_options : Dict + Alternative JMESPath options to be included when filtering expr + + Returns + ------- + Any + Data found using JMESPath expression given in envelope + """ + if not jmespath_options: + jmespath_options = {"custom_functions": PowertoolsFunctions()} + + try: + logger.debug(f"Envelope detected: {envelope}. JMESPath options: {jmespath_options}") + return jmespath.search(envelope, data, options=jmespath.Options(**jmespath_options)) + except (LexerError, TypeError, UnicodeError) as e: + message = f"Failed to unwrap event from envelope using expression. Error: {e} Exp: {envelope}, Data: {data}" # noqa: B306, E501 + raise InvalidEnvelopeExpressionError(message) diff --git a/aws_lambda_powertools/tracing/tracer.py b/aws_lambda_powertools/tracing/tracer.py index 5709b1956c2..e57d24044dc 100644 --- a/aws_lambda_powertools/tracing/tracer.py +++ b/aws_lambda_powertools/tracing/tracer.py @@ -335,7 +335,7 @@ def decorate(event, context, **kwargs): # see #465 @overload def capture_method(self, method: "AnyCallableT") -> "AnyCallableT": - ... + ... # pragma: no cover @overload def capture_method( @@ -344,7 +344,7 @@ def capture_method( capture_response: Optional[bool] = None, capture_error: Optional[bool] = None, ) -> Callable[["AnyCallableT"], "AnyCallableT"]: - ... + ... # pragma: no cover def capture_method( self, @@ -719,16 +719,15 @@ def _is_tracer_disabled() -> Union[bool, str]: Union[bool, str] """ logger.debug("Verifying whether Tracing has been disabled") - is_lambda_sam_cli = os.getenv(constants.SAM_LOCAL_ENV) - is_chalice_cli = os.getenv(constants.CHALICE_LOCAL_ENV) + is_lambda_env = os.getenv(constants.LAMBDA_TASK_ROOT_ENV) is_disabled = resolve_truthy_env_var_choice(env=os.getenv(constants.TRACER_DISABLED_ENV, "false")) if is_disabled: logger.debug("Tracing has been disabled via env var POWERTOOLS_TRACE_DISABLED") return is_disabled - if is_lambda_sam_cli or is_chalice_cli: - logger.debug("Running under SAM CLI env or not in Lambda env; disabling Tracing") + if not is_lambda_env: + logger.debug("Running outside Lambda env; disabling Tracing") return True return False diff --git a/aws_lambda_powertools/utilities/data_classes/common.py b/aws_lambda_powertools/utilities/data_classes/common.py index 66c8f15324f..fbf0502125e 100644 --- a/aws_lambda_powertools/utilities/data_classes/common.py +++ b/aws_lambda_powertools/utilities/data_classes/common.py @@ -65,7 +65,7 @@ def body(self) -> Optional[str]: @property def json_body(self) -> Any: """Parses the submitted body as json""" - return json.loads(self["body"]) + return json.loads(self.decoded_body) @property def decoded_body(self) -> str: diff --git a/aws_lambda_powertools/utilities/feature_flags/__init__.py b/aws_lambda_powertools/utilities/feature_flags/__init__.py new file mode 100644 index 00000000000..db7dfca5b57 --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/__init__.py @@ -0,0 +1,15 @@ +"""Advanced feature flags utility""" +from .appconfig import AppConfigStore +from .base import StoreProvider +from .exceptions import ConfigurationStoreError +from .feature_flags import FeatureFlags +from .schema import RuleAction, SchemaValidator + +__all__ = [ + "ConfigurationStoreError", + "FeatureFlags", + "RuleAction", + "SchemaValidator", + "AppConfigStore", + "StoreProvider", +] diff --git a/aws_lambda_powertools/utilities/feature_flags/appconfig.py b/aws_lambda_powertools/utilities/feature_flags/appconfig.py new file mode 100644 index 00000000000..30c70b6c590 --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/appconfig.py @@ -0,0 +1,92 @@ +import logging +import traceback +from typing import Any, Dict, Optional, cast + +from botocore.config import Config + +from aws_lambda_powertools.utilities.parameters import AppConfigProvider, GetParameterError, TransformParameterError + +from ...shared import jmespath_utils +from .base import StoreProvider +from .exceptions import ConfigurationStoreError, StoreClientError + +logger = logging.getLogger(__name__) + +TRANSFORM_TYPE = "json" + + +class AppConfigStore(StoreProvider): + def __init__( + self, + environment: str, + application: str, + name: str, + max_age: int = 5, + sdk_config: Optional[Config] = None, + envelope: Optional[str] = "", + jmespath_options: Optional[Dict] = None, + ): + """This class fetches JSON schemas from AWS AppConfig + + Parameters + ---------- + environment: str + Appconfig environment, e.g. 'dev/test' etc. + application: str + AppConfig application name, e.g. 'powertools' + name: str + AppConfig configuration name e.g. `my_conf` + max_age: int + cache expiration time in seconds, or how often to call AppConfig to fetch latest configuration + sdk_config: Optional[Config] + Botocore Config object to pass during client initialization + envelope : Optional[str] + JMESPath expression to pluck feature flags data from config + jmespath_options : Optional[Dict] + Alternative JMESPath options to be included when filtering expr + """ + super().__init__() + self.environment = environment + self.application = application + self.name = name + self.cache_seconds = max_age + self.config = sdk_config + self.envelope = envelope + self.jmespath_options = jmespath_options + self._conf_store = AppConfigProvider(environment=environment, application=application, config=sdk_config) + + def get_configuration(self) -> Dict[str, Any]: + """Fetch feature schema configuration from AWS AppConfig + + Raises + ------ + ConfigurationStoreError + Any validation error or AppConfig error that can occur + + Returns + ------- + Dict[str, Any] + parsed JSON dictionary + """ + try: + # parse result conf as JSON, keep in cache for self.max_age seconds + config = cast( + dict, + self._conf_store.get( + name=self.name, + transform=TRANSFORM_TYPE, + max_age=self.cache_seconds, + ), + ) + + if self.envelope: + config = jmespath_utils.unwrap_event_from_envelope( + data=config, envelope=self.envelope, jmespath_options=self.jmespath_options + ) + + return config + except (GetParameterError, TransformParameterError) as exc: + err_msg = traceback.format_exc() + if "AccessDenied" in err_msg: + raise StoreClientError(err_msg) from exc + raise ConfigurationStoreError("Unable to get AWS AppConfig configuration file") from exc diff --git a/aws_lambda_powertools/utilities/feature_flags/base.py b/aws_lambda_powertools/utilities/feature_flags/base.py new file mode 100644 index 00000000000..edb94c4f45d --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/base.py @@ -0,0 +1,51 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict + + +class StoreProvider(ABC): + @abstractmethod + def get_configuration(self) -> Dict[str, Any]: + """Get configuration from any store and return the parsed JSON dictionary + + Raises + ------ + ConfigurationStoreError + Any error that can occur during schema fetch or JSON parse + + Returns + ------- + Dict[str, Any] + parsed JSON dictionary + + **Example** + + ```python + { + "premium_features": { + "default": False, + "rules": { + "customer tier equals premium": { + "when_match": True, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium", + } + ], + } + }, + }, + "feature_two": { + "default": False + } + } + ``` + """ + return NotImplemented # pragma: no cover + + +class BaseValidator(ABC): + @abstractmethod + def validate(self): + return NotImplemented # pragma: no cover diff --git a/aws_lambda_powertools/utilities/feature_flags/exceptions.py b/aws_lambda_powertools/utilities/feature_flags/exceptions.py new file mode 100644 index 00000000000..eaea6c61cca --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/exceptions.py @@ -0,0 +1,13 @@ +class ConfigurationStoreError(Exception): + """When a configuration store raises an exception on config retrieval or parsing""" + + +class SchemaValidationError(Exception): + """When feature flag schema fails validation""" + + +class StoreClientError(Exception): + """When a store raises an exception that should be propagated to the client to fix + + For example, Access Denied errors when the client doesn't permissions to fetch config + """ diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py new file mode 100644 index 00000000000..d04e74ff293 --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -0,0 +1,260 @@ +import logging +from typing import Any, Dict, List, Optional, Union, cast + +from . import schema +from .base import StoreProvider +from .exceptions import ConfigurationStoreError + +logger = logging.getLogger(__name__) + + +class FeatureFlags: + def __init__(self, store: StoreProvider): + """Evaluates whether feature flags should be enabled based on a given context. + + It uses the provided store to fetch feature flag rules before evaluating them. + + Examples + -------- + + ```python + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + + app_config = AppConfigStore( + environment="test", + application="powertools", + name="test_conf_name", + max_age=300, + envelope="features" + ) + + feature_flags: FeatureFlags = FeatureFlags(store=app_config) + ``` + + Parameters + ---------- + store: StoreProvider + Store to use to fetch feature flag schema configuration. + """ + self._store = store + + @staticmethod + def _match_by_action(action: str, condition_value: Any, context_value: Any) -> bool: + if not context_value: + return False + mapping_by_action = { + schema.RuleAction.EQUALS.value: lambda a, b: a == b, + schema.RuleAction.STARTSWITH.value: lambda a, b: a.startswith(b), + schema.RuleAction.ENDSWITH.value: lambda a, b: a.endswith(b), + schema.RuleAction.IN.value: lambda a, b: a in b, + schema.RuleAction.NOT_IN.value: lambda a, b: a not in b, + } + + try: + func = mapping_by_action.get(action, lambda a, b: False) + return func(context_value, condition_value) + except Exception as exc: + logger.debug(f"caught exception while matching action: action={action}, exception={str(exc)}") + return False + + def _evaluate_conditions( + self, rule_name: str, feature_name: str, rule: Dict[str, Any], context: Dict[str, Any] + ) -> bool: + """Evaluates whether context matches conditions, return False otherwise""" + rule_match_value = rule.get(schema.RULE_MATCH_VALUE) + conditions = cast(List[Dict], rule.get(schema.CONDITIONS_KEY)) + + if not conditions: + logger.debug( + f"rule did not match, no conditions to match, rule_name={rule_name}, rule_value={rule_match_value}, " + f"name={feature_name} " + ) + return False + + for condition in conditions: + context_value = context.get(str(condition.get(schema.CONDITION_KEY))) + cond_action = condition.get(schema.CONDITION_ACTION, "") + cond_value = condition.get(schema.CONDITION_VALUE) + + if not self._match_by_action(action=cond_action, condition_value=cond_value, context_value=context_value): + logger.debug( + f"rule did not match action, rule_name={rule_name}, rule_value={rule_match_value}, " + f"name={feature_name}, context_value={str(context_value)} " + ) + return False # context doesn't match condition + + logger.debug(f"rule matched, rule_name={rule_name}, rule_value={rule_match_value}, name={feature_name}") + return True + + def _evaluate_rules( + self, *, feature_name: str, context: Dict[str, Any], feat_default: bool, rules: Dict[str, Any] + ) -> bool: + """Evaluates whether context matches rules and conditions, otherwise return feature default""" + for rule_name, rule in rules.items(): + rule_match_value = rule.get(schema.RULE_MATCH_VALUE) + + # Context might contain PII data; do not log its value + logger.debug(f"Evaluating rule matching, rule={rule_name}, feature={feature_name}, default={feat_default}") + if self._evaluate_conditions(rule_name=rule_name, feature_name=feature_name, rule=rule, context=context): + return bool(rule_match_value) + + # no rule matched, return default value of feature + logger.debug(f"no rule matched, returning feature default, default={feat_default}, name={feature_name}") + return feat_default + return False + + def get_configuration(self) -> Union[Dict[str, Dict], Dict]: + """Get validated feature flag schema from configured store. + + Largely used to aid testing, since it's called by `evaluate` and `get_enabled_features` methods. + + Raises + ------ + ConfigurationStoreError + Any propagated error from store + SchemaValidationError + When schema doesn't conform with feature flag schema + + Returns + ------ + Dict[str, Dict] + parsed JSON dictionary + + **Example** + + ```python + { + "premium_features": { + "default": False, + "rules": { + "customer tier equals premium": { + "when_match": True, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium", + } + ], + } + }, + }, + "feature_two": { + "default": False + } + } + ``` + """ + # parse result conf as JSON, keep in cache for max age defined in store + logger.debug(f"Fetching schema from registered store, store={self._store}") + config = self._store.get_configuration() + validator = schema.SchemaValidator(schema=config) + validator.validate() + + return config + + def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, default: bool) -> bool: + """Evaluate whether a feature flag should be enabled according to stored schema and input context + + **Logic when evaluating a feature flag** + + 1. Feature exists and a rule matches, returns when_match value + 2. Feature exists but has either no rules or no match, return feature default value + 3. Feature doesn't exist in stored schema, encountered an error when fetching -> return default value provided + + Parameters + ---------- + name: str + feature name to evaluate + context: Optional[Dict[str, Any]] + Attributes that should be evaluated against the stored schema. + + for example: `{"tenant_id": "X", "username": "Y", "region": "Z"}` + default: bool + default value if feature flag doesn't exist in the schema, + or there has been an error when fetching the configuration from the store + + Returns + ------ + bool + whether feature should be enabled or not + + Raises + ------ + SchemaValidationError + When schema doesn't conform with feature flag schema + """ + if context is None: + context = {} + + try: + features = self.get_configuration() + except ConfigurationStoreError as err: + logger.debug(f"Failed to fetch feature flags from store, returning default provided, reason={err}") + return default + + feature = features.get(name) + if feature is None: + logger.debug(f"Feature not found; returning default provided, name={name}, default={default}") + return default + + rules = feature.get(schema.RULES_KEY) + feat_default = feature.get(schema.FEATURE_DEFAULT_VAL_KEY) + if not rules: + logger.debug(f"no rules found, returning feature default, name={name}, default={feat_default}") + return bool(feat_default) + + logger.debug(f"looking for rule match, name={name}, default={feat_default}") + return self._evaluate_rules(feature_name=name, context=context, feat_default=bool(feat_default), rules=rules) + + def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> List[str]: + """Get all enabled feature flags while also taking into account context + (when a feature has defined rules) + + Parameters + ---------- + 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] + list of all feature names that either matches context or have True as default + + **Example** + + ```python + ["premium_features", "my_feature_two", "always_true_feature"] + ``` + + Raises + ------ + SchemaValidationError + When schema doesn't conform with feature flag schema + """ + if context is None: + context = {} + + features_enabled: List[str] = [] + + try: + features: Dict[str, Any] = self.get_configuration() + except ConfigurationStoreError as err: + logger.debug(f"Failed to fetch feature flags from store, returning empty list, reason={err}") + return features_enabled + + logger.debug("Evaluating all features") + for name, feature in features.items(): + rules = feature.get(schema.RULES_KEY, {}) + feature_default_value = feature.get(schema.FEATURE_DEFAULT_VAL_KEY) + if feature_default_value and not rules: + logger.debug(f"feature is enabled by default and has no defined rules, name={name}") + features_enabled.append(name) + elif self._evaluate_rules( + feature_name=name, context=context, feat_default=feature_default_value, rules=rules + ): + logger.debug(f"feature's calculated value is True, name={name}") + features_enabled.append(name) + + return features_enabled diff --git a/aws_lambda_powertools/utilities/feature_flags/schema.py b/aws_lambda_powertools/utilities/feature_flags/schema.py new file mode 100644 index 00000000000..efce82018db --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/schema.py @@ -0,0 +1,227 @@ +import logging +from enum import Enum +from typing import Any, Dict, List, Optional + +from .base import BaseValidator +from .exceptions import SchemaValidationError + +logger = logging.getLogger(__name__) + +RULES_KEY = "rules" +FEATURE_DEFAULT_VAL_KEY = "default" +CONDITIONS_KEY = "conditions" +RULE_MATCH_VALUE = "when_match" +CONDITION_KEY = "key" +CONDITION_VALUE = "value" +CONDITION_ACTION = "action" + + +class RuleAction(str, Enum): + EQUALS = "EQUALS" + STARTSWITH = "STARTSWITH" + ENDSWITH = "ENDSWITH" + IN = "IN" + NOT_IN = "NOT_IN" + + +class SchemaValidator(BaseValidator): + """Validates feature flag schema configuration + + Raises + ------ + SchemaValidationError + When schema doesn't conform with feature flag schema + + Schema + ------ + + **Feature object** + + A dictionary containing default value and rules for matching. + The value MUST be an object and MIGHT contain the following members: + + * **default**: `bool`. Defines default feature value. This MUST be present + * **rules**: `Dict[str, Dict]`. Rules object. This MIGHT be present + + ```python + { + "my_feature": { + "default": True, + "rules": {} + } + } + ``` + + **Rules object** + + A dictionary with each rule and their conditions that a feature might have. + The value MIGHT be present, and when defined it MUST contain the following members: + + * **when_match**: `bool`. Defines value to return when context matches conditions + * **conditions**: `List[Dict]`. Conditions object. This MUST be present + + ```python + { + "my_feature": { + "default": True, + "rules": { + "tenant id equals 345345435": { + "when_match": False, + "conditions": [] + } + } + } + } + ``` + + **Conditions object** + + A list of dictionaries containing conditions for a given rule. + The value MUST contain the following members: + + * **action**: `str`. Operation to perform to match a key and value. + The value MUST be either EQUALS, STARTSWITH, ENDSWITH, IN, NOT_IN + * **key**: `str`. Key in given context to perform operation + * **value**: `Any`. Value in given context that should match action operation. + + ```python + { + "my_feature": { + "default": True, + "rules": { + "tenant id equals 345345435": { + "when_match": False, + "conditions": [ + { + "action": "EQUALS", + "key": "tenant_id", + "value": "345345435", + } + ] + } + } + } + } + ``` + """ + + def __init__(self, schema: Dict[str, Any]): + self.schema = schema + + def validate(self) -> None: + logger.debug("Validating schema") + if not isinstance(self.schema, dict): + raise SchemaValidationError(f"Features must be a dictionary, schema={str(self.schema)}") + + features = FeaturesValidator(schema=self.schema) + features.validate() + + +class FeaturesValidator(BaseValidator): + """Validates each feature and calls RulesValidator to validate its rules""" + + def __init__(self, schema: Dict): + self.schema = schema + + def validate(self): + for name, feature in self.schema.items(): + logger.debug(f"Attempting to validate feature '{name}'") + self.validate_feature(name, feature) + rules = RulesValidator(feature=feature) + rules.validate() + + @staticmethod + def validate_feature(name, feature): + if not feature or not isinstance(feature, dict): + raise SchemaValidationError(f"Feature must be a non-empty dictionary, feature={name}") + + default_value = feature.get(FEATURE_DEFAULT_VAL_KEY) + if default_value is None or not isinstance(default_value, bool): + raise SchemaValidationError(f"feature 'default' boolean key must be present, feature={name}") + + +class RulesValidator(BaseValidator): + """Validates each rule and calls ConditionsValidator to validate each rule's conditions""" + + def __init__(self, feature: Dict[str, Any]): + self.feature = feature + self.feature_name = next(iter(self.feature)) + self.rules: Optional[Dict] = self.feature.get(RULES_KEY) + + def validate(self): + if not self.rules: + logger.debug("Rules are empty, ignoring validation") + return + + if not isinstance(self.rules, dict): + raise SchemaValidationError(f"Feature rules must be a dictionary, feature={self.feature_name}") + + for rule_name, rule in self.rules.items(): + logger.debug(f"Attempting to validate rule '{rule_name}'") + self.validate_rule(rule=rule, rule_name=rule_name, feature_name=self.feature_name) + conditions = ConditionsValidator(rule=rule, rule_name=rule_name) + conditions.validate() + + @staticmethod + def validate_rule(rule, rule_name, feature_name): + if not rule or not isinstance(rule, dict): + raise SchemaValidationError(f"Feature rule must be a dictionary, feature={feature_name}") + + RulesValidator.validate_rule_name(rule_name=rule_name, feature_name=feature_name) + RulesValidator.validate_rule_default_value(rule=rule, rule_name=rule_name) + + @staticmethod + def validate_rule_name(rule_name: str, feature_name: str): + if not rule_name or not isinstance(rule_name, str): + raise SchemaValidationError(f"Rule name key must have a non-empty string, feature={feature_name}") + + @staticmethod + def validate_rule_default_value(rule: Dict, rule_name: str): + rule_default_value = rule.get(RULE_MATCH_VALUE) + if not isinstance(rule_default_value, bool): + raise SchemaValidationError(f"'rule_default_value' key must have be bool, rule={rule_name}") + + +class ConditionsValidator(BaseValidator): + def __init__(self, rule: Dict[str, Any], rule_name: str): + self.conditions: List[Dict[str, Any]] = rule.get(CONDITIONS_KEY, {}) + self.rule_name = rule_name + + def validate(self): + if not self.conditions or not isinstance(self.conditions, list): + raise SchemaValidationError(f"Invalid condition, rule={self.rule_name}") + + for condition in self.conditions: + self.validate_condition(rule_name=self.rule_name, condition=condition) + + @staticmethod + def validate_condition(rule_name: str, condition: Dict[str, str]) -> None: + if not condition or not isinstance(condition, dict): + raise SchemaValidationError(f"Feature rule condition must be a dictionary, rule={rule_name}") + + # Condition can contain PII data; do not log condition value + logger.debug(f"Attempting to validate condition for '{rule_name}'") + ConditionsValidator.validate_condition_action(condition=condition, rule_name=rule_name) + ConditionsValidator.validate_condition_key(condition=condition, rule_name=rule_name) + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + @staticmethod + def validate_condition_action(condition: Dict[str, Any], rule_name: str): + action = condition.get(CONDITION_ACTION, "") + if action not in RuleAction.__members__: + allowed_values = [_action.value for _action in RuleAction] + raise SchemaValidationError( + f"'action' value must be either {allowed_values}, rule_name={rule_name}, action={action}" + ) + + @staticmethod + def validate_condition_key(condition: Dict[str, Any], rule_name: str): + key = condition.get(CONDITION_KEY, "") + if not key or not isinstance(key, str): + raise SchemaValidationError(f"'key' value must be a non empty string, rule={rule_name}") + + @staticmethod + def validate_condition_value(condition: Dict[str, Any], rule_name: str): + value = condition.get(CONDITION_VALUE, "") + if not value: + raise SchemaValidationError(f"'value' key must not be empty, rule={rule_name}") diff --git a/aws_lambda_powertools/utilities/feature_toggles/__init__.py b/aws_lambda_powertools/utilities/feature_toggles/__init__.py deleted file mode 100644 index 04237d63812..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Advanced feature toggles utility -""" -from .appconfig_fetcher import AppConfigFetcher -from .configuration_store import ConfigurationStore -from .exceptions import ConfigurationError -from .schema import ACTION, SchemaValidator -from .schema_fetcher import SchemaFetcher - -__all__ = [ - "ConfigurationError", - "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 deleted file mode 100644 index ae7c6c90e51..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py +++ /dev/null @@ -1,67 +0,0 @@ -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 ConfigurationError -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 - - 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 - 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 - ------ - ConfigurationError - 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 ConfigurationError(error_str) diff --git a/aws_lambda_powertools/utilities/feature_toggles/configuration_store.py b/aws_lambda_powertools/utilities/feature_toggles/configuration_store.py deleted file mode 100644 index 72d00bb9c03..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/configuration_store.py +++ /dev/null @@ -1,216 +0,0 @@ -import logging -from typing import Any, Dict, List, Optional, cast - -from . import schema -from .exceptions import ConfigurationError -from .schema_fetcher import SchemaFetcher - -logger = logging.getLogger(__name__) - - -class ConfigurationStore: - def __init__(self, schema_fetcher: SchemaFetcher): - """constructor - - Parameters - ---------- - 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 = cast(List[Dict], rule.get(schema.CONDITIONS_KEY)) - - for condition in conditions: - context_value = rules_context.get(str(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}, " - f"feature_name={feature_name}, context_value={str(context_value)} " - ) - # 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}, " - f"feature_name={feature_name}" - ) - return True - return False - - 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 bool(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}, " - f"feature_name={feature_name}" - ) - return feature_default_value - return False - - def get_configuration(self) -> Dict[str, Any]: - """Get configuration string from AWs AppConfig and returned the parsed JSON dictionary - - Raises - ------ - ConfigurationError - Any validation error or appconfig error that can occur - - Returns - ------ - 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() - # 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: - """Get a feature toggle boolean value. Value is calculated according to a set of rules and conditions. - - See below for explanation. - - Parameters - ---------- - 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 ConfigurationError: - logger.error("unable to get feature toggles JSON, returning provided value_if_missing value") - 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, " - f"feature_name={feature_name}, value_if_missing={value_if_missing}" - ) - 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}, " - f"default_value={feature_default_value}" - ) - return bool(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}" - ) - return self._handle_rules( - feature_name=feature_name, - rules_context=rules_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]: - """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]] - 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 ConfigurationError: - logger.error("unable to get feature toggles JSON") - 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 deleted file mode 100644 index d87f9a39dec..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/exceptions.py +++ /dev/null @@ -1,2 +0,0 @@ -class ConfigurationError(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 deleted file mode 100644 index 9d995ab59e4..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/schema.py +++ /dev/null @@ -1,84 +0,0 @@ -from enum import Enum -from logging import Logger -from typing import Any, Dict - -from .exceptions import ConfigurationError - -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: Logger): - self._logger = logger - - 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: - 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): - return 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 = schema.get(FEATURES_KEY) - if not isinstance(features_dict, dict): - return 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 deleted file mode 100644 index 89fffe1221d..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/schema_fetcher.py +++ /dev/null @@ -1,24 +0,0 @@ -from abc import ABC, abstractmethod -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 - - @abstractmethod - def get_json_configuration(self) -> Dict[str, Any]: - """Get configuration string from any configuration storing service and return the parsed JSON dictionary - - Raises - ------ - ConfigurationError - Any error that can occur during schema fetch or JSON parse - - Returns - ------- - Dict[str, Any] - parsed JSON dictionary - """ - return NotImplemented # pragma: no cover diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index c2bcc62fd69..fc1d4d47d55 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -78,9 +78,7 @@ def idempotent( try: return idempotency_handler.handle() except IdempotencyInconsistentStateError: - if i < max_handler_retries: - continue - else: + if i == max_handler_retries: # Allow the exception to bubble up after max retries exceeded raise @@ -117,7 +115,6 @@ def __init__( self.context = context self.event = event self.lambda_handler = lambda_handler - self.max_handler_retries = 2 def handle(self) -> Any: """ diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index eb43a8b30c5..0388adfbf55 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -14,7 +14,7 @@ import jmespath from aws_lambda_powertools.shared.cache_dict import LRUDict -from aws_lambda_powertools.shared.jmespath_functions import PowertoolsFunctions +from aws_lambda_powertools.shared.jmespath_utils import PowertoolsFunctions from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.idempotency.config import IdempotencyConfig from aws_lambda_powertools.utilities.idempotency.exceptions import ( diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py index dc00334277e..ae3a1be490f 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py @@ -154,7 +154,7 @@ def _update_record(self, data_record: DataRecord): "ExpressionAttributeNames": expression_attr_names, } - self.table.update_item(**kwargs) + self.table.update_item(**kwargs) # type: ignore def _delete_record(self, data_record: DataRecord) -> None: logger.debug(f"Deleting record for idempotency key: {data_record.idempotency_key}") diff --git a/aws_lambda_powertools/utilities/parameters/appconfig.py b/aws_lambda_powertools/utilities/parameters/appconfig.py index 63a8415f1ec..4a400aa7789 100644 --- a/aws_lambda_powertools/utilities/parameters/appconfig.py +++ b/aws_lambda_powertools/utilities/parameters/appconfig.py @@ -12,7 +12,7 @@ from ...shared import constants from ...shared.functions import resolve_env_var_choice -from .base import DEFAULT_PROVIDERS, BaseProvider +from .base import DEFAULT_MAX_AGE_SECS, DEFAULT_PROVIDERS, BaseProvider CLIENT_ID = str(uuid4()) @@ -110,6 +110,7 @@ def get_app_config( application: Optional[str] = None, transform: Optional[str] = None, force_fetch: bool = False, + max_age: int = DEFAULT_MAX_AGE_SECS, **sdk_options ) -> Union[str, list, dict, bytes]: """ @@ -127,6 +128,8 @@ def get_app_config( Transforms the content from a JSON object ('json') or base64 binary string ('binary') force_fetch: bool, optional Force update even before a cached item has expired, defaults to False + max_age: int + Maximum age of the cached value sdk_options: dict, optional Dictionary of options that will be passed to the Parameter Store get_parameter API call @@ -165,4 +168,6 @@ def get_app_config( sdk_options["ClientId"] = CLIENT_ID - return DEFAULT_PROVIDERS["appconfig"].get(name, transform=transform, force_fetch=force_fetch, **sdk_options) + return DEFAULT_PROVIDERS["appconfig"].get( + name, max_age=max_age, transform=transform, force_fetch=force_fetch, **sdk_options + ) diff --git a/aws_lambda_powertools/utilities/parameters/dynamodb.py b/aws_lambda_powertools/utilities/parameters/dynamodb.py index 5edae643ec0..39bd1a8d6b7 100644 --- a/aws_lambda_powertools/utilities/parameters/dynamodb.py +++ b/aws_lambda_powertools/utilities/parameters/dynamodb.py @@ -3,7 +3,7 @@ """ -from typing import Any, Dict, Optional +from typing import Dict, Optional import boto3 from boto3.dynamodb.conditions import Key @@ -141,11 +141,6 @@ class DynamoDBProvider(BaseProvider): c Parameter value c """ - table: Any = None - key_attr = None - sort_attr = None - value_attr = None - def __init__( self, table_name: str, diff --git a/aws_lambda_powertools/utilities/parameters/secrets.py b/aws_lambda_powertools/utilities/parameters/secrets.py index 6b7ea21fdf6..5699876d90e 100644 --- a/aws_lambda_powertools/utilities/parameters/secrets.py +++ b/aws_lambda_powertools/utilities/parameters/secrets.py @@ -8,7 +8,7 @@ import boto3 from botocore.config import Config -from .base import DEFAULT_PROVIDERS, BaseProvider +from .base import DEFAULT_MAX_AGE_SECS, DEFAULT_PROVIDERS, BaseProvider class SecretsProvider(BaseProvider): @@ -94,7 +94,11 @@ def _get_multiple(self, path: str, **sdk_options) -> Dict[str, str]: def get_secret( - name: str, transform: Optional[str] = None, force_fetch: bool = False, **sdk_options + name: str, + transform: Optional[str] = None, + force_fetch: bool = False, + max_age: int = DEFAULT_MAX_AGE_SECS, + **sdk_options ) -> Union[str, dict, bytes]: """ Retrieve a parameter value from AWS Secrets Manager @@ -107,6 +111,8 @@ def get_secret( Transforms the content from a JSON object ('json') or base64 binary string ('binary') force_fetch: bool, optional Force update even before a cached item has expired, defaults to False + max_age: int + Maximum age of the cached value sdk_options: dict, optional Dictionary of options that will be passed to the get_secret_value call @@ -143,4 +149,6 @@ def get_secret( if "secrets" not in DEFAULT_PROVIDERS: DEFAULT_PROVIDERS["secrets"] = SecretsProvider() - return DEFAULT_PROVIDERS["secrets"].get(name, transform=transform, force_fetch=force_fetch, **sdk_options) + return DEFAULT_PROVIDERS["secrets"].get( + name, max_age=max_age, transform=transform, force_fetch=force_fetch, **sdk_options + ) diff --git a/aws_lambda_powertools/utilities/parameters/ssm.py b/aws_lambda_powertools/utilities/parameters/ssm.py index 4bbef8bfc15..2a16ad91f08 100644 --- a/aws_lambda_powertools/utilities/parameters/ssm.py +++ b/aws_lambda_powertools/utilities/parameters/ssm.py @@ -186,7 +186,12 @@ def _get_multiple(self, path: str, decrypt: bool = False, recursive: bool = Fals def get_parameter( - name: str, transform: Optional[str] = None, decrypt: bool = False, force_fetch: bool = False, **sdk_options + name: str, + transform: Optional[str] = None, + decrypt: bool = False, + force_fetch: bool = False, + max_age: int = DEFAULT_MAX_AGE_SECS, + **sdk_options ) -> Union[str, list, dict, bytes]: """ Retrieve a parameter value from AWS Systems Manager (SSM) Parameter Store @@ -201,6 +206,8 @@ def get_parameter( If the parameter values should be decrypted force_fetch: bool, optional Force update even before a cached item has expired, defaults to False + max_age: int + Maximum age of the cached value sdk_options: dict, optional Dictionary of options that will be passed to the Parameter Store get_parameter API call @@ -240,7 +247,9 @@ def get_parameter( # Add to `decrypt` sdk_options to we can have an explicit option for this sdk_options["decrypt"] = decrypt - return DEFAULT_PROVIDERS["ssm"].get(name, transform=transform, force_fetch=force_fetch, **sdk_options) + return DEFAULT_PROVIDERS["ssm"].get( + name, max_age=max_age, transform=transform, force_fetch=force_fetch, **sdk_options + ) def get_parameters( @@ -249,6 +258,8 @@ def get_parameters( recursive: bool = True, decrypt: bool = False, force_fetch: bool = False, + max_age: int = DEFAULT_MAX_AGE_SECS, + raise_on_transform_error: bool = False, **sdk_options ) -> Union[Dict[str, str], Dict[str, dict], Dict[str, bytes]]: """ @@ -266,6 +277,11 @@ def get_parameters( If the parameter values should be decrypted force_fetch: bool, optional Force update even before a cached item has expired, defaults to False + max_age: int + Maximum age of the cached value + raise_on_transform_error: bool, optional + Raises an exception if any transform fails, otherwise this will + return a None value for each transform that failed sdk_options: dict, optional Dictionary of options that will be passed to the Parameter Store get_parameters_by_path API call @@ -305,4 +321,11 @@ def get_parameters( sdk_options["recursive"] = recursive sdk_options["decrypt"] = decrypt - return DEFAULT_PROVIDERS["ssm"].get_multiple(path, transform=transform, force_fetch=force_fetch, **sdk_options) + return DEFAULT_PROVIDERS["ssm"].get_multiple( + path, + max_age=max_age, + transform=transform, + raise_on_transform_error=raise_on_transform_error, + force_fetch=force_fetch, + **sdk_options + ) diff --git a/aws_lambda_powertools/utilities/parser/models/apigw.py b/aws_lambda_powertools/utilities/parser/models/apigw.py index 4de8ee96cc5..ed975e88e81 100644 --- a/aws_lambda_powertools/utilities/parser/models/apigw.py +++ b/aws_lambda_powertools/utilities/parser/models/apigw.py @@ -68,6 +68,13 @@ class APIGatewayEventRequestContext(BaseModel): routeKey: Optional[str] operationName: Optional[str] + @root_validator + def check_message_id(cls, values): + message_id, event_type = values.get("messageId"), values.get("eventType") + if message_id is not None and event_type != "MESSAGE": + raise TypeError("messageId is available only when the `eventType` is `MESSAGE`") + return values + class APIGatewayProxyEventModel(BaseModel): version: Optional[str] @@ -83,10 +90,3 @@ class APIGatewayProxyEventModel(BaseModel): stageVariables: Optional[Dict[str, str]] isBase64Encoded: bool body: str - - @root_validator() - def check_message_id(cls, values): - message_id, event_type = values.get("messageId"), values.get("eventType") - if message_id is not None and event_type != "MESSAGE": - raise TypeError("messageId is available only when the `eventType` is `MESSAGE`") - return values diff --git a/aws_lambda_powertools/utilities/validation/base.py b/aws_lambda_powertools/utilities/validation/base.py index b818f11a40e..13deb4d24e2 100644 --- a/aws_lambda_powertools/utilities/validation/base.py +++ b/aws_lambda_powertools/utilities/validation/base.py @@ -1,13 +1,9 @@ import logging -from typing import Any, Dict, Optional, Union +from typing import Dict, Optional, Union import fastjsonschema # type: ignore -import jmespath -from jmespath.exceptions import LexerError # type: ignore -from aws_lambda_powertools.shared.jmespath_functions import PowertoolsFunctions - -from .exceptions import InvalidEnvelopeExpressionError, InvalidSchemaFormatError, SchemaValidationError +from .exceptions import InvalidSchemaFormatError, SchemaValidationError logger = logging.getLogger(__name__) @@ -39,31 +35,3 @@ def validate_data_against_schema(data: Union[Dict, str], schema: Dict, formats: except fastjsonschema.JsonSchemaException as e: message = f"Failed schema validation. Error: {e.message}, Path: {e.path}, Data: {e.value}" # noqa: B306, E501 raise SchemaValidationError(message) - - -def unwrap_event_from_envelope(data: Union[Dict, str], envelope: str, jmespath_options: Optional[Dict]) -> Any: - """Searches data using JMESPath expression - - Parameters - ---------- - data : Dict - Data set to be filtered - envelope : str - JMESPath expression to filter data against - jmespath_options : Dict - Alternative JMESPath options to be included when filtering expr - - Returns - ------- - Any - Data found using JMESPath expression given in envelope - """ - if not jmespath_options: - jmespath_options = {"custom_functions": PowertoolsFunctions()} - - try: - logger.debug(f"Envelope detected: {envelope}. JMESPath options: {jmespath_options}") - return jmespath.search(envelope, data, options=jmespath.Options(**jmespath_options)) - except (LexerError, TypeError, UnicodeError) as e: - message = f"Failed to unwrap event from envelope using expression. Error: {e} Exp: {envelope}, Data: {data}" # noqa: B306, E501 - raise InvalidEnvelopeExpressionError(message) diff --git a/aws_lambda_powertools/utilities/validation/validator.py b/aws_lambda_powertools/utilities/validation/validator.py index 0497a49a714..02a685a1565 100644 --- a/aws_lambda_powertools/utilities/validation/validator.py +++ b/aws_lambda_powertools/utilities/validation/validator.py @@ -2,7 +2,8 @@ from typing import Any, Callable, Dict, Optional, Union from ...middleware_factory import lambda_handler_decorator -from .base import unwrap_event_from_envelope, validate_data_against_schema +from ...shared import jmespath_utils +from .base import validate_data_against_schema logger = logging.getLogger(__name__) @@ -16,7 +17,7 @@ def validator( inbound_formats: Optional[Dict] = None, outbound_schema: Optional[Dict] = None, outbound_formats: Optional[Dict] = None, - envelope: Optional[str] = None, + envelope: str = "", jmespath_options: Optional[Dict] = None, ) -> Any: """Lambda handler decorator to validate incoming/outbound data using a JSON Schema @@ -116,7 +117,9 @@ def handler(event, context): When JMESPath expression to unwrap event is invalid """ if envelope: - event = unwrap_event_from_envelope(data=event, envelope=envelope, jmespath_options=jmespath_options) + event = jmespath_utils.unwrap_event_from_envelope( + data=event, envelope=envelope, jmespath_options=jmespath_options + ) if inbound_schema: logger.debug("Validating inbound event") @@ -216,6 +219,8 @@ def handler(event, context): When JMESPath expression to unwrap event is invalid """ if envelope: - event = unwrap_event_from_envelope(data=event, envelope=envelope, jmespath_options=jmespath_options) + event = jmespath_utils.unwrap_event_from_envelope( + data=event, envelope=envelope, jmespath_options=jmespath_options + ) validate_data_against_schema(data=event, schema=schema, formats=formats) diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index a87eefbd5cd..a87daa3299a 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -730,6 +730,49 @@ This will enable full tracebacks errors in the response, print request and respo return app.resolve(event, context) ``` +### Custom serializer + +You can instruct API Gateway handler to use a custom serializer to best suit your needs, for example take into account Enums when serializing. + +=== "custom_serializer.py" + ```python hl_lines="19-20 24" + import json + from enum import Enum + from json import JSONEncoder + from typing import Dict + + class CustomEncoder(JSONEncoder): + """Your customer json encoder""" + def default(self, obj): + if isinstance(obj, Enum): + return obj.value + try: + iterable = iter(obj) + except TypeError: + pass + else: + return sorted(iterable) + return JSONEncoder.default(self, obj) + + def custom_serializer(obj) -> str: + """Your custom serializer function ApiGatewayResolver will use""" + return json.dumps(obj, cls=CustomEncoder) + + # Assigning your custom serializer + app = ApiGatewayResolver(serializer=custom_serializer) + + class Color(Enum): + RED = 1 + BLUE = 2 + + @app.get("/colors") + def get_color() -> Dict: + return { + # Color.RED will be serialized to 1 as expected now + "color": Color.RED, + "variations": {"light", "dark"}, + } + ``` ## Testing your code diff --git a/docs/core/tracer.md b/docs/core/tracer.md index b9cd86862f5..c6f2baa59fd 100644 --- a/docs/core/tracer.md +++ b/docs/core/tracer.md @@ -10,8 +10,7 @@ Tracer is an opinionated thin wrapper for [AWS X-Ray Python SDK](https://github. ## Key features * Auto capture cold start as annotation, and responses or full exceptions as metadata -* Run functions locally with SAM CLI without code change to disable tracing -* Explicitly disable tracing via env var `POWERTOOLS_TRACE_DISABLED="true"` +* Auto-disable when not running in AWS Lambda environment * Support tracing async methods, generators, and context managers * Auto patch supported modules by AWS X-Ray @@ -357,11 +356,7 @@ Tracer keeps a copy of its configuration after the first initialization. This is ## Testing your code -You can safely disable Tracer when unit testing your code using `POWERTOOLS_TRACE_DISABLED` environment variable. - -```bash -POWERTOOLS_TRACE_DISABLED=1 python -m pytest -``` +Tracer is disabled by default when not running in the AWS Lambda environment - This means no code changes or environment variables to be set. ## Tips diff --git a/docs/index.md b/docs/index.md index 104ed1d85d6..bd9d7875ece 100644 --- a/docs/index.md +++ b/docs/index.md @@ -212,6 +212,7 @@ aws serverlessrepo list-application-versions \ [Event source data classes](./utilities/data_classes.md) | Data classes describing the schema of common Lambda event triggers [Parser](./utilities/parser.md) | Data parsing and deep validation using Pydantic [Idempotency](./utilities/idempotency.md) | Idempotent Lambda handler +[Feature Flags](./utilities/feature_flags.md) | A simple rule engine to evaluate when one or multiple features should be enabled depending on the input ## Environment variables @@ -222,7 +223,7 @@ aws serverlessrepo list-application-versions \ | ------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------- | | **POWERTOOLS_SERVICE_NAME** | Sets service name used for tracing namespace, metrics dimension and structured logging | All | `"service_undefined"` | | **POWERTOOLS_METRICS_NAMESPACE** | Sets namespace used for metrics | [Metrics](./core/metrics) | `None` | -| **POWERTOOLS_TRACE_DISABLED** | Disables tracing | [Tracing](./core/tracer) | `false` | +| **POWERTOOLS_TRACE_DISABLED** | Explicitly disables tracing | [Tracing](./core/tracer) | `false` | | **POWERTOOLS_TRACER_CAPTURE_RESPONSE** | Captures Lambda or method return as metadata. | [Tracing](./core/tracer) | `true` | | **POWERTOOLS_TRACER_CAPTURE_ERROR** | Captures Lambda or method exception as metadata. | [Tracing](./core/tracer) | `true` | | **POWERTOOLS_TRACE_MIDDLEWARES** | Creates sub-segment for each custom middleware | [Middleware factory](./utilities/middleware_factory) | `false` | diff --git a/docs/media/feat_flags_evaluation_workflow.png b/docs/media/feat_flags_evaluation_workflow.png new file mode 100644 index 00000000000..deca3dfc297 Binary files /dev/null and b/docs/media/feat_flags_evaluation_workflow.png differ diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md new file mode 100644 index 00000000000..556cf9f4925 --- /dev/null +++ b/docs/utilities/feature_flags.md @@ -0,0 +1,654 @@ +--- +title: Feature flags +description: Utility +--- + +!!! note "This is currently in Beta, as we might change Store parameters in the next release." + +The feature flags utility provides a simple rule engine to define when one or multiple features should be enabled depending on the input. + +## Terminology + +Feature flags are used to modify behaviour without changing the application's code. These flags can be **static** or **dynamic**. + +**Static flags**. Indicates something is simply `on` or `off`, for example `TRACER_ENABLED=True`. + +**Dynamic flags**. Indicates something can have varying states, for example enable a premium feature for customer X not Y. + +!!! tip "You can use [Parameters utility](parameters.md) for static flags while this utility can do both static and dynamic feature flags." + +!!! warning "Be mindful that feature flags can increase the complexity of your application over time; use them sparingly." + +If you want to learn more about feature flags, their variations and trade-offs, check these articles: + +* [Feature Toggles (aka Feature Flags) - Pete Hodgson](https://martinfowler.com/articles/feature-toggles.html) +* [AWS Lambda Feature Toggles Made Simple - Ran Isenberg](https://isenberg-ran.medium.com/aws-lambda-feature-toggles-made-simple-580b0c444233) +* [Feature Flags Getting Started - CloudBees](https://www.cloudbees.com/blog/ultimate-feature-flag-guide) + +## Key features + +* Define simple feature flags to dynamically decide when to enable a feature +* Fetch one or all feature flags enabled for a given application context +* Support for static feature flags to simply turn on/off a feature without rules + +## Getting started + +### IAM Permissions + +Your Lambda function must have `appconfig:GetConfiguration` IAM permission in order to fetch configuration from AWS AppConfig. + +### Required resources + +By default, this utility provides [AWS AppConfig](https://docs.aws.amazon.com/appconfig/latest/userguide/what-is-appconfig.html) as a configuration store. + +The following sample infrastructure will be used throughout this documentation: + +=== "template.yaml" + + ```yaml hl_lines="5 11 18 25 31-50 54" + AWSTemplateFormatVersion: "2010-09-09" + Description: Lambda Powertools Feature flags sample template + Resources: + FeatureStoreApp: + Type: AWS::AppConfig::Application + Properties: + Description: "AppConfig Application for feature toggles" + Name: product-catalogue + + FeatureStoreDevEnv: + Type: AWS::AppConfig::Environment + Properties: + ApplicationId: !Ref FeatureStoreApp + Description: "Development Environment for the App Config Store" + Name: dev + + FeatureStoreConfigProfile: + Type: AWS::AppConfig::ConfigurationProfile + Properties: + ApplicationId: !Ref FeatureStoreApp + Name: features + LocationUri: "hosted" + + HostedConfigVersion: + Type: AWS::AppConfig::HostedConfigurationVersion + Properties: + ApplicationId: !Ref FeatureStoreApp + ConfigurationProfileId: !Ref FeatureStoreConfigProfile + Description: 'A sample hosted configuration version' + Content: | + { + "premium_features": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + }, + "ten_percent_off_campaign": { + "default": false + } + } + ContentType: 'application/json' + + ConfigDeployment: + Type: AWS::AppConfig::Deployment + Properties: + ApplicationId: !Ref FeatureStoreApp + ConfigurationProfileId: !Ref FeatureStoreConfigProfile + ConfigurationVersion: !Ref HostedConfigVersion + DeploymentStrategyId: "AppConfig.AllAtOnce" + EnvironmentId: !Ref FeatureStoreDevEnv + ``` + +=== "CDK" + + ```python hl_lines="11-22 24 29 35 42 50" + import json + + import aws_cdk.aws_appconfig as appconfig + from aws_cdk import core + + + class SampleFeatureFlagStore(core.Construct): + def __init__(self, scope: core.Construct, id_: str) -> None: + super().__init__(scope, id_) + + features_config = { + "premium_features": { + "default": False, + "rules": { + "customer tier equals premium": { + "when_match": True, + "conditions": [{"action": "EQUALS", "key": "tier", "value": "premium"}], + } + }, + }, + "ten_percent_off_campaign": {"default": True}, + } + + self.config_app = appconfig.CfnApplication( + self, + id="app", + name="product-catalogue", + ) + self.config_env = appconfig.CfnEnvironment( + self, + id="env", + application_id=self.config_app.ref, + name="dev-env", + ) + self.config_profile = appconfig.CfnConfigurationProfile( + self, + id="profile", + application_id=self.config_app.ref, + location_uri="hosted", + name="features", + ) + self.hosted_cfg_version = appconfig.CfnHostedConfigurationVersion( + self, + "version", + application_id=self.config_app.ref, + configuration_profile_id=self.config_profile.ref, + content=json.dumps(features_config), + content_type="application/json", + ) + self.app_config_deployment = appconfig.CfnDeployment( + self, + id="deploy", + application_id=self.config_app.ref, + configuration_profile_id=self.config_profile.ref, + configuration_version=self.hosted_cfg_version.ref, + deployment_strategy_id="AppConfig.AllAtOnce", + environment_id=self.config_env.ref, + ) + + ``` + +### Evaluating a single feature flag + +To get started, you'd need to initialize `AppConfigStore` and `FeatureFlags`. Then call `FeatureFlags` `evaluate` method to fetch, validate, and evaluate your feature. + +The `evaluate` method supports two optional parameters: + +* **context**: Value to be evaluated against each rule defined for the given feature +* **default**: Sentinel value to use in case we experience any issues with our store, or feature doesn't exist + +=== "app.py" + + ```python hl_lines="3 9 13 17-19" + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="features" + ) + + feature_flags = FeatureFlags(store=app_config) + + def lambda_handler(event, context): + # Get customer's tier from incoming request + ctx = { "tier": event.get("tier", "standard") } + + # Evaluate whether customer's tier has access to premium features + # based on `has_premium_features` rules + has_premium_features: bool = feature_flags.evaluate(name="premium_features", + context=ctx, default=False) + if has_premium_features: + # enable premium features + ... + ``` + +=== "event.json" + + ```json hl_lines="3" + { + "username": "lessa", + "tier": "premium", + "basked_id": "random_id" + } + ``` +=== "features.json" + + ```json hl_lines="2 6 9-11" + { + "premium_features": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + }, + "ten_percent_off_campaign": { + "default": false + } + } + ``` + +#### Static flags + +We have a static flag named `ten_percent_off_campaign`. Meaning, there are no conditional rules, it's either ON or OFF for all customers. + +In this case, we could omit the `context` parameter and simply evaluate whether we should apply the 10% discount. + +=== "app.py" + + ```python hl_lines="12-13" + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="features" + ) + + feature_flags = FeatureFlags(store=app_config) + + def lambda_handler(event, context): + apply_discount: bool = feature_flags.evaluate(name="ten_percent_off_campaign", + default=False) + + if apply_discount: + # apply 10% discount to product + ... + ``` + +=== "features.json" + + ```json hl_lines="2-3" + { + "ten_percent_off_campaign": { + "default": false + } + } + ``` + +### Getting all enabled features + +As you might have noticed, each `evaluate` call means an API call to the Store and the more features you have the more costly this becomes. + +You can use `get_enabled_features` method for scenarios where you need a list of all enabled features according to the input context. + +=== "app.py" + + ```python hl_lines="17-20 23" + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + + app = ApiGatewayResolver() + + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="features" + ) + + feature_flags = FeatureFlags(store=app_config) + + + @app.get("/products") + def list_products(): + ctx = { + **app.current_event.headers, + **app.current_event.json_body + } + + # all_features is evaluated to ["geo_customer_campaign", "ten_percent_off_campaign"] + all_features: list[str] = feature_flags.get_enabled_features(context=ctx) + + if "geo_customer_campaign" in all_features: + # apply discounts based on geo + ... + + if "ten_percent_off_campaign" in all_features: + # apply additional 10% for all customers + ... + + def lambda_handler(event, context): + return app.resolve(event, context) + ``` + +=== "event.json" + + ```json hl_lines="2 8" + { + "body": '{"username": "lessa", "tier": "premium", "basked_id": "random_id"}', + "resource": "/products", + "path": "/products", + "httpMethod": "GET", + "isBase64Encoded": false, + "headers": { + "CloudFront-Viewer-Country": "NL", + } + } + ``` + +=== "features.json" + + ```json hl_lines="17-18 20 27-29" + { + "premium_features": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + }, + "ten_percent_off_campaign": { + "default": true + }, + "geo_customer_campaign": { + "default": false, + "rules": { + "customer in temporary discount geo": { + "when_match": true, + "conditions": [ + { + "action": "IN", + "key": "CloudFront-Viewer-Country", + "value": ["NL", "IE", "UK", "PL", "PT"}, + } + ] + } + } + } + } + ``` + + +## Advanced + +### Schema + +This utility expects a certain schema to be stored as JSON within AWS AppConfig. + +#### Features + +A feature can simply have its name and a `default` value. This is either on or off, also known as a [static flag](#static-flags). + +=== "minimal_schema.json" + ```json hl_lines="2-3" + { + "global_feature": { + "default": true + } + } + ``` + +If you need more control and want to provide context such as user group, permissions, location, etc., you need to add rules to your feature flag configuration. + +#### Rules + +When adding `rules` to a feature, they must contain: + +1. A rule name as a key +2. `when_match` boolean value that should be used when conditions match +3. A list of `conditions` for evaluation + +=== "feature_with_rules.json" + + ```json hl_lines="4-11" + { + "premium_feature": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + } + } + ``` + +You can have multiple rules with different names. The rule engine will return the first result `when_match` of the matching rule configuration, or `default` value when none of the rules apply. + +#### Conditions + +The `conditions` block is a list of conditions that contain `action`, `key`, and `value` keys: + +=== "conditions.json" + ```json hl_lines="8-11" + { + ... + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + ``` + +The `action` configuration can have 5 different values: `EQUALS`, `STARTSWITH`, `ENDSWITH`, `IN`, `NOT_IN`. + +The `key` and `value` will be compared to the input from the context parameter. + +**For multiple conditions**, we will evaluate the list of conditions as a logical `AND`, so all conditions needs to match to return `when_match` value. + +#### Rule engine flowchart + +Now that you've seen all properties of a feature flag schema, this flowchart describes how the rule engines makes a decision on when to return `True` or `False`. + +![Rule engine ](../media/feat_flags_evaluation_workflow.png) + +### Adjusting in-memory cache + +By default, we cache configuration retrieved from the Store for 5 seconds for performance and reliability reasons. + +You can override `max_age` parameter when instantiating the store. + +```python hl_lines="7" +from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + +app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="features", + max_age=300 +) +``` + +### Envelope + +There are scenarios where you might want to include feature flags as part of an existing application configuration. + +For this to work, you need to use a JMESPath expression via the `envelope` parameter to extract that key as the feature flags configuration. + +=== "app.py" + + ```python hl_lines="7" + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="configuration", + envelope = "feature_flags" + ) + ``` + +=== "configuration.json" + + ```json hl_lines="6" + { + "logging": { + "level": "INFO", + "sampling_rate": 0.1 + }, + "feature_flags": { + "premium_feature": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + }, + "feature2": { + "default": false + } + } + } + ``` + +### Built-in store provider + +!!! info "For GA, you'll be able to bring your own store." + +#### AppConfig + +AppConfig store provider fetches any JSON document from AWS AppConfig. + +These are the available options for further customization. + +Parameter | Default | Description +------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------- +**environment** | `""` | AWS AppConfig Environment, e.g. `test` +**application** | `""` | AWS AppConfig Application +**name** | `""` | AWS AppConfig Configuration name +**envelope** | `None` | JMESPath expression to use to extract feature flags configuration from AWS AppConfig configuration +**max_age** | `5` | Number of seconds to cache feature flags configuration fetched from AWS AppConfig +**sdk_config** | `None` | [Botocore Config object](https://botocore.amazonaws.com/v1/documentation/api/latest/reference/config.html){target="_blank"} +**jmespath_options** | `None` | For advanced use cases when you want to bring your own [JMESPath functions](https://github.com/jmespath/jmespath.py#custom-functions){target="_blank"} + +=== "appconfig_store_example.py" + +```python hl_lines="19-25" +from botocore.config import Config + +import jmespath + +boto_config = Config(read_timeout=10, retries={"total_max_attempts": 2}) + +# Custom JMESPath functions +class CustomFunctions(jmespath.functions.Functions): + + @jmespath.functions.signature({'types': ['string']}) + def _func_special_decoder(self, s): + return my_custom_decoder_logic(s) + + +custom_jmespath_options = {"custom_functions": CustomFunctions()} + + +app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="configuration", + max_age=120, + envelope = "features", + sdk_config=boto_config, + jmespath_options=custom_jmespath_options +) +``` + + +## Testing your code + +You can unit test your feature flags locally and independently without setting up AWS AppConfig. + +`AppConfigStore` only fetches a JSON document with a specific schema. This allows you to mock the response and use it to verify the rule evaluation. + +!!! warning "This excerpt relies on `pytest` and `pytest-mock` dependencies" + +=== "test_feature_flags_independently.py" + + ```python hl_lines="9-11" + from typing import Dict, List, Optional + + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore, RuleAction + + + def init_feature_flags(mocker, mock_schema, envelope="") -> FeatureFlags: + """Mock AppConfig Store get_configuration method to use mock schema instead""" + + method_to_mock = "aws_lambda_powertools.utilities.feature_flags.AppConfigStore.get_configuration" + mocked_get_conf = mocker.patch(method_to_mock) + mocked_get_conf.return_value = mock_schema + + app_conf_store = AppConfigStore( + environment="test_env", + application="test_app", + name="test_conf_name", + envelope=envelope, + ) + + return FeatureFlags(store=app_conf_store) + + + def test_flags_condition_match(mocker): + # GIVEN + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant id equals 12345": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "12345", + } + ], + } + }, + } + } + + # WHEN + ctx = {"tenant_id": "12345", "username": "a"} + feature_flags = init_feature_flags(mocker=mocker, mock_schema=mocked_app_config_schema) + flag = feature_flags.evaluate(name="my_feature", context=ctx, default=False) + + # THEN + assert flag == expected_value + ``` + +## Feature flags vs Parameters vs env vars + +Method | When to use | Requires new deployment on changes | Supported services +------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------- +**[Environment variables](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html){target="_blank"}** | Simple configuration that will rarely if ever change, because changing it requires a Lambda function deployment. | Yes | Lambda +**[Parameters utility](parameters.md)** | Access to secrets, or fetch parameters in different formats from AWS System Manager Parameter Store or Amazon DynamoDB. | No | Parameter Store, DynamoDB, Secrets Manager, AppConfig +**Feature flags utility** | Rule engine to define when one or multiple features should be enabled depending on the input. | No | AppConfig diff --git a/docs/utilities/parameters.md b/docs/utilities/parameters.md index 8fc3227e2c6..871ea199e5a 100644 --- a/docs/utilities/parameters.md +++ b/docs/utilities/parameters.md @@ -437,6 +437,41 @@ For example, if you have three parameters, */param/a*, */param/b* and */param/c* values = ssm_provider.get_multiple("/param", transform="json", raise_on_transform_error=True) ``` +#### Auto-transform values on suffix + +If you use `transform` with `get_multiple()`, you might want to retrieve and transform parameters encoded in different formats. + +You can do this with a single request by using `transform="auto"`. This will instruct any Parameter to to infer its type based on the suffix and transform it accordingly. + +!!! info "`transform="auto"` feature is available across all providers, including the high level functions" + +=== "transform_auto.py" + + ```python hl_lines="6" + from aws_lambda_powertools.utilities import parameters + + ssm_provider = parameters.SSMProvider() + + def handler(event, context): + values = ssm_provider.get_multiple("/param", transform="auto") + ``` + +For example, if you have two parameters with the following suffixes `.json` and `.binary`: + +| Parameter name | Parameter value | +| --------------- | -------------------- | +| /param/a.json | [some encoded value] | +| /param/a.binary | [some encoded value] | + +The return of `ssm_provider.get_multiple("/param", transform="auto")` call will be a dictionary like: + +```json +{ + "a.json": [some value], + "b.binary": [some value] +} +``` + ### Passing additional SDK arguments You can use arbitrary keyword arguments to pass it directly to the underlying SDK method. diff --git a/mkdocs.yml b/mkdocs.yml index 7ee0fd56236..94dc9980cf1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -25,6 +25,7 @@ nav: - utilities/data_classes.md - utilities/parser.md - utilities/idempotency.md + - utilities/feature_flags.md theme: name: material diff --git a/poetry.lock b/poetry.lock index d15b11daae8..ac68acc59ed 100644 --- a/poetry.lock +++ b/poetry.lock @@ -81,20 +81,23 @@ d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] name = "boto3" -version = "1.18.1" +version = "1.18.17" description = "The AWS SDK for Python" category = "main" optional = false python-versions = ">= 3.6" [package.dependencies] -botocore = ">=1.21.1,<1.22.0" +botocore = ">=1.21.17,<1.22.0" jmespath = ">=0.7.1,<1.0.0" s3transfer = ">=0.5.0,<0.6.0" +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + [[package]] name = "botocore" -version = "1.21.1" +version = "1.21.17" description = "Low-level, data-driven core of boto 3." category = "main" optional = false @@ -228,7 +231,7 @@ pyflakes = ">=2.3.0,<2.4.0" [[package]] name = "flake8-black" -version = "0.2.1" +version = "0.2.3" description = "flake8 plugin to call black as a code style validator" category = "dev" optional = false @@ -237,6 +240,7 @@ python-versions = "*" [package.dependencies] black = "*" flake8 = ">=3.0.0" +toml = "*" [[package]] name = "flake8-bugbear" @@ -413,7 +417,7 @@ python-versions = "*" [[package]] name = "isort" -version = "5.9.2" +version = "5.9.3" description = "A Python utility / library to sort Python imports." category = "dev" optional = false @@ -592,7 +596,7 @@ mkdocs = ">=0.17" [[package]] name = "mkdocs-material" -version = "7.1.11" +version = "7.2.3" description = "A Material Design theme for MkDocs" category = "dev" optional = false @@ -693,11 +697,11 @@ python-versions = ">=2.6" [[package]] name = "pdoc3" -version = "0.9.2" +version = "0.10.0" description = "Auto-generate API documentation for Python projects." category = "dev" optional = false -python-versions = ">= 3.5" +python-versions = ">= 3.6" [package.dependencies] mako = "*" @@ -1084,7 +1088,7 @@ pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" python-versions = "^3.6.1" -content-hash = "1e91beb4537c7042746d638f86154a664cbb840c1a43b2e902586a1dc7b0b9c2" +content-hash = "f1f9f5b0dfe99881c9ec59adc3b58e8802d23b503d45ebc9908762e36af57c11" [metadata.files] appdirs = [ @@ -1111,12 +1115,12 @@ black = [ {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, ] boto3 = [ - {file = "boto3-1.18.1-py3-none-any.whl", hash = "sha256:a6399df957bfc7944fbd97e9fb0755cba29b1cb135b91d7e43fd298b268ab804"}, - {file = "boto3-1.18.1.tar.gz", hash = "sha256:ddfe4a78f04cd2d3a7a37d5cdfa07b4889b24296508786969bc968bee6b8b003"}, + {file = "boto3-1.18.17-py3-none-any.whl", hash = "sha256:69a5ebbd5fda6742d20fd536cd9b2927f2eaa8dde84ad529fe816231afcf9c68"}, + {file = "boto3-1.18.17.tar.gz", hash = "sha256:5e5f60ece9b73d48f668bef56ddcde716f013b48a62fdf9c5eac9512a5981136"}, ] botocore = [ - {file = "botocore-1.21.1-py3-none-any.whl", hash = "sha256:b845220eb580d10f7714798a96e380eb8f94dca89905a41d8a3c35119c757b01"}, - {file = "botocore-1.21.1.tar.gz", hash = "sha256:200887ce5f3b47d7499b7ded75dc65c4649abdaaddd06cebc118a3a954d6fd73"}, + {file = "botocore-1.21.17-py3-none-any.whl", hash = "sha256:5b665142bdb2c30fc86b15bc48dd8b74c9cac69dc3e20b6d8f79cb60ff368797"}, + {file = "botocore-1.21.17.tar.gz", hash = "sha256:a0d64369857d86b3a6d01b0c5933671c2394584311ce3af702271ba221b09afa"}, ] certifi = [ {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, @@ -1212,8 +1216,8 @@ flake8 = [ {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, ] flake8-black = [ - {file = "flake8-black-0.2.1.tar.gz", hash = "sha256:f26651bc10db786c03f4093414f7c9ea982ed8a244cec323c984feeffdf4c118"}, - {file = "flake8_black-0.2.1-py3-none-any.whl", hash = "sha256:941514149cb8b489cb17a4bb1cf18d84375db3b34381bb018de83509437931a0"}, + {file = "flake8-black-0.2.3.tar.gz", hash = "sha256:c199844bc1b559d91195ebe8620216f21ed67f2cc1ff6884294c91a0d2492684"}, + {file = "flake8_black-0.2.3-py3-none-any.whl", hash = "sha256:cc080ba5b3773b69ba102b6617a00cc4ecbad8914109690cfda4d565ea435d96"}, ] flake8-bugbear = [ {file = "flake8-bugbear-21.4.3.tar.gz", hash = "sha256:2346c81f889955b39e4a368eb7d508de723d9de05716c287dc860a4073dc57e7"}, @@ -1274,8 +1278,8 @@ iniconfig = [ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] isort = [ - {file = "isort-5.9.2-py3-none-any.whl", hash = "sha256:eed17b53c3e7912425579853d078a0832820f023191561fcee9d7cae424e0813"}, - {file = "isort-5.9.2.tar.gz", hash = "sha256:f65ce5bd4cbc6abdfbe29afc2f0245538ab358c14590912df638033f157d555e"}, + {file = "isort-5.9.3-py3-none-any.whl", hash = "sha256:e17d6e2b81095c9db0a03a8025a957f334d6ea30b26f9ec70805411e5c7c81f2"}, + {file = "isort-5.9.3.tar.gz", hash = "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899"}, ] jinja2 = [ {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"}, @@ -1361,8 +1365,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.11.tar.gz", hash = "sha256:cad3a693f1c28823370578e5b9c9aea418bddae0c7348ab734537391e9f2b1e5"}, - {file = "mkdocs_material-7.1.11-py2.py3-none-any.whl", hash = "sha256:0bcfb788020b72b0ebf5b2722ddf89534acaed8c3feb39c2d6dda239b49dec45"}, + {file = "mkdocs-material-7.2.3.tar.gz", hash = "sha256:688f0162e7356aa5d019cf687851f5b24c7002886bd939b8159595cc16966edf"}, + {file = "mkdocs_material-7.2.3-py2.py3-none-any.whl", hash = "sha256:039ce80555ba457faa10af993c57e9b6469918c6a5427851dcdd65591cc47fac"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.1.tar.gz", hash = "sha256:6947fb7f5e4291e3c61405bad3539d81e0b3cd62ae0d66ced018128af509c68f"}, @@ -1414,7 +1418,7 @@ pbr = [ {file = "pbr-5.6.0.tar.gz", hash = "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd"}, ] pdoc3 = [ - {file = "pdoc3-0.9.2.tar.gz", hash = "sha256:9df5d931f25f353c69c46819a3bd03ef96dd286f2a70bb1b93a23a781f91faa1"}, + {file = "pdoc3-0.10.0.tar.gz", hash = "sha256:5f22e7bcb969006738e1aa4219c75a32f34c2d62d46dc9d2fb2d3e0b0287e4b7"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, diff --git a/pyproject.toml b/pyproject.toml index 12d3c68376f..5aeed9fdcf7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "aws_lambda_powertools" -version = "1.18.1" -description = "Python utilities for AWS Lambda functions including but not limited to tracing, logging and custom metric" +version = "1.19.0" +description = "A suite of utilities for AWS Lambda functions to ease adopting best practices such as tracing, structured logging, custom metrics, and more." authors = ["Amazon Web Services"] include = ["aws_lambda_powertools/py.typed"] classifiers=[ @@ -15,7 +15,7 @@ classifiers=[ ] repository="https://github.com/awslabs/aws-lambda-powertools-python" readme = "README.md" -keywords = ["aws_lambda_powertools", "aws", "tracing", "logging", "lambda", "powertools"] +keywords = ["aws_lambda_powertools", "aws", "tracing", "logging", "lambda", "powertools", "feature_flags", "idempotency", "middleware"] license = "MIT-0" [tool.poetry.dependencies] @@ -32,24 +32,24 @@ coverage = {extras = ["toml"], version = "^5.5"} pytest = "^6.2.2" black = "^20.8b1" flake8 = "^3.9.0" -flake8-black = "^0.2.1" +flake8-black = "^0.2.3" flake8-builtins = "^1.5.3" flake8-comprehensions = "^3.4.0" flake8-debugger = "^4.0.0" flake8-fixme = "^1.1.1" flake8-isort = "^4.0.0" flake8-variables-names = "^0.0.4" -isort = "^5.9.2" +isort = "^5.9.3" pytest-cov = "^2.12.1" pytest-mock = "^3.5.1" -pdoc3 = "^0.9.2" +pdoc3 = "^0.10.0" pytest-asyncio = "^0.15.1" bandit = "^1.7.0" radon = "^4.5.0" xenon = "^0.7.3" flake8-eradicate = "^1.1.0" flake8-bugbear = "^21.3.2" -mkdocs-material = "^7.1.11" +mkdocs-material = "^7.2.3" mkdocs-git-revision-date-plugin = "^0.3.1" mike = "^0.6.0" mypy = "^0.910" diff --git a/tests/functional/event_handler/test_api_gateway.py b/tests/functional/event_handler/test_api_gateway.py index f16086ba634..1272125da8b 100644 --- a/tests/functional/event_handler/test_api_gateway.py +++ b/tests/functional/event_handler/test_api_gateway.py @@ -3,6 +3,8 @@ import zlib from copy import deepcopy from decimal import Decimal +from enum import Enum +from json import JSONEncoder from pathlib import Path from typing import Dict @@ -728,3 +730,42 @@ def get_account(account_id: str): ret = app.resolve(event, None) assert ret["statusCode"] == 200 + + +def test_custom_serializer(): + # GIVEN a custom serializer to handle enums and sets + class CustomEncoder(JSONEncoder): + def default(self, data): + if isinstance(data, Enum): + return data.value + try: + iterable = iter(data) + except TypeError: + pass + else: + return sorted(iterable) + return JSONEncoder.default(self, data) + + def custom_serializer(data) -> str: + return json.dumps(data, cls=CustomEncoder) + + app = ApiGatewayResolver(serializer=custom_serializer) + + class Color(Enum): + RED = 1 + BLUE = 2 + + @app.get("/colors") + def get_color() -> Dict: + return { + "color": Color.RED, + "variations": {"light", "dark"}, + } + + # WHEN calling handler + response = app({"httpMethod": "GET", "path": "/colors"}, None) + + # THEN then use the custom serializer + body = response["body"] + expected = '{"color": 1, "variations": ["dark", "light"]}' + assert expected == body diff --git a/tests/functional/feature_toggles/__init__.py b/tests/functional/feature_flags/__init__.py similarity index 100% rename from tests/functional/feature_toggles/__init__.py rename to tests/functional/feature_flags/__init__.py diff --git a/tests/functional/feature_flags/test_feature_flags.py b/tests/functional/feature_flags/test_feature_flags.py new file mode 100644 index 00000000000..5342105da3d --- /dev/null +++ b/tests/functional/feature_flags/test_feature_flags.py @@ -0,0 +1,589 @@ +from typing import Dict, List, Optional + +import pytest +from botocore.config import Config + +from aws_lambda_powertools.utilities.feature_flags import ConfigurationStoreError, schema +from aws_lambda_powertools.utilities.feature_flags.appconfig import AppConfigStore +from aws_lambda_powertools.utilities.feature_flags.exceptions import StoreClientError +from aws_lambda_powertools.utilities.feature_flags.feature_flags import FeatureFlags +from aws_lambda_powertools.utilities.feature_flags.schema import RuleAction +from aws_lambda_powertools.utilities.parameters import GetParameterError + + +@pytest.fixture(scope="module") +def config(): + return Config(region_name="us-east-1") + + +def init_feature_flags( + mocker, mock_schema: Dict, config: Config, envelope: str = "", jmespath_options: Optional[Dict] = None +) -> FeatureFlags: + mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get") + mocked_get_conf.return_value = mock_schema + + app_conf_fetcher = AppConfigStore( + environment="test_env", + application="test_app", + name="test_conf_name", + max_age=600, + sdk_config=config, + envelope=envelope, + jmespath_options=jmespath_options, + ) + feature_flags: FeatureFlags = FeatureFlags(store=app_conf_fetcher) + return feature_flags + + +def init_fetcher_side_effect(mocker, config: Config, side_effect) -> AppConfigStore: + mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get") + mocked_get_conf.side_effect = side_effect + return AppConfigStore( + environment="env", + application="application", + name="conf", + max_age=1, + sdk_config=config, + ) + + +# this test checks that we get correct value of feature that exists in the schema. +# we also don't send an empty context dict in this case +def test_flags_rule_does_not_match(mocker, config): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant id equals 345345435": { + "when_match": False, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "345345435", + } + ], + } + }, + } + } + + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={}, default=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 evaluate API +def test_flags_no_conditions_feature_does_not_exist(mocker, config): + expected_value = False + mocked_app_config_schema = {"my_fake_feature": {"default": True}} + + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={}, default=expected_value) + assert toggle == expected_value + + +# check that feature match works when they are no rules and we send context. +# default value is False but the feature has a True default_value. +def test_flags_no_rules(mocker, config): + expected_value = True + mocked_app_config_schema = {"my_feature": {"default": expected_value}} + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_value + + +# 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_flags_conditions_no_match(mocker, config): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant id equals 345345435": { + "when_match": False, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "345345435", + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_value + + +# check that a rule can match when it has multiple conditions, see rule name for further explanation +def test_flags_conditions_rule_not_match_multiple_conditions_match_only_one_condition(mocker, config): + expected_value = False + tenant_id_val = "6" + username_val = "a" + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant id equals 6 and username is a": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.EQUALS.value, # this condition matches + "key": "tenant_id", + "value": tenant_id_val, + }, + { + "action": RuleAction.EQUALS.value, # this condition does not + "key": "username", + "value": "bbb", + }, + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate( + name="my_feature", + context={ + "tenant_id": tenant_id_val, + "username": username_val, + }, + default=True, + ) + assert toggle == expected_value + + +def test_flags_conditions_rule_match_equal_multiple_conditions(mocker, config): + expected_value = False + tenant_id_val = "6" + username_val = "a" + mocked_app_config_schema = { + "my_feature": { + "default": True, + "rules": { + "tenant id equals 6 and username is a": { + "when_match": expected_value, + "conditions": [ + { + "action": RuleAction.EQUALS.value, # this rule will match, it has multiple conditions + "key": "tenant_id", + "value": tenant_id_val, + }, + { + "action": RuleAction.EQUALS.value, + "key": "username", + "value": username_val, + }, + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate( + name="my_feature", + context={ + "tenant_id": tenant_id_val, + "username": username_val, + }, + default=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_flags_conditions_no_rule_match_equal_multiple_conditions(mocker, config): + expected_val = True + mocked_app_config_schema = { + "my_feature": { + "default": expected_val, + "rules": { + # rule will not match + "tenant id equals 645654 and username is a": { + "when_match": False, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "645654", + }, + { + "action": RuleAction.EQUALS.value, + "key": "username", + "value": "a", + }, + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_val + + +# check rule match for multiple of action types +def test_flags_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 = { + "my_feature": { + "default": expected_value_third_check, + "rules": { + "tenant id equals 6 and username startswith a": { + "when_match": expected_value_first_check, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "6", + }, + { + "action": RuleAction.STARTSWITH.value, + "key": "username", + "value": "a", + }, + ], + }, + "tenant id equals 4446 and username startswith a and endswith z": { + "when_match": expected_value_second_check, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "4446", + }, + { + "action": RuleAction.STARTSWITH.value, + "key": "username", + "value": "a", + }, + { + "action": RuleAction.ENDSWITH.value, + "key": "username", + "value": "z", + }, + ], + }, + }, + } + } + + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + # match first rule + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "abcd"}, default=False) + assert toggle == expected_value_first_check + # match second rule + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "4446", "username": "az"}, default=False) + assert toggle == expected_value_second_check + # match no rule + toggle = feature_flags.evaluate( + name="my_feature", context={"tenant_id": "11114446", "username": "ab"}, default=False + ) + assert toggle == expected_value_third_check + # feature doesn't exist + toggle = feature_flags.evaluate( + name="my_fake_feature", + context={"tenant_id": "11114446", "username": "ab"}, + default=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_flags_match_rule_with_in_action(mocker, config): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "tenant id is contained in [6, 2]": { + "when_match": expected_value, + "conditions": [ + { + "action": RuleAction.IN.value, + "key": "tenant_id", + "value": ["6", "2"], + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_value + + +def test_flags_no_match_rule_with_in_action(mocker, config): + expected_value = False + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant id is contained in [8, 2]": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.IN.value, + "key": "tenant_id", + "value": ["8", "2"], + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_value + + +def test_flags_match_rule_with_not_in_action(mocker, config): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "tenant id is contained in [8, 2]": { + "when_match": expected_value, + "conditions": [ + { + "action": RuleAction.NOT_IN.value, + "key": "tenant_id", + "value": ["10", "4"], + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_value + + +def test_flags_no_match_rule_with_not_in_action(mocker, config): + expected_value = False + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant id is contained in [8, 2]": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.NOT_IN.value, + "key": "tenant_id", + "value": ["6", "4"], + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_value + + +def test_multiple_features_enabled(mocker, config): + expected_value = ["my_feature", "my_feature2"] + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "tenant id is contained in [6, 2]": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.IN.value, + "key": "tenant_id", + "value": ["6", "2"], + } + ], + } + }, + }, + "my_feature2": { + "default": True, + }, + "my_feature3": { + "default": False, + }, + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + enabled_list: List[str] = feature_flags.get_enabled_features(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 = { + "my_feature": { # rule will match here, feature is enabled due to rule match + "default": False, + "rules": { + "tenant id is contained in [6, 2]": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.IN.value, + "key": "tenant_id", + "value": ["6", "2"], + } + ], + } + }, + }, + "my_feature2": { + "default": True, + }, + "my_feature3": { + "default": False, + }, + # rule will not match here, feature is enabled by default + "my_feature4": { + "default": True, + "rules": { + "tenant id equals 7": { + "when_match": False, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "7", + } + ], + } + }, + }, + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + enabled_list: List[str] = feature_flags.get_enabled_features(context={"tenant_id": "6", "username": "a"}) + assert enabled_list == expected_value + + +def test_get_feature_toggle_handles_error(mocker, config): + # GIVEN a schema fetch that raises a ConfigurationStoreError + schema_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) + feature_flags = FeatureFlags(schema_fetcher) + + # WHEN calling evaluate + toggle = feature_flags.evaluate(name="Foo", default=False) + + # THEN handle the error and return the default + assert toggle is False + + +def test_get_all_enabled_feature_flags_handles_error(mocker, config): + # GIVEN a schema fetch that raises a ConfigurationStoreError + schema_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) + feature_flags = FeatureFlags(schema_fetcher) + + # WHEN calling get_enabled_features + flags = feature_flags.get_enabled_features(context=None) + + # THEN handle the error and return an empty list + assert flags == [] + + +def test_app_config_get_parameter_err(mocker, config): + # GIVEN an appconfig with a missing config + app_conf_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) + + # WHEN calling get_configuration + with pytest.raises(ConfigurationStoreError) as err: + app_conf_fetcher.get_configuration() + + # THEN raise ConfigurationStoreError error + assert "AWS AppConfig configuration" in str(err.value) + + +def test_match_by_action_no_matching_action(mocker, config): + # GIVEN an unsupported action + feature_flags = init_feature_flags(mocker, {}, config) + # WHEN calling _match_by_action + result = feature_flags._match_by_action("Foo", None, "foo") + # THEN default to False + assert result is False + + +def test_match_by_action_attribute_error(mocker, config): + # GIVEN a startswith action and 2 integer + feature_flags = init_feature_flags(mocker, {}, config) + # WHEN calling _match_by_action + result = feature_flags._match_by_action(RuleAction.STARTSWITH.value, 1, 100) + # THEN swallow the AttributeError and return False + assert result is False + + +def test_is_rule_matched_no_matches(mocker, config): + # GIVEN an empty list of conditions + rule = {schema.CONDITIONS_KEY: []} + rules_context = {} + feature_flags = init_feature_flags(mocker, {}, config) + + # WHEN calling _evaluate_conditions + result = feature_flags._evaluate_conditions( + rule_name="dummy", feature_name="dummy", rule=rule, context=rules_context + ) + + # THEN return False + assert result is False + + +def test_features_jmespath_envelope(mocker, config): + expected_value = True + mocked_app_config_schema = {"features": {"my_feature": {"default": expected_value}}} + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config, envelope="features") + toggle = feature_flags.evaluate(name="my_feature", context={}, default=False) + assert toggle == expected_value + + +# test_match_rule_with_equals_action +def test_match_condition_with_dict_value(mocker, config): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "tenant id is 6 and username is lessa": { + "when_match": expected_value, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant", + "value": {"tenant_id": "6", "username": "lessa"}, + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + ctx = {"tenant": {"tenant_id": "6", "username": "lessa"}} + toggle = feature_flags.evaluate(name="my_feature", context=ctx, default=False) + assert toggle == expected_value + + +def test_get_feature_toggle_propagates_access_denied_error(mocker, config): + # GIVEN a schema fetch that raises a StoreClientError + # due to client invalid permissions to fetch from the store + err = "An error occurred (AccessDeniedException) when calling the GetConfiguration operation" + schema_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError(err)) + feature_flags = FeatureFlags(schema_fetcher) + + # WHEN calling evaluate + # THEN raise StoreClientError error + with pytest.raises(StoreClientError, match="AccessDeniedException") as err: + feature_flags.evaluate(name="Foo", default=False) diff --git a/tests/functional/feature_flags/test_schema_validation.py b/tests/functional/feature_flags/test_schema_validation.py new file mode 100644 index 00000000000..ce85494afce --- /dev/null +++ b/tests/functional/feature_flags/test_schema_validation.py @@ -0,0 +1,287 @@ +import logging + +import pytest # noqa: F401 + +from aws_lambda_powertools.utilities.feature_flags.exceptions import SchemaValidationError +from aws_lambda_powertools.utilities.feature_flags.schema import ( + CONDITION_ACTION, + CONDITION_KEY, + CONDITION_VALUE, + CONDITIONS_KEY, + FEATURE_DEFAULT_VAL_KEY, + RULE_MATCH_VALUE, + RULES_KEY, + ConditionsValidator, + RuleAction, + RulesValidator, + SchemaValidator, +) + +logger = logging.getLogger(__name__) + +EMPTY_SCHEMA = {"": ""} + + +def test_invalid_features_dict(): + validator = SchemaValidator(schema=[]) + with pytest.raises(SchemaValidationError): + validator.validate() + + +def test_empty_features_not_fail(): + validator = SchemaValidator(schema={}) + validator.validate() + + +@pytest.mark.parametrize( + "schema", + [ + pytest.param({"my_feature": []}, id="feat_as_list"), + pytest.param({"my_feature": {}}, id="feat_empty_dict"), + pytest.param({"my_feature": {FEATURE_DEFAULT_VAL_KEY: "False"}}, id="feat_default_non_bool"), + pytest.param({"my_feature": {FEATURE_DEFAULT_VAL_KEY: False, RULES_KEY: "4"}}, id="feat_rules_non_dict"), + pytest.param("%<>[]{}|^", id="unsafe-rfc3986"), + ], +) +def test_invalid_feature(schema): + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + +def test_valid_feature_dict(): + # empty rules list + schema = {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False, RULES_KEY: []}} + validator = SchemaValidator(schema) + validator.validate() + + # no rules list at all + schema = {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False}} + validator = SchemaValidator(schema) + validator.validate() + + +def test_invalid_rule(): + # rules list is not a list of dict + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: [ + "a", + "b", + ], + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # rules RULE_MATCH_VALUE is not bool + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": { + RULE_MATCH_VALUE: "False", + } + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # missing conditions list + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": { + RULE_MATCH_VALUE: False, + } + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # condition list is empty + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": {RULE_MATCH_VALUE: False, CONDITIONS_KEY: []}, + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # condition is invalid type, not list + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": {RULE_MATCH_VALUE: False, CONDITIONS_KEY: {}}, + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + +def test_invalid_condition(): + # invalid condition action + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": { + RULE_MATCH_VALUE: False, + CONDITIONS_KEY: {CONDITION_ACTION: "stuff", CONDITION_KEY: "a", CONDITION_VALUE: "a"}, + } + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # missing condition key and value + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": { + RULE_MATCH_VALUE: False, + CONDITIONS_KEY: {CONDITION_ACTION: RuleAction.EQUALS.value}, + } + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # invalid condition key type, not string + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": { + RULE_MATCH_VALUE: False, + CONDITIONS_KEY: { + CONDITION_ACTION: RuleAction.EQUALS.value, + CONDITION_KEY: 5, + CONDITION_VALUE: "a", + }, + } + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + +def test_valid_condition_all_actions(): + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 645654 and username is a": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.EQUALS.value, + CONDITION_KEY: "tenant_id", + CONDITION_VALUE: "645654", + }, + { + CONDITION_ACTION: RuleAction.STARTSWITH.value, + CONDITION_KEY: "username", + CONDITION_VALUE: "a", + }, + { + CONDITION_ACTION: RuleAction.ENDSWITH.value, + CONDITION_KEY: "username", + CONDITION_VALUE: "a", + }, + { + CONDITION_ACTION: RuleAction.IN.value, + CONDITION_KEY: "username", + CONDITION_VALUE: ["a", "b"], + }, + { + CONDITION_ACTION: RuleAction.NOT_IN.value, + CONDITION_KEY: "username", + CONDITION_VALUE: ["c"], + }, + ], + } + }, + } + } + validator = SchemaValidator(schema) + validator.validate() + + +def test_validate_condition_invalid_condition_type(): + # GIVEN an invalid condition type of empty dict + condition = {} + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises(SchemaValidationError, match="Feature rule condition must be a dictionary"): + ConditionsValidator.validate_condition(condition=condition, rule_name="dummy") + + +def test_validate_condition_invalid_condition_action(): + # GIVEN an invalid condition action of foo + condition = {"action": "INVALID", "key": "tenant_id", "value": "12345"} + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises(SchemaValidationError, match="'action' value must be either"): + ConditionsValidator.validate_condition_action(condition=condition, rule_name="dummy") + + +def test_validate_condition_invalid_condition_key(): + # GIVEN a configuration with a missing "key" + condition = {"action": RuleAction.EQUALS.value, "value": "12345"} + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises(SchemaValidationError, match="'key' value must be a non empty string"): + ConditionsValidator.validate_condition_key(condition=condition, rule_name="dummy") + + +def test_validate_condition_missing_condition_value(): + # GIVEN a configuration with a missing condition value + condition = { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + } + + # WHEN calling validate_condition + with pytest.raises(SchemaValidationError, match="'value' key must not be empty"): + ConditionsValidator.validate_condition_value(condition=condition, rule_name="dummy") + + +def test_validate_rule_invalid_rule_type(): + # GIVEN an invalid rule type of empty list + # WHEN calling validate_rule + # THEN raise SchemaValidationError + with pytest.raises(SchemaValidationError, match="Feature rule must be a dictionary"): + RulesValidator.validate_rule(rule=[], rule_name="dummy", feature_name="dummy") + + +def test_validate_rule_invalid_rule_name(): + # GIVEN a rule name is empty + # WHEN calling validate_rule_name + # THEN raise SchemaValidationError + with pytest.raises(SchemaValidationError, match="Rule name key must have a non-empty string"): + RulesValidator.validate_rule_name(rule_name="", feature_name="dummy") diff --git a/tests/functional/feature_toggles/test_feature_toggles.py b/tests/functional/feature_toggles/test_feature_toggles.py deleted file mode 100644 index bb4b8f24dfc..00000000000 --- a/tests/functional/feature_toggles/test_feature_toggles.py +++ /dev/null @@ -1,503 +0,0 @@ -from typing import Dict, List - -import pytest -from botocore.config import Config - -from aws_lambda_powertools.utilities.feature_toggles import ConfigurationError, schema -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 -from aws_lambda_powertools.utilities.parameters import GetParameterError - - -@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 - - -def init_fetcher_side_effect(mocker, config: Config, side_effect) -> AppConfigFetcher: - mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get") - mocked_get_conf.side_effect = side_effect - return AppConfigFetcher( - environment="env", - service="service", - configuration_name="conf", - cache_seconds=1, - config=config, - ) - - -# 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 - - -def test_get_feature_toggle_handles_error(mocker, config): - # GIVEN a schema fetch that raises a ConfigurationError - schema_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) - conf_store = ConfigurationStore(schema_fetcher) - - # WHEN calling get_feature_toggle - toggle = conf_store.get_feature_toggle(feature_name="Foo", value_if_missing=False) - - # THEN handle the error and return the value_if_missing - assert toggle is False - - -def test_get_all_enabled_feature_toggles_handles_error(mocker, config): - # GIVEN a schema fetch that raises a ConfigurationError - schema_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) - conf_store = ConfigurationStore(schema_fetcher) - - # WHEN calling get_all_enabled_feature_toggles - toggles = conf_store.get_all_enabled_feature_toggles(rules_context=None) - - # THEN handle the error and return an empty list - assert toggles == [] - - -def test_app_config_get_parameter_err(mocker, config): - # GIVEN an appconfig with a missing config - app_conf_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) - - # WHEN calling get_json_configuration - with pytest.raises(ConfigurationError) as err: - app_conf_fetcher.get_json_configuration() - - # THEN raise ConfigurationError error - assert "AWS AppConfig configuration" in str(err.value) - - -def test_match_by_action_no_matching_action(mocker, config): - # GIVEN an unsupported action - conf_store = init_configuration_store(mocker, {}, config) - # WHEN calling _match_by_action - result = conf_store._match_by_action("Foo", None, "foo") - # THEN default to False - assert result is False - - -def test_match_by_action_attribute_error(mocker, config): - # GIVEN a startswith action and 2 integer - conf_store = init_configuration_store(mocker, {}, config) - # WHEN calling _match_by_action - result = conf_store._match_by_action(ACTION.STARTSWITH.value, 1, 100) - # THEN swallow the AttributeError and return False - assert result is False - - -def test_is_rule_matched_no_matches(mocker, config): - # GIVEN an empty list of conditions - rule = {schema.CONDITIONS_KEY: []} - rules_context = {} - conf_store = init_configuration_store(mocker, {}, config) - - # WHEN calling _is_rule_matched - result = conf_store._is_rule_matched("feature_name", rule, rules_context) - - # THEN return False - assert result is False diff --git a/tests/functional/feature_toggles/test_schema_validation.py b/tests/functional/feature_toggles/test_schema_validation.py deleted file mode 100644 index 184f448322a..00000000000 --- a/tests/functional/feature_toggles/test_schema_validation.py +++ /dev/null @@ -1,330 +0,0 @@ -import logging - -import pytest # noqa: F401 - -from aws_lambda_powertools.utilities.feature_toggles.exceptions import ConfigurationError -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(ConfigurationError): - validator.validate_json_schema(schema) - - schema = [] - # invalid type - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # invalid features key - schema = {FEATURES_KEY: []} - with pytest.raises(ConfigurationError): - 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(ConfigurationError): - validator.validate_json_schema(schema) - - # empty feature dict - schema = {FEATURES_KEY: {"my_feature": {}}} - with pytest.raises(ConfigurationError): - 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(ConfigurationError): - 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(ConfigurationError): - 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(ConfigurationError): - 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(ConfigurationError): - 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(ConfigurationError): - 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(ConfigurationError): - 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(ConfigurationError): - 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(ConfigurationError): - 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(ConfigurationError): - 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(ConfigurationError): - 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(ConfigurationError): - 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) - - -def test_validate_condition_invalid_condition_type(): - # GIVEN an invalid condition type of empty dict - validator = SchemaValidator(logger) - condition = {} - - # WHEN calling _validate_condition - with pytest.raises(ConfigurationError) as err: - validator._validate_condition("foo", condition) - - # THEN raise ConfigurationError - assert "invalid condition type" in str(err) - - -def test_validate_condition_invalid_condition_action(): - # GIVEN an invalid condition action of foo - validator = SchemaValidator(logger) - condition = {"action": "foo"} - - # WHEN calling _validate_condition - with pytest.raises(ConfigurationError) as err: - validator._validate_condition("foo", condition) - - # THEN raise ConfigurationError - assert "invalid action value" in str(err) - - -def test_validate_condition_invalid_condition_key(): - # GIVEN a configuration with a missing "key" - validator = SchemaValidator(logger) - condition = {"action": ACTION.EQUALS.value} - - # WHEN calling _validate_condition - with pytest.raises(ConfigurationError) as err: - validator._validate_condition("foo", condition) - - # THEN raise ConfigurationError - assert "invalid key value" in str(err) - - -def test_validate_condition_missing_condition_value(): - # GIVEN a configuration with a missing condition value - validator = SchemaValidator(logger) - condition = {"action": ACTION.EQUALS.value, "key": "Foo"} - - # WHEN calling _validate_condition - with pytest.raises(ConfigurationError) as err: - validator._validate_condition("foo", condition) - - # THEN raise ConfigurationError - assert "missing condition value" in str(err) - - -def test_validate_rule_invalid_rule_name(): - # GIVEN a rule_name not in the rule dict - validator = SchemaValidator(logger) - rule_name = "invalid_rule_name" - rule = {"missing": ""} - - # WHEN calling _validate_rule - with pytest.raises(ConfigurationError) as err: - validator._validate_rule(rule_name, rule) - - # THEN raise ConfigurationError - assert "invalid rule_name" in str(err) diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py index e100957dee7..9f61d50d656 100644 --- a/tests/functional/idempotency/conftest.py +++ b/tests/functional/idempotency/conftest.py @@ -11,11 +11,11 @@ from botocore.config import Config from jmespath import functions +from aws_lambda_powertools.shared.jmespath_utils import unwrap_event_from_envelope from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer from aws_lambda_powertools.utilities.idempotency.idempotency import IdempotencyConfig from aws_lambda_powertools.utilities.validation import envelopes -from aws_lambda_powertools.utilities.validation.base import unwrap_event_from_envelope from tests.functional.utils import load_event TABLE_NAME = "TEST_TABLE" diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index 0cf19ab9de0..0ecc84b7f9c 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -395,7 +395,6 @@ def test_idempotent_lambda_expired_during_request( lambda_apigw_event, timestamp_expired, lambda_response, - expected_params_update_item, hashed_idempotency_key, lambda_context, ): diff --git a/tests/functional/parser/test_apigw.py b/tests/functional/parser/test_apigw.py index fc679d5dc37..d657a0dbe4d 100644 --- a/tests/functional/parser/test_apigw.py +++ b/tests/functional/parser/test_apigw.py @@ -1,3 +1,6 @@ +import pytest +from pydantic import ValidationError + from aws_lambda_powertools.utilities.parser import envelopes, event_parser from aws_lambda_powertools.utilities.parser.models import APIGatewayProxyEventModel from aws_lambda_powertools.utilities.typing import LambdaContext @@ -100,3 +103,44 @@ def test_apigw_event(): assert request_context.operationName is None assert identity.apiKey is None assert identity.apiKeyId is None + + +def test_apigw_event_with_invalid_websocket_request(): + # GIVEN an event with an eventType != MESSAGE and has a messageId + event = { + "resource": "/", + "path": "/", + "httpMethod": "GET", + "headers": {}, + "multiValueHeaders": {}, + "isBase64Encoded": False, + "body": "Foo!", + "requestContext": { + "accountId": "1234", + "apiId": "myApi", + "httpMethod": "GET", + "identity": { + "sourceIp": "127.0.0.1", + }, + "path": "/", + "protocol": "Https", + "requestId": "1234", + "requestTime": "2018-09-07T16:20:46Z", + "requestTimeEpoch": 1536992496000, + "resourcePath": "/", + "stage": "test", + "eventType": "DISCONNECT", + "messageId": "messageId", + }, + } + + # WHEN calling event_parser with APIGatewayProxyEventModel + with pytest.raises(ValidationError) as err: + handle_apigw_event(event, LambdaContext()) + + # THEN raise TypeError for invalid event + errors = err.value.errors() + assert len(errors) == 1 + expected_msg = "messageId is available only when the `eventType` is `MESSAGE`" + assert errors[0]["msg"] == expected_msg + assert expected_msg in str(err.value) diff --git a/tests/functional/test_data_classes.py b/tests/functional/test_data_classes.py index cbbaf834379..f9bb1fdef73 100644 --- a/tests/functional/test_data_classes.py +++ b/tests/functional/test_data_classes.py @@ -1037,6 +1037,18 @@ def test_base_proxy_event_decode_body_encoded_true(): assert event.decoded_body == data +def test_base_proxy_event_json_body_with_base64_encoded_data(): + # GIVEN a base64 encoded json body + data = {"message": "Foo"} + data_str = json.dumps(data) + encoded_data = base64.b64encode(data_str.encode()).decode() + event = BaseProxyEvent({"body": encoded_data, "isBase64Encoded": True}) + + # WHEN calling json_body + # THEN then base64 decode and json load + assert event.json_body == data + + def test_kinesis_stream_event(): event = KinesisStreamEvent(load_event("kinesisStreamEvent.json")) diff --git a/tests/functional/test_tracing.py b/tests/functional/test_tracing.py index 617bf816f86..b330ab6316f 100644 --- a/tests/functional/test_tracing.py +++ b/tests/functional/test_tracing.py @@ -60,26 +60,11 @@ def greeting(name, message): greeting(name="Foo", message="Bar") -def test_tracer_lambda_emulator(monkeypatch, dummy_response): - # GIVEN tracer runs locally - monkeypatch.setenv("AWS_SAM_LOCAL", "true") +def test_tracer_lambda_outside_lambda_env(monkeypatch, dummy_response): + # GIVEN tracer runs locally (ie: `LAMBDA_TASK_ROOT` is not set) tracer = Tracer() - # WHEN a lambda function is run through SAM CLI - @tracer.capture_lambda_handler - def handler(event, context): - return dummy_response - - # THEN tracer should run in disabled mode, and not raise an Exception - handler({}, {}) - - -def test_tracer_chalice_cli_mode(monkeypatch, dummy_response): - # GIVEN tracer runs locally - monkeypatch.setenv("AWS_CHALICE_CLI_MODE", "true") - tracer = Tracer() - - # WHEN a lambda function is run through the Chalice CLI. + # WHEN a lambda function is run through outside of a lambda @tracer.capture_lambda_handler def handler(event, context): return dummy_response diff --git a/tests/unit/test_tracing.py b/tests/unit/test_tracing.py index fdfdf5c6d6e..2b147ec4405 100644 --- a/tests/unit/test_tracing.py +++ b/tests/unit/test_tracing.py @@ -125,9 +125,11 @@ def greeting(name, message): ) -def test_tracer_custom_metadata(mocker, dummy_response, provider_stub): +def test_tracer_custom_metadata(monkeypatch, mocker, dummy_response, provider_stub): # GIVEN Tracer is initialized with booking as the service name + monkeypatch.setenv("LAMBDA_TASK_ROOT", "/opt/") put_metadata_mock = mocker.MagicMock() + provider = provider_stub(put_metadata_mock=put_metadata_mock) tracer = Tracer(provider=provider, service="booking") @@ -143,8 +145,9 @@ def test_tracer_custom_metadata(mocker, dummy_response, provider_stub): ) -def test_tracer_custom_annotation(mocker, dummy_response, provider_stub): +def test_tracer_custom_annotation(monkeypatch, mocker, dummy_response, provider_stub): # GIVEN Tracer is initialized + monkeypatch.setenv("LAMBDA_TASK_ROOT", "/opt/") put_annotation_mock = mocker.MagicMock() provider = provider_stub(put_annotation_mock=put_annotation_mock) tracer = Tracer(provider=provider) @@ -214,8 +217,9 @@ def greeting(name, message): @mock.patch("aws_lambda_powertools.tracing.tracer.aws_xray_sdk.core.patch") -def test_tracer_patch_modules(xray_patch_mock, mocker): +def test_tracer_patch_modules(xray_patch_mock, monkeypatch, mocker): # GIVEN tracer is initialized with a list of modules to patch + monkeypatch.setenv("LAMBDA_TASK_ROOT", "/opt/") modules = ["boto3"] # WHEN modules are supported by X-Ray