diff --git a/README.md b/README.md index 78d932b..f16ff62 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Expect often updates as we discuss & decide upon different things. - [Exception Handling](#exception-handling) * [Raising Exceptions in Services / Selectors](#raising-exceptions-in-services--selectors) * [Handle Exceptions in APIs](#handle-exceptions-in-apis) + * [Error formatting](#error-formatting) - [Testing](#testing-1) * [Naming conventions](#naming-conventions) * [Example](#example) @@ -646,6 +647,92 @@ class CourseCreateApi( All of code above can be found in `utils.py` in this repository. +### Error formatting + +Next step is to generalize the format of the errors we get from our APIs. This will ease the process of displaying errors to the end user, via JavaScript. + +If we have a standard serializer and there is error with one of the fields, the message we get by default looks like this: + +```python +{ + "url": [ + "This field is required." + ] +} +``` + +If we have a validation error with just a message (`raise ValidationError('Something is wrong.')`), it will look like this: + +```python +[ + "some error" +] +``` + +Anoter error format may look like this: + +```python +{ + "detail": "Method \"GET\" not allowed." +} +``` + +Those are 3 different ways of formatting for our errors. Luckily, DRF provides a way for us to provide our own custom exception handling, where we can plug in with the formatting we want: + +In our projects, we follow this general way of formatting errors: + +```python +{ + "errors": [ + { + "message": "Error message", + "code": "Some code", + "field": "field_name" + }, + { + "message": "Error message", + "code": "Some code", + "field": "nested.field_name" + }, + ] +} +``` + +If we raise a `ValidationError`, then field is optional. + +In order to acheive that, we implement a custom exception handler: + +```python +from rest_framework.views import exception_handler + + +def exception_errors_format_handler(exc, context): + response = exception_handler(exc, context) + + # If unexpected error occurs (server error, etc.) + if response is None: + return response + + formatter = ErrorsFormatter(exc) + + response.data = formatter() + + return response +``` + +which needs to be to the `REST_FRAMEWORK` project settings: + +```python +REST_FRAMEWORK = { + 'EXCEPTION_HANDLER': 'project.app.handlers.exception_errors_format_handler', + ... +} +``` + +The magic happens in the `ErrorsFormatter` class. The implementation of that class can be found in the `utils.py` file, located in that repo. + +Combining `ApiErrorsMixin`, the custom exception handler & the errors formatter class, we can have predictable behavior in our APIs, when it comes to errors. + ## Testing In our Django projects, we split our tests depending on the type of code they represent. diff --git a/utils.py b/utils.py index a750fc5..ee84271 100644 --- a/utils.py +++ b/utils.py @@ -1,6 +1,10 @@ from rest_framework import serializers from rest_framework import exceptions as rest_exceptions +from rest_framework.views import exception_handler +from rest_framework.settings import api_settings +from rest_framework import exceptions + from django.core.exceptions import ValidationError @@ -39,7 +43,7 @@ def get_error_message(exc): return error_msg -class ExceptionHandlerMixin: +class ApiErrorsMixin: """ Mixin that transforms Django and Python exceptions into rest_framework ones. without the mixin, they return 500 status code which is not desired. @@ -58,3 +62,145 @@ class ExceptionHandlerMixin: return super().handle_exception(drf_exception) return super().handle_exception(exc) + + +class ErrorsFormatter: + """ + The current formatter gets invalid serializer errors, + uses DRF standart for code and messaging + and then parses it to the following format: + + { + "errors": [ + { + "message": "Error message", + "code": "Some code", + "field": "field_name" + }, + { + "message": "Error message", + "code": "Some code", + "field": "nested.field_name" + }, + ... + ] + } + """ + FIELD = 'field' + MESSAGE = 'message' + CODE = 'code' + ERRORS = 'errors' + + def __init__(self, exception): + self.exception = exception + + def __call__(self): + if hasattr(self.exception, 'get_full_details'): + formatted_errors = self._get_response_json_from_drf_errors( + serializer_errors=self.exception.get_full_details() + ) + else: + formatted_errors = self._get_response_json_from_error_message(message=str(self.exception)) + + return formatted_errors + + def _get_response_json_from_drf_errors(self, serializer_errors=None): + if serializer_errors is None: + serializer_errors = {} + + if type(serializer_errors) is list: + serializer_errors = { + api_settings.NON_FIELD_ERRORS_KEY: serializer_errors + } + + list_of_errors = self._get_list_of_errors(errors_dict=serializer_errors) + + response_data = { + self.ERRORS: list_of_errors + } + + return response_data + + def _get_response_json_from_error_message(self, *, message='', field=None, code='error'): + response_data = { + self.ERRORS: [ + { + self.MESSAGE: message, + self.CODE: code + } + ] + } + + if field: + response_data[self.ERRORS][self.FIELD] = field + + return response_data + + def _unpack(self, obj): + if type(obj) is list and len(obj) == 1: + return obj[0] + + return obj + + def _get_list_of_errors(self, field_path='', errors_dict=None): + """ + Error_dict is in the following format: + { + 'field1': { + 'message': 'some message..' + 'code' 'some code...' + }, + 'field2: ...' + } + """ + if errors_dict is None: + return [] + + message_value = errors_dict.get(self.MESSAGE, None) + + # Note: If 'message' is name of a field we don't want to stop the recursion here! + if message_value is not None and\ + (type(message_value) in {str, exceptions.ErrorDetail}): + if field_path: + errors_dict[self.FIELD] = field_path + return [errors_dict] + + errors_list = [] + for key, value in errors_dict.items(): + new_field_path = '{0}.{1}'.format(field_path, key) if field_path else key + key_is_non_field_errors = key == api_settings.NON_FIELD_ERRORS_KEY + + if type(value) is list: + current_level_error_list = [] + new_value = value + + for error in new_value: + # if the type of field_error is list we need to unpack it + field_error = self._unpack(error) + + if not key_is_non_field_errors: + field_error[self.FIELD] = new_field_path + + current_level_error_list.append(field_error) + else: + path = field_path if key_is_non_field_errors else new_field_path + + current_level_error_list = self._get_list_of_errors(field_path=path, errors_dict=value) + + errors_list += current_level_error_list + + return errors_list + + +def exception_errors_format_handler(exc, context): + response = exception_handler(exc, context) + + # If unexpected error occurs (server error, etc.) + if response is None: + return response + + formatter = ErrorsFormatter(exc) + + response.data = formatter() + + return response