mirror of
https://github.com/HackSoftware/Django-Styleguide.git
synced 2025-02-07 07:00:50 +03:00
Merge pull request #4 from HackSoftware/exceptions
Init some text about exceptions in services
This commit is contained in:
commit
83cdd847ec
154
README.md
154
README.md
|
@ -24,6 +24,9 @@ Expect often updates as we discuss & decide upon different things.
|
||||||
* [An example create API](#an-example-create-api)
|
* [An example create API](#an-example-create-api)
|
||||||
* [An example update API](#an-example-update-api)
|
* [An example update API](#an-example-update-api)
|
||||||
* [Nested serializers](#nested-serializers)
|
* [Nested serializers](#nested-serializers)
|
||||||
|
- [Exception Handling](#exception-handling)
|
||||||
|
* [Raising Exceptions in Services](#raising-exceptions-in-services)
|
||||||
|
* [Handle Exceptions in APIs](#handle-exceptions-in-apis)
|
||||||
- [Inspiration](#inspiration)
|
- [Inspiration](#inspiration)
|
||||||
|
|
||||||
<!-- tocstop -->
|
<!-- tocstop -->
|
||||||
|
@ -408,6 +411,157 @@ class Serializer(serializers.Serializer):
|
||||||
The implementation of `inline_serializer` can be found in `utils.py` in this repo.
|
The implementation of `inline_serializer` can be found in `utils.py` in this repo.
|
||||||
|
|
||||||
|
|
||||||
|
## Exception Handling
|
||||||
|
|
||||||
|
### Raising Exceptions in Services / Selectors
|
||||||
|
|
||||||
|
Now we have separation between our HTTP interface & the core logic of our application.
|
||||||
|
|
||||||
|
In order to keep this separation of concerns, our services and selectors must not use the `rest_framework.exception` classes because they are bounded with HTTP status codes.
|
||||||
|
|
||||||
|
Our services and selectors must use one of:
|
||||||
|
|
||||||
|
* [Python built-in exceptions](https://docs.python.org/3/library/exceptions.html)
|
||||||
|
* Exceptions from `django.core.exceptions`
|
||||||
|
* Custom exceptions, inheriting from the ones above.
|
||||||
|
|
||||||
|
Here is a good example of service that preforms some validation and raises `django.core.exceptions.ValidationError`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
def create_topic(*, name: str, course: Course) -> Topic:
|
||||||
|
if course.end_date < timezone.now():
|
||||||
|
raise ValidationError('You can not create topics for course that has ended.')
|
||||||
|
|
||||||
|
topic = Topic.objects.create(name=name, course=course)
|
||||||
|
|
||||||
|
return topic
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handle Exceptions in APIs
|
||||||
|
|
||||||
|
In order to transform the exceptions raised in the services or selectors, to a standard HTTP response, you need to catch the exception and raise something that the rest framework understands.
|
||||||
|
|
||||||
|
The best place to do this is in the `handle_exception` method of the `APIView`.
|
||||||
|
|
||||||
|
There you can map your exception to DRF exception.
|
||||||
|
|
||||||
|
Here is an example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from rest_framework import exceptions as rest_exceptions
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class CourseCreateApi(SomeAuthenticationMixin, APIView):
|
||||||
|
expected_exceptions = {
|
||||||
|
ValidationError: rest_exceptions.ValidationError
|
||||||
|
}
|
||||||
|
|
||||||
|
class InputSerializer(serializers.Serializer):
|
||||||
|
...
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
serializer = self.InputSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
create_course(**serializer.validated_data)
|
||||||
|
|
||||||
|
return Response(status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def handle_exception(self, exc):
|
||||||
|
if isinstance(exc, tuple(self.expected_exceptions.keys())):
|
||||||
|
drf_exception_class = self.expected_exceptions[exc.__class__]
|
||||||
|
drf_exception = drf_exception_class(get_error_message(exc))
|
||||||
|
|
||||||
|
return super().handle_exception(drf_exception)
|
||||||
|
|
||||||
|
return super().handle_exception(exc)
|
||||||
|
```
|
||||||
|
|
||||||
|
Here's the implementation of `get_error_message`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_first_matching_attr(obj, *attrs, default=None):
|
||||||
|
for attr in attrs:
|
||||||
|
if hasattr(obj, attr):
|
||||||
|
return getattr(obj, attr)
|
||||||
|
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def get_error_message(exc):
|
||||||
|
if hasattr(exc, 'message_dict'):
|
||||||
|
return exc.message_dict
|
||||||
|
error_msg = get_first_matching_attr(exc, 'message', 'messages')
|
||||||
|
|
||||||
|
if isinstance(error_msg, list):
|
||||||
|
error_msg = ', '.join(error_msg)
|
||||||
|
|
||||||
|
if error_msg is None:
|
||||||
|
error_msg = str(exc)
|
||||||
|
|
||||||
|
return error_msg
|
||||||
|
```
|
||||||
|
|
||||||
|
You can move this code to a mixin and use it in every API to prevent code duplication.
|
||||||
|
|
||||||
|
We call this `ExceptionHandlerMixin`. Here's a sample implementation from one of our projects:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from rest_framework import exceptions as rest_exceptions
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
from project.common.utils import get_error_message
|
||||||
|
|
||||||
|
|
||||||
|
class ExceptionHandlerMixin:
|
||||||
|
"""
|
||||||
|
Mixin that transforms Django and Python exceptions into rest_framework ones.
|
||||||
|
without the mixin, they return 500 status code which is not desired.
|
||||||
|
"""
|
||||||
|
expected_exceptions = {
|
||||||
|
ValueError: rest_exceptions.ValidationError,
|
||||||
|
ValidationError: rest_exceptions.ValidationError,
|
||||||
|
PermissionError: rest_exceptions.PermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
def handle_exception(self, exc):
|
||||||
|
if isinstance(exc, tuple(self.expected_exceptions.keys())):
|
||||||
|
drf_exception_class = self.expected_exceptions[exc.__class__]
|
||||||
|
drf_exception = drf_exception_class(get_error_message(exc))
|
||||||
|
|
||||||
|
return super().handle_exception(drf_exception)
|
||||||
|
|
||||||
|
return super().handle_exception(exc)
|
||||||
|
```
|
||||||
|
|
||||||
|
Having this mixin in mind, our API can be written like that:
|
||||||
|
|
||||||
|
```python
|
||||||
|
|
||||||
|
class CourseCreateApi(
|
||||||
|
SomeAuthenticationMixin,
|
||||||
|
ExceptionHandlerMixin,
|
||||||
|
APIView
|
||||||
|
):
|
||||||
|
class InputSerializer(serializers.Serializer):
|
||||||
|
...
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
serializer = self.InputSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
create_course(**serializer.validated_data)
|
||||||
|
|
||||||
|
return Response(status=status.HTTP_201_CREATED)
|
||||||
|
```
|
||||||
|
|
||||||
|
All of code above can be found in `utils.py` in this repository.
|
||||||
|
|
||||||
## Inspiration
|
## Inspiration
|
||||||
|
|
||||||
The way we do Django is inspired by the following things:
|
The way we do Django is inspired by the following things:
|
||||||
|
|
49
utils.py
49
utils.py
|
@ -1,3 +1,9 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
from rest_framework import exceptions as rest_exceptions
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
def create_serializer_class(name, fields):
|
def create_serializer_class(name, fields):
|
||||||
return type(name, (serializers.Serializer, ), fields)
|
return type(name, (serializers.Serializer, ), fields)
|
||||||
|
|
||||||
|
@ -9,3 +15,46 @@ def inline_serializer(*, fields, data=None, **kwargs):
|
||||||
return serializer_class(data=data, **kwargs)
|
return serializer_class(data=data, **kwargs)
|
||||||
|
|
||||||
return serializer_class(**kwargs)
|
return serializer_class(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def get_first_matching_attr(obj, *attrs, default=None):
|
||||||
|
for attr in attrs:
|
||||||
|
if hasattr(obj, attr):
|
||||||
|
return getattr(obj, attr)
|
||||||
|
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def get_error_message(exc):
|
||||||
|
if hasattr(exc, 'message_dict'):
|
||||||
|
return exc.message_dict
|
||||||
|
error_msg = get_first_matching_attr(exc, 'message', 'messages')
|
||||||
|
|
||||||
|
if isinstance(error_msg, list):
|
||||||
|
error_msg = ', '.join(error_msg)
|
||||||
|
|
||||||
|
if error_msg is None:
|
||||||
|
error_msg = str(exc)
|
||||||
|
|
||||||
|
return error_msg
|
||||||
|
|
||||||
|
|
||||||
|
class ExceptionHandlerMixin:
|
||||||
|
"""
|
||||||
|
Mixin that transforms Django and Python exceptions into rest_framework ones.
|
||||||
|
without the mixin, they return 500 status code which is not desired.
|
||||||
|
"""
|
||||||
|
expected_exceptions = {
|
||||||
|
ValueError: rest_exceptions.ValidationError,
|
||||||
|
ValidationError: rest_exceptions.ValidationError,
|
||||||
|
PermissionError: rest_exceptions.PermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
def handle_exception(self, exc):
|
||||||
|
if isinstance(exc, tuple(self.expected_exceptions.keys())):
|
||||||
|
drf_exception_class = self.expected_exceptions[exc.__class__]
|
||||||
|
drf_exception = drf_exception_class(get_error_message(exc))
|
||||||
|
|
||||||
|
return super().handle_exception(drf_exception)
|
||||||
|
|
||||||
|
return super().handle_exception(exc)
|
||||||
|
|
Loading…
Reference in New Issue
Block a user