Coverage for audoma/decorators.py: 93%

1 import logging

2 from dataclasses import dataclass

3 from functools import wraps

4 from inspect import isclass

5 from typing import (

6 Any,

7 Callable,

8 Iterable,

9 List,

10 Tuple,

11 Type,

12 Union,

13)

14

15 from rest_framework.decorators import action

16 from rest_framework.exceptions import APIException

17 from rest_framework.permissions import SAFE_METHODS

18 from rest_framework.request import Request

19 from rest_framework.response import Response

20 from rest_framework.serializers import BaseSerializer

21 from rest_framework.views import APIView

22

23 from django.conf import settings as project_settings

24 from django.core.exceptions import ImproperlyConfigured

25 from django.db.models import Model

26

27 from audoma import settings as audoma_settings

28 from audoma.operations import (

29 OperationExtractor,

30 apply_response_operation,

31)

32

33

34 logger = logging.getLogger(__name__)

35

36

37@dataclass

38 class AudomaArgs:

39 results: Union[dict, Type[BaseSerializer], str]

40 collectors: Union[dict, Type[BaseSerializer]]

41 errors: List[Union[Exception, Type[Exception]]]

42

43

44 class AudomaActionException(Exception):

45 pass

46

47

48 class 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.

54

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.

69

70 """

71

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

79

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.

86

87 Args:

88 errors - list of error to be sanitazed

89

90 Returns sanitized errors list.

91 """

92 if not _errors:

93 return _errors

94 instances = []

95 types = []

96 sanitized_errors = []

97

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 )

108

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)

118

119 sanitized_errors += types

120 return sanitized_errors

121

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

133

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.")

148

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.

156

157 Args:

158 * error - error object or class

159

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

169

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

182

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)

188

189 if not processed_error_class == error_class:

190 continue

191

192 type_matched_exceptions.append((error, error_instance))

193

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

200

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.

207

208 Args:

209 raised_error - error which has been risen

210 catched_error - error catched in try/except block

211 view - APIView object

212

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)

220

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 )

227

228 return all(

229 getattr(raised_error_result, attr) == getattr(catched_error_result, attr)

230 for attr in ["status_code", "data", "headers"]

231 )

232

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.

244

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

249

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

258

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

268

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 )

279

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

287

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

297

298 Returns:

299 Serializer class and it's config variables.

300

301 Config variables:

302 partial - says if serializer update should be partial or not.

303 view_instance - instance retrieved from view

304

305 """

306 collect_serializer_class = None

307 partial = False

308 view_instance = None

309

310 if request.method not in SAFE_METHODS:

311 collect_serializer_class = self.operation_extractor.extract_operation(

312 request, operation_category="collect"

313 )

314

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

325

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.

334

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

340

341 Returns:

342 None

343 """

344

345 if code is None:

346 raise AudomaActionException(

347 "Status code has not been returned to audoma action."

348 )

349

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 )

355

356 def __call__(self, func: Callable) -> Callable:

357 """ "

358 Call of audoma_action decorator.

359 This is where the magic happens.

360

361 Args:

362 func - decorated function

363

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)

372

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

395

396 instance, code = func(view, request, *args, **kwargs)

397

398 except Exception as processed_error:

399 self._process_error(processed_error, errors, view)

400

401 try:

402 response_operation = self.operation_extractor.extract_operation(

403 request, code=code

404 )

405

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 )

416

417 return apply_response_operation(

418 response_operation, instance, code, view, many=not func.detail

419 )

420

421 return wrapper