Quantcast
Channel: CodeSection,代码区,Python开发技术文章_教程 - CodeSec
Viewing all articles
Browse latest Browse all 9596

Build a REST API with Django A Test Driven Approach: Part 2

$
0
0

Related Course

Get Started with javascript for Web Development

JavaScript is the language on fire. Build an app for any platform you want including website, server, mobile, and desktop.

Code

The precondition to freedom is security Rand Beers

Authentication is a pivotal part of the security of an API.

But first, some recap.

Inpart 1 of this series, we learnt about how to create a bucketlist API using the TDD approach. We covered writing tests in Django and also learnt a lot about the Django Rest Framework.

We'll be covering complementary topics in part 2 of our series. For the most part, we'll delve deeper into authenticating and authorizing users in the Django-driven bucketlist API. If you haven't checkedpart 1 yet, now is the chance to do so before we start crushing it.

Ok... back to business!

Authentication vs Authorization

Authentication is usually confused with authorization. They are not the same thing.

You can think of authentication as a way to verify someone's identify. (username, password, tokens, keys et cetera) and authorization as a method that determines the level of access a verified user should be granted.

When we look at our bucketlist API, it works for the most part. It however lacks capabilities such as knowing who created a bucketlist, whether a given user is authenticated in the first place or even whether the they have the right to effect changes onto a bucketlist.

We need to fix that.

We'll implement authentication first and later drop in some authorization features.

Implementing it

Implementing authentication in a DRF API can be done. And the starting point is easy You start by keeping track of the user.

So how do we achieve this? Django provides a default User model that we can play around with.

Ok. Let's get it done.

We're going to create an owner field on the Bucketlist model . Here's why: A user can create a bucketlist which means that a bucketlist has an owner. Therefore, we'll simply add a field definition of a user inside our bucketlist model.

# rest_api/models.py from django.db import models class Bucketlist(models.Model): """This class represents the bucketlist model.""" name = models.CharField(max_length=255, blank=False, unique=True) owner = models.ForeignKey('auth.User', # ADD THIS FIELD related_name='bucketlists', on_delete=models.CASCADE) date_created = models.DateTimeField(auto_now_add=True) date_modified = models.DateTimeField(auto_now=True) def __str__(self): """Return a human readable representation of the model instance.""" return "{}".format(self.name)

The owner fieild uses a ForeignKey class that accepts a number of arguments. The first one auth.User simply points to the model class we wish to create a relationship with.

The foreign key will come from the model class auth.User to enable the relationship between the User and the Bucketlist models.

After this is done, we'll have to run our migrations to reflect the model changes in our database.

We'll run

python3 manage.py makemigrations rest_api A point to note: When writing new fields on existing tables, you might encouter this:
Build a REST API with Django   A Test Driven Approach: Part 2

The database complains that we are trying to add a non-nullable field which should not be null or lacking a value. We need a value for it since we have pre-existing data on the database. A simple hack when under a development environment would be to delete the migrations folder inside your app and the db.sqlite3 file. This will get rid of the bucketlist we created last. We can always create a new one. However, you should never do this on a production environment because you'll lose all your DB data. A cleaner way to fix it is to provide a one-off default value. But if you have no records on your db, feel free to go with the deletion fix.

After doing this, we'll commit the changes to our DB using the migrate command:

python3 manage.py migrate Refactoring Our Tests

So far, we haven't written any tests that work with the new user authentication. We'll therefore have to refactor the existing test cases.

But first, we've got to know what to write.

Let's do some analysis. The changes we need to factor in are:

Bucketlist ownership by users which points to integrating the default Django User model Ensure requests made are made by authenticated users which means we'll enforce authentication before sending HTTP requests Restrict bucketlist(s) creation to only authenticated users Restrict existing bucketlist(s) to be accessed only by their owner

These points will go a long way in guiding us to refactor our tests.

Refactoring the ModelTestCase

We'll import the default User model(django.contrib.auth.User) into our test module to create a user.

# rest_api/tests.py from django.contrib.auth.models import User

The user will help us test for the owner of the bucketlist. We'll create the User in our setUp method so that we don't have to create it every time we want to use it.

class ModelTestCase(TestCase): """This class defines the test suite for the bucketlist model.""" def setUp(self): """Define the test client and other test variables.""" user = User.objects.create(username="nerd") # ADD THIS LINE self.name = "Write world class code" # specify owner of a bucketlist self.bucketlist = Bucketlist(name=self.name, owner=user) # EDIT THIS TOO

Inside the setup method, we've just defined a test user by creating a user with a username. Then, we've added the instance of the user into the bucketlist class. The user is now the owner of that bucketlist.

Refactoring the ViewsTestCase

