Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
85 changes: 85 additions & 0 deletions checkout_sdk/checkout_response.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from collections.abc import Iterable, Mapping


class ResponseWrapper:

def __init__(self, http_metadata=None, data=None):
Expand All @@ -21,3 +24,85 @@ def _wrap(self, value):
@staticmethod
def _is_collection(value):
return isinstance(value, (tuple, list, set, frozenset))

def dict(self):
"""
Serializes the instance to a dictionary recursively.

The result shall only contain JSON compatible data structures.

Circular references shall be replaced with representations of appropriate
JSON references (https://json-spec.readthedocs.io/reference.html).

Note: attributes prefixed with a single underscore are included in the output.
If you store sensitive data on underscore-prefixed attributes,
filter the result before logging or persisting.
"""
return self._unwrap_object(self, paths_by_id={}, path=['#'])

@staticmethod
def _handle_circular_ref(method):
def decorated_method(cls, data, paths_by_id, path):
if id(data) in paths_by_id:
for ref_path in paths_by_id[id(data)]:
if path[:len(ref_path)] == ref_path:
return {'$ref': '/'.join(ref_path)}

paths_by_id[id(data)].append(path)

else:
paths_by_id[id(data)] = [path]

return method(cls, data, paths_by_id, path)

return decorated_method

@classmethod
@_handle_circular_ref
def _unwrap_object(cls, data, paths_by_id, path):
return {
attr: cls._unwrap(getattr(data, attr), paths_by_id, path + [attr])
for attr in dir(data)
if not attr.startswith('__')
and not cls._is_executable_attr(data, attr)
}

@classmethod
def _unwrap(cls, data, paths_by_id, path):
if isinstance(data, (str, int, float, bool, type(None))):
return data

elif isinstance(data, Mapping):
return cls._unwrap_mapping(data, paths_by_id, path)

elif isinstance(data, Iterable):
return cls._unwrap_iterable(data, paths_by_id, path)

else:
return cls._unwrap_object(data, paths_by_id, path)

@classmethod
@_handle_circular_ref
def _unwrap_mapping(cls, data: Mapping, paths_by_id, path):
return {
key: cls._unwrap(value, paths_by_id, path + [key])
for key, value in data.items()
if not cls._is_executable(value)
}

@classmethod
@_handle_circular_ref
def _unwrap_iterable(cls, data: Iterable, paths_by_id, path):
return [
cls._unwrap(value, paths_by_id, path + [str(idx)])
for idx, value in enumerate(data)
if not cls._is_executable(value)
]

@classmethod
def _is_executable_attr(cls, data, attr: str) -> bool:
return cls._is_executable(getattr(type(data), attr, None))

@staticmethod
def _is_executable(data) -> bool:
return hasattr(data, '__get__')
218 changes: 218 additions & 0 deletions tests/checkout_response_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
from checkout_sdk.checkout_response import ResponseWrapper


def test_serialize_atomic_types():
unwrapped_data = {
'str_attr': 'attr-value',
'int_attr': 1234,
'float_attr': 56.78,
'bool_attr': True,
'none_attr': None,
}

wrapped_data = ResponseWrapper(data=unwrapped_data)

assert wrapped_data.dict() == unwrapped_data


def test_serialize_object():
class ObjAttr:
str_attr_2 = 'attr-value-2'
int_attr = 1234
float_attr = 56.78
bool_attr = True
none_attr = None

def callable_attr(self):
pass

@property
def property_attr(self):
return 9

wrapped_data = ResponseWrapper(
data={
'str_attr_1': 'attr-value-1',
'obj_attr': ObjAttr(),
}
)

assert isinstance(wrapped_data.obj_attr, ObjAttr)

assert wrapped_data.dict() == {
'str_attr_1': 'attr-value-1',
'obj_attr': {
'str_attr_2': 'attr-value-2',
'int_attr': 1234,
'float_attr': 56.78,
'bool_attr': True,
'none_attr': None,
},
}


def test_serialize_nested_objects():
unwrapped_data = {
'str_attr_1': 'attr-value-1',
'int_attr_1': 123,
'float_attr_1': 89.1,

'obj_attr_1': {
'str_attr_2': 'attr-value-2',
'int_attr_2': 456,
'float_attr_2': 91.2,

'obj_attr_2': {
'str_attr_3': 'attr-value-3',
'int_attr_3': 789,
'float_attr_3': 12.3,
}
}
}

