June 24, 2022, 3:26 a.m.
This is a REST API I developed for fun using Django and Django Rest Framework. The API allows a user to save activities they like to do when they are bored. When the user is bored, they can send a query with either name or nature in query parameters. The full source code for this project is available here.
The REST API was developed using:
The project has two apps, an accounts
app to manage user authentication and an activities
app for managing activities. As is usual practice for Django, I added these apps to the INSTALLED_APPS
setting in settings.py
together with rest_framework
and rest_framework.authtoken
.
activity_generator/settings.py
INSTALLED_APPS += [
"rest_framework",
"rest_framework.authtoken",
"accounts",
"activities",
]
I also included the URLs of the two apps in the project urls.py
, that is in activity_generator/urls.py
.
urlpatterns += [
path("accounts/", include("accounts.urls", namespace="accounts")),
path("", include("activities.urls", namespace="activities")),
]
The accounts
app handles authentication.
Models for accounts app are defined in accounts/models.py. It has a UserManager
class which extends the django.contrib.auth.models.BaseUserManager
class and has custom create_user()
and create_superuser()
methods which take email
, first_name
, last_name
and password
as parameters. It also has a Use
r model that extends the django.contrib.auth.models.BaseUser
and django.contrib.auth.models.PermissionsMixin
classes. It also uses emai
l as the username
field thereby overriding the Django default username
field, and makes first_name
and last_name
required fields.
accounts/models.py
from django.db import models
from django.contrib.auth.models import (
AbstractBaseUser,
BaseUserManager,
PermissionsMixin,
)
from django.forms import ValidationError
class UserManager(BaseUserManager):
"""Base manager for user model."""
def create_user(self, email, first_name, last_name, password):
"""Create a new user."""
if not email:
raise ValidationError("A user must have an email address.")
email = self.normalize_email(email)
user = self.model(email=email, first_name=first_name, last_name=last_name)
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, email, first_name, last_name, password):
"""Create a new superuser."""
user = self.create_user(email, first_name, last_name, password)
user.is_superuser = True
user.is_staff = True
user.save(using=self._db)
return user
class User(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(max_length=100, unique=True)
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
is_active = models.BooleanField(default=True)
is_staff = models.BooleanField(default=False)
objects = UserManager()
USERNAME_FIELD = "email"
REQUIRED_FIELDS = ["first_name", "last_name"]
def get_full_name(self):
return f"{self.first_name} {self.last_name}"
def get_short_name(self):
return self.first_name
def __str__(self):
return self.email
The accounts
app has two serializers, UserSerializer
and AuthTokenSerializer
. The UserSerializer
extends rest_framework.serializers.ModelSerializer
while the AuthTokenSerializer
extends rest_framework.serializers.Serializer
. For the UserSerializer
, I override the create()
method to create a new user by calling the User.objects.create_user()
method. For the AuthTokenSerializer
, I also override the validate()
method for AuthTokenSerializer
to handle user authentication and provide users with tokens.
accounts/serializers.py
from django.contrib.auth import authenticate, get_user_model
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
class UserSerializer(serializers.ModelSerializer):
"""Serializer for the user object."""
class Meta:
model = get_user_model()
fields = ("email", "first_name", "last_name", "password")
extra_kwargs = {"password": {"write_only": True, "min_length": 8}}
def create(self, validated_data):
"""Create a new user with encrypted password and return the user."""
return get_user_model().objects.create_user(**validated_data)
class AuthTokenSerializer(serializers.Serializer):
"""Serializer for user authentication object."""
email = serializers.CharField()
password = serializers.CharField(
style={"input_type": "password"}, trim_whitespace=True
)
def validate(self, attrs):
"""Validate and authenticate the user."""
email = attrs.get("email")
password = attrs.get("password")
user = authenticate(
request=self.context.get("request"), username=email, password=password
)
if not user:
msg = _("Unable to authenticate with provided details.")
raise serializers.ValidationError(msg, code="authentication")
attrs["user"] = user
return attrs
The accounts
app has three views; one for creating the user, one for creating an authentication token for the user and another for managing the user.
accounts/views.py
from rest_framework import authentication, generics, permissions
from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.settings import api_settings
from .serializers import AuthTokenSerializer, UserSerializer
class CreateUserView(generics.CreateAPIView):
"""Create a new user in the system."""
serializer_class = UserSerializer
class CreateTokenView(ObtainAuthToken):
"""Create a token for a user."""
serializer_class = AuthTokenSerializer
renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES
class ManageUserView(generics.RetrieveUpdateAPIView):
"""Manage the authenticated user."""
serializer_class = UserSerializer
authentication_classes = (authentication.TokenAuthentication,)
permission_classes = (permissions.IsAuthenticated,)
def get_object(self):
"""Retrieve and return authenticated user."""
return self.request.user
Wiring the views to URLS is pretty simple and straightforward. I use the .as_view()
method for class based views as they extent rest_framework.generics
views and that’s it.
from django.urls import path
from . import views
app_name = "accounts"
urlpatterns = [
path("create/", views.CreateUserView.as_view(), name="create"),
path("token/", views.CreateTokenView.as_view(), name="token"),
path("me/", views.ManageUserView.as_view(), name="me"),
]
The accounts
app is up and running. Now, moving on to the activities
app.
The activities
app has two models Tag
and Activity
, both of which have user
as a foreign key since a user should see only their own tags and activities. A user can create tags and add them to activities when they create an activity or to an already created activity. To get activities to do when the user is bored, the user sends a GET
request to “/activities/”
url with a name or nature as a query parameter. For example, a GET
request for activities which are outdoors would be like “/activities/?nature=outdoor”
.
The model definitions are shown below.
activities/models.py
from django.db import models
from accounts.models import User
class Tag(models.Model):
"""Tags to be used for an activity."""
name = models.CharField(max_length=255)
user = models.ForeignKey(User, on_delete=models.CASCADE)
def __str__(self):
return self.name
class Activity(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
nature = models.CharField(max_length=255)
tag = models.ForeignKey(Tag, on_delete=models.CASCADE)
class Meta:
verbose_name_plural = "activities"
def __str__(self):
return f"{self.user} - {self.name}"
The serializers for both models extend rest_framework.serializers.ModelSerializer
and are very basic, with no customization whatsoever as there was no need to override any of the base serializer methods.
activities/serializers.py
from rest_framework import serializers
from activities.models import Activity, Tag
class TagSerializer(serializers.ModelSerializer):
"""Serializer for tag objects."""
class Meta:
model = Tag
fields = ("id", "name")
read_only_fields = ("id",)
class ActivitySerializer(serializers.ModelSerializer):
"""Serializer for the activity model."""
class Meta:
model = Activity
fields = ("id", "user", "name", "nature", "tag")
read_only_fields = ("id",)
For the views this time, I used rest_framework.viewsets
and rest_framewok.mixins
as well as rest_framework.permissions
. Viewsets do not have get()
and post()
methods but instead have list()
, create()
, update()
, retrieve()
, delete()
etc methods. The mixins allow us to specify which of the default viewset methods a user is allowed to do and permission classes allow us to specify whether authentication is required or not.
Since I am restricting users to accessing their own content, I write custom get_queryset()
and perform_create()
methods to override the default behaviour of those methods. The activities/views.py
is shown below.
activities/views.py
from rest_framework import mixins, viewsets
from rest_framework.authentication import TokenAuthentication
from rest_framework.permissions import IsAuthenticated
from .models import Activity, Tag
from .serializers import ActivitySerializer, TagSerializer
class TagViewSet(
viewsets.GenericViewSet, mixins.ListModelMixin, mixins.CreateModelMixin
):
"""Viewset for Tag objects."""
authentication_classes = (TokenAuthentication,)
permission_classes = (IsAuthenticated,)
queryset = Tag.objects.all()
serializer_class = TagSerializer
def get_queryset(self):
"""Return objects for the current authenticated user only."""
assigned_only = bool(int(self.request.query_params.get("assigned_only", 0)))
queryset = self.queryset
if assigned_only:
queryset = queryset.filter(activity__isnull=False)
return queryset.filter(user=self.request.user).order_by("-name").distinct()
def perform_create(self, serializer):
"""Create a new tag"""
serializer.save(user=self.request.user)
class ActivityViewSet(
viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
):
"""Manage activities in the database."""
queryset = Activity.objects.all()
serializer_class = ActivitySerializer
authentication_classes = (TokenAuthentication,)
permission_classes = (IsAuthenticated,)
def get_queryset(self):
nature = self.request.query_params.get("nature")
name = self.request.query_params.get("name")
queryset = self.queryset
if nature:
return queryset.filter(
user=self.request.user, nature__icontains=nature
).order_by("-id")
if name:
return queryset.filter(
user=self.request.user, name__icontains=name
).order_by("-id")
return queryset.filter(user=self.request.user).order_by("-id")
def perform_create(self, serializer):
"""Create a new activity."""
serializer.save(user=self.request.user)
URLs for viewsets are implemented a bit differently from those of generic views using rest_framework.routers.DefaultRouter
.
activities/urls.py
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from . import views
app_name = "activities"
router = DefaultRouter()
router.register("tags", views.TagViewSet)
router.register("activities", views.ActivityViewSet)
urlpatterns = [
path("", include(router.urls)),
]
As in all my recent projects, I used black
for code formatting by running the black .
command in a terminal with a virtualenv
enabled.
Once again for testing I used pytest since it makes it so easy to separate test data fixtures from the actual tests using the conftest.py. Especially given that the project has two apps which need to share the same authenticated and non-authenticated user clients, pytest enables me to follow the DRY principle as I just put shared fixtures in tests/unit_tests/conftest.py and then app specific fixtures, I put them in the conftest.py for each app.
I indulged myself on this project a bit by adding to the tests I usually write for models, views, forms etc, tests for serializers. For this I had to use django-factoryboy and Faker for the first time in my own app and it was fun putting together tests and fixtures for the serializers.
To test serializers, I needed to create a factories.py
in tests/unit_tests/test_accounts
for the UserFactory
and TokenFactory
, and another factories.py
in tests/unit_tests/test_activities
for TagFactory
and ActivityFactory
.
tests/unit_tests/test_accounts/factories.py
import factory
from faker import Factory as FakerFactory
from pytest_factoryboy import register
from accounts.models import User
faker = FakerFactory.create()
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
first_name = factory.LazyAttribute(lambda x: faker.first_name())
last_name = factory.LazyAttribute(lambda x: faker.last_name())
email = factory.LazyAttribute(lambda x: faker.email())
password = factory.LazyAttribute(lambda x: faker.password())
class TokenFactory(factory.Factory):
class Meta:
model = User
email = factory.LazyAttribute(lambda x: faker.email())
password = factory.LazyAttribute(lambda x: faker.password())
register(UserFactory)
register(TokenFactory)
tests/unit_tests/test_accounts/test_serializers.py
import factory
import pytest
from accounts.serializers import UserSerializer, AuthTokenSerializer
from tests.unit_tests.test_accounts.factories import UserFactory, TokenFactory
@pytest.mark.unit
def test_serialize_user_model():
user = UserFactory()
serializer = UserSerializer(user)
assert serializer.data
@pytest.mark.unit
def test_serialized_data(mocker):
valid_serialized_data = factory.build(dict, FACTORY_CLASS=UserFactory)
serializer = UserSerializer(data=valid_serialized_data)
assert serializer.is_valid(raise_exception=True)
assert serializer.errors == {}
@pytest.mark.unit
def test_serialize_token():
token = TokenFactory()
serializer = AuthTokenSerializer(token)
assert serializer
tests/unit_tests/test_activities/factories.py
import factory
from faker import Factory as FakerFactory
from pytest_factoryboy import register
from activities.models import Activity, Tag
from tests.unit_tests.test_accounts.factories import UserFactory
faker = FakerFactory.create()
class TagFactory(factory.django.DjangoModelFactory):
class Meta:
model = Tag
name = factory.lazy_attribute(lambda x: faker.word())
user = factory.SubFactory(UserFactory)
class ActivityFactory(factory.django.DjangoModelFactory):
class Meta:
model = Activity
user = factory.SubFactory(UserFactory)
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=3))
nature = factory.LazyAttribute(lambda x: faker.sentence(nb_words=3))
tag = factory.SubFactory(TagFactory)
register(TagFactory)
register(ActivityFactory)
tests/unit_tests/test_activities/test_serializers.py
import pytest
from activities.serializers import ActivitySerializer, TagSerializer
from tests.unit_tests.test_activities.factories import ActivityFactory, TagFactory
@pytest.mark.unit
def test_serialize_activity_model():
activity = ActivityFactory()
serializer = ActivitySerializer(activity)
assert serializer.data
@pytest.mark.unit
def test_activity_serialized_data(user, tag):
t = ActivityFactory.build()
valid_serialized_data = {
"user": user.pk,
"name": t.name,
"nature": t.nature,
"tag": tag.pk,
}
serializer = ActivitySerializer(data=valid_serialized_data)
assert serializer.is_valid(raise_exception=True)
assert serializer.errors == {}
@pytest.mark.unit
def test_tag_serializer():
tag = TagFactory()
serializer = TagSerializer(tag)
assert serializer.data
@pytest.mark.unit
def test_tag_serializer_data(mocker):
t = TagFactory.build()
valid_serializer_data = {"user": t.user.pk, "name": t.name}
serializer = TagSerializer(data=valid_serializer_data)
assert serializer.is_valid(raise_exception=True)
assert serializer.errors == {}
I used GitHub Actions and defined three .yaml files for unit tests, black formatting and coverage and uploaded the coverage report to Codecov.
This was a fun project to build and experience TDD at work. It also made me appreciate testing event more as I had to set up factories to use for serializers. You can try and build your own version of this API to your own specification and hope you had fun following my own account of this API.
Pytest equivalent of
unittest.TestCase.assertTrue()
is
assert some_condition
.
For example
self.assertTrue(form.is_valid())
using pytest would be
assert form.is_valid()
.
✨Magic ✨