CommonLounge Archive

Hands-on Assignment: Creating an API Part 2

May 17, 2018

In the last assignment, we showed you how to use Django Rest Framework to set up your own API. In this part, we’ll ask you to create the API for both Post and Comment objects so that you can get some good hands-on practice :). We’ll include our sample solutions following each section as well as a through explanation. We’ll also cover some important security principles and implement the voting logic together.

Step 1: Post API

Create the initial Serializer

Perfect! Now we’ll move on to creating our PostSerializer. You should be able to create the initial serializer based on what we’ve covered for the User and SubReddit serializers.

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

Here’s how we created the PostSerializer in reddit/serializers.py.

class PostSerializer(BaseSerializer):
    submitter = serializers.PrimaryKeyRelatedField(required=False, read_only=True)
    children = serializers.PrimaryKeyRelatedField(many=True, required=False, read_only=True)
    subreddits = serializers.PrimaryKeyRelatedField(many=True, queryset=SubReddit.objects.all(), required=True)
    class Meta:
        model = Post
        fields = ('eid', 'title', 'submitter', 'text', 'children', 'subreddits', 'comment_count', 'upvote_count', 'downvote_count')
        read_only_fields = ('comment_count', 'upvote_count', 'downvote_count')

Let’s walk through the rationale behind some of the fields once more.

  • We declare submitter as a PrimaryKeyRelatedField. It is not required to be provided by the API, and it is read_only. We don’t need this information to be passed in, since we can just use the logged in user as the submitter. If we accepted this in the API, then we could just make a rogue request and submit a post as someone else.

When designing API’s, it’s very important to think about security and locking down your API to just the functionality that you want to provide.

  • children is similar to posts in the SubRedditSerializer above.
  • subreddits is similar to moderators in the SubRedditSerializer above.

The last thing to note is that we can also set readonlyfields in the Meta class itself. Since we didn’t explicitly declare comment_count, upvote_count, etc. and we don’t want the counts changing through the API, we add these fields to the read_only_fields tuple. Important: remember that fields and `readonlyfields` are tuples, so if there is only one element within the parenthesis, you must include a comma at the end, otherwise it will be interpreted as a string and throw an error.


Next, add the validation for subreddits, since each post must be in at least one subreddit.

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

Here’s how we created the validate_subreddits method.

class PostSerializer(BaseSerializer):
    ...
    class Meta:
        model = Post
       ...
    ###### ADD THIS METHOD ###########
    def validate_subreddits(self, value):
        if not value or len(value) == 0:
            raise serializers.ValidationError('Need to include at least one subreddit to post to!')
        return value
    ##################################Next, add the validation for subreddits, since each post must be in at least one subreddit. 

Next, add the created method for your posts. Remember that you will need to add the posts to the various subreddits!

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

Here’s how we created the create method!

class PostSerializer(BaseSerializer):
    ...
    def validate_subreddits(self, value):
        ...
    ###### ADD THIS METHOD ###########
    def create(self, validated_data):
        subreddits = validated_data.pop('subreddits')
        validated_data['submitter'] = self.context['request'].user
        post = Post.objects.create(**validated_data)
        for subreddit in subreddits:
            SubRedditPost.objects.create(subreddit=subreddit, post=post)
        return post
    ##################################

This is mostly similar to the create method of the SubReddit serializer.

There are just a few minor differences.

  1. We set the submitter to the logged in user.
  2. To add the post to the various subreddit’s, we will create SubRedditPost objects.

Create the initial ViewSet

Create the PostViewSet in reddit/serializers.py.

class PostViewSet(viewsets.ModelViewSet):
    """
    API endpoint that allows Posts to be viewed or edited.
    """
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    permission_classes = (permissions.IsAuthenticatedOrReadOnly,)

Note that we’re inheriting from viewsets.ModelViewSet, so we’ll automatically get all of the action methods we mentioned earlier such as retrieve, list, etc.

Also go to reddit/urls.py and register the post urls.

router = routers.DefaultRouter()
router.register(r'users', viewsets.UserViewSet)
router.register(r'subreddits', viewsets.SubRedditViewSet)
## ADD THIS
router.register(r'posts', viewsets.PostViewSet)

Testing Creating Posts

Finally, we’re now ready to test creating posts.

Navigate to http://localhost:8000/api/v1/posts/.