wrapped_data = ResponseWrapper(data=unwrapped_data)

assert isinstance(wrapped_data.obj_attr_1, ResponseWrapper)
assert isinstance(wrapped_data.obj_attr_1.obj_attr_2, ResponseWrapper)

assert wrapped_data.dict() == unwrapped_data


def test_serialize_mapping():
unwrapped_data = {
'str_attr_1': 'attr-value-1',
'int_attr_1': 123,
'float_attr_1': 45.6,

'mapping_attr': {
'str_attr_2': 'attr-value-2',
'obj_attr': {
'str_attr_3': 'attr-value-3'
},
},
}

wrapped_data = ResponseWrapper(data=unwrapped_data)

assert isinstance(wrapped_data.mapping_attr.obj_attr, ResponseWrapper)

mapping = unwrapped_data['mapping_attr'].copy()
mapping['obj_attr'] = wrapped_data.mapping_attr.obj_attr
mapping['callable_attr'] = lambda: None
wrapped_data.mapping_attr = mapping

assert wrapped_data.dict() == unwrapped_data


def test_serialize_iterable():
unwrapped_data = {
'str_attr_1': 'attr-value-1',
'int_attr_1': 123,
'float_attr_1': 45.6,

'iterable_attr': [
'list-item-1',
{
'str_attr_2': 'attr-value-2'
},
],
}

wrapped_data = ResponseWrapper(data=unwrapped_data)

assert isinstance(wrapped_data.iterable_attr, list)
assert isinstance(wrapped_data.iterable_attr[1], ResponseWrapper)

wrapped_data.iterable_attr.append(lambda: None)

assert wrapped_data.dict() == unwrapped_data


def test_serialize_list_from_any_iterables():
unwrapped_data = {
'tuple_attr': (
'tuple-item-1',
'tuple-item-2',
12.34,
),
'set_attr': {
'set-item-1',
'set-item-2',
56.78,
},
}

wrapped_data = ResponseWrapper(data=unwrapped_data)

assert isinstance(wrapped_data.tuple_attr, tuple)
assert isinstance(wrapped_data.set_attr, set)

serialized_data = wrapped_data.dict()

assert isinstance(serialized_data['tuple_attr'], list)
assert isinstance(serialized_data['set_attr'], list)

assert tuple(serialized_data['tuple_attr']) == unwrapped_data['tuple_attr']
assert set(serialized_data['set_attr']) == unwrapped_data['set_attr']


def test_serialize_circular_references():
unwrapped_data = {
'str_attr': 'attr-value',
'iterable_attr_1': [
0,
1,
2,
{
'obj_attr': {
'iterable_attr_2': [3, 4],
},
},
],
'iterable_attr_3': [5, 6],
'iterable_attr_4': [7, 8],
'iterable_attr_5': [9, 0],
}
unwrapped_data['iterable_attr_1'][3]['obj_attr']['circular_ref'] = \
unwrapped_data['iterable_attr_1']

unwrapped_data['iterable_attr_1'][3]['obj_attr']['forward_ref'] = \
unwrapped_data['iterable_attr_3']

unwrapped_data['iterable_attr_1'][3]['obj_attr']['iterable_attr_2'][1] = \
unwrapped_data['iterable_attr_1'][3]

unwrapped_data['iterable_attr_4'][0] = unwrapped_data['iterable_attr_5']
unwrapped_data['iterable_attr_5'][1] = unwrapped_data['iterable_attr_4']

wrapped_data = ResponseWrapper(http_metadata=unwrapped_data)

assert wrapped_data.dict() == {
'http_metadata': {
'str_attr': 'attr-value',
'iterable_attr_1': [
0,
1,
2,
{
'obj_attr': {
'iterable_attr_2': [
3,
{'$ref': '#/http_metadata/iterable_attr_1/3'}
],
'circular_ref': {'$ref': '#/http_metadata/iterable_attr_1'},
'forward_ref': [5, 6],
},
},
],
'iterable_attr_3': [5, 6],
'iterable_attr_4': [
[9, {'$ref': '#/http_metadata/iterable_attr_4'}],
8,
],
'iterable_attr_5': [
9,
[{'$ref': '#/http_metadata/iterable_attr_5'}, 8],
],
},
}