· 5 years ago · Oct 12, 2020, 11:52 AM
1from abc import ABC
2from datetime import datetime, date, time
3from distutils.version import LooseVersion
4from functools import cached_property
5from typing import Any, Awaitable, Optional, Union, Callable
6
7import ujson
8from tornado.web import RequestHandler, Finish, HTTPError
9
10from project_python.exceptions import *
11from project_python.identity import IdentityFactory
12from project_python.identity.types import IdentityObject
13from project_python.tool.callings import require
14from project_python.types import ByteStr, Readable
15from .constants import *
16
17__all__ = [
18 'BaseHandler',
19 'api_version',
20]
21
22if sentry_sdk := require('sentry_sdk', strict=False):
23 class SentryMixin(RequestHandler, ABC):
24 __slots__ = ['_sentry_scope_context', '_sentry_scope']
25
26 def __init__(self, *args, **kwargs):
27 super().__init__(*args, **kwargs)
28 self._sentry_scope_context = sentry_sdk.push_scope()
29 self._sentry_scope = self._sentry_scope_context.__enter__()
30
31 def __del__(self):
32 self._sentry_scope_context.__exit__(None, None, None)
33 parent = super()
34 if hasattr(parent, '__del__'):
35 parent.__del__()
36
37 def _handle_request_exception(self, e: BaseException) -> None:
38 sentry_sdk.capture_exception(e, scope=self._sentry_scope)
39 super()._handle_request_exception(e)
40else:
41 class SentryMixin(RequestHandler, ABC):
42 pass
43
44if require('rest_framework', strict=False):
45 class DRFMixin(RequestHandler, ABC):
46 def _convert_exception(self, e: BaseException) -> BaseException:
47 from rest_framework.exceptions import ValidationError
48 from project_python.frameworks.rest_framework.tools import validation_error_to_custom
49
50 if isinstance(e, ValidationError):
51 return validation_error_to_custom(e)
52 return e
53else:
54 class DRFMixin(RequestHandler, ABC):
55 pass
56
57if require('django', strict=False):
58 class DjangoMixin(RequestHandler, ABC):
59 def _convert_exception(self, e: BaseException) -> BaseException:
60 from django.core.exceptions import ValidationError
61 from project_python.frameworks.django.tools import validation_error_to_custom
62
63 if isinstance(e, ValidationError):
64 return validation_error_to_custom(e)
65 return e
66else:
67 class DjangoMixin(RequestHandler, ABC):
68 pass
69
70
71class _BaseHandler(RequestHandler):
72 """
73 Base class for HTTP request handlers.
74 """
75
76 # ========= Params ========= #
77 SUPPORTED_METHODS = HTTPMethod.ALL
78 COOKIE_DOMAIN: Optional[str] = None
79 ALLOW_ORIGIN: str = '*'
80
81 async def prepare(self):
82 version = self.get_query_argument('v', None)
83 version = version and LooseVersion(version)
84
85 for method_name in HTTPMethod.ALL:
86 method = getattr(self, method_name, None)
87 if not method or not hasattr(method, '_api_versions_'):
88 continue
89
90 if version is None:
91 raise HTTPError(404, reason='Missing API version (?v=) argument')
92
93 for min_version, max_version, fn in getattr(method, '_api_versions_'):
94 if min_version <= version:
95 if max_version is None or version <= max_version:
96 setattr(self, method_name, fn)
97 break
98 else:
99 raise HTTPError(404, reason='Method does not exists in this API version')
100
101 return super().prepare()
102
103 # ========= Public methods ========= #
104 @cached_property
105 def json(self) -> dict:
106 body = self.request.body
107 content_type: str = self.request.headers.get(Header.content_type, '')
108 if body and content_type and content_type.startswith((MIME.application.json, MIME.application.x_json)):
109 return ujson.decode(body) if body else {}
110
111 @cached_property
112 def body(self) -> dict:
113 if isinstance(self.request.body_arguments, dict):
114 return {
115 key: self.get_body_argument(key)
116 for key in self.request.body_arguments.keys()
117 }
118 return {}
119
120 @cached_property
121 def params(self) -> dict:
122 if isinstance(self.request.query_arguments, dict):
123 return {
124 key: self.get_query_argument(key)
125 for key in self.request.query_arguments.keys()
126 }
127 return {}
128
129 @cached_property
130 def cookies(self) -> dict:
131 return {
132 key: self.get_secure_cookie(key)
133 for key in self.request.cookies.keys()
134 }
135
136 @cached_property
137 def headers(self) -> dict:
138 return dict(self.request.headers)
139
140 @cached_property
141 def identity(self) -> Optional[IdentityObject]:
142 return IdentityFactory.sign_in(
143 body=self.body,
144 json=self.json,
145 params=self.params,
146 cookies=self.cookies,
147 meta=self.headers,
148 )
149
150 # ========= Methods overrides ========= #
151 def write(self, chunk: Any, json: bool = None) -> None:
152 if json is None:
153 json = isinstance(chunk, (dict, list, datetime, date, time, int, float))
154
155 if json:
156 self.set_header(Header.content_type, MIME.application.json)
157 chunk = ujson.encode(chunk, ensure_ascii=False, indent=2)
158
159 super().write(chunk=chunk)
160
161 def write_error(self, status_code, message=None, errors=None, **kwargs):
162 if not message:
163 message = kwargs.get('exc_info', (None, None))[1]
164
165 if isinstance(message, Exception):
166 message = self._convert_exception(message)
167
168 if isinstance(message, Finish):
169 pass
170
171 elif isinstance(message, HTTPError):
172 self.set_status(message.status_code)
173 self.write(message.reason)
174
175 elif isinstance(message, CustomError):
176 self.set_status(message.code)
177 self.write(vars(message))
178
179 self.finish()
180
181 def write_file(self, readable: Union[str, Readable], name: str = 'download', content_type: str = 'octet-stream'):
182 self.set_header(Header.content_type, content_type)
183 self.set_header(Header.content_disposition, f'inline; filename="{name}"')
184
185 if isinstance(readable, str):
186 readable = open(readable, 'rb')
187
188 self.write(readable.read())
189 readable.close()
190 self.finish()
191
192 def head(self):
193 self.set_status(200)
194
195 def options(self, *args, **kwargs):
196 self.set_header(AccessControlHeader.ALLOW_METHODS, ", ".join(self.SUPPORTED_METHODS))
197 self.set_status(200)
198
199 def set_default_headers(self):
200 super().set_default_headers()
201
202 # that'll be enough for now, use config otherwise
203 self.set_header(AccessControlHeader.ALLOW_HEADERS, 'Content-Type, Set-Cookie')
204 self.set_header(AccessControlHeader.ALLOW_ORIGIN, self.ALLOW_ORIGIN)
205 self.set_header(AccessControlHeader.ALLOW_CREDENTIALS, 'true')
206 self.set_header(Header.cache_control, 'no-cache, no-store, must-revalidate')
207
208 def set_secure_cookie(self, name: str, value: ByteStr, expires_days: int = 30, **kwargs: Any) -> None:
209 if 'domain' not in kwargs:
210 kwargs['domain'] = self.COOKIE_DOMAIN
211 return super().set_secure_cookie(name, value, expires_days, **kwargs)
212
213 async def data_received(self, chunk: bytes) -> Optional[Awaitable[None]]:
214 """
215 You may nest this and overwrite but actually this method mostly useless
216 Triggers each time a packet of bytes is being received. See? I said that it is useless! (mostly)
217 """
218 pass
219
220 def _convert_exception(self, e: BaseException) -> Union[Finish, HTTPError, CustomError]:
221 if isinstance(e, (Finish, HTTPError, CustomError)):
222 return e
223
224 return CustomError(str(e))
225
226 def _handle_request_exception(self, e: BaseException) -> None:
227 e = self._convert_exception(e)
228
229 if isinstance(e, Finish):
230 # Not an error; just finish the request without logging.
231 if not self._finished:
232 self.finish(*e.args)
233
234 elif isinstance(e, HTTPError):
235 self.write_error(e.status_code, e)
236
237 elif isinstance(e, CustomError):
238 self.write_error(e.code, e)
239
240
241class BaseHandler(DRFMixin, DjangoMixin, SentryMixin, _BaseHandler, RequestHandler):
242 pass
243
244
245def api_version(min_version: str, max_version: str = None) -> Callable:
246 def decorator(fn: Callable) -> Callable:
247 fn._api_versions_ = [
248 (LooseVersion(min_version), max_version and LooseVersion(max_version), fn),
249 ]
250 fn._has_max_wildcard = max_version is None
251
252 def _api_version(_min: str, _max: str = None) -> Callable:
253 # noinspection PyProtectedMember,PyUnresolvedReferences
254 def _decorator(_fn: Callable) -> Callable:
255 if _max is None and fn._has_max_wildcard:
256 raise RuntimeError('API handler with wildcard version already exists')
257
258 fn._api_versions_.append((LooseVersion(_min), _max and LooseVersion(_max), _fn))
259
260 return fn
261
262 return _decorator
263
264 fn.api_version = _api_version
265
266 return fn
267
268 return decorator
269