Generic ViewSets— Mulitple Serializers

Caronex Labs
Django Rest for Not Beginners
10 min readJul 27, 2020

Hey there! In this article, we will be exploring ways in which we can hook up multiple serializers in a single ViewSet. This article is actually a part of a series that you can find here. However, you do not need any prior context if you are using this article just as a reference. With that introduction, let’s get started.

Like every time, here is a list of prerequisites you should be familiar with to properly appreciate this article:

  • Generic ViewSets — You can check out the docs or our article here.
  • Custom Endpoints — Again, the docs… or our article here.
  • Custom Mixins — Not completely required, but you’ll be more aware of what’s happening. We have an article on this here. Find the docs here.
  • AbstractUser — Not completely required, but you’ll be more aware of what’s happening. Find the docs here.
  • ModelSerializers. Find the docs here.

The Aim:

Alright then, before we begin, let’s have a look at what we hope to build:

Just a reminder of our primary goal. We are building a REST API Backend for the Profile Page of Medium. This One:

As you may have noticed, we do have some new fields here as well. Namely, the Last Edited and the Articles fields.

In the last few articles, we built the ViewSets that allow us to retrieve the users profile information and the users articles. We used custom endpoints and custom mixins to achieve the same, you can go through these articles here if you wish to have a closer look.

Again, if you’re just using this as a reference, please don’t worry too much about the context. The code below is what we have completed as of now and it should be enough to get all of us on the same page.

The Model:

users_module/models.py

The Views:

This one is a little more complex, but I’m sure you’ll get it. Just give it a good read.

users_module/views.py

The Serializers:

users_module/serializers.py

The URLs:

We should almost always use routers when building URLs. For simplicity, we have built our endpoints this way:

If you wish to get an even clearer idea of the project, you can check it out here on GitHub.

Now that everyone’s on the same page, let’s have a look at where we go next.

This time, we are going to build the Edit Profile option. This will entail the following:

  • Update the serializer to allow only certain fields to be edited.
  • Add an endpoint to change the Profile Picture, Twitter Profile URL, Profile Name and Bio.
  • Add the Last Edited to reflect the change.
  • Make the way the serializer is selected more scalable. (This will become clearer soon.)
  • Convert the endpoint to a Mixin to make it more scalable as well.

Alright then, let’s go through the above points one by one.

1. Updating The Serializer -

Now that we have fields which should not be edited, we will have to mark them as read-only . You should already be familiar with this if you’ve come this far, so I’m just going to show the new serializer here:

users_module/serializers.py

Just a reminder: Some of the read-only fields might seem out of place, but that’s because we’re building this backend just for the profile page. We will only consider what we are allowed to edit from there.

2. Adding the Endpoint -

The most optimum course of action is to add this functionality inside of the me endpoint. But in order to gain a better understanding of how things work, we will get there in smaller steps.

Let’s first start by building a patch endpoint that only works on the current user.

This is what that endpoint would look like:

from datetime import dateclass UserViewSet(MeMixin, GenericViewSet):
...

def
patch(self, request, *args, **kwargs):
serializer = self.get_serializer_class()
user = User.objects.get(user_id=request.user.user_id)
data = serializer(user, request.data, partial=True)
data.is_valid(raise_exception=True)
data.save()
return Response({
'message': "User Profile Updates"
},
status=status.HTTP_200_OK,
)

Let’s break the above function down:

  • The name patch is a reserved name that instructs the viewset to call this function if a patch request is sent to the base URL of the viewset.
  • By calling the serializer and passing the current user, the data of the request and the keyword partial , we are telling the serializer that — Take this user, and the data we have received, and try to update the user with that data. It also indicates that we need to update the user partially, meaning that we only need to update the fields passed in the request… leave the others untouched.
  • The is_valid(raise_exception=True) function checks if the serializer was able to perform the requested patch. By passing raise_exception we tell the serializer to automatically create a Bad Request response and return it to the frontend. This way we don’t have to create a failure Response manually.
  • data.save() is the function that finally performs the patching and updates the database to reflect the changes.
  • We are not updating the last_edited . Let’s fix that in a minute.

3. Add The Last_Edited Field

To store last_edited
We will make a change in the models, make the following change to the User Model -

class User(AbstractUser):
# The following fields are declared by Django by default.
# I have mentioned them just so that there is no doubt.

