Skip to content

Commit 8edcfbc

Browse files
haobiboQPod0
andauthored
update service apis (#9)
Co-authored-by: QPod0 <[email protected]>
1 parent 9db9a0c commit 8edcfbc

20 files changed

Lines changed: 307 additions & 380 deletions

src/aloha/service/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import sys
2+
3+
from .api import v0, v1, v2
4+
5+
for module in (v0, v1, v2):
6+
full_name = '{}.{}'.format(__package__, module.__name__.rsplit('.')[-1])
7+
sys.modules[full_name] = sys.modules[module.__name__]

src/aloha/service/api/__init__.py

Whitespace-only changes.

src/aloha/service/api/v0.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
__all__ = ('APIHandler', 'APICaller',)
2+
3+
import json
4+
import logging
5+
from abc import ABC
6+
7+
from ..http import AbstractApiClient, AbstractApiHandler
8+
9+
10+
class APIHandler(AbstractApiHandler, ABC):
11+
MAP_ERROR_INFO = {
12+
'BAD_REQUEST': {'code': '5101', 'message': ['Bad request: fail to parse body as JSON object!']}
13+
}
14+
15+
async def post(self, *args, **kwargs):
16+
body_arguments = self.request_body
17+
kwargs.update(body_arguments)
18+
19+
resp = dict(code=5200, message=['success'])
20+
try:
21+
result = self.response(*args, **kwargs) # this call may throw TypeError when argument missing
22+
resp['data'] = result
23+
except Exception as e:
24+
if self.LOG.level == logging.DEBUG:
25+
self.LOG.error(e, exc_info=True)
26+
return self.finish({'code': 5201, 'message': [repr(e)]})
27+
28+
resp = json.dumps(resp, ensure_ascii=False, default=str, separators=(',', ':'))
29+
return self.finish(resp)
30+
31+
32+
class APICaller(AbstractApiClient):
33+
def wrap_request_data(self, data: dict) -> dict:
34+
assert isinstance(data, dict), "Data object must be a dict!"
35+
return data

src/aloha/service/api/v1.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
__all__ = ('APIHandler', 'APICaller', 'sign_data', 'sign_check')
2+
3+
import json
4+
import logging
5+
import uuid
6+
from abc import ABC
7+
8+
from ..http import AbstractApiClient, AbstractApiHandler
9+
from ...encrypt.hash import get_md5_of_str, get_sha256_of_str
10+
from ...settings import SETTINGS
11+
12+
APP_ID_KEYS = SETTINGS.config.get('APP_ID_KEYS', {})
13+
APP_OPTIONS = SETTINGS.config.get('APP_OPTIONS', {})
14+
FUNC_SIGN_CHECK = {'md5': get_md5_of_str, 'sha256': get_sha256_of_str}
15+
func_sign_check_default = FUNC_SIGN_CHECK.get(APP_OPTIONS.get('sign_method', 'md5'))
16+
17+
18+
class APIHandler(AbstractApiHandler, ABC):
19+
MAP_ERROR_INFO = {
20+
'BAD_REQUEST': {'code': '5101', 'message': ['Bad request: fail to parse body as JSON object!']},
21+
'MISSING_ARGS': {'code': '5102', 'message': ['Required argument field(s) missing...']},
22+
'SIGN_CHECK_FAIL': {'code': '5104', 'message': ['Invalid sign, sign check failed!']},
23+
}
24+
25+
async def post(self):
26+
body_arguments = self.request_body
27+
28+
try:
29+
salt_uuid = body_arguments.pop('salt_uuid')
30+
app_id = body_arguments.pop('app_id')
31+
sign = body_arguments.pop('sign')
32+
data = body_arguments.pop('data')
33+
except KeyError: # cannot find default key from parsed body
34+
return self.finish(self.MAP_ERROR_INFO['MISSING_ARGS'])
35+
36+
is_valid_req = sign_check(salt_uuid=salt_uuid, app_id=app_id, sign=sign, data=data) # , sign_method='sha256'
37+
if not is_valid_req:
38+
return self.finish(self.MAP_ERROR_INFO['SIGN_CHECK_FAIL'])
39+
40+
resp = dict(code=5200, message=['success'])
41+
try:
42+
result = self.response(**data) # this call may throw TypeError when argument missing
43+
resp['data'] = result
44+
resp['salt_uuid'] = salt_uuid
45+
except Exception as e:
46+
if self.LOG.level == logging.DEBUG:
47+
self.LOG.error(e, exc_info=True)
48+
return self.finish({'code': 5201, 'message': [repr(e)]})
49+
50+
resp = json.dumps(resp, ensure_ascii=False, default=str, separators=(',', ':'))
51+
return self.finish(resp)
52+
53+
54+
class APICaller(AbstractApiClient):
55+
APP_ID_KEYS = AbstractApiClient.config.get('APP_ID_KEYS', {})
56+
57+
def wrap_request_data(
58+
self, data, app_id: str = None, app_key: str = None, salt_uuid: str = None, sign: str = None, sign_method: str = None
59+
):
60+
if app_id is None:
61+
# if len(APP_ID_KEYS) != 1:
62+
# raise RuntimeError('Please specify 1 and only 1 in APP_ID_KEYS in configurations!')
63+
app_id = list(self.APP_ID_KEYS.keys())[0]
64+
salt_uuid = salt_uuid or str(uuid.uuid1())
65+
sign = sign or sign_data(
66+
salt_uuid=salt_uuid,
67+
app_id=app_id,
68+
app_key=app_key or self.APP_ID_KEYS.get(app_id),
69+
data=data,
70+
sign_method=sign_method
71+
)
72+
return {
73+
'salt_uuid': salt_uuid,
74+
'app_id': app_id,
75+
'sign': sign,
76+
'data': data
77+
}
78+
79+
80+
def sign_data(salt_uuid: str, app_id: str, app_key: str, data, sign_method: str = None):
81+
data_str = str(json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(',', ':')))
82+
public_key = app_id + salt_uuid + data_str + app_key
83+
84+
func_sign_check = func_sign_check_default if sign_method is None else FUNC_SIGN_CHECK.get(sign_method)
85+
if func_sign_check is None:
86+
raise ValueError('Invalid `sign_method`: %s' % sign_method)
87+
sign = func_sign_check(public_key)
88+
return sign
89+
90+
91+
def sign_check(salt_uuid: str, app_id: str, sign: str, data, sign_method: str = None, date_time=None):
92+
"""Sign Validation
93+
:param salt_uuid: Universal Unified ID for 1) Signature, 2) Log tracing
94+
:param app_id: APP ID
95+
:param sign: sing = hash(app_id + salt_uuid + data + app_key)
96+
:param data: data object, will be serialized to JSON string
97+
:param date_time: not used for now
98+
:param sign_method: Sign method, one of the following: md5, sha256
99+
:return: If the signature passed validation
100+
"""
101+
102+
func_sign_check = func_sign_check_default if sign_method is None else FUNC_SIGN_CHECK.get(sign_method)
103+
if func_sign_check is None:
104+
raise ValueError('Invalid `sign_method`: %s' % sign_method)
105+
106+
app_key = APP_ID_KEYS.get(app_id)
107+
if app_key is None: # APP_ID not in the dict, unknown APP_ID
108+
return False
109+
110+
data_str = str(json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(',', ':')))
111+
112+
# --> Compatible with older version API
113+
right_sign = func_sign_check(app_id + salt_uuid + app_key)
114+
if sign == right_sign:
115+
return True
116+
# <--
117+
118+
public_key = str(json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(',', ':')))
119+
public_key = app_id + salt_uuid + public_key + app_key
120+
right_sign = func_sign_check(public_key)
121+
return sign == right_sign
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,41 @@
1-
import abc
1+
__all__ = ('APIHandler', 'APICaller',)
2+
23
import json
34
import logging
4-
from datetime import datetime
5+
from abc import ABC
6+
from datetime import datetime, timedelta
57
from typing import Optional, Awaitable
68

