Skip to content

Commit f3dfd86

Browse files
author
Arthur Jen
authored
Merge pull request #4 from fortmatic/ajen_issue_3994_implement_user_class
Implement User resource
2 parents cc0913c + c102014 commit f3dfd86

24 files changed

+1114
-51
lines changed

.pre-commit-config.yaml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
22
- repo: https://github.com/pre-commit/pre-commit-hooks
3-
rev: v2.4.0
3+
rev: v2.5.0
44
hooks:
55
- id: flake8
66
language_version: python3.6
@@ -29,21 +29,21 @@ repos:
2929
- id: check-json
3030
files: \.(jshintrc|json)$
3131
- repo: https://github.com/pre-commit/mirrors-autopep8
32-
rev: v1.4.4
32+
rev: v1.5.1
3333
hooks:
3434
- id: autopep8
3535
language_version: python3.6
3636
- repo: https://github.com/asottile/add-trailing-comma
37-
rev: v1.5.0
37+
rev: v2.0.1
3838
hooks:
3939
- id: add-trailing-comma
4040
- repo: https://github.com/asottile/reorder_python_imports
41-
rev: v1.9.0
41+
rev: v2.2.0
4242
hooks:
4343
- id: reorder-python-imports
4444
language_version: python3.6
4545
- repo: https://github.com/asottile/pyupgrade
46-
rev: v1.25.2
46+
rev: v2.1.1
4747
hooks:
4848
- id: pyupgrade
4949
args:

magic_admin/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
from magic_admin.error import *
21
from magic_admin.magic import Magic
32

43

4+
# Magic API secret key.
5+
api_secret_key = None
6+
7+
# A grace period time in second applied to the nbf field for token validation.
58
did_token_nbf_grace_period_s = 300

magic_admin/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
base_url = 'https://api.stagef.magic.link'
2+
3+
api_secret_api_key_missing_message = 'API secret key is missing. Please specify ' \
4+
'an API secret key when you instantiate the `Magic(api_secret_key=<KEY>)` ' \
5+
'object or use the environment variable, `MAGIC_API_SECRET_KEY`. You can ' \
6+
'get your API secret key from https://dashboard.magic.link. If you are having ' \
7+
'trouble, please don\'t hesitate to reach out to us at support@magic.link'

magic_admin/error.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,76 @@ def __repr__(self):
1313
message=self._message,
1414
)
1515

16+
def to_dict(self):
17+
return {'message': str(self)}
18+
1619

1720
class DIDTokenError(MagicError):
1821
pass
22+
23+
24+
class APIConnectionError(MagicError):
25+
pass
26+
27+
28+
class RequestError(MagicError):
29+
30+
def __init__(
31+
self,
32+
message=None,
33+
http_status=None,
34+
http_code=None,
35+
http_resp_data=None,
36+
http_message=None,
37+
http_error_code=None,
38+
http_request_params=None,
39+
http_request_data=None,
40+
http_method=None,
41+
):
42+
super().__init__(message)
43+
self.http_status = http_status
44+
self.http_code = http_code
45+
self.http_resp_data = http_resp_data
46+
self.http_message = http_message
47+
self.http_error_code = http_error_code
48+
self.http_request_params = http_request_params
49+
self.http_request_data = http_request_data
50+
self.http_method = http_method
51+
52+
def __repr__(self):
53+
return '{error_class}(message={message!r}, ' \
54+
'http_error_code={http_error_code}, ' \
55+
'http_code={http_code}).'.format(
56+
error_class=self.__class__.__name__,
57+
message=self._message or None,
58+
http_error_code=self.http_error_code or None,
59+
http_code=self.http_code or None,
60+
)
61+
62+
def to_dict(self):
63+
_dict = super().to_dict()
64+
for attr in self.__dict__:
65+
if attr.startswith('http_'):
66+
_dict[attr] = self.__dict__[attr]
67+
68+
return _dict
69+
70+
71+
class RateLimitingError(RequestError):
72+
pass
73+
74+
75+
class BadRequestError(RequestError):
76+
pass
77+
78+
79+
class AuthenticationError(RequestError):
80+
pass
81+
82+
83+
class ForbiddenError(RequestError):
84+
pass
85+
86+
87+
class APIError(RequestError):
88+
pass