user_id = models.AutoField(primary_key=True)
username = models.CharField(max_length=100, unique=True)
email = models.EmailField(unique=True)
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)

# The following fields are the custom fields.

bio = models.TextField(blank=True)
headline = models.CharField(max_length=300)
following = models.IntegerField(default=0)
articles = models.IntegerField(default=0)
profile_picture = models.URLField(blank=True)
# The following field is what we just added. last_edited = models.DateTimeField(auto_now=True)

This way, every time a user object is saved, the last_edited field will automatically get updated.

Make sure to add the last_edited field to the read_only fields in your serializer.

Before we move on to the next part, let’s catch a breather and make sure we’re all on the same page.

Here are the pages that we have made changes to since the beginning of this article.

Models

users_module/models.py

Views

users_module/views.py

Serializers

users_module/serializers.py

We’re lucky in that this code works. The two endpoints we have right now, patch and me , use the same serializer. The only difference being that one uses the PATCH method while the other uses the GET method.

By adding read-only fields to our serializer, we can make the same serializer work for both these cases. But… what do you do when multiple endpoints in the same viewset need different serializers?

Let’s find out…

4. Make The Serializer Selection Scalable

Generic ViewSets come with the get_serializer_class() function. This function returns the serializer class that should be used by a endpoint inside of that viewset.

We are already using this function inside our endpoints. We call the self.get_serializer_class() to get the serializer class that we need to use for the current endpoint.

So, we will just tweak that inbuilt function a bit, to suite our needs.

The new get_serializer_class() function:

class UserViewSet(MeMixin, GenericViewSet):
...

def get_serializer_class(self):
if self.action == 'me':
if self.request.method == 'GET':
return UserSerializer
else:
if self.request.method == 'PATCH':
return UserSerializer

It might seem… useless… but this is just an example. This is how you would do it if there were actually 2 different serializers.

Let’s break it down:

  • Most importantly, what this function returns will be the serializer that will be used. This is the key to modifying the function to do whatever you might need.
  • There are two keywords we have used here that may be new to you:
    - self.action — This is the name of the endpoint being called. It is literally the name of the function that links to the endpoint. However, if you use the function on the base URL, the way we have done with patch() , it does not return a self.action
    - self.request.method — Returns the HTTP method being called.
  • Again, we’re returning the same serializer in both cases, but this is to show you how it is to be done.

In order for this to work, we will have to update our URLs as well:

from django.contrib import admin
from django.urls import path
from rest_framework.authtoken.views import obtain_auth_token

from users_module.views import UserViewSet

urlpatterns = [
path('admin/', admin.site.urls),
# We edited the following endpoint: path('users/me', UserViewSet.as_view({'get': 'me', 'patch': 'patch'}), name="user_view_set"), path('api-token-auth/', obtain_auth_token, name='api_token_auth'), # <-- And here

]

If you’ve read any of the previous article, I have this habit of making code and then calling it ugly…

It’s ugly.

This works, but I happen to have developed a coding pattern that makes this look even better. Whatever you read beyond this is completely optional to do. But if you’re interested… here we go.

You know what would make this code really really clean?

Switch Case

But python does not have switch case

So here’s the solution.

Let’s create a python dictionary that stores the serializer we wish to call for every endpoint.

It’ll look something like this :

viewset_serializers = {
'me' : UserSerializer,
'patch' : UserSerializer
}

and now, our get_serializer_class() function is reduced to:

def get_serializer_class(self):
return self.viewset_serializers.get(self.action)

So much better…

This is as scalable as we can make our serializer selection for now. Any time you have a new endpoint, just add an entry in the viewset_serializers variable, and you’re done.

However, our patch endpoint is a little confusing. You should avoid using the method names directly. They work, but they will confuse you eventually. Instead, if you want to use HTTP calls on the base URL, just use the inbuilt mixins (UpdateModelMixin, CreateModelMixin, etc) and edit the functions that come with it.

For now, we’re gonna do one last thing. We’re gonna move our patch request into our me endpoint. Because it would make more sense to call the patch on the me endpoint to update the current user.

5. Convert the endpoint to a Mixin

Let’s start by simply moving our patch logic into the MeMixin and solve any issues we face.

The MeMixin :

class MeMixin:

@action(methods=['get', 'patch'], detail=False)
def me(self, request):
serializer = self.get_serializer_class()

if request.method == 'GET':

