Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 0 additions & 22 deletions aws_lambda_powertools/shared/jmespath_functions.py

This file was deleted.

55 changes: 55 additions & 0 deletions aws_lambda_powertools/shared/jmespath_utils.py
Original file line number Diff line number Diff line change
@@ -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)
15 changes: 15 additions & 0 deletions aws_lambda_powertools/utilities/feature_flags/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
92 changes: 92 additions & 0 deletions aws_lambda_powertools/utilities/feature_flags/appconfig.py
Original file line number Diff line number Diff line change
@@ -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,
cache_seconds: int,
sdk_config: Optional[Config] = None,
envelope: 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`
cache_seconds: int
cache expiration time, how often to call AppConfig to fetch latest configuration
sdk_config: Optional[Config]
Botocore Config object to pass during client initialization
envelope : str
JMESPath expression to pluck feature flags data from config
jmespath_options : Dict
Alternative JMESPath options to be included when filtering expr
"""
super().__init__()
self.environment = environment
self.application = application
self.name = name
self.cache_seconds = cache_seconds
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
50 changes: 50 additions & 0 deletions aws_lambda_powertools/utilities/feature_flags/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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
13 changes: 13 additions & 0 deletions aws_lambda_powertools/utilities/feature_flags/exceptions.py
Original file line number Diff line number Diff line change
@@ -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
"""
Loading