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

feat(idempotency): add support to custom serialization/deserialization on idempotency decorator #2951

Merged
Show file tree
Hide file tree
Changes from 52 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
6d85c94
add serializer/deserilizer
aradyaron Jul 30, 2023
e142c4b
improve docs
aradyaron Jul 30, 2023
151080b
seperate serializers of input and output
aradyaron Jul 30, 2023
9774248
feat(idempotency): custom serialization - add a helper serailizer cla…
aradyaron Aug 4, 2023
641905c
fix tests
aradyaron Aug 12, 2023
e7a3712
Merge branch 'aws-powertools:develop' into feat/idempotency-output-se…
aradyaron Aug 13, 2023
f1daf07
idemoponcy changes
aradyaron Aug 13, 2023
17b721c
Merge branch 'feat/idempotency-output-serializer' of github.com:arady…
aradyaron Aug 13, 2023
cd6f269
Merge branch 'develop' into feat/idempotency-output-serializer
leandrodamascena Aug 14, 2023
6c4d5d4
Merge branch 'develop' into feat/idempotency-output-serializer
leandrodamascena Aug 15, 2023
423fe7c
Merge branch 'develop' into feat/idempotency-output-serializer
leandrodamascena Aug 15, 2023
aebc3ed
Merge branch 'develop' into feat/idempotency-output-serializer
leandrodamascena Aug 16, 2023
cf307e5
Merge branch 'develop' into feat/idempotency-output-serializer
aradyaron Aug 17, 2023
c3797d7
Merge branch 'aws-powertools:develop' into feat/idempotency-output-se…
aradyaron Aug 18, 2023
008aecf
* Add NoOpSerializer instead of checking for None
aradyaron Aug 18, 2023
45a64b5
Merge branch 'develop' into feat/idempotency-output-serializer
aradyaron Aug 18, 2023
f2e818e
Merge branch 'develop' into feat/idempotency-output-serializer
aradyaron Aug 19, 2023
1b39f31
make pr
aradyaron Aug 20, 2023
654396a
Merge branch 'develop' into feat/idempotency-output-serializer
aradyaron Aug 20, 2023
bf25130
update function description
aradyaron Aug 21, 2023
1075631
Merge branch 'develop' into feat/idempotency-output-serializer
aradyaron Aug 21, 2023
7394007
Merge branch 'develop' into feat/idempotency-output-serializer
aradyaron Aug 23, 2023
d8361af
Merge branch 'develop' into feat/idempotency-output-serializer
aradyaron Aug 25, 2023
baf49bc
Cr fixes
aradyaron Aug 25, 2023
58c803c
Merge branch 'feat/idempotency-output-serializer' of github.com:arady…
aradyaron Aug 25, 2023
f8e591a
Merge branch 'develop' into feat/idempotency-output-serializer
aradyaron Aug 25, 2023
fc38f63
add support to dynamic model deducation
aradyaron Aug 25, 2023
9f08bf0
add negative tests
aradyaron Aug 25, 2023
fdde7b9
add implelementation for dataclass
aradyaron Aug 25, 2023
fe4ff3d
add dataclass serializer
aradyaron Aug 25, 2023
4fc2875
Merge branch 'develop' into feat/idempotency-output-serializer
aradyaron Aug 27, 2023
1a6aa42
Merge branch 'develop' into feat/idempotency-output-serializer
leandrodamascena Aug 29, 2023
89394d6
Merge branch 'develop' into feat/idempotency-output-serializer
leandrodamascena Aug 29, 2023
18a19a7
Merge branch 'develop' into feat/idempotency-output-serializer
leandrodamascena Aug 29, 2023
8b7d526
Merge branch 'develop' into feat/idempotency-output-serializer
leandrodamascena Aug 30, 2023
988dcfa
Merge pull request #2 from aradyaron/feat/idempotency-output-serializ…
aradyaron Aug 31, 2023
18ae349
cr change requests
aradyaron Aug 31, 2023
f6dc695
rename example file
aradyaron Aug 31, 2023
cc5a1bd
fix lines
aradyaron Aug 31, 2023
17de6a0
Merge branch 'develop' into feat/idempotency-output-serializer
leandrodamascena Sep 1, 2023
ba2ebc6
add more docs and examples
aradyaron Sep 1, 2023
6052783
remove redundant type annotation
aradyaron Sep 1, 2023
ebacef8
Merge branch 'feat/idempotency-output-serializer' of github.com:arady…
aradyaron Sep 1, 2023
39b27a0
fix: reading improvements
rubenfonseca Sep 1, 2023
e71dd19
fix: mypy errors
rubenfonseca Sep 1, 2023
a08ba6d
add newline
aradyaron Sep 1, 2023
020adce
Merge branch 'develop' into feat/idempotency-output-serializer
aradyaron Sep 2, 2023
af2ecb4
revert file format change
aradyaron Sep 2, 2023
c0469b6
add newline
aradyaron Sep 2, 2023
82caabe
Merge branch 'develop' into feat/idempotency-output-serializer
leandrodamascena Sep 4, 2023
cef68c7
Merge branch 'develop' into feat/idempotency-output-serializer
aradyaron Sep 4, 2023
7aaed7c
Merge branch 'develop' into feat/idempotency-output-serializer
aradyaron Sep 5, 2023
f750b3b
Merge branch 'develop' into feat/idempotency-output-serializer
leandrodamascena Sep 5, 2023
bfbc636
Adjusts in the documentation
leandrodamascena Sep 5, 2023
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
25 changes: 20 additions & 5 deletions aws_lambda_powertools/utilities/idempotency/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
BasePersistenceLayer,
DataRecord,
)
from aws_lambda_powertools.utilities.idempotency.serialization.base import (
BaseIdempotencySerializer,
)
from aws_lambda_powertools.utilities.idempotency.serialization.no_op import (
NoOpSerializer,
)