7-
from tornado import web
8-
9+
from ..http import AbstractApiClient, AbstractApiHandler
910
from ...encrypt import jwt
10-
from ...logger import LOG
1111
from ...settings import SETTINGS
1212

13-
_RESP_BAD_REQUEST = {'code': '5101', 'message': ['Bad request: fail to parse body as JSON object!']}
14-
15-
16-
class APIHandler(web.RequestHandler):
17-
LOG = LOG
18-
19-
def __init__(self, *args, **kwargs):
20-
self.api_args: Optional[tuple] = None
21-
self.api_kwargs: Optional[dict] = None
22-
super().__init__(*args, **kwargs)
23-
24-
@abc.abstractmethod
25-
def response(self, *args, **kwargs) -> dict:
26-
raise NotImplementedError()
27-
28-
@property
29-
def request_id(self):
30-
if 'Request-ID' not in self.request.headers:
31-
self.request.headers['Request-ID'] = datetime.now().strftime('%Y%m%d-%H%M%S-%f')
32-
return self.request.headers.get('Request-ID')
33-
34-
def set_default_headers(self) -> None:
35-
self.set_header('Content-Type', 'application/json; charset=utf-8')
36-
37-
def data_received(self, chunk: bytes) -> Optional[Awaitable[None]]:
38-
pass
39-
40-
def on_finish(self) -> None:
41-
func_callback = getattr(self, 'callback', None)
42-
if not callable(func_callback):
43-
return
44-
45-
return func_callback(*self.api_args, **self.api_kwargs)
4613

