Skip to content

Commit 518757d

Browse files
committed
Add to_dict method to models for serialization
Closes #26
1 parent 165d40a commit 518757d

File tree

3 files changed

+103
-29
lines changed

3 files changed

+103
-29
lines changed

HISTORY.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ History
1212
classes are no longer immutable. For most users, these differences should
1313
not impact their integration.
1414
* BREAKING CHANGE: Model attributes that were formerly tuples are now lists.
15+
* Added ``to_dict`` methods to the model classes. These return a dict version
16+
of the object that is suitable for serialization. It recursively calls
17+
``to_dict`` or the equivalent on all objects contained within the object.
1518
* The minFraud Factors subscores have been deprecated. They will be removed
1619
in March 2025. Please see `our release notes <https://dev.maxmind.com/minfraud/release-notes/2024/#deprecation-of-risk-factor-scoressubscores>`_
1720
for more information.

minfraud/models.py

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,31 @@
1414
import geoip2.records
1515

1616

17-
class IPRiskReason(SimpleEquality):
17+
class _Serializable(SimpleEquality):
18+
def to_dict(self):
19+
"""Returns a dict of the object suitable for serialization"""
20+
result = {}
21+
for key, value in self.__dict__.items():
22+
if hasattr(value, "to_dict") and callable(value.to_dict):
23+
result[key] = value.to_dict()
24+
elif hasattr(value, "raw"):
25+
# geoip2 uses "raw" for historical reasons
26+
result[key] = value.raw
27+
elif isinstance(value, list):
28+
result[key] = [
29+
(
30+
item.to_dict()
31+
if hasattr(item, "to_dict") and callable(item.to_dict)
32+
else item
33+
)
34+
for item in value
35+
]
36+
else:
37+
result[key] = value
38+
return result
39+
40+
41+
class IPRiskReason(_Serializable):
1842
"""Reason for the IP risk.
1943
2044
This class provides both a machine-readable code and a human-readable
@@ -202,23 +226,32 @@ class IPAddress(geoip2.models.Insights):
202226

203227
def __init__(
204228
self,
205-
locales: Sequence[str],
229+
locales: Optional[Sequence[str]],
206230
*,
207231
country: Optional[Dict] = None,
208232
location: Optional[Dict] = None,
209233
risk: Optional[float] = None,
210234
risk_reasons: Optional[List[Dict]] = None,
211235
**kwargs,
212236
) -> None:
213-
214-
super().__init__(kwargs, locales=list(locales))
237+
# For raw attribute
238+
if country is not None:
239+
kwargs["country"] = country
240+
if location is not None:
241+
kwargs["location"] = location
242+
if risk is not None:
243+
kwargs["risk"] = risk
244+
if risk_reasons is not None:
245+
kwargs["risk_reasons"] = risk_reasons
246+
247+
super().__init__(kwargs, locales=list(locales or []))
215248
self.country = GeoIP2Country(locales, **(country or {}))
216249
self.location = GeoIP2Location(**(location or {}))
217250
self.risk = risk
218251
self.risk_reasons = [IPRiskReason(**x) for x in risk_reasons or []]
219252

220253

221-
class ScoreIPAddress(SimpleEquality):
254+
class ScoreIPAddress(_Serializable):
222255
"""Information about the IP address for minFraud Score.
223256
224257
.. attribute:: risk
@@ -235,7 +268,7 @@ def __init__(self, *, risk: Optional[float] = None, **_):
235268
self.risk = risk
236269

237270