magic_admin/http_client.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import platform
2+
3+
import simplejson
4+
from requests import Session
5+
from requests.adapters import HTTPAdapter
6+
from requests.packages.urllib3.util.retry import Retry
7+
8+
import magic_admin
9+
from magic_admin import version
10+
from magic_admin.config import api_secret_api_key_missing_message
11+
from magic_admin.config import base_url
12+
from magic_admin.error import APIConnectionError
13+
from magic_admin.error import APIError
14+
from magic_admin.error import AuthenticationError
15+
from magic_admin.error import BadRequestError
16+
from magic_admin.error import ForbiddenError
17+
from magic_admin.error import RateLimitingError
18+
from magic_admin.response import MagicResponse
19+
20+
21+
class RequestsClient:
22+
23+
def __init__(self, retries, timeout, backoff_factor):
24+
self._retries = retries
25+
self._timeout = timeout
26+
self._backoff_factor = backoff_factor
27+
28+
self._setup_request_session()
29+
30+
@staticmethod
31+
def _get_platform_info():
32+
platform_info = {}
33+
34+
for attr, func in [
35+
['platform', platform.platform],
36+
['language_version', platform.python_version],
37+
['uname', platform.uname],
38+
]:
39+
try:
40+
val = str(func())
41+
except Exception as e:
42+
val = '<{}>'.format(str(e))
43+
44+
platform_info[attr] = val
45+
46+
return platform_info
47+
48+
def _setup_request_session(self):
49+
"""Take advantage of the ``requets.Session``. If client is making several
50+
requests to the same host, the underlying TCP connection will be reused,
51+
which can result in a significant performance increase.
52+
"""
53+
self.http = Session()
54+
self.http.mount(
55+
base_url,
56+
HTTPAdapter(
57+
max_retries=Retry(
58+
total=self._retries,
59+
backoff_factor=self._backoff_factor,
60+
),
61+
),
62+
)
63+
64+
def _get_request_headers(self):
65+
user_agent = {
66+
'language': 'python',
67+
'sdk_version': version.VERSION,
68+
'publisher': 'magic',
69+
'http_lib': self.__class__.__name__,
70+
**self._get_platform_info(),
71+
}
72+
73+
if magic_admin.api_secret_key is None:
74+
raise AuthenticationError(api_secret_api_key_missing_message)
75+
76+
return {
77+
'X-Magic-Secret-Key': magic_admin.api_secret_key,
78+
'User-Agent': simplejson.dumps(user_agent),
79+
}
80+
81+
def request(self, method, url, params=None, data=None):
82+
try:
83+
api_resp = self.http.request(
84+
method,
85+
url,
86+
params=params,
87+
# Requests auto-converts this to JSON and add content-type
88+
# `application/json`.
89+
json=data,
90+
headers=self._get_request_headers(),
91+
timeout=self._timeout,
92+
)
93+
except Exception as e:
94+
return self._handle_request_error(e)
95+
96+
return self._parse_and_convert_to_api_response(
97+
api_resp,
98+
params,
99+
data,
100+
)
101+
102+
def _parse_and_convert_to_api_response(self, resp, request_params, request_data):
103+
status_code = resp.status_code
104+
105+
if 200 <= status_code < 300:
106+
return MagicResponse(resp.content, resp.json(), status_code)
107+
108+
if status_code == 429:
109+
error_class = RateLimitingError
110+
elif status_code == 400:
111+
error_class = BadRequestError
112+
elif status_code == 401:
113+
error_class = AuthenticationError
114+
elif status_code == 403:
115+
error_class = ForbiddenError
116+
else:
117+
error_class = APIError
118+
119+
resp_data = resp.json()
120+
raise error_class(
121+
http_status=resp_data.get('status'),
122+
http_code=status_code,
123+
http_resp_data=resp_data.get('data'),
124+
http_message=resp_data.get('message'),
125+
http_error_code=resp_data.get('error_code'),
126+
http_request_params=request_params,
127+
http_request_data=request_data,
128+
http_method=resp.request.method,
129+
)
130+
131+
def _handle_request_error(self, e):
132+
message = 'Unexpected error thrown while communicating to Magic. ' \
133+
'Please reach out to support@magic.link if the problem continues. ' \
134+
'Error message: {error_class} was raised - {error_message}'.format(
135+
error_class=e.__class__.__name__,
136+
error_message=str(e) or 'no error message.',
137+
)
138+
139+
raise APIConnectionError(message)

