Skip to content

Commit 3660dbf

Browse files
feat(event_source): support custom json_deserializer; add json_body in SQSEvent (#2200)
Co-authored-by: heitorlessa <[email protected]>
1 parent fff75bd commit 3660dbf

File tree

6 files changed

+94
-13
lines changed

6 files changed

+94
-13
lines changed

Diff for: aws_lambda_powertools/utilities/data_classes/common.py

+13-3
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
import base64
22
import json
33
from collections.abc import Mapping
4-
from typing import Any, Dict, Iterator, List, Optional
4+
from typing import Any, Callable, Dict, Iterator, List, Optional
55

66
from aws_lambda_powertools.shared.headers_serializer import BaseHeadersSerializer
77

88

99
class DictWrapper(Mapping):
1010
"""Provides a single read only access to a wrapper dict"""
1111

12-
def __init__(self, data: Dict[str, Any]):
12+
def __init__(self, data: Dict[str, Any], json_deserializer: Optional[Callable] = None):
13+
"""
14+
Parameters
15+
----------
16+
data : Dict[str, Any]
17+
Lambda Event Source Event payload
18+
json_deserializer : Callable, optional
19+
function to deserialize `str`, `bytes`, bytearray` containing a JSON document to a Python `obj`,
20+
by default json.loads
21+
"""
1322
self._data = data
1423
self._json_data: Optional[Any] = None
24+
self._json_deserializer = json_deserializer or json.loads
1525

1626
def __getitem__(self, key: str) -> Any:
1727
return self._data[key]
@@ -122,7 +132,7 @@ def body(self) -> Optional[str]:
122132
def json_body(self) -> Any:
123133
"""Parses the submitted body as json"""
124134
if self._json_data is None:
125-
self._json_data = json.loads(self.decoded_body)
135+
self._json_data = self._json_deserializer(self.decoded_body)
126136
return self._json_data
127137

128138
@property

Diff for: aws_lambda_powertools/utilities/data_classes/kafka_event.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import base64
2-
import json
32
from typing import Any, Dict, Iterator, List, Optional
43

54
from aws_lambda_powertools.utilities.data_classes.common import DictWrapper
@@ -55,7 +54,7 @@ def decoded_value(self) -> bytes:
5554
def json_value(self) -> Any:
5655
"""Decodes the text encoded data as JSON."""
5756
if self._json_data is None:
58-
self._json_data = json.loads(self.decoded_value.decode("utf-8"))
57+
self._json_data = self._json_deserializer(self.decoded_value.decode("utf-8"))
5958
return self._json_data
6059

6160
@property
@@ -117,7 +116,7 @@ def records(self) -> Iterator[KafkaEventRecord]:
117116
"""The Kafka records."""
118117
for chunk in self["records"].values():
119118
for record in chunk:
120-
yield KafkaEventRecord(record)
119+
yield KafkaEventRecord(data=record, json_deserializer=self._json_deserializer)
121120

122121
@property
123122
def record(self) -> KafkaEventRecord:

Diff for: aws_lambda_powertools/utilities/data_classes/kinesis_firehose_event.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import base64
2-
import json
32
from typing import Iterator, Optional
43

54
from aws_lambda_powertools.utilities.data_classes.common import DictWrapper
@@ -75,7 +74,7 @@ def data_as_text(self) -> str:
7574
def data_as_json(self) -> dict:
7675
"""Decoded base64-encoded data loaded to json"""
7776
if self._json_data is None:
78-
self._json_data = json.loads(self.data_as_text)
77+
self._json_data = self._json_deserializer(self.data_as_text)
7978
return self._json_data
8079

8180

@@ -110,4 +109,4 @@ def region(self) -> str:
110109
@property
111110
def records(self) -> Iterator[KinesisFirehoseRecord]:
112111
for record in self["records"]:
113-
yield KinesisFirehoseRecord(record)
112+
yield KinesisFirehoseRecord(data=record, json_deserializer=self._json_deserializer)

Diff for: aws_lambda_powertools/utilities/data_classes/sqs_event.py

+31-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Dict, Iterator, Optional
1+
from typing import Any, Dict, Iterator, Optional
22

33
from aws_lambda_powertools.utilities.data_classes.common import DictWrapper
44

@@ -103,6 +103,35 @@ def body(self) -> str:
103103
"""The message's contents (not URL-encoded)."""
104104
return self["body"]
105105

106+
@property
107+
def json_body(self) -> Any:
108+
"""Deserializes JSON string available in 'body' property
109+
110+
Notes
111+
-----
112+
113+
**Strict typing**
114+
115+
Caller controls the type as we can't use recursive generics here.
116+
117+
JSON Union types would force caller to have to cast a type. Instead,
118+
we choose Any to ease ergonomics and other tools receiving this data.
119+
120+
Examples
121+
--------
122+
123+
**Type deserialized data from JSON string**
124+
125+
```python
126+
data: dict = record.json_body # {"telemetry": [], ...}
127+
# or
128+
data: list = record.json_body # ["telemetry_values"]
129+
```
130+
"""
131+
if self._json_data is None:
132+
self._json_data = self._json_deserializer(self["body"])
133+
return self._json_data
134+
106135
@property
107136
def attributes(self) -> SQSRecordAttributes:
108137
"""A map of the attributes requested in ReceiveMessage to their respective values."""
@@ -157,4 +186,4 @@ class SQSEvent(DictWrapper):
157186
@property
158187
def records(self) -> Iterator[SQSRecord]:
159188
for record in self["Records"]:
160-
yield SQSRecord(record)
189+
yield SQSRecord(data=record, json_deserializer=self._json_deserializer)

Diff for: tests/events/sqsEvent.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
{
2626
"messageId": "2e1424d4-f796-459a-8184-9c92662be6da",
2727
"receiptHandle": "AQEBzWwaftRI0KuVm4tP+/7q1rGgNqicHq...",
28-
"body": "Test message2.",
28+
"body": "{\"message\": \"foo1\"}",
2929
"attributes": {
3030
"ApproximateReceiveCount": "1",
3131
"SentTimestamp": "1545082650636",
@@ -39,4 +39,4 @@
3939
"awsRegion": "us-east-2"
4040
}
4141
]
42-
}
42+
}

