panther 4.3.7__py3-none-any.whl → 5.0.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- panther/__init__.py +1 -1
- panther/_load_configs.py +78 -64
- panther/_utils.py +1 -1
- panther/app.py +126 -60
- panther/authentications.py +26 -9
- panther/base_request.py +27 -2
- panther/base_websocket.py +26 -27
- panther/cli/create_command.py +1 -0
- panther/cli/main.py +19 -27
- panther/cli/monitor_command.py +8 -4
- panther/cli/template.py +11 -6
- panther/cli/utils.py +3 -2
- panther/configs.py +7 -9
- panther/db/cursor.py +23 -7
- panther/db/models.py +26 -19
- panther/db/queries/base_queries.py +1 -1
- panther/db/queries/mongodb_queries.py +172 -10
- panther/db/queries/pantherdb_queries.py +5 -5
- panther/db/queries/queries.py +1 -1
- panther/events.py +10 -4
- panther/exceptions.py +24 -2
- panther/generics.py +2 -2
- panther/main.py +80 -117
- panther/middlewares/__init__.py +1 -1
- panther/middlewares/base.py +15 -19
- panther/middlewares/monitoring.py +42 -0
- panther/openapi/__init__.py +1 -0
- panther/openapi/templates/openapi.html +27 -0
- panther/openapi/urls.py +5 -0
- panther/openapi/utils.py +167 -0
- panther/openapi/views.py +101 -0
- panther/pagination.py +1 -1
- panther/panel/middlewares.py +10 -0
- panther/panel/templates/base.html +14 -0
- panther/panel/templates/create.html +21 -0
- panther/panel/templates/create.js +1270 -0
- panther/panel/templates/detail.html +55 -0
- panther/panel/templates/home.html +9 -0
- panther/panel/templates/home.js +30 -0
- panther/panel/templates/login.html +47 -0
- panther/panel/templates/sidebar.html +13 -0
- panther/panel/templates/table.html +73 -0
- panther/panel/templates/table.js +339 -0
- panther/panel/urls.py +10 -5
- panther/panel/utils.py +98 -0
- panther/panel/views.py +143 -0
- panther/request.py +3 -0
- panther/response.py +91 -53
- panther/routings.py +7 -2
- panther/serializer.py +1 -1
- panther/utils.py +34 -26
- panther/websocket.py +3 -0
- {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/METADATA +19 -17
- panther-5.0.0b1.dist-info/RECORD +75 -0
- {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/WHEEL +1 -1
- panther-4.3.7.dist-info/RECORD +0 -57
- {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/entry_points.txt +0 -0
- {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/licenses/LICENSE +0 -0
- {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/top_level.txt +0 -0
panther/__init__.py
CHANGED
panther/_load_configs.py
CHANGED
@@ -16,7 +16,8 @@ from panther.db.queries.mongodb_queries import BaseMongoDBQuery
|
|
16
16
|
from panther.db.queries.pantherdb_queries import BasePantherDBQuery
|
17
17
|
from panther.exceptions import PantherError
|
18
18
|
from panther.middlewares.base import WebsocketMiddleware, HTTPMiddleware
|
19
|
-
from panther.
|
19
|
+
from panther.middlewares.monitoring import MonitoringMiddleware, WebsocketMonitoringMiddleware
|
20
|
+
from panther.panel.views import HomeView
|
20
21
|
from panther.routings import finalize_urls, flatten_urls
|
21
22
|
|
22
23
|
__all__ = (
|
@@ -27,7 +28,6 @@ __all__ = (
|
|
27
28
|
'load_timezone',
|
28
29
|
'load_database',
|
29
30
|
'load_secret_key',
|
30
|
-
'load_monitoring',
|
31
31
|
'load_throttling',
|
32
32
|
'load_user_model',
|
33
33
|
'load_log_queries',
|
@@ -36,8 +36,8 @@ __all__ = (
|
|
36
36
|
'load_auto_reformat',
|
37
37
|
'load_background_tasks',
|
38
38
|
'load_default_cache_exp',
|
39
|
-
'load_authentication_class',
|
40
39
|
'load_urls',
|
40
|
+
'load_authentication_class',
|
41
41
|
'load_websocket_connections',
|
42
42
|
'check_endpoints_inheritance',
|
43
43
|
)
|
@@ -94,7 +94,15 @@ def load_templates_dir(_configs: dict, /) -> None:
|
|
94
94
|
if config.TEMPLATES_DIR == '.':
|
95
95
|
config.TEMPLATES_DIR = config.BASE_DIR
|
96
96
|
|
97
|
-
config.JINJA_ENVIRONMENT = jinja2.Environment(
|
97
|
+
config.JINJA_ENVIRONMENT = jinja2.Environment(
|
98
|
+
loader=jinja2.ChoiceLoader(
|
99
|
+
loaders=(
|
100
|
+
jinja2.FileSystemLoader(searchpath=config.TEMPLATES_DIR),
|
101
|
+
jinja2.PackageLoader(package_name='panther', package_path='panel/templates/'),
|
102
|
+
jinja2.PackageLoader(package_name='panther', package_path='openapi/templates/'),
|
103
|
+
)
|
104
|
+
)
|
105
|
+
)
|
98
106
|
|
99
107
|
|
100
108
|
def load_database(_configs: dict, /) -> None:
|
@@ -126,11 +134,6 @@ def load_secret_key(_configs: dict, /) -> None:
|
|
126
134
|
config.SECRET_KEY = secret_key.encode()
|
127
135
|
|
128
136
|
|
129
|
-
def load_monitoring(_configs: dict, /) -> None:
|
130
|
-
if _configs.get('MONITORING'):
|
131
|
-
config.MONITORING = True
|
132
|
-
|
133
|
-
|
134
137
|
def load_throttling(_configs: dict, /) -> None:
|
135
138
|
if throttling := _configs.get('THROTTLING'):
|
136
139
|
config.THROTTLING = throttling
|
@@ -147,42 +150,45 @@ def load_log_queries(_configs: dict, /) -> None:
|
|
147
150
|
|
148
151
|
|
149
152
|
def load_middlewares(_configs: dict, /) -> None:
|
150
|
-
from panther.middlewares import BaseMiddleware
|
151
|
-
|
152
153
|
middlewares = {'http': [], 'ws': []}
|
153
154
|
|
154
155
|
# Collect Middlewares
|
155
156
|
for middleware in _configs.get('MIDDLEWARES') or []:
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
157
|
+
# This block is for Backward Compatibility
|
158
|
+
if isinstance(middleware, list | tuple):
|
159
|
+
if len(middleware) == 1:
|
160
|
+
middleware = middleware[0]
|
161
|
+
elif len(middleware) == 2:
|
162
|
+
_deprecated_warning(
|
163
|
+
field='MIDDLEWARES',
|
164
|
+
message='`data` does not supported in middlewares anymore, as your data is static you may want '
|
165
|
+
'to pass them to your middleware with config variables'
|
166
|
+
)
|
167
|
+
middleware = middleware[0]
|
168
|
+
else:
|
169
|
+
raise _exception_handler(
|
170
|
+
field='MIDDLEWARES', error=f'{middleware} should be dotted path or type of a middleware class')
|
171
|
+
|
172
|
+
# `middleware` can be type or path of a class
|
173
|
+
if not callable(middleware):
|
173
174
|
try:
|
174
|
-
|
175
|
+
middleware = import_class(middleware)
|
175
176
|
except (AttributeError, ModuleNotFoundError):
|
176
|
-
raise _exception_handler(
|
177
|
+
raise _exception_handler(
|
178
|
+
field='MIDDLEWARES', error=f'{middleware} is not a valid middleware path or type')
|
177
179
|
|
178
|
-
if issubclass(
|
179
|
-
|
180
|
+
if issubclass(middleware, (MonitoringMiddleware, WebsocketMonitoringMiddleware)):
|
181
|
+
config.MONITORING = True
|
180
182
|
|
181
|
-
if
|
182
|
-
middlewares['http'].append(
|
183
|
-
|
184
|
-
|
185
|
-
|
183
|
+
if issubclass(middleware, HTTPMiddleware):
|
184
|
+
middlewares['http'].append(middleware)
|
185
|
+
elif issubclass(middleware, WebsocketMiddleware):
|
186
|
+
middlewares['ws'].append(middleware)
|
187
|
+
else:
|
188
|
+
raise _exception_handler(
|
189
|
+
field='MIDDLEWARES',
|
190
|
+
error='is not a sub class of `HTTPMiddleware` or `WebsocketMiddleware`'
|
191
|
+
)
|
186
192
|
|
187
193
|
config.HTTP_MIDDLEWARES = middlewares['http']
|
188
194
|
config.WS_MIDDLEWARES = middlewares['ws']
|
@@ -204,32 +210,6 @@ def load_default_cache_exp(_configs: dict, /) -> None:
|
|
204
210
|
config.DEFAULT_CACHE_EXP = default_cache_exp
|
205
211
|
|
206
212
|
|
207
|
-
def load_authentication_class(_configs: dict, /) -> None:
|
208
|
-
"""Should be after `load_secret_key()`"""
|
209
|
-
if authentication := _configs.get('AUTHENTICATION'):
|
210
|
-
config.AUTHENTICATION = import_class(authentication)
|
211
|
-
|
212
|
-
if ws_authentication := _configs.get('WS_AUTHENTICATION'):
|
213
|
-
config.WS_AUTHENTICATION = import_class(ws_authentication)
|
214
|
-
|
215
|
-
load_jwt_config(_configs)
|
216
|
-
|
217
|
-
|
218
|
-
def load_jwt_config(_configs: dict, /) -> None:
|
219
|
-
"""Only Collect JWT Config If Authentication Is JWTAuthentication"""
|
220
|
-
auth_is_jwt = (
|
221
|
-
getattr(config.AUTHENTICATION, '__name__', None) == 'JWTAuthentication' or
|
222
|
-
getattr(config.WS_AUTHENTICATION, '__name__', None) == 'QueryParamJWTAuthentication'
|
223
|
-
)
|
224
|
-
jwt = _configs.get('JWTConfig', {})
|
225
|
-
if auth_is_jwt or jwt:
|
226
|
-
if 'key' not in jwt:
|
227
|
-
if config.SECRET_KEY is None:
|
228
|
-
raise _exception_handler(field='JWTConfig', error='`JWTConfig.key` or `SECRET_KEY` is required.')
|
229
|
-
jwt['key'] = config.SECRET_KEY.decode()
|
230
|
-
config.JWT_CONFIG = JWTConfig(**jwt)
|
231
|
-
|
232
|
-
|
233
213
|
def load_urls(_configs: dict, /, urls: dict | None) -> None:
|
234
214
|
"""
|
235
215
|
Return tuple of all urls (as a flat dict) and (as a nested dict)
|
@@ -261,7 +241,34 @@ def load_urls(_configs: dict, /, urls: dict | None) -> None:
|
|
261
241
|
|
262
242
|
config.FLAT_URLS = flatten_urls(urls)
|
263
243
|
config.URLS = finalize_urls(config.FLAT_URLS)
|
264
|
-
|
244
|
+
|
245
|
+
|
246
|
+
def load_authentication_class(_configs: dict, /) -> None:
|
247
|
+
"""Should be after `load_secret_key()` and `load_urls()`"""
|
248
|
+
if authentication := _configs.get('AUTHENTICATION'):
|
249
|
+
config.AUTHENTICATION = import_class(authentication)
|
250
|
+
|
251
|
+
if ws_authentication := _configs.get('WS_AUTHENTICATION'):
|
252
|
+
config.WS_AUTHENTICATION = import_class(ws_authentication)
|
253
|
+
|
254
|
+
load_jwt_config(_configs)
|
255
|
+
|
256
|
+
|
257
|
+
def load_jwt_config(_configs: dict, /) -> None:
|
258
|
+
"""Only Collect JWT Config If Authentication Is JWTAuthentication"""
|
259
|
+
auth_is_jwt = (
|
260
|
+
getattr(config.AUTHENTICATION, '__name__', None) == 'JWTAuthentication' or
|
261
|
+
getattr(config.WS_AUTHENTICATION, '__name__', None) == 'QueryParamJWTAuthentication'
|
262
|
+
)
|
263
|
+
jwt = _configs.get('JWTConfig', {})
|
264
|
+
|
265
|
+
using_panel_views = HomeView in config.FLAT_URLS.values()
|
266
|
+
if auth_is_jwt or using_panel_views:
|
267
|
+
if 'key' not in jwt:
|
268
|
+
if config.SECRET_KEY is None:
|
269
|
+
raise _exception_handler(field='JWTConfig', error='`JWTConfig.key` or `SECRET_KEY` is required.')
|
270
|
+
jwt['key'] = config.SECRET_KEY.decode()
|
271
|
+
config.JWT_CONFIG = JWTConfig(**jwt)
|
265
272
|
|
266
273
|
|
267
274
|
def load_websocket_connections():
|
@@ -281,6 +288,9 @@ def load_websocket_connections():
|
|
281
288
|
def check_endpoints_inheritance():
|
282
289
|
"""Should be after `load_urls()`"""
|
283
290
|
for _, endpoint in config.FLAT_URLS.items():
|
291
|
+
if endpoint == {}:
|
292
|
+
continue
|
293
|
+
|
284
294
|
if isinstance(endpoint, types.FunctionType):
|
285
295
|
check_function_type_endpoint(endpoint=endpoint)
|
286
296
|
else:
|
@@ -289,3 +299,7 @@ def check_endpoints_inheritance():
|
|
289
299
|
|
290
300
|
def _exception_handler(field: str, error: str | Exception) -> PantherError:
|
291
301
|
return PantherError(f"Invalid '{field}': {error}")
|
302
|
+
|
303
|
+
|
304
|
+
def _deprecated_warning(field: str, message: str):
|
305
|
+
return logger.warning(f"DEPRECATED '{field}': {message}")
|
panther/_utils.py
CHANGED
@@ -10,7 +10,6 @@ from typing import Any, Generator, Iterator, AsyncGenerator
|
|
10
10
|
|
11
11
|
from panther.exceptions import PantherError
|
12
12
|
from panther.file_handler import File
|
13
|
-
from panther.websocket import GenericWebsocket
|
14
13
|
|
15
14
|
logger = logging.getLogger('panther')
|
16
15
|
|
@@ -100,6 +99,7 @@ def check_function_type_endpoint(endpoint: types.FunctionType) -> Callable:
|
|
100
99
|
|
101
100
|
def check_class_type_endpoint(endpoint: Callable) -> Callable:
|
102
101
|
from panther.app import GenericAPI
|
102
|
+
from panther.websocket import GenericWebsocket
|
103
103
|
|
104
104
|
if not issubclass(endpoint, (GenericAPI, GenericWebsocket)):
|
105
105
|
raise PantherError(
|
panther/app.py
CHANGED
@@ -1,7 +1,9 @@
|
|
1
1
|
import functools
|
2
2
|
import logging
|
3
|
+
import traceback
|
4
|
+
import typing
|
3
5
|
from datetime import timedelta
|
4
|
-
from typing import Literal
|
6
|
+
from typing import Literal, Callable
|
5
7
|
|
6
8
|
from orjson import JSONDecodeError
|
7
9
|
from pydantic import ValidationError, BaseModel
|
@@ -22,6 +24,10 @@ from panther.exceptions import (
|
|
22
24
|
ThrottlingAPIError,
|
23
25
|
BadRequestAPIError
|
24
26
|
)
|
27
|
+
from panther.exceptions import PantherError
|
28
|
+
from panther.middlewares import HTTPMiddleware
|
29
|
+
from panther.openapi import OutputSchema
|
30
|
+
from panther.permissions import BasePermission
|
25
31
|
from panther.request import Request
|
26
32
|
from panther.response import Response
|
27
33
|
from panther.serializer import ModelSerializer
|
@@ -33,87 +39,127 @@ logger = logging.getLogger('panther')
|
|
33
39
|
|
34
40
|
|
35
41
|
class API:
|
42
|
+
"""
|
43
|
+
input_model: The `request.data` will be validated with this attribute, It will raise an
|
44
|
+
`panther.exceptions.BadRequestAPIError` or put the validated data in the `request.validated_data`.
|
45
|
+
output_schema: This attribute only used in creation of OpenAPI scheme which is available in `panther.openapi.urls`
|
46
|
+
You may want to add its `url` to your urls.
|
47
|
+
auth: It will authenticate the user with header of its request or raise an
|
48
|
+
`panther.exceptions.AuthenticationAPIError`.
|
49
|
+
permissions: List of permissions that will be called sequentially after authentication to authorize the user.
|
50
|
+
throttling: It will limit the users' request on a specific (time-bucket, path)
|
51
|
+
cache: Response of the request will be cached.
|
52
|
+
cache_exp_time: Specify the expiry time of the cache. (default is `config.DEFAULT_CACHE_EXP`)
|
53
|
+
methods: Specify the allowed methods.
|
54
|
+
middlewares: These middlewares have inner priority than global middlewares.
|
55
|
+
"""
|
56
|
+
func: Callable
|
57
|
+
|
36
58
|
def __init__(
|
37
59
|
self,
|
38
60
|
*,
|
61
|
+
methods: list[Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE']] | None = None,
|
39
62
|
input_model: type[ModelSerializer] | type[BaseModel] | None = None,
|
40
|
-
output_model: type[
|
63
|
+
output_model: type[BaseModel] | None = None,
|
64
|
+
output_schema: OutputSchema | None = None,
|
41
65
|
auth: bool = False,
|
42
|
-
permissions: list | None = None,
|
66
|
+
permissions: list[BasePermission] | None = None,
|
43
67
|
throttling: Throttling | None = None,
|
44
68
|
cache: bool = False,
|
45
69
|
cache_exp_time: timedelta | int | None = None,
|
46
|
-
|
70
|
+
middlewares: list[HTTPMiddleware] | None = None,
|
47
71
|
):
|
72
|
+
self.methods = {m.upper() for m in methods} if methods else None
|
48
73
|
self.input_model = input_model
|
49
|
-
self.
|
74
|
+
self.output_schema = output_schema
|
50
75
|
self.auth = auth
|
51
76
|
self.permissions = permissions or []
|
52
77
|
self.throttling = throttling
|
53
78
|
self.cache = cache
|
54
|
-
self.cache_exp_time = cache_exp_time
|
55
|
-
self.
|
79
|
+
self.cache_exp_time = cache_exp_time
|
80
|
+
self.middlewares: list[HTTPMiddleware] | None = middlewares
|
56
81
|
self.request: Request | None = None
|
82
|
+
if output_model:
|
83
|
+
deprecation_message = (
|
84
|
+
traceback.format_stack(limit=2)[0] +
|
85
|
+
'\nThe `output_model` argument has been removed in Panther v5 and is no longer available.'
|
86
|
+
'\nPlease update your code to use the new approach. More info: '
|
87
|
+
'https://pantherpy.github.io/open_api/'
|
88
|
+
)
|
89
|
+
raise PantherError(deprecation_message)
|
57
90
|
|
58
91
|
def __call__(self, func):
|
92
|
+
self.func = func
|
93
|
+
|
59
94
|
@functools.wraps(func)
|
60
95
|
async def wrapper(request: Request) -> Response:
|
61
|
-
|
96
|
+
chained_func = self.handle_endpoint
|
97
|
+
if self.middlewares:
|
98
|
+
for middleware in reversed(self.middlewares):
|
99
|
+
chained_func = middleware(chained_func)
|
100
|
+
return await chained_func(request=request)
|
101
|
+
|
102
|
+
# Store attributes on the function, so have the same behaviour as class-based (useful in `openapi.view.OpenAPI`)
|
103
|
+
wrapper.auth = self.auth
|
104
|
+
wrapper.methods = self.methods
|
105
|
+
wrapper.permissions = self.permissions
|
106
|
+
wrapper.input_model = self.input_model
|
107
|
+
wrapper.output_schema = self.output_schema
|
108
|
+
return wrapper
|
62
109
|
|
63
|
-
|
64
|
-
|
65
|
-
return self.options()
|
110
|
+
async def handle_endpoint(self, request: Request) -> Response:
|
111
|
+
self.request = request
|
66
112
|
|
67
|
-
|
68
|
-
|
69
|
-
|
113
|
+
# 0. Preflight
|
114
|
+
if self.request.method == 'OPTIONS':
|
115
|
+
return self.options()
|
70
116
|
|
71
|
-
|
72
|
-
|
117
|
+
# 1. Check Method
|
118
|
+
if self.methods and self.request.method not in self.methods:
|
119
|
+
raise MethodNotAllowedAPIError
|
73
120
|
|
74
|
-
|
75
|
-
|
121
|
+
# 2. Authentication
|
122
|
+
await self.handle_authentication()
|
76
123
|
|
77
|
-
|
78
|
-
|
124
|
+
# 3. Permissions
|
125
|
+
await self.handle_permission()
|
79
126
|
|
80
|
-
|
81
|
-
|
82
|
-
self.handle_input_validation()
|
127
|
+
# 4. Throttling
|
128
|
+
await self.handle_throttling()
|
83
129
|
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
return Response(data=cached.data, headers=cached.headers, status_code=cached.status_code)
|
130
|
+
# 5. Validate Input
|
131
|
+
if self.request.method in {'POST', 'PUT', 'PATCH'}:
|
132
|
+
self.handle_input_validation()
|
88
133
|
|
89
|
-
|
90
|
-
|
134
|
+
# 6. Get Cached Response
|
135
|
+
if self.cache and self.request.method == 'GET':
|
136
|
+
if cached := await get_response_from_cache(request=self.request, cache_exp_time=self.cache_exp_time):
|
137
|
+
return Response(data=cached.data, headers=cached.headers, status_code=cached.status_code)
|
91
138
|
|
92
|
-
|
93
|
-
|
94
|
-
response = await func(**kwargs)
|
95
|
-
else:
|
96
|
-
response = func(**kwargs)
|
139
|
+
# 7. Put PathVariables and Request(If User Wants It) In kwargs
|
140
|
+
kwargs = self.request.clean_parameters(self.func)
|
97
141
|
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
if response.pagination:
|
104
|
-
response.data = await response.pagination.template(response.data)
|
142
|
+
# 8. Call Endpoint
|
143
|
+
if is_function_async(self.func):
|
144
|
+
response = await self.func(**kwargs)
|
145
|
+
else:
|
146
|
+
response = self.func(**kwargs)
|
105
147
|
|
106
|
-
|
107
|
-
|
108
|
-
|
148
|
+
# 9. Clean Response
|
149
|
+
if not isinstance(response, Response):
|
150
|
+
response = Response(data=response)
|
151
|
+
if response.pagination:
|
152
|
+
response.data = await response.pagination.template(response.data)
|
109
153
|
|
110
|
-
|
111
|
-
|
112
|
-
|
154
|
+
# 10. Set New Response To Cache
|
155
|
+
if self.cache and self.request.method == 'GET':
|
156
|
+
await set_response_in_cache(request=self.request, response=response, cache_exp_time=self.cache_exp_time)
|
113
157
|
|
114
|
-
|
158
|
+
# 11. Warning CacheExpTime
|
159
|
+
if self.cache_exp_time and self.cache is False:
|
160
|
+
logger.warning('"cache_exp_time" won\'t work while "cache" is False')
|
115
161
|
|
116
|
-
return
|
162
|
+
return response
|
117
163
|
|
118
164
|
async def handle_authentication(self) -> None:
|
119
165
|
if self.auth:
|
@@ -144,7 +190,7 @@ class API:
|
|
144
190
|
@classmethod
|
145
191
|
def options(cls):
|
146
192
|
headers = {
|
147
|
-
'Access-Control-Allow-Methods': 'DELETE, GET, PATCH, POST, PUT, OPTIONS',
|
193
|
+
'Access-Control-Allow-Methods': 'DELETE, GET, PATCH, POST, PUT, OPTIONS, HEAD',
|
148
194
|
'Access-Control-Allow-Headers': 'Accept, Authorization, User-Agent, Content-Type',
|
149
195
|
}
|
150
196
|
return Response(headers=headers)
|
@@ -165,14 +211,39 @@ class API:
|
|
165
211
|
raise JSONDecodeAPIError
|
166
212
|
|
167
213
|
|
168
|
-
class
|
214
|
+
class MetaGenericAPI(type):
|
215
|
+
def __new__(
|
216
|
+
cls,
|
217
|
+
cls_name: str,
|
218
|
+
bases: tuple[type[typing.Any], ...],
|
219
|
+
namespace: dict[str, typing.Any],
|
220
|
+
**kwargs
|
221
|
+
):
|
222
|
+
if cls_name == 'GenericAPI':
|
223
|
+
return super().__new__(cls, cls_name, bases, namespace)
|
224
|
+
if 'output_model' in namespace:
|
225
|
+
deprecation_message = (
|
226
|
+
traceback.format_stack(limit=2)[0] +
|
227
|
+
'\nThe `output_model` argument has been removed in Panther v5 and is no longer available.'
|
228
|
+
'\nPlease update your code to use the new approach. More info: '
|
229
|
+
'https://pantherpy.github.io/open_api/'
|
230
|
+
)
|
231
|
+
raise PantherError(deprecation_message)
|
232
|
+
return super().__new__(cls, cls_name, bases, namespace)
|
233
|
+
|
234
|
+
|
235
|
+
class GenericAPI(metaclass=MetaGenericAPI):
|
236
|
+
"""
|
237
|
+
Check out the documentation of `panther.app.API()`.
|
238
|
+
"""
|
169
239
|
input_model: type[ModelSerializer] | type[BaseModel] | None = None
|
170
|
-
|
240
|
+
output_schema: OutputSchema | None = None
|
171
241
|
auth: bool = False
|
172
242
|
permissions: list | None = None
|
173
243
|
throttling: Throttling | None = None
|
174
244
|
cache: bool = False
|
175
245
|
cache_exp_time: timedelta | int | None = None
|
246
|
+
middlewares: list[HTTPMiddleware] | None = None
|
176
247
|
|
177
248
|
async def get(self, *args, **kwargs):
|
178
249
|
raise MethodNotAllowedAPIError
|
@@ -189,12 +260,6 @@ class GenericAPI:
|
|
189
260
|
async def delete(self, *args, **kwargs):
|
190
261
|
raise MethodNotAllowedAPIError
|
191
262
|
|
192
|
-
async def get_input_model(self, request: Request) -> type[ModelSerializer] | type[BaseModel] | None:
|
193
|
-
return None
|
194
|
-
|
195
|
-
async def get_output_model(self, request: Request) -> type[ModelSerializer] | type[BaseModel] | None:
|
196
|
-
return None
|
197
|
-
|
198
263
|
async def call_method(self, request: Request):
|
199
264
|
match request.method:
|
200
265
|
case 'GET':
|
@@ -213,11 +278,12 @@ class GenericAPI:
|
|
213
278
|
raise MethodNotAllowedAPIError
|
214
279
|
|
215
280
|
return await API(
|
216
|
-
input_model=self.input_model
|
217
|
-
|
281
|
+
input_model=self.input_model,
|
282
|
+
output_schema=self.output_schema,
|
218
283
|
auth=self.auth,
|
219
284
|
permissions=self.permissions,
|
220
285
|
throttling=self.throttling,
|
221
286
|
cache=self.cache,
|
222
287
|
cache_exp_time=self.cache_exp_time,
|
288
|
+
middlewares=self.middlewares,
|
223
289
|
)(func)(request=request)
|
panther/authentications.py
CHANGED
@@ -29,9 +29,9 @@ class BaseAuthentication:
|
|
29
29
|
msg = f'{cls.__name__}.authentication() is not implemented.'
|
30
30
|
raise cls.exception(msg) from None
|
31
31
|
|
32
|
-
@
|
33
|
-
def exception(message: str, /) -> type[AuthenticationAPIError]:
|
34
|
-
logger.error(f'
|
32
|
+
@classmethod
|
33
|
+
def exception(cls, message: str | Exception, /) -> type[AuthenticationAPIError]:
|
34
|
+
logger.error(f'{cls.__name__} Error: "{message}"')
|
35
35
|
return AuthenticationAPIError
|
36
36
|
|
37
37
|
|
@@ -151,16 +151,33 @@ class JWTAuthentication(BaseAuthentication):
|
|
151
151
|
key = generate_hash_value_from_string(token)
|
152
152
|
return bool(await redis.exists(key))
|
153
153
|
|
154
|
-
@staticmethod
|
155
|
-
def exception(message: str | JWTError | UnicodeEncodeError, /) -> type[AuthenticationAPIError]:
|
156
|
-
logger.error(f'JWT Authentication Error: "{message}"')
|
157
|
-
return AuthenticationAPIError
|
158
|
-
|
159
154
|
|
160
155
|
class QueryParamJWTAuthentication(JWTAuthentication):
|
161
156
|
@classmethod
|
162
157
|
def get_authorization_header(cls, request: Request | Websocket) -> str:
|
163
158
|
if auth := request.query_params.get('authorization'):
|
164
159
|
return auth
|
165
|
-
msg = '
|
160
|
+
msg = '`authorization` query param not found.'
|
161
|
+
raise cls.exception(msg) from None
|
162
|
+
|
163
|
+
|
164
|
+
class CookieJWTAuthentication(JWTAuthentication):
|
165
|
+
@classmethod
|
166
|
+
def get_authorization_header(cls, request: Request | Websocket) -> str:
|
167
|
+
if token := request.headers.get_cookies().get('access_token'):
|
168
|
+
return token
|
169
|
+
msg = '`access_token` Cookie not found.'
|
166
170
|
raise cls.exception(msg) from None
|
171
|
+
|
172
|
+
@classmethod
|
173
|
+
async def authentication(cls, request: Request | Websocket) -> Model:
|
174
|
+
token = cls.get_authorization_header(request)
|
175
|
+
|
176
|
+
if redis.is_connected and await cls._check_in_cache(token=token):
|
177
|
+
msg = 'User logged out'
|
178
|
+
raise cls.exception(msg) from None
|
179
|
+
|
180
|
+
payload = cls.decode_jwt(token)
|
181
|
+
user = await cls.get_user(payload)
|
182
|
+
user._auth_token = token
|
183
|
+
return user
|
panther/base_request.py
CHANGED
@@ -1,4 +1,3 @@
|
|
1
|
-
from collections import namedtuple
|
2
1
|
from collections.abc import Callable
|
3
2
|
from urllib.parse import parse_qsl
|
4
3
|
|
@@ -46,14 +45,40 @@ class Headers:
|
|
46
45
|
items = ', '.join(f'{k}={v}' for k, v in self.__headers.items())
|
47
46
|
return f'Headers({items})'
|
48
47
|
|
48
|
+
def __contains__(self, item):
|
49
|
+
return (item in self.__headers) or (item in self.__pythonic_headers)
|
50
|
+
|
49
51
|
__repr__ = __str__
|
50
52
|
|
51
53
|
@property
|
52
54
|
def __dict__(self):
|
53
55
|
return self.__headers
|
54
56
|
|
57
|
+
def get_cookies(self) -> dict:
|
58
|
+
"""
|
59
|
+
request.headers.cookie:
|
60
|
+
'csrftoken=aaa; sessionid=bbb; access_token=ccc; refresh_token=ddd'
|
61
|
+
|
62
|
+
request.headers.get_cookies():
|
63
|
+
{
|
64
|
+
'csrftoken': 'aaa',
|
65
|
+
'sessionid': 'bbb',
|
66
|
+
'access_token': 'ccc',
|
67
|
+
'refresh_token': 'ddd',
|
68
|
+
}
|
69
|
+
"""
|
70
|
+
if self.cookie:
|
71
|
+
return {k.strip(): v for k, v in (c.split('=', maxsplit=1) for c in self.cookie.split(';'))}
|
72
|
+
return {}
|
73
|
+
|
74
|
+
|
75
|
+
class Address:
|
76
|
+
def __init__(self, ip, port):
|
77
|
+
self.ip = ip
|
78
|
+
self.port = port
|
55
79
|
|
56
|
-
|
80
|
+
def __str__(self):
|
81
|
+
return f'{self.ip}:{self.port}'
|
57
82
|
|
58
83
|
|
59
84
|
class BaseRequest:
|