Skip to content

Commit 321f493

Browse files
feat(idempotency): support dataclasses & pydantic models payloads (aws-powertools#908)
Co-authored-by: heitorlessa <[email protected]>
1 parent ccefa87 commit 321f493

File tree

6 files changed

+242
-64
lines changed

6 files changed

+242
-64
lines changed

Diff for: aws_lambda_powertools/utilities/idempotency/base.py

+18-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,23 @@
2121
logger = logging.getLogger(__name__)
2222

2323

24+
def _prepare_data(data: Any) -> Any:
25+
"""Prepare data for json serialization.
26+
27+
We will convert Python dataclasses, pydantic models or event source data classes to a dict,
28+
otherwise return data as-is.
29+
"""
30+
if hasattr(data, "__dataclass_fields__"):
31+
import dataclasses
32+
33+
return dataclasses.asdict(data)
34+
35+
if callable(getattr(data, "dict", None)):
36+
return data.dict()
37+
38+
return getattr(data, "raw_event", data)
39+
40+
2441
class IdempotencyHandler:
2542
"""
2643
Base class to orchestrate calls to persistence layer.
@@ -52,7 +69,7 @@ def __init__(
5269
Function keyword arguments
5370
"""
5471
self.function = function
55-
self.data = function_payload
72+
self.data = _prepare_data(function_payload)
5673
self.fn_args = function_args
5774
self.fn_kwargs = function_kwargs
5875

Diff for: aws_lambda_powertools/utilities/idempotency/persistence/base.py

-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""
22
Persistence layers supporting idempotency
33
"""
4-
54
import datetime
65
import hashlib
76
import json
@@ -226,7 +225,6 @@ def _generate_hash(self, data: Any) -> str:
226225
Hashed representation of the provided data
227226
228227
"""
229-
data = getattr(data, "raw_event", data) # could be a data class depending on decorator order
230228
hashed_data = self.hash_function(json.dumps(data, cls=Encoder, sort_keys=True).encode())
231229
return hashed_data.hexdigest()
232230

Diff for: docs/utilities/idempotency.md

+95-17
Original file line numberDiff line numberDiff line change
@@ -124,45 +124,50 @@ You can quickly start by initializing the `DynamoDBPersistenceLayer` class and u
124124

125125
Similar to [idempotent decorator](#idempotent-decorator), you can use `idempotent_function` decorator for any synchronous Python function.
126126

127-
When using `idempotent_function`, you must tell us which keyword parameter in your function signature has the data we should use via **`data_keyword_argument`** - Such data must be JSON serializable.
127+
When using `idempotent_function`, you must tell us which keyword parameter in your function signature has the data we should use via **`data_keyword_argument`**.
128+
129+
!!! info "We support JSON serializable data, [Python Dataclasses](https://docs.python.org/3.7/library/dataclasses.html){target="_blank"}, [Parser/Pydantic Models](parser.md){target="_blank"}, and our [Event Source Data Classes](./data_classes.md){target="_blank"}."
128130

129131
!!! warning "Make sure to call your decorated function using keyword arguments"
130132

131-
=== "app.py"
133+
=== "batch_sample.py"
132134

133135
This example also demonstrates how you can integrate with [Batch utility](batch.md), so you can process each record in an idempotent manner.
134136

135-
```python hl_lines="4 13 18 25"
136-
import uuid
137-
138-
from aws_lambda_powertools.utilities.batch import sqs_batch_processor
139-
from aws_lambda_powertools.utilities.idempotency import idempotent_function, DynamoDBPersistenceLayer, IdempotencyConfig
137+
```python hl_lines="4-5 16 21 29"
138+
from aws_lambda_powertools.utilities.batch import (BatchProcessor, EventType,
139+
batch_processor)
140+
from aws_lambda_powertools.utilities.data_classes.sqs_event import SQSRecord
141+
from aws_lambda_powertools.utilities.idempotency import (
142+
DynamoDBPersistenceLayer, IdempotencyConfig, idempotent_function)
140143

141144

145+
processor = BatchProcessor(event_type=EventType.SQS)
142146
dynamodb = DynamoDBPersistenceLayer(table_name="idem")
143147
config = IdempotencyConfig(
144-
event_key_jmespath="messageId", # see "Choosing a payload subset for idempotency" section
148+
event_key_jmespath="messageId", # see Choosing a payload subset section
145149
use_local_cache=True,
146150
)
147151

148-
@idempotent_function(data_keyword_argument="data", config=config, persistence_store=dynamodb)
149-
def dummy(arg_one, arg_two, data: dict, **kwargs):
150-
return {"data": data}
151-
152152

153153
@idempotent_function(data_keyword_argument="record", config=config, persistence_store=dynamodb)
154-
def record_handler(record):
154+
def record_handler(record: SQSRecord):
155155
return {"message": record["body"]}
156156

157157

158-
@sqs_batch_processor(record_handler=record_handler)
158+
@idempotent_function(data_keyword_argument="data", config=config, persistence_store=dynamodb)
159+
def dummy(arg_one, arg_two, data: dict, **kwargs):
160+
return {"data": data}
161+
162+
163+
@batch_processor(record_handler=record_handler, processor=processor)
159164
def lambda_handler(event, context):
160165
# `data` parameter must be called as a keyword argument to work
161166
dummy("hello", "universe", data="test")
162-
return {"statusCode": 200}
167+
return processor.response()
163168
```
164169

165-
=== "Example event"
170+
=== "Batch event"
166171

167172
```json hl_lines="4"
168173
{
@@ -193,6 +198,79 @@ When using `idempotent_function`, you must tell us which keyword parameter in yo
193198
}
194199
```
195200

201+
=== "dataclass_sample.py"
202+
203+
```python hl_lines="3-4 23 32"
204+
from dataclasses import dataclass
205+
206+
from aws_lambda_powertools.utilities.idempotency import (
207+
DynamoDBPersistenceLayer, IdempotencyConfig, idempotent_function)
208+
209+
dynamodb = DynamoDBPersistenceLayer(table_name="idem")
210+
config = IdempotencyConfig(
211+
event_key_jmespath="order_id", # see Choosing a payload subset section
212+
use_local_cache=True,
213+
)
214+
215+
@dataclass
216+
class OrderItem:
217+
sku: str
218+
description: str
219+
220+
@dataclass
221+
class Order:
222+
item: OrderItem
223+
order_id: int
224+
225+
226+
@idempotent_function(data_keyword_argument="order", config=config, persistence_store=dynamodb)
227+
def process_order(order: Order):
228+
return f"processed order {order.order_id}"
229+
230+
231+
order_item = OrderItem(sku="fake", description="sample")
232+
order = Order(item=order_item, order_id="fake-id")
233+
234+
# `order` parameter must be called as a keyword argument to work
235+
process_order(order=order)
236+
```
237+
238+
=== "parser_pydantic_sample.py"
239+
240+
```python hl_lines="1-2 22 31"
241+
from aws_lambda_powertools.utilities.idempotency import (
242+
DynamoDBPersistenceLayer, IdempotencyConfig, idempotent_function)
243+
from aws_lambda_powertools.utilities.parser import BaseModel
244+
245+
dynamodb = DynamoDBPersistenceLayer(table_name="idem")
246+
config = IdempotencyConfig(
247+
event_key_jmespath="order_id", # see Choosing a payload subset section
248+
use_local_cache=True,
249+
)
250+
251+
252+
class OrderItem(BaseModel):
253+
sku: str
254+
description: str
255+
256+
257+
class Order(BaseModel):
258+
item: OrderItem
259+
order_id: int
260+
261+
262+
@idempotent_function(data_keyword_argument="order", config=config, persistence_store=dynamodb)
263+
def process_order(order: Order):
264+
return f"processed order {order.order_id}"
265+
266+
267+
order_item = OrderItem(sku="fake", description="sample")
268+
order = Order(item=order_item, order_id="fake-id")
269+
270+
# `order` parameter must be called as a keyword argument to work
271+
process_order(order=order)
272+
```
273+
196274
### Choosing a payload subset for idempotency
197275

198276
!!! tip "Dealing with always changing payloads"
@@ -209,7 +287,7 @@ Imagine the function executes successfully, but the client never receives the re
209287
!!! warning "Idempotency for JSON payloads"
210288
The payload extracted by the `event_key_jmespath` is treated as a string by default, so will be sensitive to differences in whitespace even when the JSON payload itself is identical.
211289

212-
To alter this behaviour, we can use the [JMESPath built-in function](jmespath_functions.md#powertools_json-function) `powertools_json()` to treat the payload as a JSON object rather than a string.
290+
To alter this behaviour, we can use the [JMESPath built-in function](jmespath_functions.md#powertools_json-function) `powertools_json()` to treat the payload as a JSON object (dict) rather than a string.
213291

214292
=== "payment.py"
215293

Diff for: tests/functional/idempotency/conftest.py

+6-12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import datetime
2-
import hashlib
32
import json
43
from collections import namedtuple
54
from decimal import Decimal
@@ -11,20 +10,15 @@
1110
from botocore.config import Config
1211
from jmespath import functions
1312

14-
from aws_lambda_powertools.shared.json_encoder import Encoder
1513
from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer
1614
from aws_lambda_powertools.utilities.idempotency.idempotency import IdempotencyConfig
1715
from aws_lambda_powertools.utilities.jmespath_utils import extract_data_from_envelope
1816
from aws_lambda_powertools.utilities.validation import envelopes
19-
from tests.functional.utils import load_event
17+
from tests.functional.utils import hash_idempotency_key, json_serialize, load_event
2018

2119
TABLE_NAME = "TEST_TABLE"
2220

2321

24-
def serialize(data):
25-
return json.dumps(data, sort_keys=True, cls=Encoder)
26-
27-
2822
@pytest.fixture(scope="module")
2923
def config() -> Config:
3024
return Config(region_name="us-east-1")
@@ -66,12 +60,12 @@ def lambda_response():
6660

6761
@pytest.fixture(scope="module")
6862
def serialized_lambda_response(lambda_response):
69-
return serialize(lambda_response)
63+
return json_serialize(lambda_response)
7064

7165

7266
@pytest.fixture(scope="module")
7367
def deserialized_lambda_response(lambda_response):
74-
return json.loads(serialize(lambda_response))
68+
return json.loads(json_serialize(lambda_response))
7569

7670

7771
@pytest.fixture
@@ -150,20 +144,20 @@ def expected_params_put_item_with_validation(hashed_idempotency_key, hashed_vali
150144
def hashed_idempotency_key(lambda_apigw_event, default_jmespath, lambda_context):
151145
compiled_jmespath = jmespath.compile(default_jmespath)
152146
data = compiled_jmespath.search(lambda_apigw_event)
153-
return "test-func.lambda_handler#" + hashlib.md5(serialize(data).encode()).hexdigest()
147+
return "test-func.lambda_handler#" + hash_idempotency_key(data)
154148

155149

156150
@pytest.fixture
157151
def hashed_idempotency_key_with_envelope(lambda_apigw_event):
158152
event = extract_data_from_envelope(
159153
data=lambda_apigw_event, envelope=envelopes.API_GATEWAY_HTTP, jmespath_options={}
160154
)
161-
return "test-func.lambda_handler#" + hashlib.md5(serialize(event).encode()).hexdigest()
155+
return "test-func.lambda_handler#" + hash_idempotency_key(event)
162156

163157

164158
@pytest.fixture
165159
def hashed_validation_key(lambda_apigw_event):
166-
return hashlib.md5(serialize(lambda_apigw_event["requestContext"]).encode()).hexdigest()
160+
return hash_idempotency_key(lambda_apigw_event["requestContext"])
167161

168162

169163
@pytest.fixture

0 commit comments

Comments
 (0)