data = serializer(
instance=self.get_me_config().get('instance'),
many=self.get_me_config().get('many')
).data
return Response(data, status=status.HTTP_200_OK)

elif request.method == 'PATCH':

user = User.objects.get(user_id=request.user.user_id)
data = serializer(user, request.data, partial=True)
data.is_valid(raise_exception=True)
data.save()
return Response({
'message': "User Profile Updated"
},
status=status.HTTP_200_OK,
)

Here’s the breakdown:

  • We added‘patch’ to the allowed methods for the me endpoint. Check the action decorator .
  • We added an if-else statement to perform two separate logic depending on the HTTP method called.

And here we face our first issue. We’re calling the get_serializer_class() which we haven’t updated to reflect this new change. So let’s do that.

First, we will have to update the structure of the viewset_serializers variable because we now have two different endpoints under the same function.

viewset_serializers = {
'me' : {
'get' : UserSerializer,
'patch' : UserSerializer
}
}

This is an interesting change, and I hope you see how this is going to allow us to scale the serializer selection even more now.

In accordance to this change, we will also edit the get_serializer_class() function.

def get_serializer_class(self):
return self.viewset_serializers.get(self.action).get(self.request.method)

This is where we face our second problem. We are using our MeMixin in both our viewsets, but our StoryViewSet doesn’t have a patch method.

If you aren’t following the series, this part probably won’t make as much sense. I do recommend giving it a read however, it’ll help you realize how to twist and manipulate our code to accommodate any requirements you may have.

We already have a get_me_config() function in both our viewsets . We will make use of them to deal with this problem.

We will include a allowed_methods list in this config that allows us to list down the allowed methods for both the viewsets individually:

The UserViewSet get_me_config() function:

def get_me_config(self):
return {
'instance': self.request.user,
'many': False,
'
allowed_methods' : ['get', 'patch']
}

Similarly, the StoryViewSet get_me_config() function:

def get_me_config(self):
return {
'instance': self.request.user,
'many': False,
'
allowed_methods' : ['get']
}

I really hope you already see where we are going with this…

We will now update our MeMixin to reflect this change.

MeMixin :

class MeMixin:

@action(methods=['get', 'patch'], detail=False)
def me(self, request):
serializer = self.get_serializer_class()

if request.method == 'GET' and 'GET' in self.get_me_config().get('allowed_methods'):

data = serializer(
instance=self.get_me_config().get('instance'),
many=self.get_me_config().get('many')
).data
return Response(data, status=status.HTTP_200_OK)

elif request.method == 'PATCH' and 'PATCH' in self.get_me_config().get('allowed_methods'):
user = User.objects.get(user_id=request.user.user_id)
data = serializer(user, request.data, partial=True)
data.is_valid(raise_exception=True)
data.save()
return Response({
'message': "User Profile Updated"
},
status=status.HTTP_200_OK,
)

else:
return Response({
'message': f"Unsupported method {request.method}.",
},
status=status.HTTP_400_BAD_REQUEST
)

Here’s the breakdown:

  • In both our if conditions, we now check if the method is present in the allowed_methods of the get_me_config() .
  • We added an else condition to return an error if an unsupported method is used.

You will have to edit your URLs to reflect this change as follows:

from django.contrib import admin
from django.urls import path
from rest_framework.authtoken.views import obtain_auth_token

from users_module.views import UserViewSet

urlpatterns = [
path('admin/', admin.site.urls),
# We edited the following endpoint so that both methods point to the 'me' endpoint. path('users/me', UserViewSet.as_view({'get': 'me', 'patch': 'me'}), name="user_view_set"), path('api-token-auth/', obtain_auth_token, name='api_token_auth'), # <-- And here

]

Well… that was a lot. But it was definitely worth it. Let’s look at our code now and you’ll see why.

Our application is besides the point here. So when you look at the code, think of how it would work on a large scale project. How much code and confusion it would save on a project you’ve recently worked on.

Views

Anytime you wish to add a new endpoint:

  • Create the serializer
  • Add it to the viewset_serializers
  • Create the function

Well… that is how you deal with multiple serializers in a single viewset . Just a reminder… creating a dictionary to store the serializers is something we have come up with and it’s a very young idea. I’m sure you will find ways to make this even better. If you do, please do share it in the comments here or on any of our social media. We would love to improve this pattern.

That’s it for this time, We hope this helps you improve your code. If you have any questions, ask away… we would love to help you out.

Until next time…

--

--