Skip to content

Commit eabc742

Browse files
committed
Add serializer to response wrapper
It can be useful, when the response fully or partially need to be stored in a database in a JSON serialized form. From python 3.8, the `_unwrap` method could be replaced with using singledispatchmethod.
1 parent 87dfcef commit eabc742

File tree

2 files changed

+292
-0
lines changed

2 files changed

+292
-0
lines changed

checkout_sdk/checkout_response.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
from collections.abc import Iterable, Mapping
2+
from inspect import isfunction, ismethod
3+
4+
15
class ResponseWrapper:
26

37
def __init__(self, http_metadata=None, data=None):
@@ -21,3 +25,77 @@ def _wrap(self, value):
2125
@staticmethod
2226
def _is_collection(value):
2327
return isinstance(value, (tuple, list, set, frozenset))
28+
29+
def dict(self):
30+
"""
31+
Serializes the instance to a dictionary recursively.
32+
33+
The result shall only contain JSON compatible data structures.
34+
35+
Circular references shall be replaced with representations of appropriate
36+
JSON references (https://json-spec.readthedocs.io/reference.html).
37+
"""
38+
return self._unwrap_object(self, cache={}, path=['#'])
39+
40+
@staticmethod
41+
def _cache(method):
42+
def decorated_method(cls, data, cache, path):
43+
if id(data) in cache:
44+
for ref_path in cache[id(data)]:
45+
if path[:len(ref_path)] == ref_path:
46+
return {'$ref': '/'.join(ref_path)}
47+
48+
cache[id(data)].append(path)
49+
50+
else:
51+
cache[id(data)] = [path]
52+
53+
return method(cls, data, cache, path)
54+
55+
return decorated_method
56+
57+
@classmethod
58+
@_cache
59+
def _unwrap_object(cls, data, cache, path):
60+
return {
61+
key: cls._unwrap(attr, cache, path + [key])
62+
for key in dir(data)
63+
if not key.startswith('__')
64+
and not cls._is_function(attr := getattr(data, key))
65+
}
66+
67+
@classmethod
68+
def _unwrap(cls, data, cache, path):
69+
if isinstance(data, (str, int, float, bool, type(None))):
70+
return data
71+
72+
elif isinstance(data, Mapping):
73+
return cls._unwrap_mapping(data, cache, path)
74+
75+
elif isinstance(data, Iterable):
76+
return cls._unwrap_iterable(data, cache, path)
77+
78+
else:
79+
return cls._unwrap_object(data, cache, path)
80+
81+
@classmethod
82+
@_cache
83+
def _unwrap_mapping(cls, data: Mapping, cache, path):
84+
return {
85+
key: cls._unwrap(value, cache, path + [key])
86+
for key, value in data.items()
87+
if not cls._is_function(value)
88+
}
89+
90+
@classmethod
91+
@_cache
92+
def _unwrap_iterable(cls, data: Iterable, cache, path):
93+
return [
94+
cls._unwrap(value, cache, path + [str(idx)])
95+
for idx, value in enumerate(data)
96+
if not cls._is_function(value)
97+
]
98+
99+
@staticmethod
100+
def _is_function(data):
101+
return isfunction(data) or ismethod(data)