Since views deals with mainly making requests, we'll ensure only authenticated and authorized users have access to the bucketlist API.

Let's write some code for it

# rest_api/tests.py # import fall here # Model Test Case is here class ViewTestCase(TestCase): """Test suite for the api views.""" def setUp(self): """Define the test client and other test variables.""" user = User.objects.create(username="nerd") # Initialize client and force it to use authentication self.client = APIClient() self.client.force_authenticate(user=user) # Since user model instance is not serializable, use its Id/PK self.bucketlist_data = {'name': 'Go to Ibiza', 'owner': user.id} self.response = self.client.post( reverse('create'), self.bucketlist_data, format="json") def test_api_can_create_a_bucketlist(self): """Test the api has bucket creation capability.""" self.assertEqual(self.response.status_code, status.HTTP_201_CREATED) def test_authorization_is_enforced(self): """Test that the api has user authorization.""" new_client = APIClient() res = new_client.get('/bucketlists/', kwargs={'pk': 3}, format="json") self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) def test_api_can_get_a_bucketlist(self): """Test the api can get a given bucketlist.""" bucketlist = Bucketlist.objects.get(id=1) response = self.client.get( '/bucketlists/', kwargs={'pk': bucketlist.id}, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertContains(response, bucketlist) def test_api_can_update_bucketlist(self): """Test the api can update a given bucketlist.""" bucketlist = Bucketlist.objects.get() change_bucketlist = {'name': 'Something new'} res = self.client.put( reverse('details', kwargs={'pk': bucketlist.id}), change_bucketlist, format='json' ) self.assertEqual(res.status_code, status.HTTP_200_OK) def test_api_can_delete_bucketlist(self): """Test the api can delete a bucketlist.""" bucketlist = Bucketlist.objects.get() response = self.client.delete( reverse('details', kwargs={'pk': bucketlist.id}), format='json', follow=True) self.assertEquals(response.status_code, status.HTTP_204_NO_CONTENT)

We initialized the ApiClient and forced it to use authentication. This enforces the API's security. The bucketlist ownership has been factored in as well. Also, notice how we consistently use self.client in each test method instead of creating new ones? This is to ensure that we reuse the authenticated client. Reusability is good practice. :-) Great!

Run the tests. They should fail for now .

python3 manage.py test rest_api

The next step is to refactor our code to make these failing tests pass.

How To Pass Those Tests! First, Integrate the User

For the most part, any changes you make in your model should be reflected in your serializers too. This is because serializers interface directly with the model which aids in changing weird-looking query sets to json and vice versa.

Let's edit our bucketlist serializer. We'll simply jump into the serializers.py file and write a custom field that we'll preferably call owner . This is the owner of a bucketlist.

# rest_api/serializers.py class BucketlistSerializer(serializers.ModelSerializer): """Serializer to map the model instance into json format.""" owner = serializers.ReadOnlyField(source='owner.username') # ADD THIS LINE class Meta: """Map this serializer to a model and their fields.""" model = Bucketlist fields = ('id', 'name', 'owner', 'date_created', 'date_modified') # ADD 'owner' read_only_fields = ('date_created', 'date_modified')

The owner field is read-only so that a user using our api cannot alter the owner of a bucketlist. Don't forget to add the owner into the fields as directed above.

Let's run this and see if it works: Start the server python3 manage.py runserver

When we access it from localhost, we should see something like this:


Build a REST API with Django   A Test Driven Approach: Part 2

Now, we need to make a way to save the owner when a new bucketlist is created. Saving a bucketlist is done in a class called CreateView that we defined in views.py . We'll edit our CreateView class by adding a perform_create(self, serializer) method. This method gives us control on how to save our serializer.

# rest_api/views.py # We are inside the CreateView class ... def perform_create(self, serializer): """Save the post data when creating a new bucketlist.""" serializer.save(owner=self.request.user) # Add owner=self.request.user

The serializer.save() accepts field arguments. Here, we specified the owner argument. Why? Because our serializer has it as a field which means that we can specify the owner in a serializer's save method that will then save the bucketlist with a user as its owner.

We should now get an error that looks like this when we try to create a bucketlist
Build a REST API with Django   A Test Driven Approach: Part 2
The DB complains. Why a Value Error? Good question It's simply because we are trying to save a bucketlist from the browser without specifying the owner !

Our new non-nullable owner field needs a value before the serializer can validate and save a bucketlist.

Let's fix that right away.

In our urls.py , we'll add a route for helping the user to log in to our api before creating a bucketlist. We do this to allow a bucketlist to have an owner, that is if the logged in user decides to create one.