14+
class APIHandler(AbstractApiHandler, ABC):
4715
async def prepare(self, ) -> Optional[Awaitable[None]]:
4816
access_token = self.request.headers.get('Access-Token')
4917
if access_token is None:
5018
return self.finish({
5119
'msg': 'Invalid Access-Token in request header!'
5220
})
5321
else:
54-
secret_key = SETTINGS.config['APP_SECRET_KEY'] # 'HCTECH-ASKBOB-REC:10062462'
22+
secret_key = SETTINGS.config['APP_SECRET_KEY']
5523
# options = None
5624
# TODO: if not validate expiration
5725
options = {"verify_exp": False}
5826
access_token = jwt.decode(secret_key, access_token, options=options)
5927
if not isinstance(access_token, dict):
60-
LOG.error('Invalid Access-Token found in request for [%s]: %s' % (
28+
self.LOG.error('Invalid Access-Token found in request for [%s]: %s' % (
6129
str(self.request.full_url()), access_token
6230
))
6331
return self.finish({
6432
'msg': access_token
6533
})
66-
6734
self.set_header('Request-ID', self.request_id)
6835

6936
async def post(self, *args, **kwargs):
70-
content_type: str = self.request.headers.get('Content-Type', 'application/json; charset=utf-8')
71-
if content_type.startswith('multipart/form-data'): # only parse files when 'Content-Type' starts with 'multipart/form-data'
72-
body_arguments = self.request.body_arguments
73-
else:
74-
try:
75-
body = self.request.body.decode('utf-8')
76-
body_arguments = json.loads(body)
77-
except (UnicodeDecodeError, json.decoder.JSONDecodeError): # invalid request body, cannot be parsed as JSON
78-
return self.finish(_RESP_BAD_REQUEST)
37+
body_arguments = self.request_body
7938
kwargs.update(body_arguments)
80-
8139
try:
8240
if self.LOG.level == logging.DEBUG:
8341
s_kwargs = json.dumps(kwargs, ensure_ascii=False)
@@ -91,13 +49,11 @@ async def post(self, *args, **kwargs):
9149

9250
if isinstance(resp, (dict, list)):
9351
resp = json.dumps(resp, ensure_ascii=False, default=str, separators=(',', ':'))
94-
elif isinstance(resp, str):
95-
pass
9652
return self.finish(resp)
9753

9854
async def get(self, *args, **kwargs):
99-
query = {k: v[0].decode('utf-8') for k, v in self.request.arguments.items()}
100-
kwargs.update(query)
55+
query_arguments = self.request_param
56+
kwargs.update(query_arguments)
10157
try:
10258
self.LOG.debug('GET Request [%s]: %s' % (self.request_id, kwargs))
10359
self.api_args, self.api_kwargs = args or (), kwargs or {}
@@ -109,6 +65,33 @@ async def get(self, *args, **kwargs):
10965

11066
if isinstance(resp, (dict, list)):
11167
resp = json.dumps(resp, ensure_ascii=False, default=str, separators=(',', ':'))
112-
elif isinstance(resp, str):
113-
pass
11468
return self.finish(resp)
69+
70+
71+
class APICaller(AbstractApiClient):
72+
APP_ID_KEYS = AbstractApiClient.config.get('APP_ID_KEYS', {})
73+
APP_SECRET_KEY = AbstractApiClient.config['APP_SECRET_KEY']
74+
75+
def wrap_request_data(self, data: dict) -> dict:
76+
assert isinstance(data, dict), "Data object must be a dict!"
77+
return data
78+
79+
def get_headers(self, app_id: str = None, app_key: str = None) -> dict:
80+
if app_id is None:
81+
# if len(APP_ID_KEYS) != 1:
82+
# raise RuntimeError('Please specify 1 and only 1 in APP_ID_KEYS in configurations!')
83+
app_id = list(self.APP_ID_KEYS.keys())[0]
84+
85+
expire_time = datetime.now() + timedelta(days=1)
86+
87+
access_token = jwt.encode(
88+
secret_key=self.APP_SECRET_KEY,
89+
payload={
90+
'exp': int(expire_time.timestamp()),
91+
'aid': app_id
92+
}
93+
)
94+
95+
headers = super().get_headers()
96+
headers.update({'Access-Token': access_token})
97+
return headers

src/aloha/service/http/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1+
from .base_api_handler import AbstractApiHandler
12
from .plain_http_handler import PlainHttpHandler
3+
from .base_api_client import AbstractApiClient
Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,58 @@
1+
import uuid
2+
from abc import ABC, abstractmethod
3+
14
import requests
25
from requests.adapters import HTTPAdapter, Retry
36

47
from ...logger import LOG
8+
from ...settings import SETTINGS
59

610

7-
class APICaller:
8-
retry_method_whitelist = frozenset(['GET', 'POST'])
9-
retry_status_forcelist = frozenset({413, 429, 503, 502, 504})
11+
class AbstractApiClient(ABC):
12+
LOG = LOG
13+
RETRY_METHOD_WHITELIST: frozenset = frozenset(['GET', 'POST'])
14+
RETRY_STATUS_FORCELIST: frozenset = frozenset({413, 429, 503, 502, 504})
15+
config = SETTINGS.config
1016

1117
@classmethod
1218
def get_request_session(cls, total_retries: int = 3, *args, **kwargs) -> requests.Session:
1319
session = requests.Session()
1420
# https://urllib3.readthedocs.io/en/latest/reference/urllib3.util.html#urllib3.util.Retry.DEFAULT_ALLOWED_METHODS
1521
retries = Retry(
16-
total=total_retries, backoff_factor=0.1, method_whitelist=cls.retry_method_whitelist, status_forcelist=cls.retry_status_forcelist
22+
total=total_retries, backoff_factor=0.1, method_whitelist=cls.RETRY_METHOD_WHITELIST, status_forcelist=cls.RETRY_STATUS_FORCELIST
1723
)
1824
for prefix in ('http://', 'https://'):
1925
session.mount(prefix, HTTPAdapter(max_retries=retries))
2026
return session
2127

22-
@staticmethod
23-
def wrap_request_data(data):
28+
def get_headers(self, *args, **kwargs) -> dict:
29+
headers = {
30+
'Content-Type': 'application/json',
31+
'Request-ID': str(uuid.uuid1()),
32+
}
33+
return headers
34+
35+
@abstractmethod
36+
def wrap_request_data(self, data: dict) -> dict:
2437
assert isinstance(data, dict), "Data object must be a dict!"
25-
return data
38+
raise NotImplementedError()
39+
# return data
2640

27-
def call(self, api_url, timeout=5, **kwargs):
41+
def call(self, api_url: str, data: dict = None, timeout=5, **kwargs):
2842
"""Trigger API call
29-
3043
:param api_url: do NOT start with slash (/)
44+
:param data: a dictionary which includes the request data
3145
:param timeout: requests timeout in seconds
32-
:param kwargs: request data
46+
:param kwargs: keywords arguments which will be updated to data
3347
:return:
3448
"""
35-
payload = APICaller.wrap_request_data(data=kwargs)
36-
LOG.debug('Calling API: %s' % api_url)
49+
body = data or dict()
50+
body.update(kwargs)
51+
payload = self.wrap_request_data(data=body)
52+
LOG.debug('Calling api: %s' % api_url)
3753
session = self.get_request_session()
3854
resp = session.post(
39-
api_url, json=payload, timeout=timeout, headers={'Content-Type': 'application/json'}
55+
api_url, json=payload, timeout=timeout, headers=self.get_headers()
4056
)
4157

4258
try:

0 commit comments

Comments
 (0)