Click on the Raw Data tab, leave the Media type as application/json. Let’s enter in some data for our first post!

  • Use your subreddit eid from earlier.
  • Remember how comment_count was one of the fields was marked as read_only. Let’s check if it actually works. In our creation request, we’ll also put in a dummy value of comment_count and see what happens.
{
    "title": "Our First Post",
    "text": "Some sample text here.",
    "subreddits": ["97f56643-36f6-4330-810b-a974c589ef07"], 
    "comment_count": 5
}

You should get a HTTP 201 Created response like the following:

HTTP 201 Created
Allow: GET, POST, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept
{
    "eid": "56314cde-3a49-4958-9234-354e6edde40a",
    "title": "Our First Post",
    "submitter": 1,
    "text": "Some sample text here.",
    "children": [],
    "subreddits": [
        "97f56643-36f6-4330-810b-a974c589ef07"
    ],
    "comment_count": 0,
    "upvote_count": 0,
    "downvote_count": 0
}

Voila! Your post was created but the comment_count is still 0!

Allowing Posts to be Edited via the API

Next, add the updating logic for posts just like we did for subreddit’s.

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

Here’s how we’d do it:

First we’ll modify PostSerializer in reddit/serializers.py to make sure that the subreddits cannot be modified once they’ve been created. Remember that the submitter is already read_only=True, so we don’t have to include that.

class PostSerializer(ContentSerializer):
    ....
    protected_update_fields = ['subreddits']

Next, we’ll create a new permission since we only want the submitter of a post to be able to edit it. We created the following in reddit/permissions.py.

class IsSubmitterOrReadOnly(permissions.BasePermission):
    """
    Object-level permission to only allow owners of an object to edit it.
    Assumes the model instance has an `owner` attribute.
    """
    def has_object_permission(self, request, view, obj):
        # Read permissions are allowed to any request,
        # so we'll always allow GET, HEAD or OPTIONS requests.
        if request.method in permissions.SAFE_METHODS:
            return True
        # Instance must have an attribute named `owner`.
        return obj.submitter == request.user

Finally, we went back to viewsets.py and modified the permissions class in PostViewSet to include our IsSubmitterOrReadOnly permission. Make sure to import it first!

from .permissions import IsModeratorOrReadOnly, IsSubmitterOrReadOnly
class PostViewSet(viewsets.ModelViewSet):
    ...
    permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsSubmitterOrReadOnly)

Testing Modifying Posts

Let’s test! We’ll use the eid of our Post that we just created a little earlier.

Navigate to http://localhost:8000/api/v1/posts/56314cde-3a49-4958-9234-354e6edde40a/. Replace this eid with whatever your’s was.

Enter in the following into the Content section of the Raw Data tab.

{
    "eid": "56314cde-3a49-4958-9234-354e6edde40a",
    "title": "Our First Post modified"
}

Click on the “Patch” button.

You should have received a successful HTTP 200 OK response. Try various attack vectors such as:

  • Passing in a different eid → nothing should happen since it’s a read_only field.
  • Passing in the subreddits field → A 400 Bad Request error will be thrown with the message: {"subreddits": "You cannot change this field."}.

Listing Posts

We want to be able to support the search functionality we had earlier as well as showing a list of all posts. To do this we’ll need to modify our PostViewSet to add our own list method.

class PostViewSet(viewsets.ModelViewSet, VotingMixin):
    """
    API endpoint that allows Posts to be viewed or edited.
    """
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsSubmitterOrReadOnly)
    ###### ADD THIS ############
    def list(self, request, *args, **kwargs):
        query = request.query_params.get('query', None)
        if query:
            posts = Post.objects.filter(Q(text__icontains=query)|Q(title__icontains=query)).order_by('-date_created')
        else:
            posts = Post.objects.all().order_by('-date_created')
        serializer = PostSerializer(posts, many=True, context={'request': request})
        return Response(serializer.data)
    ###############################

The implementation is very similar to the post_list view that we had earlier. On line 10, we fetch the query variable from the request.query_params which is a dictionary of all query parameters and is automatically populated by DRF.

If the query exists, we do the filter just like we did earlier, else we just sort it by post’s with the latest date_created, i.e. a sort in the descending order of date_created.

Finally, we pass the list of posts to the PostSerializer. This is the first time we’re actually explicitly using the serializer ourselves, so you can get a sense of the syntax required. We pass in many=True since we’re sending a list of Post objects to the serializer. We also pass in the context parameter with the request object. This is how your serializers get access to self.context['request'].user!

To get the serialized output, we just have to call serializer.data, and we can directly pass this to the Response object and return it!


