This commit is contained in:
Radoslav Georgiev 2021-11-21 18:03:00 +02:00
parent 85bca3199a
commit 0aa2c23dc2
No known key found for this signature in database
GPG Key ID: 0B7753A4DFCE646D

399
README.md
View File

@ -44,6 +44,7 @@ Django styleguide that we use in [HackSoft](https://hacksoft.io).
* [Describe how your API errors are going to look like.](#describe-how-your-api-errors-are-going-to-look-like) * [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) * [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 1 - Use DRF's default exceptions, with very little modifications.](#approach-1---use-drfs-default-exceptions-with-very-little-modifications)
* [HackSoft's proposed approach](#hacksofts-proposed-approach)
- [Testing](#testing-2) - [Testing](#testing-2)
* [Naming conventions](#naming-conventions) * [Naming conventions](#naming-conventions)
- [Celery](#celery) - [Celery](#celery)
@ -1032,7 +1033,7 @@ Our general guidelines are:
Followed by some specific approaches: Followed by some specific approaches:
1. Use DRF's default exceptions, with very little modifications. 1. Use DRF's default exceptions, with very little modifications.
1. Validation throws `ValidationError`, business logic throws `ApplicationError`. 1. HackSoft's proposed approach.
### How exception handling works (in the context of DRF) ### How exception handling works (in the context of DRF)
@ -1248,10 +1249,24 @@ 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: In order to achieve that, this is how our custom exception handler is going to look like:
```python ```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): def drf_default_with_modifications_exception_handler(exc, ctx):
if isinstance(exc, DjangoValidationError): if isinstance(exc, DjangoValidationError):
exc = exceptions.ValidationError(as_serializer_error(exc)) 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) response = exception_handler(exc, ctx)
# If unexpected error occurs (server error, etc.) # If unexpected error occurs (server error, etc.)
@ -1266,6 +1281,8 @@ def drf_default_with_modifications_exception_handler(exc, ctx):
return response 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: Now, lets run a set of tests:
Code: Code:
@ -1291,6 +1308,44 @@ Response:
Code: 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 ```python
def some_service(): def some_service():
raise RestValidationError("Some error message") raise RestValidationError("Some error message")
@ -1416,6 +1471,348 @@ Response:
} }
``` ```
### HackSoft's proposed approach
We are going to propose an approach, that can be easily extended into something that works well for you.
Here are the major ideas:
1. Your application will have 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 let DRF handle everything else, by default, except for `ValidationError`. We are going to treat `ValidationError` as something related to a form (fields).
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. We can have `ApplicationValidationError` and `ApplicationPermissionError`, as an additional hierarchy.
1. We 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 ## Testing
In our Django projects, we split our tests depending on the type of code they represent. In our Django projects, we split our tests depending on the type of code they represent.