tests/checkout_response_test.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
from checkout_sdk.checkout_response import ResponseWrapper
2+
3+
4+
def test_serialize_atomic_types():
5+
unwrapped_data = {
6+
'str_attr': 'attr-value',
7+
'int_attr': 1234,
8+
'float_attr': 56.78,
9+
'bool_attr': True,
10+
'none_attr': None,
11+
}
12+
13+
wrapped_data = ResponseWrapper(data=unwrapped_data)
14+
15+
assert wrapped_data.dict() == unwrapped_data
16+
17+
18+
def test_serialize_object():
19+
class ObjAttr:
20+
str_attr_2 = 'attr-value-2'
21+
int_attr = 1234
22+
float_attr = 56.78
23+
bool_attr = True
24+
none_attr = None
25+
26+
def callable_attr(self):
27+
pass
28+
29+
wrapped_data = ResponseWrapper(
30+
data={
31+
'str_attr_1': 'attr-value-1',
32+
'obj_attr': ObjAttr(),
33+
}
34+
)
35+
36+
assert isinstance(wrapped_data.obj_attr, ObjAttr)
37+
38+
assert wrapped_data.dict() == {
39+
'str_attr_1': 'attr-value-1',
40+
'obj_attr': {
41+
'str_attr_2': 'attr-value-2',
42+
'int_attr': 1234,
43+
'float_attr': 56.78,
44+
'bool_attr': True,
45+
'none_attr': None,
46+
},
47+
}
48+
49+
50+
def test_serialize_nested_objects():
51+
unwrapped_data = {
52+
'str_attr_1': 'attr-value-1',
53+
'int_attr_1': 123,
54+
'float_attr_1': 89.1,
55+
56+
'obj_attr_1': {
57+
'str_attr_2': 'attr-value-2',
58+
'int_attr_2': 456,
59+
'float_attr_2': 91.2,
60+
61+
'obj_attr_2': {
62+
'str_attr_3': 'attr-value-3',
63+
'int_attr_3': 789,
64+
'float_attr_3': 12.3,
65+
}
66+
}
67+
}
68+
69+
wrapped_data = ResponseWrapper(data=unwrapped_data)
70+
71+
assert isinstance(wrapped_data.obj_attr_1, ResponseWrapper)
72+
assert isinstance(wrapped_data.obj_attr_1.obj_attr_2, ResponseWrapper)
73+
74+
assert wrapped_data.dict() == unwrapped_data
75+
76+
77+
def test_serialize_mapping():
78+
unwrapped_data = {
79+
'str_attr_1': 'attr-value-1',
80+
'int_attr_1': 123,
81+
'float_attr_1': 45.6,
82+
83+
'mapping_attr': {
84+
'str_attr_2': 'attr-value-2',
85+
'obj_attr': {
86+
'str_attr_3': 'attr-value-3'
87+
},
88+
},
89+
}
90+
91+
wrapped_data = ResponseWrapper(data=unwrapped_data)
92+
93+
assert isinstance(wrapped_data.mapping_attr.obj_attr, ResponseWrapper)
94+
95+
mapping = unwrapped_data['mapping_attr'].copy()
96+
mapping['obj_attr'] = wrapped_data.mapping_attr.obj_attr
97+
mapping['callable_attr'] = lambda: None
98+
wrapped_data.mapping_attr = mapping
99+
100+
assert wrapped_data.dict() == unwrapped_data
101+
102+
103+
def test_serialize_iterable():
104+
unwrapped_data = {
105+
'str_attr_1': 'attr-value-1',
106+
'int_attr_1': 123,
107+
'float_attr_1': 45.6,
108+
109+
'iterable_attr': [
110+
'list-item-1',
111+
{
112+
'str_attr_2': 'attr-value-2'
113+
},
114+
],
115+
}
116+
117+
wrapped_data = ResponseWrapper(data=unwrapped_data)
118+
119+
assert isinstance(wrapped_data.iterable_attr, list)
120+
assert isinstance(wrapped_data.iterable_attr[1], ResponseWrapper)
121+
122+
wrapped_data.iterable_attr.append(lambda: None)
123+
124+
assert wrapped_data.dict() == unwrapped_data
125+
126+
127+
def test_serialize_list_from_any_iterables():
128+
unwrapped_data = {
129+
'tuple_attr': (
130+
'tuple-item-1',
131+
'tuple-item-2',
132+
12.34,
133+
),
134+
'set_attr': {
135+
'set-item-1',
136+
'set-item-2',
137+
56.78,
138+
},
139+
}
140+
141+
wrapped_data = ResponseWrapper(data=unwrapped_data)
142+
143+
assert isinstance(wrapped_data.tuple_attr, tuple)
144+
assert isinstance(wrapped_data.set_attr, set)
145+
146+
serialized_data = wrapped_data.dict()
147+
148+
assert isinstance(serialized_data['tuple_attr'], list)
149+
assert isinstance(serialized_data['set_attr'], list)
150+
151+
assert tuple(serialized_data['tuple_attr']) == unwrapped_data['tuple_attr']
152+
assert set(serialized_data['set_attr']) == unwrapped_data['set_attr']
153+
154+
155+
def test_serialize_circular_references():
156+
unwrapped_data = {
157+
'str_attr': 'attr-value',
158+
'iterable_attr_1': [
159+
0,
160+
1,
161+
2,
162+
{
163+
'obj_attr': {
164+
'iterable_attr_2': [3, 4],
165+
},
166+
},
167+
],
168+
'iterable_attr_3': [5, 6],
169+
'iterable_attr_4': [7, 8],
170+
'iterable_attr_5': [9, 0],
171+
}
172+
unwrapped_data['iterable_attr_1'][3]['obj_attr']['circular_ref'] = \
173+
unwrapped_data['iterable_attr_1']
174+
175+
unwrapped_data['iterable_attr_1'][3]['obj_attr']['forward_ref'] = \
176+
unwrapped_data['iterable_attr_3']
177+
178+
unwrapped_data['iterable_attr_1'][3]['obj_attr']['iterable_attr_2'][1] = \
179+
unwrapped_data['iterable_attr_1'][3]
180+
181+
unwrapped_data['iterable_attr_4'][0] = unwrapped_data['iterable_attr_5']
182+
unwrapped_data['iterable_attr_5'][1] = unwrapped_data['iterable_attr_4']
183+
184+
wrapped_data = ResponseWrapper(http_metadata=unwrapped_data)
185+
186+
assert wrapped_data.dict() == {
187+
'http_metadata': {
188+
'str_attr': 'attr-value',
189+
'iterable_attr_1': [
190+
0,
191+
1,
192+
2,
193+
{
194+
'obj_attr': {
195+
'iterable_attr_2': [
196+
3,
197+
{'$ref': '#/http_metadata/iterable_attr_1/3'}
198+
],
199+
'circular_ref': {'$ref': '#/http_metadata/iterable_attr_1'},
200+
'forward_ref': [5, 6],
201+
},
202+
},
203+
],
204+
'iterable_attr_3': [5, 6],
205+
'iterable_attr_4': [
206+
[9, {'$ref': '#/http_metadata/iterable_attr_4'}],
207+
8,
208+
],
209+
'iterable_attr_5': [
210+
9,
211+
[{'$ref': '#/http_metadata/iterable_attr_5'}, 8],
212+
],
213+
},
214+
}

0 commit comments

Comments
 (0)