Let’s test it out! If we go to http://localhost:8000/api/v1/posts/?query=our we’ll see that the posts have been filtered appropriately. Play around with different values of the query parameter and see how the results change.

Step 2: Comments

Take a shot at creating the serializers, viewsets, urls, etc for comments yourself based on what you’ve learned.

>>>>>>>>>>> Try it yourself now, before moving on! >>>>>>>>>>>

>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

Here’s what we did.

We modified reddit/serializers.py to include a CommentSerializer.

class CommentSerializer(BaseSerializer):
    author = serializers.PrimaryKeyRelatedField(required=False, read_only=True)
    children = serializers.PrimaryKeyRelatedField(many=True, required=False, read_only=True)
    post = serializers.PrimaryKeyRelatedField(queryset=Post.objects.all(), required=True, read_only=False)
    parent = serializers.PrimaryKeyRelatedField(queryset=Comment.objects.all(), required=False, read_only=False, allow_null=True)
    class Meta:
        model = Comment
        fields = ('eid', 'post', 'author', 'text', 'parent', 'children', 'upvote_count', 'downvote_count')
        read_only_fields = ('upvote_count', 'downvote_count')
    def create(self, validated_data):
        validated_data['author'] = self.context['request'].user
        comment = Comment.objects.create(**validated_data)
        return comment

Couple of interesting points here:

  • For the parent field, we set allow_null to True since a comment doesn’t necessarily need to have a parent.
  • We have no protected_update_fields since we don’t allow comments to be edited.
  • In the create method, we set the author of the comment to the logged in user.

We modified reddit/viewsets.py to include a CommentViewSet.

class CommentViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin, mixins.CreateModelMixin):
    """
    API endpoint that allows Comments to be viewed.
    """
    queryset = Comment.objects.all()
    serializer_class = CommentSerializer
    permission_classes = (permissions.IsAuthenticatedOrReadOnly,)

Since we only want to enable retrieving comments and creating comments, we inherited from GenericViewSet, RetrieveModelMixin, and CreateModelMixin.

We finally registered the router in reddit/urls.py.

router.register(r'comments', viewsets.CommentViewSet)

Try creating some comments yourself by going to http://localhost:8000/api/v1/comments/. Make sure to try out different variations where parent is None, etc. When you add comments to a post, you should see the comment_count being updated!

Voting

Finally, we need to hook up the voting logic.

First, we need to return the value of the user vote for each Comment or Post object. Since we’ll need this functionality in both the serializers, let’s create another serializer to consolidate this shared need.

Create ContentSerializer inside reddit/serializers.py.

class ContentSerializer(BaseSerializer):
    user_vote = serializers.SerializerMethodField()
    def get_user_vote(self, obj):
        return obj.get_user_vote(self.context['request'].user)

Here we’ll learn about DRF’s SerializerMethodField. This is a read-only field and obtains its value by calling a method. It can be used to add any sort of data to the serialized representation of your object.

We simply declare user_vote to be a SerializerMethodField, and then if we define a method that is named get_user_vote, DRF will automatically return the output of the method as the value associated with user_vote. Again, the DRF magic is at play here. It just looks for a method matching get_x where x is the field name.

Now, lets modify our PostSerializer and CommentSerializer to use this ContentSerializer. We will have both these serializers inherit from ContentSerializer instead of BaseSerializer. We will also add user_vote to the fields list inside the Meta class for both.

class PostSerializer(ContentSerializer):
    ...
    class Meta:
        model = Post
        fields = ('eid', 'title', 'submitter', 'text', 'children', 'subreddits', 'comment_count', 'upvote_count', 'downvote_count', 'user_vote')
        read_only_fields = ('comment_count', 'upvote_count', 'downvote_count')

class CommentSerializer(ContentSerializer):
    ...
    class Meta:
        model = Comment
        fields = ('eid', 'post', 'author', 'text', 'parent', 'children', 'upvote_count', 'downvote_count', 'user_vote')
        read_only_fields = ('upvote_count', 'downvote_count')

Now if you try to go to a post like http://localhost:8000/api/v1/posts/56314cde-3a49-4958-9234-354e6edde40a/, you’ll see output that includes the user_vote field. Remember to replace the eid above with your own post eid.

HTTP 200 OK
Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept
{
    "eid": "56314cde-3a49-4958-9234-354e6edde40a",
    "title": "Our First Post modified",
    "submitter": 2,
    "text": "Some sample text here.",
    "children": [],
    "subreddits": [
        "97f56643-36f6-4330-810b-a974c589ef07"
    ],
    "comment_count": 0,
    "upvote_count": 0,
    "downvote_count": 0,
    "user_vote": null
}

