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 aPrimaryKeyRelatedField
. It is not required to be provided by the API, and it isread_only
. We don’t need this information to be passed in, since we can just use the logged in user as thesubmitter
. 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 toposts
in theSubRedditSerializer
above.subreddits
is similar tomoderators
in theSubRedditSerializer
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.
- We set the
submitter
to the logged in user. - 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 asread_only
. Let’s check if it actually works. In our creation request, we’ll also put in a dummy value ofcomment_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 aread_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 setallow_null
toTrue
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 thepost
method. And it’s only allowed for users who have been authenticated — we do this by setting thepermission_classes
to[permissions.IsAuthenticated]
.- The
vote_helper
function is very similar to ourvote
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 anUserVote.UP_VOTE
orUserVote.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.