Generic ViewSets— Mulitple Serializers
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:
The Views:
This one is a little more complex, but I’m sure you’ll get it. Just give it a good read.
The Serializers:
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:
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 theviewset
to call this function if a patch request is sent to the base URL of theviewset
. - 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 thisuser
, and thedata
we have received, and try toupdate
theuser
with that data. It also indicates that we need to update theuser
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 theserializer
was able to perform the requestedpatch
. By passingraise_exception
we tell theserializer
to automatically create aBad Request
response and return it to the frontend. This way we don’t have to create a failureResponse
manually. data.save()
is the function that finally performs thepatching
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
Views
Serializers
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 withpatch()
, it does not return aself.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 theme
endpoint. Check theaction 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 theget_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…