Handling Upvotes and Downvotes

Lastly, we need to add view actions for up voting or down voting.

To do this, we’re going to create 2 custom detail routes, one for the upvote and the other for the downvote action. A detail route lets DRF know that a particular action can be performed on a particular instance of the object in question (i.e. a comment or a post for us).

We’re also going to put all this logic in another class that both PostViewSet and CommentViewSet will inherit from.

Create a file called mixins.py at reddit/mixins.py and paste in the following.

from .models import *
from .serializers import *
from rest_framework.response import Response
from rest_framework import permissions
from rest_framework.decorators import detail_route, list_route
class VotingMixin(object):
    @staticmethod
    def vote_helper(pk, request, vote_type):
        content_obj = Votable.get_object(pk)
        user = request.user
        content_obj.toggle_vote(user, vote_type)
        if isinstance(content_obj, Comment): Serializer = CommentSerializer
        else: Serializer = PostSerializer
        serializer = Serializer(content_obj, context={'request': request})
        return Response(serializer.data)
    @detail_route(methods=['post'], url_name='upvote', url_path='upvote', permission_classes=[permissions.IsAuthenticated])
    def upvote(self, request, pk=None):
        return VotingMixin.vote_helper(pk, request, UserVote.UP_VOTE)
    @detail_route(methods=['post'], url_name='downvote', url_path='downvote', permission_classes=[permissions.IsAuthenticated])
    def downvote(self, request, pk=None):
        return VotingMixin.vote_helper(pk, request, UserVote.DOWN_VOTE)

Let’s unpack this:

  • @detail_route is the decorator that lets DRF know that this function corresponds to an action we can perform on this object. In our case, we will only allow the post method. And it’s only allowed for users who have been authenticated — we do this by setting the permission_classes to [permissions.IsAuthenticated].
  • The vote_helper function is very similar to our vote view function from before. However, now we won’t always return a post object. If the object being voted upon is a comment, we will return a serialized representation of that comment, else we’ll we will return a serialized representation of that post.
  • Both of the upvotes / downvote methods simply call the vote_helper function and pass in whether it’s an UserVote.UP_VOTE or UserVote.DOWN_VOTE action.

Let’s integrate this into both of PostViewSet and CommentViewSet. Go to reddit/viewsets.py.

First import the mixin:

from .mixins import VotingMixin

Then make sure that both PostViewSet and CommentViewSet inherit from VotingMixin.

class PostViewSet(viewsets.ModelViewSet, VotingMixin):
    ...

and

class CommentViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin, mixins.CreateModelMixin):
    ...

Let’s try it out!

Go to http://localhost:8000/api/v1/posts/56314cde-3a49-4958-9234-354e6edde40a/upvote/ and just click on the “Post” button. Don’t worry about what’s filled in the Content box. It will be ignored.

Remember to replace the eid above with your own post eid.

You should see the following:

HTTP 200 OK
Allow: POST, OPTIONS
Content-Type: application/json
Vary: Accept
{
    "eid": "56314cde-3a49-4958-9234-354e6edde40a",
    "title": "Our First Post modified",
    "submitter": 2,
    "text": "Some sample text here.",
    "children": [],
    "subreddits": [
        "97f56643-36f6-4330-810b-a974c589ef07"
    ],
    "comment_count": 0,
    "upvote_count": 1,
    "downvote_count": 0,
    "user_vote": 1
}

Notice how our upvote_count went up to 1, and our user_vote value is also 1.

Conclusion

Congratulations! You just created your first API using Django Rest Framework. Try adding more functionality to your API (i.e. implement some of the same extensions we suggested in the earlier Reddit project via the API).

The final version of the code is here. (For Django 1.11 users, use the final version here).

Note that you’ll have to run the following commands to install the packages, run migrations, etc.

python3 -m venv myvenv
source myvenv/bin/activate
pip install -r requirements.txt
python manage.py makemigrations
python manage.py migrate
python manage.py createsuperuser

Next Steps

While we’ve created a very simple API here, when you deploy an API to production you must include permissions so that only those users with access can make changes using the API. For example, you would not want someone to delete your post or edit it without your permission. This was beyond the scope of this tutorial, but we recommend reading the documentation on Django REST Framework’s website, especially the tutorial on Authentication & Permissions.


© 2016-2022. All rights reserved.