loading...

Create proper REST API with Django, DRF, JWT and OpenApi

misiekofski profile image Michal Dobrzycki ・7 min read

Summary of this post.

In this post I’ll introduce following concepts, feel free to skip to the next post, if you are familiar with them already:

  1. Create Django project with Django Rest Framework
  2. Add OpenApi specs to generate dynamically API documentation
  3. Use JWT (JSON Web Token) for authentication and authorization

Step #1 - create new project

Start a new project (either with PyCharm -> new Django project or django-admin startproject restapi_article). I won't cover basics, because You can always go to Django Tutorial and learn from official documentation (which I find the best at the time of writing this article).

Once we have Django project set up we need to install python libraries below (for best experience You should use virtual environment):

  • Django
  • djangorestframework
  • drf-yasg
  • djangorestframework-simplejwt

It's always good to conform to standards, so we should create requirements.txt file with all libraries. You can create it by using this command in the project root folder pip freeze > requirements.txt. The file should look like this:

asgiref==3.2.10
certifi==2020.6.20
chardet==3.0.4
coreapi==2.3.3
coreschema==0.0.4
Django==3.1.1
djangorestframework==3.11.1
djangorestframework-simplejwt==4.4.0
drf-yasg==1.17.1
idna==2.10
inflection==0.5.1
itypes==1.2.0
Jinja2==2.11.2
MarkupSafe==1.1.1
packaging==20.4
PyJWT==1.7.1
pyparsing==2.4.7
pytz==2020.1
requests==2.24.0
ruamel.yaml==0.16.12
ruamel.yaml.clib==0.2.2
six==1.15.0
sqlparse==0.3.1
uritemplate==3.0.1
urllib3==1.25.10

Next create an application with command python3 manage.py startapp restapi

Your file structure after creation should look like this:

. 
├── restapi
│   ├── migrations
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── models.py
│   ├── tests.py
│   └── views.py
├── restapi_article
│   ├── __init__.py
│   ├── asgi.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── venv
├── db.sqlite3
├── manage.py
└── requirements.txt

And add to our restapi_article\settings.py those three apps

INSTALLED_APPS = [
    ...
    'rest_framework',
    'drf_yasg',
    'restapi',
]

Edit urls.py to use root restapi.urls as main API application urls, this way we will have localhost:8000/admin to play with Django admin panel, and /api/v1 endpoint as prefix for all urls (which is a really good way to version your API, as it might easily change in the future)

from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/v1/', include('restapi.urls')),
]

Step 2 - create data models

I want to have a Drinking Diary application, where I can select a day and then post a note for myself. Also, I want to have a user profile with Bio field, location and date of Birth.

Profile and Day will be linked as Many-To-Many relationship through the DrinkingDay model where I can post a note for a given day (what I drank and with whom for example).

TODO (future): create an Event model, where you can ask some people to join you in drinking. It will have to combine multiple Profiles/Users with one Date

We will also use signals to create hooks for User creation, so we need only to create or update the Profile model, and User will be created/updated automatically with one request.

models.py

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


class Day(models.Model):
    day = models.DateField()


class Profile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    bio = models.TextField(max_length=500, blank=True)
    location = models.CharField(max_length=30, blank=True)
    birth_date = models.DateField(null=True, blank=True)
    days = models.ManyToManyField(Day, through='DrinkingDay')


class DrinkingDay(models.Model):
    profile = models.ForeignKey(Profile, on_delete=models.CASCADE)
    day = models.ForeignKey(Day, on_delete=models.CASCADE)
    note = models.TextField(max_length=1000, blank=True)

# Use signals so our Profile model will be automatically created
# or updated when we create or update User instance
# https://docs.djangoproject.com/en/3.1/topics/signals/
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)
http://127.0.0.1:8000/api/v1/profile/

@receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs):
    instance.profile.save()

Now let's create and apply migrations for DB and then create superuser to be able to login to our admin panel. You need to run those three commands one by one (you can change email and username of course)

python manage.py makemigrations
python manage.py migrate
python manage.py createsuperuser --email admin@example.com --username admin

Step 3 - create serializers

Now we need to add serializers. Serializer in shortcut is a way to create JSON from an object.
Let's start writing serializers for User and Profile model (we need to do them both, as it won't work without User):

serializers.py

from django.contrib.auth.models import User
from rest_framework import serializers

from restapi.models import Profile


class UserSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = User
        fields = ['url', 'username', 'email', 'groups']


class ProfileSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = Profile
        fields = ['user', 'bio', 'location', 'birth_date', 'days']

Step 4 - create viewsets

Now that we have serializers in place, we can create Views in Django. So at every url we will see what we want. Let's do that for both UserView and ProfileView (although here we could omit User view, as it's not required.

Change views.py (in restapi folder):

from django.contrib.auth.models import User
from rest_framework import viewsets

from restapi.models import Profile
from restapi.serializers import ProfileSerializer, UserSerializer


class UserViewSet(viewsets.ModelViewSet):
    """
    API endpoint that allows users to be viewed or edited.
    """
    queryset = User.objects.all().order_by('-date_joined')
    serializer_class = UserSerializer


class ProfileViewSet(viewsets.ModelViewSet):
    """
    API endpoint that allows profiles to be viewed or edited.
    """
    queryset = Profile.objects.all()
    serializer_class = ProfileSerializer

Step 5 - register router for Viewsets

DRF allows us to use something called routers. This will handle automatic URL routing to Django for us.

Change urls.py (in restapi folder):

from django.urls import path, include
from rest_framework import routers

from restapi import views

router = routers.DefaultRouter()
router.register(r'profile', views.ProfileViewSet)
router.register(r'user', views.UserViewSet)

urlpatterns = [
    path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
    path('', include(router.urls)),
]

Now we can run our app with python manage.py runserver and go to http://127.0.0.1:8000/api/v1/ to see that our application is working :)

If you created a superuser, that should automatically create Profile for him, so going to http://127.0.0.1:8000/api/v1/profile/1/ should allow you to update your superuser Profile.

Step 6 - add Swagger documentation

Everything is already good to go, we just need to create URL for showing Swagger documentation. Also, it's a good practice to create schema_view to describe our API for other developers.

Update urls.py (in restapi folder):

from django.urls import path, include, re_path
from drf_yasg import openapi
from drf_yasg.views import get_schema_view
from rest_framework import routers, permissions

from restapi import views

router = routers.DefaultRouter()
router.register(r'profile', views.ProfileViewSet)
router.register(r'user', views.UserViewSet)

schema_view = get_schema_view(
   openapi.Info(
      title="Drinking Day API",
      default_version='v1',
      description="This API allows us to keep a diary of our daily drinking",
      terms_of_service="https://www.scvconsultants.com",
      contact=openapi.Contact(email="michal@scvconsultants.com"),
      license=openapi.License(name="MIT License"),
   ),
   public=True,
   permission_classes=(permissions.AllowAny,),
)

urlpatterns = [
    path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
    path('', include(router.urls)),
    re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
    path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
]

Now we should be able to see Swagger UI at:
http://127.0.0.1:8000/api/v1/swagger/
and .json + .yaml schema at links below:
http://127.0.0.1:8000/api/v1/swagger.json
http://127.0.0.1:8000/api/v1/swagger.yaml

Step 7 - Add JWT tokens to limit access to Profile model.

First, we need to update settings.py to add JWT auth classes. I also add pagination as this is a great practice to use default page size for collections (we don't need to send everything at once, right?).

# REST FRAMEWORK config
REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 25,
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    )
}

Then we need to create an endpoint for grabbing and refreshing Token (as tokens should expire due to security reasons). You can set up it yourself with the official documentation.

Let's add it in urls.py

from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
)

urlpatterns = [
    ...
    path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    ...
]

Now you can go to: http://127.0.0.1:8000/api/v1/token/ and send your superuser credentials as a POST and you should get a beautiful working token, which you can decode and verify here and it should decode to user_id that corresponds with your credentials (user_id will be probably 1).

Adding now permissions to our views is really easy, first import in views.py

from rest_framework import permissions

And then in our specific view (let's use ProfileViewSet for this) add:

class ProfileViewSet(viewsets.ModelViewSet, mixins.UpdateModelMixin):
    ...
    permission_classes = [permissions.IsAuthenticatedOrReadOnly]

Now we can easily add users, but we can't update our Profile unless we are providing proper JWT Token. The whole permissions list is available in Rest Framework documentation

Coming next:

  • Creating CI/CD with usage of Github and Heroku.
  • Dockerization
  • Integration tests for API with pytest

Posted on by:

misiekofski profile

Michal Dobrzycki

@misiekofski

Automated Testing, DevOps, Django and Python

Discussion

pic
Editor guide