0

I am using Django REST Framework with simple JWT, and I don't like how permission classes generate a response without giving me the final say on what gets sent to the client. With other class-based views not restricting permissions (login, registration, etc.), I have control over how I handle exceptions, and I can choose how the response data is structured.

However, anytime I introduce permission classes, undesired behavior occurs. My desired structure is best represented in my LoginView (see try/except block):

NON_FIELD_ERRORS_KEY = settings.REST_FRAMEWORK['NON_FIELD_ERRORS_KEY']

class LoginView(GenericAPIView):
    """
    View for taking in an existing user's credentials and authorizing them if valid or denying access if invalid.
    """
    serializer_class = LoginSerializer

    def post(self, request):
        """
        POST method for taking a token from a query string, checking if it is valid, and logging in the user if valid, or returning an error response if invalid.
        """
        serializer = self.serializer_class(data=request.data)

        try:
            serializer.is_valid(raise_exception=True)
        except AuthenticationFailed as e:
            return Response({NON_FIELD_ERRORS_KEY: [e.detail]}, status=e.status_code)

        return Response(serializer.data, status=status.HTTP_200_OK)

However, what happens when I try to define a view using a permission class?

class LogoutView(GenericAPIView):
    """
    View for taking in a user's access token and logging them out.
    """
    serializer_class = LogoutSerializer
    permission_classes = [IsAuthenticated]

    def post(self, request):
        """
        POST method for taking a token from a request body, checking if it is valid, and logging out the user if valid, or returning an error response if invalid.
        """
        access = request.META.get('HTTP_AUTHORIZATION', '')
        serializer = self.serializer_class(data={'access': access})

        try:
            serializer.is_valid(raise_exception=True)
        except AuthenticationFailed as e:
            return Response({NON_FIELD_ERRORS_KEY: [e.detail]}, status=e.status_code)

        return Response(serializer.save(), status=status.HTTP_205_RESET_CONTENT)

When I go to test this, requests with an invalid Authorization header are handled outside the scope of post(), so execution never reaches the method at all. Instead, I am forced to deal with a response that is inconsistent with the rest of my project. Here's an example:

# Desired output
{
    'errors': [
        ErrorDetail(string='Given token not valid for any token type', code='token_not_valid')
    ]
}

# Actual output
{
    'detail': ErrorDetail(string='Given token not valid for any token type', code='token_not_valid'),
    'code': ErrorDetail(string='token_not_valid', code='token_not_valid'),
    'messages': [
        {
            'token_class': ErrorDetail(string='AccessToken', code='token_not_valid'),
            'token_type': ErrorDetail(string='access', code='token_not_valid'),
            'message': ErrorDetail(string='Token is invalid or expired', code='token_not_valid')
        }
    ]
}

Is there a simple way to change how these responses are formatted?

Matt McCarthy
  • 424
  • 6
  • 19
  • I think I found a solution: overriding the `initial()` method in `APIView`. I'll write up an answer once I get things working to my liking. – Matt McCarthy Apr 20 '21 at 11:56

1 Answers1

0

After stepping through the code, I found that the exceptions with which I was concerned were being raised in the last commented section of APIView.initial.

# rest_framework/views.py (lines 399-416)

def initial(self, request, *args, **kwargs):
    """
    Runs anything that needs to occur prior to calling the method handler.
    """
    self.format_kwarg = self.get_format_suffix(**kwargs)

    # Perform content negotiation and store the accepted info on the request
    neg = self.perform_content_negotiation(request)
    request.accepted_renderer, request.accepted_media_type = neg

    # Determine the API version, if versioning is in use.
    version, scheme = self.determine_version(request, *args, **kwargs)
    request.version, request.versioning_scheme = version, scheme

    # Ensure that the incoming request is permitted
    self.perform_authentication(request)
    self.check_permissions(request)
    self.check_throttles(request)

To generate a custom response, either create a custom implementation of APIView with an overloaded initial() method, or override the method in specific instances of APIView (or descendants such as GenericAPIView).

# my_app/views.py

def initial(self, request, *args, **kwargs):
    """
    This method overrides the default APIView method so exceptions can be handled.
    """
    try:
        super().initial(request, *args, **kwargs)
    except (AuthenticationFailed, InvalidToken) as exc:
        raise AuthenticationFailed(
            {NON_FIELD_ERRORS_KEY: [_('The provided token is invalid.')]},
            'invalid')
    except NotAuthenticated as exc:
        raise NotAuthenticated(
            {
                NON_FIELD_ERRORS_KEY:
                [_('Authentication credentials were not provided.')]
            }, 'not_authenticated')
Matt McCarthy
  • 424
  • 6
  • 19