MAX_RETRIES = 2
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -51,6 +57,7 @@ def __init__(
function_payload: Any,
config: IdempotencyConfig,
persistence_store: BasePersistenceLayer,
output_serializer: Optional[BaseIdempotencySerializer] = None,
function_args: Optional[Tuple] = None,
function_kwargs: Optional[Dict] = None,
):
Expand All @@ -65,12 +72,16 @@ def __init__(
Idempotency Configuration
persistence_store : BasePersistenceLayer
Instance of persistence layer to store idempotency records
rubenfonseca marked this conversation as resolved.
Show resolved Hide resolved
output_serializer: Optional[BaseIdempotencySerializer]
Serializer to transform the data to and from a dictionary.
If not supplied, no serialization is done via the NoOpSerializer
function_args: Optional[Tuple]
Function arguments
function_kwargs: Optional[Dict]
Function keyword arguments
"""
self.function = function
self.output_serializer = output_serializer or NoOpSerializer()
rubenfonseca marked this conversation as resolved.
Show resolved Hide resolved
self.data = deepcopy(_prepare_data(function_payload))
self.fn_args = function_args
self.fn_kwargs = function_kwargs
Expand Down Expand Up @@ -170,7 +181,7 @@ def _get_idempotency_record(self) -> Optional[DataRecord]:

return data_record

def _handle_for_status(self, data_record: DataRecord) -> Optional[Dict[Any, Any]]:
def _handle_for_status(self, data_record: DataRecord) -> Optional[Any]:
"""
Take appropriate action based on data_record's status

Expand All @@ -180,8 +191,9 @@ def _handle_for_status(self, data_record: DataRecord) -> Optional[Dict[Any, Any]

Returns
-------
Optional[Dict[Any, Any]
Optional[Any]
Function's response previously used for this idempotency key, if it has successfully executed already.
In case an output serializer is configured, the response is deserialized.

Raises
------
Expand All @@ -206,8 +218,10 @@ def _handle_for_status(self, data_record: DataRecord) -> Optional[Dict[Any, Any]
f"Execution already in progress with idempotency key: "
f"{self.persistence_store.event_key_jmespath}={data_record.idempotency_key}",
)

return data_record.response_json_as_dict()
response_dict: Optional[dict] = data_record.response_json_as_dict()
if response_dict is not None:
return self.output_serializer.from_dict(response_dict)
return None

def _get_function_response(self):
try:
Expand All @@ -226,7 +240,8 @@ def _get_function_response(self):

else:
try:
self.persistence_store.save_success(data=self.data, result=response)
serialized_response: dict = self.output_serializer.to_dict(response) if response else None
self.persistence_store.save_success(data=self.data, result=serialized_response)
except Exception as save_exception:
raise IdempotencyPersistenceLayerError(
"Failed to update record state to success in idempotency store",
Expand Down
12 changes: 12 additions & 0 deletions aws_lambda_powertools/utilities/idempotency/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,15 @@ class IdempotencyKeyError(BaseError):
"""
Payload does not contain an idempotent key
"""


class IdempotencyModelTypeError(BaseError):
"""
Model type does not match expected payload output
"""


class IdempotencyNoSerializationModelError(BaseError):
"""
No model was supplied to the serializer
"""
19 changes: 18 additions & 1 deletion aws_lambda_powertools/utilities/idempotency/idempotency.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
import functools
import logging
import os
from typing import Any, Callable, Dict, Optional, cast
from inspect import isclass
from typing import Any, Callable, Dict, Optional, Type, Union, cast

from aws_lambda_powertools.middleware_factory import lambda_handler_decorator
from aws_lambda_powertools.shared import constants
Expand All @@ -14,6 +15,10 @@
from aws_lambda_powertools.utilities.idempotency.persistence.base import (
BasePersistenceLayer,
)
from aws_lambda_powertools.utilities.idempotency.serialization.base import (
BaseIdempotencyModelSerializer,
BaseIdempotencySerializer,
)
from aws_lambda_powertools.utilities.typing import LambdaContext

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -85,6 +90,7 @@ def idempotent_function(
data_keyword_argument: str,
persistence_store: BasePersistenceLayer,
config: Optional[IdempotencyConfig] = None,
output_serializer: Optional[Union[BaseIdempotencySerializer, Type[BaseIdempotencyModelSerializer]]] = None,
) -> Any:
"""
Decorator to handle idempotency of any function
Expand All @@ -99,6 +105,11 @@ def idempotent_function(
Instance of BasePersistenceLayer to store data
config: IdempotencyConfig
Configuration
output_serializer: Optional[Union[BaseIdempotencySerializer, Type[BaseIdempotencyModelSerializer]]]
Serializer to transform the data to and from a dictionary.
If not supplied, no serialization is done via the NoOpSerializer.
In case a serializer of type inheriting BaseIdempotencyModelSerializer is given,
the serializer is derived from the function return type.

Examples
--------
Expand All @@ -124,9 +135,14 @@ def process_order(customer_id: str, order: dict, **kwargs):
data_keyword_argument=data_keyword_argument,
persistence_store=persistence_store,
config=config,
output_serializer=output_serializer,
),
)

if isclass(output_serializer) and issubclass(output_serializer, BaseIdempotencyModelSerializer):
# instantiate an instance of the serializer class
output_serializer = output_serializer.instantiate(function.__annotations__.get("return", None))

config = config or IdempotencyConfig()

@functools.wraps(function)
Expand All @@ -147,6 +163,7 @@ def decorate(*args, **kwargs):
function_payload=payload,
config=config,
persistence_store=persistence_store,
output_serializer=output_serializer,
function_args=args,
function_kwargs=kwargs,
)
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""
Serialization for supporting idempotency
"""
from abc import ABC, abstractmethod
from typing import Any, Dict


class BaseIdempotencySerializer(ABC):
"""
Abstract Base Class for Idempotency serialization layer, supporting dict operations.
"""

@abstractmethod
def to_dict(self, data: Any) -> Dict:
raise NotImplementedError("Implementation of to_dict is required")

@abstractmethod
def from_dict(self, data: Dict) -> Any:
raise NotImplementedError("Implementation of from_dict is required")


class BaseIdempotencyModelSerializer(BaseIdempotencySerializer):
"""
Abstract Base Class for Idempotency serialization layer, for using a model as data object representation.
"""

@classmethod
@abstractmethod
def instantiate(cls, model_type: Any) -> BaseIdempotencySerializer:
"""
Creates an instance of a serializer based on a provided model type.
In case the model_type is unknown, None will be sent as `model_type`.
It's on the implementer to verify that:
- None is handled correctly
- A model type not matching the expected types is handled

Parameters
----------
model_type: Any
The model type to instantiate the class for

Returns
-------
BaseIdempotencySerializer
Instance of the serializer class
"""
pass
leandrodamascena marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import Any, Callable, Dict

from aws_lambda_powertools.utilities.idempotency.serialization.base import BaseIdempotencySerializer


class CustomDictSerializer(BaseIdempotencySerializer):
def __init__(self, to_dict: Callable[[Any], Dict], from_dict: Callable[[Dict], Any]):
"""
Parameters
----------
to_dict: Callable[[Any], Dict]
A function capable of transforming the saved data object representation into a dictionary
from_dict: Callable[[Dict], Any]
A function capable of transforming the saved dictionary into the original data object representation
"""
self.__to_dict: Callable[[Any], Dict] = to_dict
self.__from_dict: Callable[[Dict], Any] = from_dict

def to_dict(self, data: Any) -> Dict:
return self.__to_dict(data)

def from_dict(self, data: Dict) -> Any:
return self.__from_dict(data)
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from dataclasses import asdict, is_dataclass
from typing import Any, Dict, Type

from aws_lambda_powertools.utilities.idempotency.exceptions import (
IdempotencyModelTypeError,
IdempotencyNoSerializationModelError,
)
from aws_lambda_powertools.utilities.idempotency.serialization.base import (
BaseIdempotencyModelSerializer,
BaseIdempotencySerializer,
)

DataClass = Any


class DataclassSerializer(BaseIdempotencyModelSerializer):
"""
A serializer class for transforming data between dataclass objects and dictionaries.
"""

def __init__(self, model: Type[DataClass]):
"""
Parameters
----------
model: Type[DataClass]
A dataclass type to be used for serialization and deserialization
"""
self.__model: Type[DataClass] = model

def to_dict(self, data: DataClass) -> Dict:
return asdict(data)

def from_dict(self, data: Dict) -> DataClass:
return self.__model(**data)

@classmethod
def instantiate(cls, model_type: Any) -> BaseIdempotencySerializer:
if model_type is None:
raise IdempotencyNoSerializationModelError("No serialization model was supplied")

if not is_dataclass(model_type):
raise IdempotencyModelTypeError("Model type is not inherited of dataclass type")
return cls(model=model_type)
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from typing import Dict

from aws_lambda_powertools.utilities.idempotency.serialization.base import BaseIdempotencySerializer


class NoOpSerializer(BaseIdempotencySerializer):
def __init__(self):
"""
Parameters
----------
Default serializer, does not transform data
"""

def to_dict(self, data: Dict) -> Dict:
return data

def from_dict(self, data: Dict) -> Dict:
return data
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from typing import Any, Dict, Type

from pydantic import BaseModel

from aws_lambda_powertools.utilities.idempotency.exceptions import (
IdempotencyModelTypeError,
IdempotencyNoSerializationModelError,
)
from aws_lambda_powertools.utilities.idempotency.serialization.base import (
BaseIdempotencyModelSerializer,
BaseIdempotencySerializer,
)


class PydanticSerializer(BaseIdempotencyModelSerializer):
"""Pydantic serializer for idempotency models"""

def __init__(self, model: Type[BaseModel]):
"""
Parameters
----------
model: Model
Pydantic model to be used for serialization
"""
self.__model: Type[BaseModel] = model

def to_dict(self, data: BaseModel) -> Dict:
if callable(getattr(data, "model_dump", None)):
# Support for pydantic V2
return data.model_dump() # type: ignore[unused-ignore,attr-defined]
return data.dict()

def from_dict(self, data: Dict) -> BaseModel:
if callable(getattr(self.__model, "model_validate", None)):
# Support for pydantic V2
return self.__model.model_validate(data) # type: ignore[unused-ignore,attr-defined]
return self.__model.parse_obj(data)

@classmethod
def instantiate(cls, model_type: Any) -> BaseIdempotencySerializer:
if model_type is None:
raise IdempotencyNoSerializationModelError("No serialization model was supplied")

if not issubclass(model_type, BaseModel):
raise IdempotencyModelTypeError("Model type is not inherited from pydantic BaseModel")

return cls(model=model_type)
30 changes: 30 additions & 0 deletions docs/utilities/idempotency.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,36 @@ When using `idempotent_function`, you must tell us which keyword parameter in yo
--8<-- "examples/idempotency/src/working_with_idempotent_function_pydantic.py"
```

#### Output Serialization
leandrodamascena marked this conversation as resolved.
Show resolved Hide resolved

By supplying an output serializer, you can control the return type of the function, allowing cleaner integration with the rest of your code base.

=== "Using Pydantic"
Explicitly passing the model type
```python hl_lines="3-8 24-25 32-35 55"
--8<-- "examples/idempotency/src/working_with_idempotent_function_pydantic_output_serializer.py"
```
Deducing the model type from the return type annotation
```python hl_lines="3-8 24-25 42-46 55"
--8<-- "examples/idempotency/src/working_with_idempotent_function_pydantic_output_serializer.py"
```

=== "Using Dataclasses"
Explicitly passing the model type
```python hl_lines="1 5-8 27-29 36-39 59"
--8<-- "examples/idempotency/src/working_with_idempotent_function_dataclass_output_serializer.py"
```
Deducing the model type from the return type annotation
```python hl_lines="1 5-8 27-29 46-50 60"
--8<-- "examples/idempotency/src/working_with_idempotent_function_dataclass_output_serializer.py"
```

=== "Using A Custom Type (Dataclasses)"

```python hl_lines="3-8 26-28 32-44 51"
--8<-- "examples/idempotency/src/working_with_idempotent_function_custom_output_serializer.py"
```

#### Batch integration

You can can easily integrate with [Batch utility](batch.md){target="_blank"} via context manager. This ensures that you process each record in an idempotent manner, and guard against a [Lambda timeout](#lambda-timeouts) idempotent situation.
Expand Down
Loading