Skip to content

Commit 92d4a6d

Browse files
heitorlessaMichael Brewer
and
Michael Brewer
authored
refactor(feature_flags): optimize UX and maintenance (#563)
Co-authored-by: Michael Brewer <[email protected]>
1 parent 79294f7 commit 92d4a6d

26 files changed

+1560
-1309
lines changed

.pre-commit-config.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ repos:
2424
types: [python]
2525
- id: isort
2626
name: formatting::isort
27-
entry: poetry run isort -rc
27+
entry: poetry run isort
2828
language: system
2929
types: [python]
3030
- repo: local

aws_lambda_powertools/shared/jmespath_functions.py

-22
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import base64
2+
import gzip
3+
import json
4+
from typing import Any, Dict, Optional, Union
5+
6+
import jmespath
7+
from jmespath.exceptions import LexerError
8+
9+
from aws_lambda_powertools.utilities.validation import InvalidEnvelopeExpressionError
10+
from aws_lambda_powertools.utilities.validation.base import logger
11+
12+
13+
class PowertoolsFunctions(jmespath.functions.Functions):
14+
@jmespath.functions.signature({"types": ["string"]})
15+
def _func_powertools_json(self, value):
16+
return json.loads(value)
17+
18+
@jmespath.functions.signature({"types": ["string"]})
19+
def _func_powertools_base64(self, value):
20+
return base64.b64decode(value).decode()
21+
22+
@jmespath.functions.signature({"types": ["string"]})
23+
def _func_powertools_base64_gzip(self, value):
24+
encoded = base64.b64decode(value)
25+
uncompressed = gzip.decompress(encoded)
26+
27+
return uncompressed.decode()
28+
29+
30+
def unwrap_event_from_envelope(data: Union[Dict, str], envelope: str, jmespath_options: Optional[Dict]) -> Any:
31+
"""Searches data using JMESPath expression
32+
33+
Parameters
34+
----------
35+
data : Dict
36+
Data set to be filtered
37+
envelope : str
38+
JMESPath expression to filter data against
39+
jmespath_options : Dict
40+
Alternative JMESPath options to be included when filtering expr
41+
42+
Returns
43+
-------
44+
Any
45+
Data found using JMESPath expression given in envelope
46+
"""
47+
if not jmespath_options:
48+
jmespath_options = {"custom_functions": PowertoolsFunctions()}
49+
50+
try:
51+
logger.debug(f"Envelope detected: {envelope}. JMESPath options: {jmespath_options}")
52+
return jmespath.search(envelope, data, options=jmespath.Options(**jmespath_options))
53+
except (LexerError, TypeError, UnicodeError) as e:
54+
message = f"Failed to unwrap event from envelope using expression. Error: {e} Exp: {envelope}, Data: {data}" # noqa: B306, E501
55+
raise InvalidEnvelopeExpressionError(message)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Advanced feature flags utility"""
2+
from .appconfig import AppConfigStore
3+
from .base import StoreProvider
4+
from .exceptions import ConfigurationStoreError
5+
from .feature_flags import FeatureFlags
6+
from .schema import RuleAction, SchemaValidator
7+
8+
__all__ = [
9+
"ConfigurationStoreError",
10+
"FeatureFlags",
11+
"RuleAction",
12+
"SchemaValidator",
13+
"AppConfigStore",
14+
"StoreProvider",
15+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import logging
2+
import traceback
3+
from typing import Any, Dict, Optional, cast
4+
5+
from botocore.config import Config
6+
7+
from aws_lambda_powertools.utilities.parameters import AppConfigProvider, GetParameterError, TransformParameterError
8+
9+
from ...shared import jmespath_utils
10+
from .base import StoreProvider
11+
from .exceptions import ConfigurationStoreError, StoreClientError
12+
13+
logger = logging.getLogger(__name__)
14+
15+
TRANSFORM_TYPE = "json"
16+
17+
18+
class AppConfigStore(StoreProvider):
19+
def __init__(
20+
self,
21+
environment: str,
22+
application: str,
23+
name: str,
24+
cache_seconds: int,
25+
sdk_config: Optional[Config] = None,
26+
envelope: str = "",
27+
jmespath_options: Optional[Dict] = None,
28+
):
29+
"""This class fetches JSON schemas from AWS AppConfig
30+
31+
Parameters
32+
----------
33+
environment: str
34+
Appconfig environment, e.g. 'dev/test' etc.
35+
application: str
36+
AppConfig application name, e.g. 'powertools'
37+
name: str
38+
AppConfig configuration name e.g. `my_conf`
39+
cache_seconds: int
40+
cache expiration time, how often to call AppConfig to fetch latest configuration
41+
sdk_config: Optional[Config]
42+
Botocore Config object to pass during client initialization
43+
envelope : str
44+
JMESPath expression to pluck feature flags data from config
45+
jmespath_options : Dict
46+
Alternative JMESPath options to be included when filtering expr
47+
"""
48+
super().__init__()
49+
self.environment = environment
50+
self.application = application
51+
self.name = name
52+
self.cache_seconds = cache_seconds
53+
self.config = sdk_config
54+
self.envelope = envelope
55+
self.jmespath_options = jmespath_options
56+
self._conf_store = AppConfigProvider(environment=environment, application=application, config=sdk_config)
57+
58+
def get_configuration(self) -> Dict[str, Any]:
59+
"""Fetch feature schema configuration from AWS AppConfig
60+
61+
Raises
62+
------
63+
ConfigurationStoreError
64+
Any validation error or AppConfig error that can occur
65+
66+
Returns
67+
-------
68+
Dict[str, Any]
69+
parsed JSON dictionary
70+
"""
71+
try:
72+
# parse result conf as JSON, keep in cache for self.max_age seconds
73+
config = cast(
74+
dict,
75+
self._conf_store.get(
76+
name=self.name,
77+
transform=TRANSFORM_TYPE,
78+
max_age=self.cache_seconds,
79+
),
80+
)
81+
82+
if self.envelope:
83+
config = jmespath_utils.unwrap_event_from_envelope(
84+
data=config, envelope=self.envelope, jmespath_options=self.jmespath_options
85+
)
86+
87+
return config
88+
except (GetParameterError, TransformParameterError) as exc:
89+
err_msg = traceback.format_exc()
90+
if "AccessDenied" in err_msg:
91+
raise StoreClientError(err_msg) from exc
92+
raise ConfigurationStoreError("Unable to get AWS AppConfig configuration file") from exc
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Any, Dict
3+
4+
5+
class StoreProvider(ABC):
6+
@abstractmethod
7+
def get_configuration(self) -> Dict[str, Any]:
8+
"""Get configuration from any store and return the parsed JSON dictionary
9+
10+
Raises
11+
------
12+
ConfigurationStoreError
13+
Any error that can occur during schema fetch or JSON parse
14+
15+
Returns
16+
-------
17+
Dict[str, Any]
18+
parsed JSON dictionary
19+
20+
**Example**
21+
22+
```python
23+
{
24+
"premium_features": {
25+
"default": False,
26+
"rules": {
27+
"customer tier equals premium": {
28+
"when_match": True,
29+
"conditions": [
30+
{
31+
"action": "EQUALS",
32+
"key": "tier",
33+
"value": "premium",
34+
}
35+
],
36+
}
37+
},
38+
},
39+
"feature_two": {
40+
"default": False
41+
}
42+
}
43+
"""
44+
return NotImplemented # pragma: no cover
45+
46+
47+
class BaseValidator(ABC):
48+
@abstractmethod
49+
def validate(self):
50+
return NotImplemented # pragma: no cover
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
class ConfigurationStoreError(Exception):
2+
"""When a configuration store raises an exception on config retrieval or parsing"""
3+
4+
5+
class SchemaValidationError(Exception):
6+
"""When feature flag schema fails validation"""
7+
8+
9+
class StoreClientError(Exception):
10+
"""When a store raises an exception that should be propagated to the client to fix
11+
12+
For example, Access Denied errors when the client doesn't permissions to fetch config
13+
"""

0 commit comments

Comments
 (0)