# rest_api/urls.py # imports fall here urlpatterns = { url(r'^auth/', include('rest_framework.urls', # ADD THIS URL namespace='rest_framework')), url(r'^bucketlists/$', CreateView.as_view(), name="create"), url(r'^bucketlists/(?P<pk>[0-9]+)/$', DetailsView.as_view(), name="details"), } urlpatterns = format_suffix_patterns(urlpatterns)

This new line includes the DRF routes that provides a default login template to authenticate a user. You can call the route anything you want apart from auth .

Save the file. It will automatically refresh the running server instance.

You should now see a login button on the top right of the screen when you access

http://localhost/bucketlists/

Clicking the button will redirect to a login template.
Build a REST API with Django   A Test Driven Approach: Part 2

Let's create a super-user for which to log in with.

python3 manage.py createsuperuser

Logging in should be a breeze with the username and password we just specified.

Authorization: Adding permissions

Right now, any user can view and edit any bucketlist. We'd want to tie the user to their bucketlist so that only the owner can effect changes like editing and deletion to it.

A default permission check

We can use the default permission package to restrict bucketlist access to authenticated users only.

In views.py we'll import the permission classes

from rest_framework import permissions

Then inside our CreateView class we'll add the permission class IsAuthenticated .

# rest_api/views.py class CreateView(generics.ListCreateAPIView): """This class handles the GET and POSt requests of our rest api.""" queryset = Bucketlist.objects.all() serializer_class = BucketlistSerializer permission_classes = (permissions.IsAuthenticated,) # ADD THIS LINE

The permission class IsAuthenticated will deny permission to any unauthenticated user, and allow permission otherwise. We could have used IsAuthenticatedOrReadOnly which permits unauthenticated users if the request is one of the "safe" methods ( GET , HEAD and OPTIONS ). But we want full security we'll stick to IsAuthenticated .

Custom Permission

Right now, any authenticated user can see the other user's bucketlists. To implement the full concept of ownership, we'll have to create a custom permission.

Let's create a file called permissions.py inside the rest_api directory. Inside this file, we write the following code:

from rest_framework.permissions import BasePermission from .models import Bucketlist class IsOwner(BasePermission): """Custom permission class to allow only bucketlist owners to edit them.""" def has_object_permission(self, request, view, obj): """Return True if permission is granted to the bucketlist owner.""" if isinstance(obj, Bucketlist): return obj.owner == request.user return obj.owner == request.user

The class above implements a permission which holds by this truth The user has to be the owner to have that object's permission. If they are indeed the owner of that bucketlist, it returns True, else False.

We just have to add it inside our permission_classes tuple and we are set. For clarity, the updated view.py should now look like this:

# rest_api/views.py from rest_framework import generics, permissions from .permissions import IsOwner from .serializers import BucketlistSerializer from .models import Bucketlist class CreateView(generics.ListCreateAPIView): """This class handles the GET and POSt requests of our rest api.""" queryset = Bucketlist.objects.all() serializer_class = BucketlistSerializer permission_classes = ( permissions.IsAuthenticated, IsOwner) def perform_create(self, serializer): """Save the post data when creating a new bucketlist.""" serializer.save(owner=self.request.user) class DetailsView(generics.RetrieveUpdateDestroyAPIView): """This class handles GET, PUT, PATCH and DELETE requests.""" queryset = Bucketlist.objects.all() serializer_class = BucketlistSerializer permission_classes = ( permissions.IsAuthenticated, IsOwner)

If we log out and try to get the bucketlists, we'll be hit by a HTTP 403 Forbidden response. This means that our authentication and authorization is actually working!

Awesome!


Build a REST API with Django   A Test Driven Approach: Part 2

Finally, we run our tests and see whether they'll pass:

python3 manage.py test
Build a REST API with Django   A Test Driven Approach: Part 2

Moving on swiftly.

What about Token-based Authentication?

Token authentication is appropriate for client server setups especially when the consumption clients are native desktop or native mobile.

This is how it works A user requests a security token from the server. The server generates the token and associates it with that user. After sending the token, the server waits for the user to request for resources using that specific token. The user can then use the token to authenticate and prove to the server that he/she is indeed a valid user.

For us to use token authentication in our API, we'll have to set up some configurations on the settings.py file.

Let's add rest_framework.authtoken in our list of installed apps like this:

# project/settings.py INSTALLED_APPS = ( 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', 'rest_api', # note the comma (if you lack it, errors! the horror!) 'rest_framework.authtoken' # ADD THIS LINE )

Every time we create a user, we'd like to also create a security token for them. But how do we ensure that a user creation will also trigger a token creation?

Enter signals.

Django comes packed with a signal dispatcher . A dispatcher is like a messanger sent forth to notify others about an event that just happened. When a user is created, a post_save signal will be emitted by the User model. A receiver (which is simply a function) will then help us catch this post_save signal and immediately create the token.

