diff --git a/README.md b/README.md
index e2bede7..7835720 100644
--- a/README.md
+++ b/README.md
@@ -37,10 +37,14 @@ Django styleguide that we use in [HackSoft](https://hacksoft.io).
* [Nested serializers](#nested-serializers)
* [Advanced serialization](#advanced-serialization)
- [Urls](#urls)
-- [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)
+- [Errors & Exception Handling](#errors--exception-handling)
+ * [How exception handling works (in the context of DRF)](#how-exception-handling-works-in-the-context-of-drf)
+ + [DRF's `ValidationError`](#drfs-validationerror)
+ + [Django's `ValidationError`](#djangos-validationerror)
+ * [Describe how your API errors are going to look like.](#describe-how-your-api-errors-are-going-to-look-like)
+ * [Know how to change the default exception handling behavior.](#know-how-to-change-the-default-exception-handling-behavior)
+ * [Approach 1 - Use DRF's default exceptions, with very little modifications.](#approach-1---use-drfs-default-exceptions-with-very-little-modifications)
+ * [Approach 2 - HackSoft's proposed way](#approach-2---hacksofts-proposed-way)
- [Testing](#testing-2)
* [Naming conventions](#naming-conventions)
- [Celery](#celery)
@@ -1014,250 +1018,806 @@ urlpatterns = [
**Splitting urls like that can give you the extra flexibility to move separate domain patterns to separate modules**, especially for really big projects, where you'll often have merge conflicts in `urls.py`.
-## Exception Handling
+## Errors & Exception Handling
-### Raising Exceptions in Services / Selectors
+Errors & exception handling is a big topic & quite often - the details are specific for a given project.
-Now we have a separation between our HTTP interface & the core logic of our application.
+That's why we'll split things into two - **general guidelines**, followed by some **specific approaches** for error handling.
-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 general guidelines are:**
-Our services and selectors must use one of:
+1. Know how exception handling works (we'll give context for Django Rest Framework).
+1. Describe how your API errors are going to look like.
+1. Know how to change the default exception handling behavior.
-* [Python built-in exceptions](https://docs.python.org/3/library/exceptions.html)
-* Exceptions from `django.core.exceptions`
-* Custom exceptions, inheriting from the ones above.
+**Followed by some specific approaches:**
-Here is a good example of service that performs some validation and raises `django.core.exceptions.ValidationError`:
+1. Use DRF's default exceptions, with very little modifications.
+1. HackSoft's proposed approach.
+
+### How exception handling works (in the context of DRF)
+
+DRF has an excellent guide on how exceptions are being handled, so make sure to read it first -
+
+Additonally, here's a neat diadgram with an overview of the process:
+
+![Exception handler (1)](https://user-images.githubusercontent.com/387867/142426205-2c0356e6-ce20-425e-a811-072c3334edb0.png)
+
+Basically, if the exception handler cannot handle the given exception & returns `None`, this will result in an unhandled exception & a `500 Server Error`. This is often good, because you won't be silencing errors, that you need to pay attention to.
+
+**Now, there are some quirks, that we need to pay attention to.**
+
+#### DRF's `ValidationError`
+
+For example, if we simply raise a `rest_framework.exceptions.ValidationError` like that:
```python
-from django.core.exceptions import ValidationError
+from rest_framework import exceptions
-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
+def some_service():
+ raise ValidationError("Error message here.")
```
-### Handle Exceptions in APIs
+The response payload is going to look like this:
-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 Python/Django exception to a DRF exception.
-
-By default, the [`handle_exception` method implementation in DRF](https://www.django-rest-framework.org/api-guide/exceptions/#exception-handling-in-rest-framework-views) handles Django's built-in `Http404` and `PermissionDenied` exceptions, thus there is no need for you to handle it by hand.
-
-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 `ApiErrorsMixin`. 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 ApiErrorsMixin:
- """
- 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,
- ApiErrorsMixin,
- 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 the code above can be found in [here](https://github.com/HackSoftware/Styleguide-Example/blob/master/styleguide_example/api/mixins.py#L70), in the [Styleguide-Example](https://github.com/HackSoftware/Styleguide-Example) repo.
-
-### Error formatting
-
-The 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
+```json
[
- "some error"
+ "Some message"
]
```
-Another error format may look like this:
+This looks strange, because if we do it like this:
```python
+from rest_framework import exceptions
+
+
+def some_service():
+ raise exceptions.ValidationError({"error": "Some message"})
+```
+
+The response payload is going to look like this:
+
+```json
{
- "detail": "Method \"GET\" not allowed."
+ "error": "Some message"
}
```
-**Those are 3 different ways of formatting for our errors.** What we want to have is a single format, for all errors.
+That's basically what we passed as the `detail` of the `ValidationError`. But it's a different data structure from the initial array.
-Luckily, DRF provides a way for us to give our own custom exception handler, where we can implement the desired formatting:
-
-In our projects, we format the errors like that:
+Now, if we decide to raise another of the DRF's built-in exceptions:
```python
+from rest_framework import exceptions
+
+
+def some_service():
+ raise exceptions.NotFound()
+```
+
+The response payload is going to look like this:
+
+```json
{
- "errors": [
- {
- "message": "Error message",
- "code": "Some code",
- "field": "field_name"
- },
- {
- "message": "Error message",
- "code": "Some code",
- "field": "nested.field_name"
- },
- ]
+ "detail": "Not found."
}
```
-If we raise a `ValidationError`, then the field is optional.
+That's entirely different from what we saw as behavior from the `ValidationError` and this might cause problems.
-In order to achieve that, we implement a custom exception handler:
+So far, the default DRF behavior can get us:
+
+- An array.
+- A dictionarry.
+- A specific `{"detail": "something"}` result.
+
+**So if we need to use the default DRF behavior, we need to take care of this inconsistency.**
+
+#### Django's `ValidationError`
+
+Now, DRF's default exception handling is not playing nice with Django's `ValidationError`.
+
+This piece of code:
```python
+from django.core.exceptions import ValidationError as DjangoValidationError
+
+
+def some_service():
+ raise DjangoValidationError("Some error message")
+```
+
+Will result in an unhandled exception, causing `500 Server Error`.
+
+This will also happen if this `ValidationError` comes from model validation, for example:
+
+```python
+def some_service():
+ user = BaseUser()
+ user.full_clean() # Throws ValidationError
+ user.save()
+```
+
+This will also result in `500 Server Error`.
+
+If we want to start handling this, as if it was `rest_framework.exceptions.ValidationError`, we need to roll-out our own [custom exception handler](https://www.django-rest-framework.org/api-guide/exceptions/#custom-exception-handling):
+
+```python
+from django.core.exceptions import ValidationError as DjangoValidationError
+
from rest_framework.views import exception_handler
+from rest_framework.serializers import as_serializer_error
+from rest_framework import exceptions
-def exception_errors_format_handler(exc, context):
- response = exception_handler(exc, context)
+def custom_exception_handler(exc, ctx):
+ if isinstance(exc, DjangoValidationError):
+ exc = exceptions.ValidationError(as_serializer_error(exc))
- # If an unexpected error occurs (server error, etc.)
+ response = exception_handler(exc, ctx)
+
+ # 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:
+This is basically the default implementation, with the addition of this piece of code:
```python
-REST_FRAMEWORK = {
- 'EXCEPTION_HANDLER': 'project.app.handlers.exception_errors_format_handler',
- ...
+if isinstance(exc, DjangoValidationError):
+ exc = exceptions.ValidationError(as_serializer_error(exc))
+```
+
+Since we need to map between `django.core.exceptions.ValidationError` and `rest_framework.exceptions.ValidationError`, we are using DRF's `as_serializer_error`, which is used internally in the serializers, just for that.
+
+With that, we can now have Django's `ValidationError` playing nice with DRF's exception handler.
+
+### Describe how your API errors are going to look like.
+
+This is very important and should be done as early as possible in any given project.
+
+This is basically agreening upon what the interface of your API errors - **How an error is going to look like as an API response?**
+
+This is very project specific, you can use some of the popular APIs for inspiration:
+
+- Stripe -
+
+As an example, we might decide that our errors are going to look like this:
+
+1. `4**` and `5**` status codes for different types of errors.
+1. Each error will be a dictionarry with a single `message` key, containing the error message.
+
+```json
+{
+ "message": "Some error message here"
}
```
-**The magic happens in the `ErrorsFormatter` class.**
+That's simple enough:
-The implementation of that class can be found [here](https://github.com/HackSoftware/Styleguide-Example/blob/master/styleguide_example/api/errors.py), in the [Styleguide-Example](https://github.com/HackSoftware/Styleguide-Example) repo.
+- `400` will be used for validation errors.
+- `401` for auth errors.
+- `403` for permission errors.
+- `404` for not found errors.
+- `429` for throttling errors.
+- `500` for server errors (we need to be careful not to silence an exception causing 500 and always report that in services like Sentry)
-Combining `ApiErrorsMixin`, the custom exception handler & the errors formatter class, we can have predictable behavior in our APIs, when it comes to errors.
+Again, this is up to you & it's specific to the project. **We'll propose something similiar for one of the specific approaches.**
-**A note:**
+### Know how to change the default exception handling behavior.
-> We've moved away from this particular way of formatting errors & we'll be updating the styleguide with a more generic approach.
+This is also important, because when you decide how your errors are going to look like, you need to implement this as custom exception handling.
+
+We've already provided an example for that in the paragraph above, talking about Django's `ValidationError`.
+
+We'll also provide additional examples in the sections below.
+
+### Approach 1 - Use DRF's default exceptions, with very little modifications.
+
+DRF's error handling is good. It'd be great, if the end result was always consistent. Those are the little modifications that we are going to do.
+
+We want to end up with errors, always looking like that:
+
+```json
+{
+ "detail": "Some error"
+}
+```
+
+or
+
+```json
+{
+ "detail": ["Some error", "Another error"]
+}
+```
+
+or
+
+```json
+{
+ "detail": { "key": "... some arbitrary nested structure ..." }
+}
+```
+
+Basically, make sure we always have a dictionary with a `detail` key.
+
+Additonally, we want to handle Django's `ValidationError` as well.
+
+In order to achieve that, this is how our custom exception handler is going to look like:
+
+```python
+from django.core.exceptions import ValidationError as DjangoValidationError, PermissionDenied
+from django.http import Http404
+
+from rest_framework.views import exception_handler
+from rest_framework import exceptions
+from rest_framework.serializers import as_serializer_error
+
+
+def drf_default_with_modifications_exception_handler(exc, ctx):
+ if isinstance(exc, DjangoValidationError):
+ exc = exceptions.ValidationError(as_serializer_error(exc))
+
+ if isinstance(exc, Http404):
+ exc = exceptions.NotFound()
+
+ if isinstance(exc, PermissionDenied):
+ exc = exceptions.PermissionDenied()
+
+ response = exception_handler(exc, ctx)
+
+ # If unexpected error occurs (server error, etc.)
+ if response is None:
+ return response
+
+ if isinstance(exc.detail, (list, dict)):
+ response.data = {
+ "detail": response.data
+ }
+
+ return response
+```
+
+We kind-of replicate the original exception handler, so we can deal with an `APIException` after that (looking for `detail`).
+
+Now, lets run a set of tests:
+
+Code:
+
+```python
+def some_service():
+ raise DjangoValidationError("Some error message")
+```
+
+Response:
+
+```json
+{
+ "detail": {
+ "non_field_errors": [
+ "Some error message"
+ ]
+ }
+}
+```
+
+---
+
+Code:
+
+```python
+from django.core.exceptions import PermissionDenied
+
+def some_service():
+ raise PermissionDenied()
+```
+
+Response:
+
+```json
+{
+ "detail": "You do not have permission to perform this action."
+}
+```
+
+---
+
+Code:
+
+```python
+from django.http import Http404
+
+def some_service():
+ raise Http404()
+```
+
+Response:
+
+```json
+{
+ "detail": "Not found."
+}
+```
+
+---
+
+Code:
+
+```python
+def some_service():
+ raise RestValidationError("Some error message")
+```
+
+Response:
+
+```json
+{
+ "detail": [
+ "Some error message"
+ ]
+}
+```
+
+---
+
+Code:
+
+```python
+def some_service():
+ raise RestValidationError(detail={"error": "Some error message"})
+```
+
+Response:
+
+```json
+{
+ "detail": {
+ "error": "Some error message"
+ }
+}
+```
+
+---
+
+Code:
+
+```python
+class NestedSerializer(serializers.Serializer):
+ bar = serializers.CharField()
+
+
+class PlainSerializer(serializers.Serializer):
+ foo = serializers.CharField()
+ email = serializers.EmailField(min_length=200)
+
+ nested = NestedSerializer()
+
+
+def some_service():
+ serializer = PlainSerializer(data={
+ "email": "foo",
+ "nested": {}
+ })
+ serializer.is_valid(raise_exception=True)
+
+```
+
+Response:
+
+```json
+{
+ "detail": {
+ "foo": [
+ "This field is required."
+ ],
+ "email": [
+ "Ensure this field has at least 200 characters.",
+ "Enter a valid email address."
+ ],
+ "nested": {
+ "bar": [
+ "This field is required."
+ ]
+ }
+ }
+}
+```
+
+---
+
+Code:
+
+```python
+from rest_framework import exceptions
+
+
+def some_service():
+ raise exceptions.Throttled()
+```
+
+Response:
+
+```json
+{
+ "detail": "Request was throttled."
+}
+```
+
+---
+
+Code:
+
+```python
+def some_service():
+ user = BaseUser()
+ user.full_clean()
+```
+
+Response:
+
+```json
+{
+ "detail": {
+ "password": [
+ "This field cannot be blank."
+ ],
+ "email": [
+ "This field cannot be blank."
+ ]
+ }
+}
+```
+
+### Approach 2 - HackSoft's proposed way
+
+We are going to propose an approach, that can be easily extended into something that works well for you.
+
+**Here are the key ideas:**
+
+1. **Your application will have its own hierarchy of exceptions**, that are going to be thrown by the business logic.
+1. Lets say, for simplicity, that we are going to have only 1 error - `ApplicationError`.
+ - This is going to be defined in a special `core` app, within `exceptions` module. Basically, having `project.core.exceptions.ApplicationError`.
+1. We want to let DRF handle everything else, by default.
+1. `ValidationError` is now special and it's going to be handled differently.
+ - `ValidationError` should only come from either serializer or a model validation.
+
+---
+
+**We are going to define the following structure for our errors:**
+
+```json
+{
+ "message": "The error message here",
+ "extra": {}
+}
+```
+
+The `extra` key can hold arbitrary data, for the purposes of passing information to the frontend.
+
+For example, whenerver we have a `ValidationError` (usually coming from a Serializer or a Model), we are going to present the error like that:
+
+```json
+{
+ "message": "Validation error.",
+ "extra": {
+ "fields": {
+ "password": [
+ "This field cannot be blank."
+ ],
+ "email": [
+ "This field cannot be blank."
+ ]
+ }
+ },
+}
+```
+
+This can be communicated with the frontend, so they can look for `extra.fields`, to present those specific errors to the user.
+
+In order to achieve that, the custom exception handler is going to look like this:
+
+```python
+from django.core.exceptions import ValidationError as DjangoValidationError, PermissionDenied
+from django.http import Http404
+
+from rest_framework.views import exception_handler
+from rest_framework import exceptions
+from rest_framework.serializers import as_serializer_error
+from rest_framework.response import Response
+
+from styleguide_example.core.exceptions import ApplicationError
+
+
+def hacksoft_proposed_exception_handler(exc, ctx):
+ """
+ {
+ "message": "Error message",
+ "extra": {}
+ }
+ """
+ if isinstance(exc, DjangoValidationError):
+ exc = exceptions.ValidationError(as_serializer_error(exc))
+
+ if isinstance(exc, Http404):
+ exc = exceptions.NotFound()
+
+ if isinstance(exc, PermissionDenied):
+ exc = exceptions.PermissionDenied()
+
+ response = exception_handler(exc, ctx)
+
+ # If unexpected error occurs (server error, etc.)
+ if response is None:
+ if isinstance(exc, ApplicationError):
+ data = {
+ "message": exc.message,
+ "extra": exc.extra
+ }
+ return Response(data, status=400)
+
+ return response
+
+ if isinstance(exc.detail, (list, dict)):
+ response.data = {
+ "detail": response.data
+ }
+
+ if isinstance(exc, exceptions.ValidationError):
+ response.data["message"] = "Validation error"
+ response.data["extra"] = {
+ "fields": response.data["detail"]
+ }
+ else:
+ response.data["message"] = response.data["detail"]
+ response.data["extra"] = {}
+
+ del response.data["detail"]
+
+ return response
+```
+
+Take a look at that code & try to understand what's going on. **The strategy is - reuse as much as possible from DRF & then adjust.**
+
+Now, we are going to have the following behavior:
+
+Code:
+
+```python
+from styleguide_example.core.exceptions import ApplicationError
+
+
+def trigger_application_error():
+ raise ApplicationError(message="Something is not correct", extra={"type": "RANDOM"})
+```
+
+Response:
+
+```json
+{
+ "message": "Something is not correct",
+ "extra": {
+ "type": "RANDOM"
+ }
+}
+```
+---
+
+Code:
+
+```python
+def some_service():
+ raise DjangoValidationError("Some error message")
+```
+
+Response:
+
+```json
+{
+ "message": "Validation error",
+ "extra": {
+ "fields": {
+ "non_field_errors": [
+ "Some error message"
+ ]
+ }
+ }
+}
+```
+
+---
+
+Code:
+
+```python
+from django.core.exceptions import PermissionDenied
+
+def some_service():
+ raise PermissionDenied()
+```
+
+Response:
+
+```json
+{
+ "message": "You do not have permission to perform this action.",
+ "extra": {}
+}
+```
+
+---
+
+Code:
+
+```python
+from django.http import Http404
+
+def some_service():
+ raise Http404()
+```
+
+Response:
+
+```json
+{
+ "message": "Not found.",
+ "extra": {}
+}
+```
+
+---
+
+Code:
+
+```python
+def some_service():
+ raise RestValidationError("Some error message")
+```
+
+Response:
+
+```json
+{
+ "message": "Validation error",
+ "extra": {
+ "fields": [
+ "Some error message"
+ ]
+ }
+}
+```
+
+---
+
+Code:
+
+```python
+def some_service():
+ raise RestValidationError(detail={"error": "Some error message"})
+```
+
+Response:
+
+```json
+{
+ "message": "Validation error",
+ "extra": {
+ "fields": {
+ "error": "Some error message"
+ }
+ }
+}
+```
+
+---
+
+Code:
+
+```python
+class NestedSerializer(serializers.Serializer):
+ bar = serializers.CharField()
+
+
+class PlainSerializer(serializers.Serializer):
+ foo = serializers.CharField()
+ email = serializers.EmailField(min_length=200)
+
+ nested = NestedSerializer()
+
+
+def some_service():
+ serializer = PlainSerializer(data={
+ "email": "foo",
+ "nested": {}
+ })
+ serializer.is_valid(raise_exception=True)
+
+```
+
+Response:
+
+```json
+{
+ "message": "Validation error",
+ "extra": {
+ "fields": {
+ "foo": [
+ "This field is required."
+ ],
+ "email": [
+ "Ensure this field has at least 200 characters.",
+ "Enter a valid email address."
+ ],
+ "nested": {
+ "bar": [
+ "This field is required."
+ ]
+ }
+ }
+ }
+}
+```
+
+---
+
+Code:
+
+```python
+from rest_framework import exceptions
+
+
+def some_service():
+ raise exceptions.Throttled()
+```
+
+Response:
+
+```json
+{
+ "message": "Request was throttled.",
+ "extra": {}
+}
+```
+
+---
+
+Code:
+
+```python
+def some_service():
+ user = BaseUser()
+ user.full_clean()
+```
+
+Response:
+
+```json
+{
+ "message": "Validation error",
+ "extra": {
+ "fields": {
+ "password": [
+ "This field cannot be blank."
+ ],
+ "email": [
+ "This field cannot be blank."
+ ]
+ }
+ }
+}
+```
+---
+
+Now, this can be extended & made to better suit your needs:
+
+1. You can have `ApplicationValidationError` and `ApplicationPermissionError`, as an additional hierarchy.
+1. You can reimplement DRF's default exception handler, instead of reusing it (copy-paste it & adjust to your needs).
+
+**The general idea is - figure out what kind of error handling you need and then implement it accordingly.**
## Testing