Diff for: tests/functional/test_data_classes.py

+44
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,47 @@ def message(self) -> str:
113113
assert DataClassSample(data1).raw_event is data1
114114

115115

116+
def test_dict_wrapper_with_default_custom_json_deserializer():
117+
class DataClassSample(DictWrapper):
118+
@property
119+
def json_body(self) -> dict:
120+
return self._json_deserializer(self["body"])
121+
122+
data = {"body": '{"message": "foo1"}'}
123+
event = DataClassSample(data=data)
124+
assert event.json_body == json.loads(data["body"])
125+
126+
127+
def test_dict_wrapper_with_valid_custom_json_deserializer():
128+
class DataClassSample(DictWrapper):
129+
@property
130+
def json_body(self) -> dict:
131+
return self._json_deserializer(self["body"])
132+
133+
def fake_json_deserializer(record: dict):
134+
return json.loads(record)
135+
136+
data = {"body": '{"message": "foo1"}'}
137+
event = DataClassSample(data=data, json_deserializer=fake_json_deserializer)
138+
assert event.json_body == json.loads(data["body"])
139+
140+
141+
def test_dict_wrapper_with_invalid_custom_json_deserializer():
142+
class DataClassSample(DictWrapper):
143+
@property
144+
def json_body(self) -> dict:
145+
return self._json_deserializer(self["body"])
146+
147+
def fake_json_deserializer() -> None:
148+
# invalid fn signature should raise TypeError
149+
pass
150+
151+
data = {"body": {"message": "foo1"}}
152+
with pytest.raises(TypeError):
153+
event = DataClassSample(data=data, json_deserializer=fake_json_deserializer)
154+
assert event.json_body == {"message": "foo1"}
155+
156+
116157
def test_dict_wrapper_implements_mapping():
117158
class DataClassSample(DictWrapper):
118159
pass
@@ -926,6 +967,9 @@ def test_seq_trigger_event():
926967
assert record.queue_url == "https://sqs.us-east-2.amazonaws.com/123456789012/my-queue"
927968
assert record.aws_region == "us-east-2"
928969

970+
record_2 = records[1]
971+
assert record_2.json_body == {"message": "foo1"}
972+
929973

930974
def test_default_api_gateway_proxy_event():
931975
event = APIGatewayProxyEvent(load_event("apiGatewayProxyEvent_noVersionAuth.json"))

0 commit comments

Comments
 (0)