magic_admin/magic.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,42 @@
1+
import os
2+
3+
import magic_admin
4+
from magic_admin.config import api_secret_api_key_missing_message
5+
from magic_admin.error import AuthenticationError
16
from magic_admin.resources.base import ResourceComponent
27

38

9+
RETRIES = 3
10+
TIMEOUT = 10
11+
BACKOFF_FACTOR = 0.02
12+
13+
414
class Magic:
515

616
def __getattr__(self, attribute_name):
717
try:
8-
return getattr(self.resource, attribute_name)
18+
return getattr(self._resource, attribute_name)
919
except AttributeError:
1020
pass
1121

1222
return super().__getattribute__(attribute_name)
1323

14-
def __init__(self):
15-
self.resource = ResourceComponent()
24+
def __init__(
25+
self,
26+
api_secret_key=None,
27+
retries=RETRIES,
28+
timeout=TIMEOUT,
29+
backoff_factor=BACKOFF_FACTOR,
30+
):
31+
self._resource = ResourceComponent()
32+
33+
self._resource.setup_request_client(retries, timeout, backoff_factor)
34+
self._set_api_secret_key(api_secret_key)
35+
36+
def _set_api_secret_key(self, api_secret_key):
37+
magic_admin.api_secret_key = api_secret_key or os.environ.get(
38+
'MAGIC_API_SECRET_KEY',
39+
)
40+
41+
if magic_admin.api_secret_key is None:
42+
raise AuthenticationError(api_secret_api_key_missing_message)

magic_admin/resources/base.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
from magic_admin.config import base_url
2+
from magic_admin.http_client import RequestsClient
3+
4+
15
class ResourceMeta(type):
26

37
def __init__(cls, name, bases, cls_dict):
@@ -11,6 +15,8 @@ def __init__(cls, name, bases, cls_dict):
1115

1216
class ResourceComponent(metaclass=ResourceMeta):
1317

18+
_base_url = base_url
19+
1420
def __getattr__(self, resource_name):
1521
if resource_name in self._registry:
1622
return self._registry[resource_name]
@@ -21,3 +27,23 @@ def __getattr__(self, resource_name):
2127
resource_name=resource_name,
2228
),
2329
)
30+
31+
def setup_request_client(self, retries, timeout, backoff_factor):
32+
_request_client = RequestsClient(retries, timeout, backoff_factor)
33+
34+
for resource in self._registry.values():
35+
setattr(resource, '_request_client', _request_client)
36+
37+
def _construct_url(self, url_path):
38+
return '{base_url}{url_path}'.format(
39+
base_url=self._base_url,
40+
url_path=url_path,
41+
)
42+
43+
def request(self, method, url_path, params=None, data=None):
44+
return self._request_client.request(
45+
method.lower(),
46+
self._construct_url(url_path),
47+
params=params,
48+
data=data,
49+
)

magic_admin/resources/token.py

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from magic_admin.error import DIDTokenError
88
from magic_admin.resources.base import ResourceComponent
9+
from magic_admin.utils.did_token import parse_public_address_from_issuer
910
from magic_admin.utils.time import apply_did_token_nbf_grace_period
1011
from magic_admin.utils.time import epoch_time_now
1112

@@ -25,19 +26,6 @@ class Token(ResourceComponent):
2526
'tid',
2627
])
2728

28-
@staticmethod
29-
def _parse_public_address(issuer):
30-
"""
31-
Args:
32-
issuer (str): Issuer (the signer, the "user"). This field is represented
33-
as a Decentralized Identifier populated with the user's Ethereum
34-
public key.
35-
36-
Returns:
37-
public_address (str): An Ethereum public key.
38-
"""
39-
return issuer.split(':')[-1]
40-
4129
@classmethod
4230
def _check_required_fields(cls, claim):
4331
"""
@@ -133,7 +121,7 @@ def get_public_address(cls, did_token):
133121
Returns:
134122
public_address (str): An Ethereum public key.
135123
"""
136-
return cls._parse_public_address(cls.get_issuer(did_token))
124+
return parse_public_address_from_issuer(cls.get_issuer(did_token))
137125

138126
@classmethod
139127
def validate(cls, did_token):

0 commit comments

Comments
 (0)