Coverage for audoma/decorators.py: 93%
152 statements
« prev ^ index » next coverage.py v6.4.2, created at 2022-08-08 06:12 +0000
« prev ^ index » next coverage.py v6.4.2, created at 2022-08-08 06:12 +0000
1import logging
2from dataclasses import dataclass
3from functools import wraps
4from inspect import isclass
5from typing import (
6 Any,
7 Callable,
8 Iterable,
9 List,
10 Tuple,
11 Type,
12 Union,
13)
15from rest_framework.decorators import action
16from rest_framework.exceptions import APIException
17from rest_framework.permissions import SAFE_METHODS
18from rest_framework.request import Request
19from rest_framework.response import Response
20from rest_framework.serializers import BaseSerializer
21from rest_framework.views import APIView
23from django.conf import settings as project_settings
24from django.core.exceptions import ImproperlyConfigured
25from django.db.models import Model
27from audoma import settings as audoma_settings
28from audoma.operations import (
29 OperationExtractor,
30 apply_response_operation,
31)
34logger = logging.getLogger(__name__)
37@dataclass
38class AudomaArgs:
39 results: Union[dict, Type[BaseSerializer], str]
40 collectors: Union[dict, Type[BaseSerializer]]
41 errors: List[Union[Exception, Type[Exception]]]
44class AudomaActionException(Exception):
45 pass
48class audoma_action:
49 """
50 This is a custom action decorator which allows to define collectors, results and errors.
51 This decorator also applies the collect serializer if such has been defined.
52 It also prevents from raising not defined errors, so if you want to raise an exception
53 its' object or class has to be passed into audoma_action decorator.
55 Args:
56 collectors - collect serializers, it may be passed as a dict: {'http_method': serializer_class}
57 or just as a serializer_class. Those serializer are being used to
58 collect data from user.
59 NOTE: - it is not possible to define collectors for SAFE_METHODS
60 results - response serializers/messages, it may be passed as a dict in three forms:
61 * {'http_method': serializer_class}
62 * {'http_method': {status_code: serializer_class, status_code: serializer_class}}
63 * {status_code: serializer_class}
64 * just as a serializer_class
65 errors - list of exception objects, list of exceptions which may be raised in decorated method.
66 'audoma_action' will not allow raising any other exceptions than those
67 ignore_view_collectors - If set to True, decorator is ignoring view collect serializers.
68 May be useful if we don't want to falback to default view collect serializer retrieval.
70 """
72 def _sanitize_kwargs(self, kwargs: dict) -> dict:
73 if kwargs.get("serializer_class", None):
74 raise ImproperlyConfigured(
75 "serializer_class parameter is not allowed in audoma_action, \
76 use collectors and responses instead."
77 )
78 return kwargs
80 def _sanitize_error(
81 self, _errors: List[Union[Exception, Type[Exception]]]
82 ) -> List[Union[Exception, Type[Exception]]]:
83 """ "
84 This method sanitizes passed errors list.
85 This prevents defining Exception Type and same Type instance in errors list.
87 Args:
88 errors - list of error to be sanitazed
90 Returns sanitized errors list.
91 """
92 if not _errors:
93 return _errors
94 instances = []
95 types = []
96 sanitized_errors = []
98 for error in _errors:
99 if isclass(error):
100 types.append(error)
101 elif isinstance(error, Exception):
102 instances.append(error)
103 else:
104 raise ImproperlyConfigured(
105 f"Something that is not an Exception instance or class has been passed \
106 to audoma_action errors list. The value which caused exception: {error}"
107 )
109 # check if there are no repetitions
110 for instance in instances:
111 if type(instance) in types:
112 raise ImproperlyConfigured(
113 f"Exception has been passed multiple times as an instance and as type, \
114 exception type: {type(instance)}"
115 )
116 else:
117 sanitized_errors.append(instance)
119 sanitized_errors += types
120 return sanitized_errors
122 def __init__(
123 self,
124 collectors: Union[dict, BaseSerializer] = None,
125 results: Union[dict, BaseSerializer, str] = None,
126 errors: List[Union[Exception, Type[Exception]]] = None,
127 ignore_view_collectors: bool = False,
128 **kwargs,
129 ) -> None:
130 self.collectors = collectors or {}
131 self.results = results or {}
132 self.ignore_view_collectors = ignore_view_collectors
134 try:
135 self.errors = self._sanitize_error(errors) or []
136 self.kwargs = self._sanitize_kwargs(kwargs) or {}
137 self.methods = kwargs.get("methods")
138 self.framework_decorator = action(**kwargs)
139 self.operation_extractor = OperationExtractor(collectors, results, errors)
140 if all(method in SAFE_METHODS for method in self.methods) and collectors:
141 raise ImproperlyConfigured(
142 "There should be no collectors defined if there are not create/update requests accepted."
143 )
144 except ImproperlyConfigured as e:
145 if project_settings.DEBUG:
146 raise e
147 logger.exception("audoma_action has been improperly configured.")
149 def _get_error_instance_and_class(
150 self, error: Union[Exception, Type[Exception]]
151 ) -> Tuple[Exception, Type[Exception]]:
152 """
153 This is an internal helper method.
154 Beacuse we accept errors as instances and classes
155 it helps to determine which one is passed.
157 Args:
158 * error - error object or class
160 Returns: instance and class of passed exception.
161 """
162 if isclass(error):
163 error_class = error
164 error_instance = error()
165 else:
166 error_instance = error
167 error_class = type(error)
168 return error_instance, error_class
170 def _get_type_matched_exceptions(
171 self,
172 errors: List[Union[Exception, Type[Exception]]],
173 processed_error_class: Type[Exception],
174 ) -> List[Union[Exception, Type[Exception]]]:
175 """
176 This helper function extracts all errors which are
177 of the same type as the processed error.
178 It simply returns a list of those errors.
179 Args:
180 errors - list of errors, allowed to be risen in decorated view
181 processed_error_class - an exception which has been raised in decorated view
183 Returns a List of exceptions and exception classes, with matching exception type.
184 """
185 type_matched_exceptions = []
186 for error in errors:
187 error_instance, error_class = self._get_error_instance_and_class(error)
189 if not processed_error_class == error_class:
190 continue
192 type_matched_exceptions.append((error, error_instance))
194 if not type_matched_exceptions:
195 raise AudomaActionException(
196 f"There is no class or instance of {processed_error_class} \
197 defined in audoma_action errors."
198 )
199 return type_matched_exceptions
201 def _compare_errors_content(
202 self, raised_error: Exception, catched_error: Exception, view: APIView
203 ) -> bool:
204 """
205 This is a helper function which checks if both raised and catched error are the same.
206 To ensure that errors are the same it checks if both errors, have the same content.
208 Args:
209 raised_error - error which has been risen
210 catched_error - error catched in try/except block
211 view - APIView object
213 Returns:
214 True if both errors have the same content, False otherwise.
215 """
216 handler = view.get_exception_handler()
217 handler_context = view.get_exception_handler_context()
218 raised_error_result = handler(raised_error, handler_context)
219 catched_error_result = handler(catched_error, handler_context)
221 if not raised_error_result:
222 raise AudomaActionException(
223 f"Current exception handler is unable to \
224 handle raised exception: {type(raised_error)}.\
225 To handle this type of exception you should write custom exception handler."
226 )
228 return all(
229 getattr(raised_error_result, attr) == getattr(catched_error_result, attr)
230 for attr in ["status_code", "data", "headers"]
231 )
233 def _process_error(
234 self,
235 processed_error: Union[Exception, Type[Exception]],
236 errors: List[Union[Exception, Type[Exception]]],
237 view: APIView,
238 ) -> None:
239 """
240 This function processes the risen error.
241 It checks if such error should be risen.
242 If such error has not been defined/handler is unable to handle it.
243 There will be additioanla exception raised or logged, depends on the DEBUG setting.
245 Args:
246 processed_error - the error which has been risen
247 errors - list of errors which may be raised in decorated method
248 view - APIView object
250 Returns:
251 processed_error instance if such error has been defined.
252 """
253 (
254 processed_error_instance,
255 processed_error_class,
256 ) = self._get_error_instance_and_class(processed_error)
257 error_match = False
259 try:
260 # get all errors with same class as raised exception
261 type_matched_exceptions = self._get_type_matched_exceptions(
262 errors, processed_error_class
263 )
264 for error, error_instance in type_matched_exceptions:
265 if isclass(error):
266 error_match = True
267 break
269 elif self._compare_errors_content(
270 processed_error_instance, error_instance, view
271 ):
272 error_match = True
273 break
274 if not error_match:
275 raise AudomaActionException(
276 f"Raised error: {processed_error_instance} has not been \
277 defined in audoma_action errors."
278 )
280 except AudomaActionException as e:
281 if project_settings.DEBUG:
282 raise e
283 logger.exception(
284 "Error has occured during audoma_action exception processing."
285 )
286 raise processed_error_instance
288 def _retrieve_collect_serializer_class_with_config(
289 self, request: Request, func: Callable, view: APIView
290 ) -> Tuple[Type[BaseSerializer], bool, Any]:
291 """
292 Retrieves collector serializer class and it's config variables.
293 Args:
294 request - request object
295 func - decorated function
296 view - view object, which action func belongs to
298 Returns:
299 Serializer class and it's config variables.
301 Config variables:
302 partial - says if serializer update should be partial or not.
303 view_instance - instance retrieved from view
305 """
306 collect_serializer_class = None
307 partial = False
308 view_instance = None
310 if request.method not in SAFE_METHODS:
311 collect_serializer_class = self.operation_extractor.extract_operation(
312 request, operation_category="collect"
313 )
315 if not collect_serializer_class and not self.ignore_view_collectors:
316 collect_serializer_class = view.get_serializer_class()
317 # Get object if collect serializer is used to update existing instance
318 if func.detail and request.method in ["PUT", "PATCH"]:
319 view_instance = view.get_object()
320 partial = True if request.method.lower() == "patch" else False
321 else:
322 partial = False
323 view_instance = None
324 return collect_serializer_class, partial, view_instance
326 def _action_return_checks(
327 self,
328 code: int,
329 instance: Union[Iterable, str, APIException, Model],
330 response_operation: Union[str, APIException, BaseSerializer],
331 ) -> None:
332 """
333 Perform additional checks for variables returned by view action method.
335 Args:
336 code - status code of the response, returned as an int
337 instance - instance which will be passed to the response serializer
338 response_operation - serializer class, APIException or string instance which
339 will be used to create the response
341 Returns:
342 None
343 """
345 if code is None:
346 raise AudomaActionException(
347 "Status code has not been returned to audoma action."
348 )
350 if not isinstance(response_operation, str) and instance is None:
351 raise AudomaActionException(
352 "Instance returned in audoma_action decorated \
353 method may not be None if result operation is not str message"
354 )
356 def __call__(self, func: Callable) -> Callable:
357 """ "
358 Call of audoma_action decorator.
359 This is where the magic happens.
361 Args:
362 func - decorated function
364 Returns:
365 wrapper callable.
366 """
367 func._audoma = AudomaArgs(
368 collectors=self.collectors, results=self.results, errors=self.errors
369 )
370 # apply action decorator
371 func = self.framework_decorator(func)
373 @wraps(func)
374 def wrapper(view: APIView, request: Request, *args, **kwargs) -> Response:
375 # extend errors too allow default errors occurance
376 errors = func._audoma.errors
377 errors += audoma_settings.COMMON_API_ERRORS + getattr(
378 project_settings, "COMMON_API_ERRORS", []
379 )
380 (
381 collect_serializer_class,
382 partial,
383 view_instance,
384 ) = self._retrieve_collect_serializer_class_with_config(request, func, view)
385 try:
386 if collect_serializer_class:
387 collect_serializer = collect_serializer_class(
388 view_instance,
389 data=request.data,
390 partial=partial,
391 context={"request": request},
392 )
393 collect_serializer.is_valid(raise_exception=True)
394 kwargs["collect_serializer"] = collect_serializer
396 instance, code = func(view, request, *args, **kwargs)
398 except Exception as processed_error:
399 self._process_error(processed_error, errors, view)
401 try:
402 response_operation = self.operation_extractor.extract_operation(
403 request, code=code
404 )
406 self._action_return_checks(
407 code=code, instance=instance, response_operation=response_operation
408 )
409 except AudomaActionException as e:
410 if project_settings.DEBUG:
411 raise e
412 logger.exception(
413 "Error has occured during audoma_action \
414 processing action function execution result"
415 )
417 return apply_response_operation(
418 response_operation, instance, code, view, many=not func.detail
419 )
421 return wrapper