Coverage for audoma/decorators.py: 93%

152 statements  

« 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) 

14 

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 

22 

23from django.conf import settings as project_settings 

24from django.core.exceptions import ImproperlyConfigured 

25from django.db.models import Model 

26 

27from audoma import settings as audoma_settings 

28from audoma.operations import ( 

29 OperationExtractor, 

30 apply_response_operation, 

31) 

32 

33 

34logger = logging.getLogger(__name__) 

35 

36 

37@dataclass 

38class AudomaArgs: 

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

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

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

42 

43 

44class AudomaActionException(Exception): 

45 pass 

46 

47 

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. 

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