Our receiver will live in our models.py file. A couple of imports to add: the post_save signal , the default User model , the Token model and the receiver :

from django.db.models.signals import post_save from django.contrib.auth.models import User from rest_framework.authtoken.models import Token from django.dispatch import receiver

Then write the receiver at the bottom of the file like this:

# rest_api/models.py from django.db.models.signals import post_save from django.contrib.auth.models import User from rest_framework.authtoken.models import Token from django.dispatch import receiver class Bucketlist(models.Model): """This class represents the bucketlist model.""" name = models.CharField(max_length=255, blank=False, unique=True) owner = models.ForeignKey( 'auth.User', related_name='bucketlists', on_delete=models.CASCADE) date_created = models.DateTimeField(auto_now_add=True) date_modified = models.DateTimeField(auto_now=True) def __str__(self): """Return a human readable representation of the model instance.""" return "{}".format(self.name) # This receiver handles token creation immediately a new user is created. @receiver(post_save, sender=User) def create_auth_token(sender, instance=None, created=False, **kwargs): if created: Token.objects.create(user=instance)

Note that the receiver is NOT indented inside the Bucketlist model class . It's a common mistake to indent it inside the class.

We also need to provide a way for the user to obtain the token. A url will serve the purpose. Write the following lines of code on the urls.py:

# rest_api/urls.py from rest_framework.authtoken.views import obtain_auth_token # add this import urlpatterns = { url(r'^bucketlists/$', CreateView.as_view(), name="create"), url(r'^bucketlists/(?P<pk>[0-9]+)/$', DetailsView.as_view(), name="details"), url(r'^auth/', include('rest_framework.urls', namespace='rest_framework')), url(r'^users/$', UserView.as_view(), name="users"), url(r'users/(?P<pk>[0-9]+)/$', UserDetailsView.as_view(), name="user_details"), url(r'^get-token/', obtain_auth_token), # Add this line } urlpatterns = format_suffix_patterns(urlpatterns)

The rest framework is so powerful that it provides a built-in view which handles obtaining the token when a user posts their username and password.

We'll go ahead with making migrations and migrate the changes to the database so that our app can tap the power of this built-in view.

python3 manage.py makemigrations && python3 manage.py migrate

Finally, we add some configs to the settings so that our app can authenticate with both BasicAuthentication and TokenAuthentication.

# project/settings.py REST_FRAMEWORK = { 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticated', ), 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework.authentication.BasicAuthentication', 'rest_framework.authentication.TokenAuthentication', ) }

The DEFAULT_AUTHENTICATION_CLASSES config tells the app that we wish to configure more than one ways of authenticating the user. We specify the ways by referencing the built-in authentication classes inside this tuple.

Run it

Once saved, the server will automagically restart with the added changes if it's already running. However, it's good to just rerun the server with python3 manage.py runserver

To visually test whether our api still stands, we'll make the HTTP requests on Postman

Postman Step 1: Obtain that token

For clients to authenticate, the token obtained should be included in the Authorization HTTP header. We prepend the word Token followed by a space character. The header should look like this:

Authorization: Token 2777b09199c62bcf9418ad846dd0e4bbdfc6ee4b

Don't forget to put the space in between.

We'll make a post request to http://localhost:8000/get-token/ , specifying the username and password in the process.


Build a REST API with Django   A Test Driven Approach: Part 2
Postman Step 2: Use obtained token in Authorization header

For the subsequent requests, we'll have to include the Authorization header if we ever want to access the API resources.

A common mistake that might cause errors here is inputing an incorrect format for the Authorization header. Here's a common error message from the server:

{ "detail": "Authentication credentials were not provided." }

Ensure you input this format instead Token <your-new-token-is-here> . If you want to have a different keyword in the header, such as Bearer , simply subclass TokenAuthentication and set the keyword class variable.

Let's try sending a GET request. It should yield something like this:
Build a REST API with Django   A Test Driven Approach: Part 2
Feel free to play around with your now well secured API. Conclusion

If you've read this to the end, you are awesome!

We've covered quite a lot! From implementing user authentication to creating custom permissions for implementing authorization, we've covered most of securing a Django API.

We also conveniently defined a token-based authentication layer so that mobile and desktop clients can securely consume our API. But the most important thing is that we refactored our tests to accomodate the changes. This is paramount to anything else and remains the heart of Test Driven Development.

If you get to that point where you ask yourself, " what is going on here? ", I highly recommend you take a look atPart 1 of this series which aptly provides a detailed tutorial on building a bucketlist API the TDD way.

Happy coding!


Viewing all articles
Browse latest Browse all 9596

Trending Articles