Skip to content

Commit f63a5da

Browse files
committed
feat: support JMESPath envelope/options to extract features
1 parent 1f62696 commit f63a5da

File tree

9 files changed

+111
-80
lines changed

9 files changed

+111
-80
lines changed

aws_lambda_powertools/shared/jmespath_functions.py

Lines changed: 0 additions & 22 deletions
This file was deleted.
Lines changed: 55 additions & 0 deletions
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)

aws_lambda_powertools/utilities/feature_flags/appconfig.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

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

8+
from ...shared import jmespath_utils
89
from .base import StoreProvider
910
from .exceptions import ConfigurationError
1011

@@ -21,6 +22,8 @@ def __init__(
2122
name: str,
2223
cache_seconds: int,
2324
config: Optional[Config] = None,
25+
envelope: str = "",
26+
jmespath_options: Optional[Dict] = None,
2427
):
2528
"""This class fetches JSON schemas from AWS AppConfig
2629
@@ -36,18 +39,28 @@ def __init__(
3639
cache expiration time, how often to call AppConfig to fetch latest configuration
3740
config: Optional[Config]
3841
boto3 client configuration
42+
envelope : str
43+
JMESPath expression to pluck feature flags data from config
44+
jmespath_options : Dict
45+
Alternative JMESPath options to be included when filtering expr
3946
"""
40-
super().__init__(name, cache_seconds)
41-
self._logger = logger
47+
super().__init__()
48+
self.environment = environment
49+
self.application = application
50+
self.name = name
51+
self.cache_seconds = cache_seconds
52+
self.config = config
53+
self.envelope = envelope
54+
self.jmespath_options = jmespath_options
4255
self._conf_store = AppConfigProvider(environment=environment, application=application, config=config)
4356

4457
def get_json_configuration(self) -> Dict[str, Any]:
45-
"""Get configuration string from AWs AppConfig and return the parsed JSON dictionary
58+
"""Get configuration string from AWS AppConfig and return the parsed JSON dictionary
4659
4760
Raises
4861
------
4962
ConfigurationError
50-
Any validation error or appconfig error that can occur
63+
Any validation error or AppConfig error that can occur
5164
5265
Returns
5366
-------
@@ -56,13 +69,17 @@ def get_json_configuration(self) -> Dict[str, Any]:
5669
"""
5770
try:
5871
# parse result conf as JSON, keep in cache for self.max_age seconds
59-
return cast(
60-
dict,
61-
self._conf_store.get(
62-
name=self.name,
63-
transform=TRANSFORM_TYPE,
64-
max_age=self._cache_seconds,
65-
),
72+
config = self._conf_store.get(
73+
name=self.name,
74+
transform=TRANSFORM_TYPE,
75+
max_age=self.cache_seconds,
6676
)
77+
78+
if self.envelope:
79+
config = jmespath_utils.unwrap_event_from_envelope(
80+
data=config, envelope=self.envelope, jmespath_options=self.jmespath_options
81+
)
82+
83+
return cast(dict, config)
6784
except (GetParameterError, TransformParameterError) as exc:
6885
raise ConfigurationError("Unable to get AWS AppConfig configuration file") from exc

aws_lambda_powertools/utilities/feature_flags/base.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,6 @@
33

44

55
class StoreProvider(ABC):
6-
def __init__(self, configuration_name: str, cache_seconds: int):
7-
self.name = configuration_name
8-
self._cache_seconds = cache_seconds
9-
106
@abstractmethod
117
def get_json_configuration(self) -> Dict[str, Any]:
128
"""Get configuration string from any configuration storing application and return the parsed JSON dictionary

aws_lambda_powertools/utilities/idempotency/persistence/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import jmespath
1515

