After having written a few view classes for endpoints, it is quite clear that there is way too much repetition. The reason for the repetition is the need to generate clear and different error messages from the backend so that the frontend will not have to do much. My first thought was that I would have to define a base view class that would define a wrapper method that would include these exception blocks and then all other view classes would inherit this base class. But this seemed like a very basic requirement that a million other Django developers might have wanted and so the chances that this would not somehow already be built into DRF seemed unlikely. After a little bit of googling, the answer was in the APIView class in DRF.
The GenericAPIView has only a few method such as get_queryset, get_serializer_class, get_object etc. What I was looking for was a base method similar to how the View class in Django had the dispatch method and a few others that were called under the hood. The GenericAPIView inherits the APIView class. The APIView class has a number of methods that get called under the hood, and one of them is the handle_exception method. The documentation says that any exception that is thrown by any handler method (get, post, patch etc) is passed to this method which either returns the appropriate Response or re-raises the exception if it can't handle it.
To fully appreciate what is going on, one really has to read the source code in Django Rest Framework. This is the second time I found myself browsing DRF source code and in my opinion, every one should regularly do so, as reading the source code gives you an understanding of DRF that is far deeper than just reading the documentation.
The handle_exception method is in the views.py file inside rest_framework folder of the Github repo:
def handle_exception(self, exc): """ Handle any exception that occurs, by returning an appropriate response, or re-raising the error. """ if isinstance(exc, (exceptions.NotAuthenticated, exceptions.AuthenticationFailed)): # WWW-Authenticate header for 401 responses, else coerce to 403 auth_header = self.get_authenticate_header(self.request) if auth_header: exc.auth_header = auth_header else: exc.status_code = status.HTTP_403_FORBIDDEN exception_handler = self.get_exception_handler() context = self.get_exception_handler_context() response = exception_handler(exc, context) if response is None: self.raise_uncaught_exception(exc) response.exception = True return response
There is a special handling for errors related to a user not being authenticated or authentication failed. But the actual exception handling is a bit dynamic and the method is returned by the get_exception_handler method:
def get_exception_handler(self): """ Returns the exception handler that this view uses. """ return self.settings.EXCEPTION_HANDLER
The exception handler to be used is defined in the settings, and this is probably in case you want to choose a custom exception handler. But, the EXCEPTION_HANDLER is the settings is merely the method defined in the same file:
def exception_handler(exc, context): """ Returns the response that should be used for any given exception. By default we handle the REST framework `APIException`, and also Django's built-in `Http404` and `PermissionDenied` exceptions. Any unhandled exceptions may return `None`, which will cause a 500 error to be raised. """ if isinstance(exc, Http404): exc = exceptions.NotFound(*(exc.args)) elif isinstance(exc, PermissionDenied): exc = exceptions.PermissionDenied(*(exc.args)) if isinstance(exc, exceptions.APIException): headers = {} if getattr(exc, 'auth_header', None): headers['WWW-Authenticate'] = exc.auth_header if getattr(exc, 'wait', None): headers['Retry-After'] = '%d' % exc.wait if isinstance(exc.detail, (list, dict)): data = exc.detail else: data = {'detail': exc.detail} set_rollback() return Response(data, status=exc.status_code, headers=headers) return None
There is special handling for 404 and permission denied errors (403). But other than all it does it checks if the error is of type APIException.
class APIException(Exception): """ Base class for REST framework exceptions. Subclasses should provide `.status_code` and `.default_detail` properties. """ status_code = status.HTTP_500_INTERNAL_SERVER_ERROR default_detail = _('A server error occurred.') default_code = 'error' def __init__(self, detail=None, code=None): if detail is None: detail = self.default_detail if code is None: code = self.default_code self.detail = _get_error_details(detail, code) def __str__(self): return str(self.detail) def get_codes(self): """ Return only the code part of the error details. Eg. {"name": ["required"]} """ return _get_codes(self.detail) def get_full_details(self): """ Return both the message & code parts of the error details. Eg. {"name": [{"message": "This field is required.", "code": "required"}]} """ return _get_full_details(self.detail)
So APIException inherits Exception but defines status_code and detail. The exception_handler method merely extracts the detail attribute along with the status_code in the APIException object and returns Response with the data being a dictionary containing the detail message. So, this exception_handler in reality is doing what I have been trying to do manually with different except blocks.
So, the first is to define a CustomAPIError that subclasses this APIException:
class CustomAPIError(APIException): def __init__(self, status_code, detail): self.status_code = status_code self.detail = detail
And all the other error classes like Http400Error etc can all be deleted. Where I was throwing these specific errors, I now throw the CustomAPIError. And the view classes can be much simpler, as once these errors are thrown, the handle_exception will call the exception_handler which will return the error Response. So, now the POST method handler for courses will be:
def post(self, request, *args, **kwargs): '''Create a new course - POST request''' self.authenticate(request) serializer = self.get_serializer(data=request.data) self.perform_create(serializer) return Response( serializer.data, status=status.HTTP_201_CREATED )
No need to enclose it in a try/except with different excepts throwing different errors - all taken care of by handle_exception. The only problem is that now logging is a bit broken. That will need that base class with a custom handle_exception method that uses logging.