mirror of
				https://github.com/HackSoftware/Django-Styleguide.git
				synced 2025-11-04 01:17:29 +03:00 
			
		
		
		
	Merge pull request #38 from HackSoftware/exceptions/error-formatter
Introduce section about error formatting
This commit is contained in:
		
						commit
						6165ddbcb9
					
				
							
								
								
									
										89
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										89
									
								
								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,94 @@ 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 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
 | 
			
		||||
 | 
			
		||||
In our Django projects, we split our tests depending on the type of code they represent.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										148
									
								
								utils.py
									
									
									
									
									
								
							
							
						
						
									
										148
									
								
								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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue
	
	Block a user