Merge pull request #38 from HackSoftware/exceptions/error-formatter

Introduce section about error formatting
This commit is contained in:
Radoslav Georgiev 2020-01-19 15:43:55 +02:00 committed by GitHub
commit 6165ddbcb9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 236 additions and 1 deletions

View File

@ -30,6 +30,7 @@ Expect often updates as we discuss & decide upon different things.
- [Exception Handling](#exception-handling) - [Exception Handling](#exception-handling)
* [Raising Exceptions in Services / Selectors](#raising-exceptions-in-services--selectors) * [Raising Exceptions in Services / Selectors](#raising-exceptions-in-services--selectors)
* [Handle Exceptions in APIs](#handle-exceptions-in-apis) * [Handle Exceptions in APIs](#handle-exceptions-in-apis)
* [Error formatting](#error-formatting)
- [Testing](#testing-1) - [Testing](#testing-1)
* [Naming conventions](#naming-conventions) * [Naming conventions](#naming-conventions)
* [Example](#example) * [Example](#example)
@ -646,6 +647,94 @@ class CourseCreateApi(
All of code above can be found in `utils.py` in this repository. 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 an 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"
]
```
Another error format may look like this:
```python
{
"detail": "Method \"GET\" not allowed."
}
```
**Those are 3 different ways of formatting for our errors.** What we want to have is a single format, for all errors.
Luckily, DRF provides a way for us to give our own custom exception handler, where we can implement the desired formatting: <https://www.django-rest-framework.org/api-guide/exceptions/#custom-exception-handling>
In our projects, we format the errors like that:
```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 added 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 ## Testing
In our Django projects, we split our tests depending on the type of code they represent. In our Django projects, we split our tests depending on the type of code they represent.

148
utils.py
View File

@ -1,6 +1,10 @@
from rest_framework import serializers from rest_framework import serializers
from rest_framework import exceptions as rest_exceptions 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 from django.core.exceptions import ValidationError
@ -39,7 +43,7 @@ def get_error_message(exc):
return error_msg return error_msg
class ExceptionHandlerMixin: class ApiErrorsMixin:
""" """
Mixin that transforms Django and Python exceptions into rest_framework ones. Mixin that transforms Django and Python exceptions into rest_framework ones.
without the mixin, they return 500 status code which is not desired. 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(drf_exception)
return super().handle_exception(exc) 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