FullStack JWT Auth: User serializers, Views, and Endpoints
Tags: webdev, django, tutorial, svelte
Published: 2022-02-08 12:50:08

## Introduction

This is the second article in this series which aim to build a full stack `#transitionalapp`. This article will be totally based on implementing the endpoints that will be consumed by our SvelteKit app.

## Source code

The overall source code for this project can be accessed here:

{% github  Sirneij/django_svelte_jwt_auth  %}

## Live version

This project was deployed on heroku (backend) and vercel (frontend) and its live version can be accessed [here](https://django-sveltekit-jwt-auth.vercel.app/).

## Step 1: User serializers

Create a `serializers.py` file in the `accounts` app and fill it with the following:

```python
# backend -> accounts -> serializers.py

from django.contrib.auth import authenticate
from rest_framework import exceptions, serializers
from rest_framework_simplejwt.tokens import RefreshToken, TokenError

from .models import User
from .utils import validate_email as email_is_valid


class RegistrationSerializer(serializers.ModelSerializer[User]):
    """Serializers registration requests and creates a new user."""

    password = serializers.CharField(max_length=128, min_length=8, write_only=True)

    class Meta:
        model = User
        fields = [
            'email',
            'username',
            'password',
            'bio',
            'full_name',
        ]

    def validate_email(self, value: str) -> str:
        """Normalize and validate email address."""
        valid, error_text = email_is_valid(value)
        if not valid:
            raise serializers.ValidationError(error_text)
        try:
            email_name, domain_part = value.strip().rsplit('@', 1)
        except ValueError:
            pass
        else:
            value = '@'.join([email_name, domain_part.lower()])

        return value

    def create(self, validated_data):  # type: ignore
        """Return user after creation."""
        user = User.objects.create_user(
            username=validated_data['username'], email=validated_data['email'], password=validated_data['password']
        )
        user.bio = validated_data.get('bio', '')
        user.full_name = validated_data.get('full_name', '')
        user.save(update_fields=['bio', 'full_name'])
        return user


class LoginSerializer(serializers.ModelSerializer[User]):
    email = serializers.CharField(max_length=255)
    username = serializers.CharField(max_length=255, read_only=True)
    password = serializers.CharField(max_length=128, write_only=True)
    is_staff = serializers.BooleanField(read_only=True)

    tokens = serializers.SerializerMethodField()

    def get_tokens(self, obj):  # type: ignore
        """Get user token."""
        user = User.objects.get(email=obj.email)

        return {'refresh': user.tokens['refresh'], 'access': user.tokens['access']}

    class Meta:
        model = User
        fields = ['email', 'username', 'password', 'tokens', 'is_staff']

    def validate(self, data):  # type: ignore
        """Validate and return user login."""
        email = data.get('email', None)
        password = data.get('password', None)
        if email is None:
            raise serializers.ValidationError('An email address is required to log in.')

        if password is None:
            raise serializers.ValidationError('A password is required to log in.')

        user = authenticate(username=email, password=password)

        if user is None:
            raise serializers.ValidationError('A user with this email and password was not found.')

        if not user.is_active:
            raise serializers.ValidationError('This user is not currently activated.')

        return user


class UserSerializer(serializers.ModelSerializer[User]):
    """Handle serialization and deserialization of User objects."""

    password = serializers.CharField(max_length=128, min_length=8, write_only=True)

    class Meta:
        model = User
        fields = (
            'email',
            'username',
            'password',
            'tokens',
            'bio',
            'full_name',
            'birth_date',
            'is_staff',
        )
        read_only_fields = ('tokens', 'is_staff')

    def update(self, instance, validated_data):  # type: ignore
        """Perform an update on a User."""

        password = validated_data.pop('password', None)

        for (key, value) in validated_data.items():
            setattr(instance, key, value)

        if password is not None:
            instance.set_password(password)

        instance.save()

        return instance


class LogoutSerializer(serializers.Serializer[User]):
    refresh = serializers.CharField()

    def validate(self, attrs):  # type: ignore
        """Validate token."""
        self.token = attrs['refresh']
        return attrs

    def save(self, **kwargs):  # type: ignore
        """Validate save backlisted token."""

        try:
            RefreshToken(self.token).blacklist()

        except TokenError as ex:
            raise exceptions.AuthenticationFailed(ex)

```

That's a lot of snippets! However, if you are somewhat familiar with [Django REST Framework][1], it shouldn't be hard to decipher. Let's zoom in on each serializer.

- `RegistrationSerializer`: This is the default serializer for user registration. It expects `email`, `username`, `password`,`bio`, and `full_name` fields to be supplied during registration. As expected, `password` was made to be write_only to prevent making it readable to users. It houses a custom validation method, `validate_email`, and overrides default `create` method. The `validate_email` method ensures the inputted email address is truly an email address by using a method in `utils.py` file for proper checking. The content of this file is:

  ```python
  #backend -> accounts -> utils.py
  from django.core.exceptions import ValidationError
  from django.core.validators import validate_email as django_validate_email


  def validate_email(value: str) -> tuple[bool, str]:
      """Validate a single email."""
      message_invalid = 'Enter a valid email address.'

      if not value:
          return False, message_invalid
      # Check the regex, using the validate_email from django.
      try:
          django_validate_email(value)
      except ValidationError:
          return False, message_invalid

      return True, ''

  ```

  For the `create` method, it's pretty straightforward. We simply create a user using `create_user` method defined in our custom Manager in the previous article and then use the performant `update_fields` argument to save `bio` and `full_name`.

- `LoginSerializer`: The app's default login serializer. It uses `get_tokens` method to fetch the requesting user's pair of tokens. It also ensures all inputted data are properly validated via the `validate` method.

- `UserSerializer` will later be used to update the requesting user's data.

- `LogoutSerializer`: This serializer tends to use [Simple JWT][2]'s blacklist feature to ensure that such token is made invalid and can't be used for future requests. This is the reason we opted for the library. See the documentation for details about this.

## Step 2: Views and Endpoints

Now that we have painstakingly defined our serializers, let's forge ahead to create the views that will handle all the requests. Open up `accounts/views.py` file and fill the following in:

```python
#backend -> accounts -> views.py

from typing import Any, Optional

from django.conf import settings
from rest_framework import status
from rest_framework.generics import RetrieveUpdateAPIView
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView

from .models import User
from .renderers import UserJSONRenderer
from .serializers import (
    LoginSerializer,
    LogoutSerializer,
    RegistrationSerializer,
    UserSerializer,
)


class RegistrationAPIView(APIView):
    permission_classes = (AllowAny,)
    renderer_classes = (UserJSONRenderer,)
    serializer_class = RegistrationSerializer

    def post(self, request: Request) -> Response:
        """Return user response after a successful registration."""
        user_request = request.data.get('user', {})
        serializer = self.serializer_class(data=user_request)
        serializer.is_valid(raise_exception=True)
        serializer.save()
        return Response(serializer.data, status=status.HTTP_201_CREATED)


class LoginAPIView(APIView):
    permission_classes = (AllowAny,)
    renderer_classes = (UserJSONRenderer,)
    serializer_class = LoginSerializer

    def post(self, request: Request) -> Response:
        """Return user after login."""
        user = request.data.get('user', {})

        serializer = self.serializer_class(data=user)
        if not serializer.is_valid():
            print(serializer.errors)
            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

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


class UserRetrieveUpdateAPIView(RetrieveUpdateAPIView):
    permission_classes = (IsAuthenticated,)
    renderer_classes = (UserJSONRenderer,)
    serializer_class = UserSerializer

    def retrieve(self, request: Request, *args: dict[str, Any], **kwargs: dict[str, Any]) -> Response:
        """Return user on GET request."""
        serializer = self.serializer_class(request.user, context={'request': request})

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

    def update(self, request: Request, *args: dict[str, Any], **kwargs: dict[str, Any]) -> Response:
        """Return updated user."""
        serializer_data = request.data.get('user', {})

        serializer = self.serializer_class(
            request.user, data=serializer_data, partial=True, context={'request': request}
        )
        serializer.is_valid(raise_exception=True)
        serializer.save()

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


class LogoutAPIView(APIView):
    serializer_class = LogoutSerializer

    permission_classes = (IsAuthenticated,)

    def post(self, request: Request) -> Response:
        """Validate token and save."""
        serializer = self.serializer_class(data=request.data)
        serializer.is_valid(raise_exception=True)
        serializer.save()

        return Response(status=status.HTTP_204_NO_CONTENT)

```

They are simple views that inherit from `APIView` and some other generic classes shipped with [Django REST Framework][1]. It should be noted that we have a custom `UserJSONRenderer` class located in `renderers.py` with the following content:

```python
#backend -> accounts -> renderers.py

import json
from typing import Any, Mapping, Optional

from rest_framework.renderers import JSONRenderer


class UserJSONRenderer(JSONRenderer):
    """Custom method."""

    charset = 'utf-8'

    def render(
        self,
        data: dict[str, Any],
        media_type: Optional[str] = None,
        renderer_context: Optional[Mapping[str, Any]] = None,
    ) -> str:
        """Return a well formatted user jSON."""
        errors = data.get('errors', None)
        token = data.get('token', None)
        if errors is not None:
            return super(UserJSONRenderer, self).render(data)

        if token is not None and isinstance(token, bytes):
            # Also as mentioned above, we will decode `token` if it is of type
            # bytes.
            data['token'] = token.decode('utf-8')

        # Finally, we can render our data under the "user" namespace.
        return json.dumps({'user': data})

```

The whole essence of this class is to give a custom formatting of the request and response data to our endpoints. If it was not defined, our endpoints would expect and respond with data in the following format:

```json
{
  "email": "sirneij@xyz.com",
  "username": "sirjon",
  "password": "somepassword",
  "bio": "I am a researcher",
  "full_name": "John Owolabi Nelson"
}
```

But with that renderer in place, the expected data format will be:

```json
{
  "user": {
    "email": "sirneij@xyz.com",
    "username": "sirjon",
    "password": "somepassword",
    "bio": "I am a researcher",
    "full_name": "John Owolabi Nelson"
  }
}
```

This is not required but preferred by me. If you don't want this, you can omit it and also remove this line from all the views:

```python
...
user = request.data.get('user', {})
...
```

and point the `data` attribute of your serializer class to `request.data` instead.

Let's make our endpoints now. Create a `urls.py` in the `accounts` app and make the content look like:

```python
#backend -> accounts -> urls.py
from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

from .views import (
    LoginAPIView,
    LogoutAPIView,
    RegistrationAPIView,
    UserRetrieveUpdateAPIView,
)

app_name = 'accounts'

urlpatterns = [
    path('register/', RegistrationAPIView.as_view(), name='register_user'),
    path('login/', LoginAPIView.as_view(), name='login_user'),
    path('logout/', LogoutAPIView.as_view(), name="logout_user"),
    path('user/', UserRetrieveUpdateAPIView.as_view(), name='user'),
    path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]
```

The last path, `path('token/refresh/', TokenRefreshView.as_view(),name='token_refresh')`, is important to help recreate `access` tokens for existing users who want to login.

Now, let's make django aware of these patterns. Open up `backend/urls.py` and make it look like:

```python
# backend -> backend -> urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('accounts.urls', namespace='accounts')),
]
```

Voilà! Our endpoints are up and running! You can test them using Postman or anything. But I prefer this awesome VS Code extension, [Thunder Client][3]. It's nice and sleek. Ensure you send in the proper data to avoid errors. Concerning the error responses, let's make one last customization to our endpoint. Create a new file in the `accounts` app, call it whatever you like but I will pick `exceptions.py`. Fill it with the following:

```python
#backend -> accounts -> exceptions.py
from typing import Any, Optional

from rest_framework.response import Response
from rest_framework.views import exception_handler


def core_exception_handler(exc: Exception, context: dict[str, Any]) -> Optional[Response]:
    """Error handler for the API."""
    response = exception_handler(exc, context)
    handlers = {'ValidationError': _handle_generic_error}

    exception_class = exc.__class__.__name__

    if exception_class in handlers:

        return handlers[exception_class](exc, context, response)

    return response


def _handle_generic_error(exc: Exception, context: dict[str, Any], response: Optional[Response]) -> Optional[Response]:
    if response:
        response.data = {'errors': response.data}

        return response
    return None
```

Then go to to your `settings.py` file and point REST framework to use this custom error format:

```python
#backend -> backend -> settings.py

...
# REST_FRAMEWORK
REST_FRAMEWORK = {
    'EXCEPTION_HANDLER': 'accounts.exceptions.core_exception_handler',
    'NON_FIELD_ERRORS_KEY': 'error',
    'DEFAULT_AUTHENTICATION_CLASSES': ('rest_framework_simplejwt.authentication.JWTAuthentication',),
}
...
```

Now we are sure to have something like:

```json
{
  "errors": {
    "email": ["Enter a valid email address."]
  }
}
```

In case of field errors!

That's it! We are done with the back-end! Let's move to the front-end stuff where we'll talk about the awesome `SvelteKit`! I can't wait!!!

## Outro

Enjoyed this article, consider [contacting me for a job, something worthwhile or buying a coffee ☕](mailto:sirneij@gmail.com). You can also connect with/follow me on [LinkedIn](https://www.linkedin.com/in/idogun-john-nelson/).

[1]: https://www.django-rest-framework.org/ "Django REST Framework."
[2]: https://django-rest-framework-simplejwt.readthedocs.io/en/latest/ "Simple JWT."
[3]: https://www.thunderclient.com/ "Thunder Client"