diff --git a/README.md b/README.md index 89802f3..09b2114 100644 --- a/README.md +++ b/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 update API](#an-example-update-api) * [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) @@ -408,6 +411,157 @@ class Serializer(serializers.Serializer): 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 The way we do Django is inspired by the following things: diff --git a/utils.py b/utils.py index 36c1f6c..a750fc5 100644 --- a/utils.py +++ b/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): 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(**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)