1616
from aws_lambda_powertools.shared.cache_dict import LRUDict
17-
from aws_lambda_powertools.shared.jmespath_functions import PowertoolsFunctions
17+
from aws_lambda_powertools.shared.jmespath_utils import PowertoolsFunctions
1818
from aws_lambda_powertools.shared.json_encoder import Encoder
1919
from aws_lambda_powertools.utilities.idempotency.config import IdempotencyConfig
2020
from aws_lambda_powertools.utilities.idempotency.exceptions import (
Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
import logging
2-
from typing import Any, Dict, Optional, Union
2+
from typing import Dict, Optional, Union
33

44
import fastjsonschema # type: ignore
5-
import jmespath
6-
from jmespath.exceptions import LexerError # type: ignore
75

8-
from aws_lambda_powertools.shared.jmespath_functions import PowertoolsFunctions
9-
10-
from .exceptions import InvalidEnvelopeExpressionError, InvalidSchemaFormatError, SchemaValidationError
6+
from .exceptions import InvalidSchemaFormatError, SchemaValidationError
117

128
logger = logging.getLogger(__name__)
139

@@ -39,31 +35,3 @@ def validate_data_against_schema(data: Union[Dict, str], schema: Dict, formats:
3935
except fastjsonschema.JsonSchemaException as e:
4036
message = f"Failed schema validation. Error: {e.message}, Path: {e.path}, Data: {e.value}" # noqa: B306, E501
4137
raise SchemaValidationError(message)
42-
43-
44-
def unwrap_event_from_envelope(data: Union[Dict, str], envelope: str, jmespath_options: Optional[Dict]) -> Any:
45-
"""Searches data using JMESPath expression
46-
47-
Parameters
48-
----------
49-
data : Dict
50-
Data set to be filtered
51-
envelope : str
52-
JMESPath expression to filter data against
53-
jmespath_options : Dict
54-
Alternative JMESPath options to be included when filtering expr
55-
56-
Returns
57-
-------
58-
Any
59-
Data found using JMESPath expression given in envelope
60-
"""
61-
if not jmespath_options:
62-
jmespath_options = {"custom_functions": PowertoolsFunctions()}
63-
64-
try:
65-
logger.debug(f"Envelope detected: {envelope}. JMESPath options: {jmespath_options}")
66-
return jmespath.search(envelope, data, options=jmespath.Options(**jmespath_options))
67-
except (LexerError, TypeError, UnicodeError) as e:
68-
message = f"Failed to unwrap event from envelope using expression. Error: {e} Exp: {envelope}, Data: {data}" # noqa: B306, E501
69-
raise InvalidEnvelopeExpressionError(message)

aws_lambda_powertools/utilities/validation/validator.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
from typing import Any, Callable, Dict, Optional, Union
33

44
from ...middleware_factory import lambda_handler_decorator
5-
from .base import unwrap_event_from_envelope, validate_data_against_schema
5+
from ...shared import jmespath_utils
6+
from .base import validate_data_against_schema
67

78
logger = logging.getLogger(__name__)
89

@@ -16,7 +17,7 @@ def validator(
1617
inbound_formats: Optional[Dict] = None,
1718
outbound_schema: Optional[Dict] = None,
1819
outbound_formats: Optional[Dict] = None,
19-
envelope: Optional[str] = None,
20+
envelope: str = "",
2021
jmespath_options: Optional[Dict] = None,
2122
) -> Any:
2223
"""Lambda handler decorator to validate incoming/outbound data using a JSON Schema
@@ -116,7 +117,9 @@ def handler(event, context):
116117
When JMESPath expression to unwrap event is invalid
117118
"""
118119
if envelope:
119-
event = unwrap_event_from_envelope(data=event, envelope=envelope, jmespath_options=jmespath_options)
120+
event = jmespath_utils.unwrap_event_from_envelope(
121+
data=event, envelope=envelope, jmespath_options=jmespath_options
122+
)
120123

121124
if inbound_schema:
122125
logger.debug("Validating inbound event")
@@ -216,6 +219,8 @@ def handler(event, context):
216219
When JMESPath expression to unwrap event is invalid
217220
"""
218221
if envelope:
219-
event = unwrap_event_from_envelope(data=event, envelope=envelope, jmespath_options=jmespath_options)
222+
event = jmespath_utils.unwrap_event_from_envelope(
223+
data=event, envelope=envelope, jmespath_options=jmespath_options
224+
)
220225

221226
validate_data_against_schema(data=event, schema=schema, formats=formats)

tests/functional/feature_toggles/test_feature_toggles.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Dict, List
1+
from typing import Dict, List, Optional
22

33
import pytest
44
from botocore.config import Config
@@ -15,7 +15,9 @@ def config():
1515
return Config(region_name="us-east-1")
1616

1717

18-
def init_feature_flags(mocker, mock_schema: Dict, config: Config) -> FeatureFlags:
18+
def init_feature_flags(
19+
mocker, mock_schema: Dict, config: Config, envelope: str = "", jmespath_options: Optional[Dict] = None
20+
) -> FeatureFlags:
1921
mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get")
2022
mocked_get_conf.return_value = mock_schema
2123

@@ -25,6 +27,8 @@ def init_feature_flags(mocker, mock_schema: Dict, config: Config) -> FeatureFlag
2527
name="test_conf_name",
2628
cache_seconds=600,
2729
config=config,
30+
envelope=envelope,
31+
jmespath_options=jmespath_options,
2832
)
2933
feature_flags: FeatureFlags = FeatureFlags(store=app_conf_fetcher)
3034
return feature_flags
@@ -73,7 +77,7 @@ def test_toggles_rule_does_not_match(mocker, config):
7377
# you get the default value of False that was sent to the evaluate API
7478
def test_toggles_no_conditions_feature_does_not_exist(mocker, config):
7579
expected_value = False
76-
mocked_app_config_schema = {"features": {"my_fake_feature": {"default": True}}}
80+
mocked_app_config_schema = {"my_fake_feature": {"default": True}}
7781

7882
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
7983
toggle = feature_flags.evaluate(name="my_feature", context={}, default=expected_value)
@@ -448,3 +452,11 @@ def test_is_rule_matched_no_matches(mocker, config):
448452

449453
# THEN return False
450454
assert result is False
455+
456+
457+
def test_features_jmespath_envelope(mocker, config):
458+
expected_value = True
459+
mocked_app_config_schema = {"features": {"my_feature": {"default": expected_value}}}
460+
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config, envelope="features")
461+
toggle = feature_flags.evaluate(name="my_feature", context={}, default=False)
462+
assert toggle == expected_value

tests/functional/idempotency/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@
1111
from botocore.config import Config
1212
from jmespath import functions
1313

14+
from aws_lambda_powertools.shared.jmespath_utils import unwrap_event_from_envelope
1415
from aws_lambda_powertools.shared.json_encoder import Encoder
1516
from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer
1617
from aws_lambda_powertools.utilities.idempotency.idempotency import IdempotencyConfig
1718
from aws_lambda_powertools.utilities.validation import envelopes
18-
from aws_lambda_powertools.utilities.validation.base import unwrap_event_from_envelope
1919
from tests.functional.utils import load_event
2020

2121
TABLE_NAME = "TEST_TABLE"

0 commit comments

Comments
 (0)