More views and viewsets

A better URL structure

We have three API endpoints

  • /polls/ and /polls/<pk>/
  • /choices/
  • /vote/

They get the work done, but we can make our API more intuitive by nesting them correctly. Our redesigned urls look like this:

  • /polls/ and /polls/<pk>
  • /polls/<pk>/choices/ to GET the choices for a specific poll, and to create choices for a specific poll. (Idenitfied by the <pk>)
  • /polls/<pk>/choices/<choice_pk>/vote/ - To vote for the choice identified by <choice_pk> under poll with <pk>.

Changing the views

We will make changes to ChoiceList and CreateVote, because the /polls/ and /polls/<pk> have not changed.

from rest_framework import generics
from rest_framework.views import APIView
from rest_framework import status
from rest_framework.response import Response

from .models import Poll, Choice
from .serializers import PollSerializer, ChoiceSerializer, VoteSerializer

# ...
# PollList and PollDetail views

class ChoiceList(generics.ListCreateAPIView):
    def get_queryset(self):
        queryset = Choice.objects.filter(poll_id=self.kwargs["pk"])
        return queryset
    serializer_class = ChoiceSerializer


class CreateVote(APIView):
    serializer_class = VoteSerializer

    def post(self, request, pk, choice_pk):
        voted_by = request.data.get("voted_by")
        data = {'choice': choice_pk, 'poll': pk, 'voted_by': voted_by}
        serializer = VoteSerializer(data=data)
        if serializer.is_valid():
            vote = serializer.save()
            return Response(serializer.data, status=status.HTTP_201_CREATED)
        else:
            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

And change your urls.py to a nested structure.

#...
urlpatterns = [
    path("polls/<int:pk>/choices/", ChoiceList.as_view(), name="choice_list"),
    path("polls/<int:pk>/choices/<int:choice_pk>/vote/", CreateVote.as_view(), name="create_vote"),

]

You can see the changes by doing a GET to http://localhost:8000/polls/1/choices/, which should give you.

[
    {
        "id": 1,
        "votes": [],
        "choice_text": "Flask",
        "poll": 1
    },
    {
        "id": 2,
        "votes": [
        ],
        "choice_text": "Django",
        "poll": 1
    }
]

You can vote for choices 2, of poll 1 by doing a POST to http://localhost:8000/polls/1/choices/2/vote/ with data {"voted_by": 1}.

{
    "id": 2,
    "choice": 2,
    "poll": 1,
    "voted_by": 1
}

Lets get back to ChoiceList.

# urls.py
#...
urlpatterns = [
    # ...
    path("polls/<int:pk>/choices/", ChoiceList.as_view(), name="choice_list"),
]

# apiviews.py
# ...

class ChoiceList(generics.ListCreateAPIView):
    def get_queryset(self):
        queryset = Choice.objects.filter(poll_id=self.kwargs["pk"])
        return queryset
    serializer_class = ChoiceSerializer

From the urls, we pass on pk to ChoiceList. We override the get_queryset method, to filter on choices with this poll_id, and let DRF handle the rest.

And for CreateVote,

# urls.py
#...
urlpatterns = [
    # ...
    path("polls/<int:pk>/choices/<int:choice_pk>/vote/", CreateVote.as_view(), name="create_vote"),
]

# apiviews.py
# ...

class CreateVote(APIView):

    def post(self, request, pk, choice_pk):
        voted_by = request.data.get("voted_by")
        data = {'choice': choice_pk, 'poll': pk, 'voted_by': voted_by}
        serializer = VoteSerializer(data=data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data, status=status.HTTP_201_CREATED)
        else:
            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

We pass on poll id and choice id. We subclass this from APIView, rather than a generic view, because we competely customize the behaviour. This is similar to our earlier APIView, where in we are passing the data to a serializer, and saving or returning an error depending on whether the serializer is valid.

Introducing Viewsets and Routers

Our urls are looking good, and we have a views with very little code duplication, but we can do better.

The /polls/ and /polls/<pk>/ urls require two view classes, with the same serializer and base queryset. We can group them into a viewset, and connect them to the urls using a router.

This is what it will look like:

# urls.py
# ...
from rest_framework.routers import DefaultRouter
from .apiviews import PollViewSet


router = DefaultRouter()
router.register('polls', PollViewSet, basename='polls')


urlpatterns = [
    # ...
]

urlpatterns += router.urls

# apiviews.py
# ...
from rest_framework import viewsets

from .models import Poll, Choice
from .serializers import PollSerializer, ChoiceSerializer, VoteSerializer


class PollViewSet(viewsets.ModelViewSet):
    queryset = Poll.objects.all()
    serializer_class = PollSerializer

There is no change at all to the urls or to the responses. You can verify this by doing a GET to /polls/ and /polls/<pk>/.

Choosing the base class to use

We have seen 4 ways to build API views until now

  • Pure Django views
  • APIView subclasses
  • generics.* subclasses
  • viewsets.ModelViewSet

So which one should you use when? My rule of thumb is,

  • Use viewsets.ModelViewSet when you are going to allow all or most of CRUD operations on a model.
  • Use generics.* when you only want to allow some operations on a model
  • Use APIView when you want to completely customize the behaviour.

Next steps

In the next chapter, we will look at adding access control to our apis.