238-
class Issuer(SimpleEquality):
271+
class Issuer(_Serializable):
239272
"""Information about the credit card issuer.
240273
241274
.. attribute:: name
@@ -293,7 +326,7 @@ def __init__(
293326
self.matches_provided_phone_number = matches_provided_phone_number
294327

295328

296-
class Device(SimpleEquality):
329+
class Device(_Serializable):
297330
"""Information about the device associated with the IP address.
298331
299332
In order to receive device output from minFraud Insights or minFraud
@@ -353,7 +386,7 @@ def __init__(
353386
self.local_time = local_time
354387

355388

356-
class Disposition(SimpleEquality):
389+
class Disposition(_Serializable):
357390
"""Information about disposition for the request as set by custom rules.
358391
359392
In order to receive a disposition, you must be use the minFraud custom
@@ -402,7 +435,7 @@ def __init__(
402435
self.rule_label = rule_label
403436

404437

405-
class EmailDomain(SimpleEquality):
438+
class EmailDomain(_Serializable):
406439
"""Information about the email domain passed in the request.
407440
408441
.. attribute:: first_seen
@@ -421,7 +454,7 @@ def __init__(self, *, first_seen: Optional[str] = None, **_):
421454
self.first_seen = first_seen
422455

423456

424-
class Email(SimpleEquality):
457+
class Email(_Serializable):
425458
"""Information about the email address passed in the request.
426459
427460
.. attribute:: domain
@@ -484,7 +517,7 @@ def __init__(
484517
self.is_high_risk = is_high_risk
485518

486519

487-
class CreditCard(SimpleEquality):
520+
class CreditCard(_Serializable):
488521
"""Information about the credit card based on the issuer ID number.
489522
490523
.. attribute:: country
@@ -578,7 +611,7 @@ def __init__(
578611
self.type = type
579612

580613

581-
class BillingAddress(SimpleEquality):
614+
class BillingAddress(_Serializable):
582615
"""Information about the billing address.
583616
584617
.. attribute:: distance_to_ip_location
@@ -644,7 +677,7 @@ def __init__(
644677
self.is_in_ip_country = is_in_ip_country
645678

646679

647-
class ShippingAddress(SimpleEquality):
680+
class ShippingAddress(_Serializable):
648681
"""Information about the shipping address.
649682
650683
.. attribute:: distance_to_ip_location
@@ -733,7 +766,7 @@ def __init__(
733766
self.distance_to_billing_address = distance_to_billing_address
734767

735768

736-
class Phone(SimpleEquality):
769+
class Phone(_Serializable):
737770
"""Information about the billing or shipping phone number.
738771
739772
.. attribute:: country
@@ -790,7 +823,7 @@ def __init__(
790823
self.number_type = number_type
791824

792825

793-
class ServiceWarning(SimpleEquality):
826+
class ServiceWarning(_Serializable):
794827
"""Warning from the web service.
795828
796829
.. attribute:: code
@@ -837,7 +870,7 @@ def __init__(
837870
self.input_pointer = input_pointer
838871

839872

840-
class Subscores(SimpleEquality):
873+
class Subscores(_Serializable):
841874
"""Risk factor scores used in calculating the overall risk score.
842875
843876
.. deprecated:: 2.12.0
@@ -1081,7 +1114,7 @@ def __init__(
10811114
self.time_of_day = time_of_day
10821115

10831116

1084-
class Reason(SimpleEquality):
1117+
class Reason(_Serializable):
10851118
"""The risk score reason for the multiplier.
10861119
10871120
This class provides both a machine-readable code and a human-readable
@@ -1174,7 +1207,7 @@ def __init__(
11741207
self.reason = reason
11751208

11761209

1177-
class RiskScoreReason(SimpleEquality):
1210+
class RiskScoreReason(_Serializable):
11781211
"""The risk score multiplier and the reasons for that multiplier.
11791212
11801213
.. attribute:: multiplier
@@ -1209,7 +1242,7 @@ def __init__(
12091242
self.reasons = [Reason(**x) for x in reasons or []]
12101243

12111244

1212-
class Factors(SimpleEquality):
1245+
class Factors(_Serializable):
12131246
"""Model for Factors response.
12141247
12151248
.. attribute:: id
@@ -1397,7 +1430,7 @@ def __init__(
13971430
]
13981431

13991432

1400-
class Insights(SimpleEquality):
1433+
class Insights(_Serializable):
14011434
"""Model for Insights response.
14021435
14031436
.. attribute:: id
@@ -1557,7 +1590,7 @@ def __init__(
15571590
self.warnings = [ServiceWarning(**x) for x in warnings or []]
15581591

15591592

1560-
class Score(SimpleEquality):
1593+
class Score(_Serializable):
15611594
"""Model for Score response.
15621595
15631596
.. attribute:: id

tests/test_models.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55

66
class TestModels(unittest.TestCase):
7+
def setUp(self):
8+
self.maxDiff = 20_000
9+
710
def test_billing_address(self):
811
address = BillingAddress(**self.address_dict)
912
self.check_address(address)
@@ -261,14 +264,15 @@ def test_risk_score_reason(self):
261264

262265
def test_score(self):
263266
id = "b643d445-18b2-4b9d-bad4-c9c4366e402a"
264-
score = Score(
265-
id=id,
266-
funds_remaining=10.01,
267-
queries_remaining=123,
268-
risk_score=0.01,
269-
ip_address={"risk": 99},
270-
warnings=[{"code": "INVALID_INPUT"}],
271-
)
267+
response = {
268+
"id": id,
269+
"funds_remaining": 10.01,
270+
"queries_remaining": 123,
271+
"risk_score": 0.01,
272+
"ip_address": {"risk": 99},
273+
"warnings": [{"code": "INVALID_INPUT"}],
274+
}
275+
score = Score(**response)
272276

273277
self.assertEqual(id, score.id)
274278
self.assertEqual(10.01, score.funds_remaining)
@@ -277,11 +281,15 @@ def test_score(self):
277281
self.assertEqual("INVALID_INPUT", score.warnings[0].code)
278282
self.assertEqual(99, score.ip_address.risk)
279283

284+
self.assertEqual(response, self._remove_empty_values(score.to_dict()))
285+
280286
def test_insights(self):
281287
response = self.factors_response()
288+
del response["risk_score_reasons"]
282289
del response["subscores"]
283290
insights = Insights(None, **response)
284291
self.check_insights_data(insights, response["id"])
292+
self.assertEqual(response, self._remove_empty_values(insights.to_dict()))
285293

286294
def test_factors(self):
287295
response = self.factors_response()
@@ -313,6 +321,8 @@ def test_factors(self):
313321
)
314322
self.assertEqual(0.17, factors.subscores.time_of_day)
315323

324+
self.assertEqual(response, self._remove_empty_values(factors.to_dict()))
325+
316326
def factors_response(self):
317327
return {
318328
"id": "b643d445-18b2-4b9d-bad4-c9c4366e402a",
@@ -399,3 +409,31 @@ def check_risk_score_reasons_data(self, reasons):
399409
self.assertEqual(
400410
"Risk due to IP being an Anonymous IP", reasons[0].reasons[0].reason
401411
)
412+
413+
def _remove_empty_values(self, data):
414+
if isinstance(data, dict):
415+
m = {}
416+
for k, v in data.items():
417+
v = self._remove_empty_values(v)
418+
if self._is_not_empty(v):
419+
m[k] = v
420+
return m
421+
422+
if isinstance(data, list):
423+
ls = []
424+
for e in data:
425+
e = self._remove_empty_values(e)
426+
if self._is_not_empty(e):
427+
ls.append(e)
428+
return ls
429+
430+
return data
431+
432+
def _is_not_empty(self, v):
433+
if v is None:
434+
return False
435+
if isinstance(v, dict) and not v:
436+
return False
437+
if isinstance(v, list) and not v:
438+
return False
439+
return True

0 commit comments

Comments
 (0)