Add section about error formatting

- Include a bunch of things for `utils.py`
This commit is contained in:
Radoslav Georgiev 2020-01-19 15:26:46 +02:00
parent 55e135436d
commit f2e237765c
2 changed files with 234 additions and 1 deletions

View File

@ -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: <https://www.django-rest-framework.org/api-guide/exceptions/#custom-exception-handling>
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.

148
utils.py
View File

@ -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