Developer and blog author Dmitriy Chukhin codes at his desk in the Caktus office

As part of our work to make sharp web apps at Caktus, we frequently create API endpoints that allow other software to interact with a server. Oftentimes this means using a frontend app (React, Vue, or Angular), though it could also mean connecting some other piece of software to interact with a server. A lot of our API endpoints, across projects, end up functioning in similar ways, so we have become efficient at writing them, and this blog post gives an example of how to do so.

First, a few resources: read more about API endpoints in this previous blog post and review documentation on Django Rest Framework.

A typical request for an API endpoint may be something like: 'the front end app needs to be able to read, create, and update companies through the API'. Here is a summary of creating a model, a serializer, and a view for such a scenario, including tests for each part:

Part 1: Model

For this example, we’ll assume that a Company model doesn’t currently exist in Django, so we will create one with some basic fields:

# models.py
from django.db import models


class Company(models.Model):
    name = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    website = models.URLField(blank=True)
    street_line_1 = models.CharField(max_length=255)
    street_line_2 = models.CharField(max_length=255, blank=True)
    city = models.CharField(max_length=80)
    state = models.CharField(max_length=80)
    zipcode = models.CharField(max_length=10)

    def __str__(self):
        return self.name

Writing tests is important for making sure our app works well, so we add one for the __str__() method. Note: we use the factory-boy and Faker libraries for creating test data:

# tests/factories.py
from factory import DjangoModelFactory, Faker

from ..models import Company


class CompanyFactory(DjangoModelFactory):
    name = Faker('company')
    description = Faker('text')
    website = Faker('url')
    street_line_1 = Faker('street_address')
    city = Faker('city')
    state = Faker('state_abbr')
    zipcode = Faker('zipcode')

    class Meta:
        model = Company
# tests/test_models.py
from django.test import TestCase

from ..models import Company
from .factories import CompanyFactory


class CompanyTestCase(TestCase):
    def test_str(self):
        """Test for string representation."""
        company = CompanyFactory()
        self.assertEqual(str(company), company.name)

With a model created, we can move on to creating a serializer for handling the data going in and out of our app for the Company model.

Part 2: Serializer

Django Rest Framework uses serializers to handle converting data between JSON or XML and native Python objects. There are a number of helpful serializers we can import that will make serializing our objects easier. The most common one we use is a ModelSerializer, which conveniently can be used to serialize data for Company objects:

# serializers.py
from rest_framework.serializers import ModelSerializer

from .models import Company

class CompanySerializer(ModelSerializer):
    class Meta:
        model = Company
        fields = (
            'id', 'name', 'description', 'website', 'street_line_1', 'street_line_2',
            'city', 'state', 'zipcode'
        )

That is all that’s required for defining a serializer, though a lot more customization can be added, such as:

  • outputting fields that don’t exist on the model (maybe something like is_new_company, or other data that can be calculated on the backend)
  • custom validation logic for when data is sent to the endpoint for any of the fields
  • custom logic for creates (POST requests) or updates (PUT or PATCH requests)

It’s also beneficial to add a simple test for our serializer, making sure that the values for each of the fields in the serializer match the values for each of the fields on the model:

# tests/test_serializers.py
from django.test import TestCase

from ..serializers import CompanySerializer
from .factories import CompanyFactory


class CompanySerializer(TestCase):
    def test_model_fields(self):
        """Serializer data matches the Company object for each field."""
        company = CompanyFactory()
        for field_name in [
            'id', 'name', 'description', 'website', 'street_line_1', 'street_line_2',
            'city', 'state', 'zipcode'
        ]:
            self.assertEqual(
                serializer.data[field_name],
                getattr(company, field_name)
            )

Part 3: View

The view is the layer in which we hook up a URL to a queryset, and a serializer for each object in the queryset. Django Rest Framework again provides helpful objects that we can use to define our view. Since we want to create an API endpoint for reading, creating, and updating Company objects, we can use Django Rest Framework mixins for such actions. Django Rest Framework does provide a ModelViewSet which by default allows handling of POST, PUT, PATCH, and DELETE requests, but since we don’t need to handle DELETE requests, we can use the relevant mixins for each of the actions we need:

# views.py
from rest_framework.mixins import (
    CreateModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin
)
from rest_framework.viewsets import GenericViewSet

from .models import Company
from .serializers import CompanySerializer


class CompanyViewSet(GenericViewSet,  # generic view functionality
                     CreateModelMixin,  # handles POSTs
                     RetrieveModelMixin,  # handles GETs for 1 Company
                     UpdateModelMixin,  # handles PUTs and PATCHes
                     ListModelMixin):  # handles GETs for many Companies

      serializer_class = CompanySerializer
      queryset = Company.objects.all()

And to hook up our viewset to a URL:

# urls.py
from django.conf.urls import include, re_path
from rest_framework.routers import DefaultRouter
from .views import CompanyViewSet


router = DefaultRouter()
router.register(company, CompanyViewSet, base_name='company')

urlpatterns = [
    re_path('^', include(router.urls)),
]

Now we have an API endpoint that allows making GET, POST, PUT, and PATCH requests to read, create, and update Company objects. In order to make sure it works just as we expect, we add some tests:

# tests/test_views.py
from django.test import TestCase
from django.urls import reverse
from rest_framework import status

from .factories import CompanyFactory, UserFactory


class CompanyViewSetTestCase(TestCase):
      def setUp(self):
          self.user = UserFactory(email='testuser@example.com')
          self.user.set_password('testpassword')
          self.user.save()
          self.client.login(email=self.user.email, password='testpassword')
          self.list_url = reverse('company-list')

      def get_detail_url(self, company_id):
          return reverse(self.company-detail, kwargs={'id': company_id})

      def test_get_list(self):
          """GET the list page of Companies."""
          companies = [CompanyFactory() for i in range(0, 3)]

          response = self.client.get(self.list_url)

          self.assertEqual(response.status_code, status.HTTP_200_OK)
          self.assertEqual(
              set(company['id'] for company in response.data['results']),
              set(company.id for company in companies)
          )

      def test_get_detail(self):
          """GET a detail page for a Company."""
          company = CompanyFactory()
          response = self.client.get(self.get_detail_url(company.id))
          self.assertEqual(response.status_code, status.HTTP_200_OK)
          self.assertEqual(response.data['name'], company.name)

      def test_post(self):
          """POST to create a Company."""
          data = {
              'name': 'New name',
              'description': 'New description',
              'street_line_1': 'New street_line_1',
              'city': 'New City',
              'state': 'NY',
              'zipcode': '12345',
          }
          self.assertEqual(Company.objects.count(), 0)
          response = self.client.post(self.list_url, data=data)
          self.assertEqual(response.status_code, status.HTTP_201_CREATED)
          self.assertEqual(Company.objects.count(), 1)
          company = Company.objects.all().first()
          for field_name in data.keys():
                self.assertEqual(getattr(company, field_name), data[field_name])

      def test_put(self):
          """PUT to update a Company."""
          company = CompanyFactory()
          data = {
              'name': 'New name',
              'description': 'New description',
              'street_line_1': 'New street_line_1',
              'city': 'New City',
              'state': 'NY',
              'zipcode': '12345',
          }
          response = self.client.put(
              self.get_detail_url(company.id),
              data=data
          )
          self.assertEqual(response.status_code, status.HTTP_200_OK)

          # The object has really been updated
          company.refresh_from_db()
          for field_name in data.keys():
              self.assertEqual(getattr(company, field_name), data[field_name])

      def test_patch(self):
          """PATCH to update a Company."""
          company = CompanyFactory()
          data = {'name': 'New name'}
          response = self.client.patch(
              self.get_detail_url(company.id),
              data=data
          )
          self.assertEqual(response.status_code, status.HTTP_200_OK)

          # The object has really been updated
          company.refresh_from_db()
          self.assertEqual(company.name, data['name'])

      def test_delete(self):
          """DELETEing is not implemented."""
          company = CompanyFactory()
          response = self.client.delete(self.get_detail_url(company.id))
          self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)

As the app becomes more complicated, we add more functionality (and more tests) to handle things like permissions and required fields. For a quick way to limit permissions to authenticated users, we add the following to our settings file:

# settings file
REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',)
}

And add a test that only permissioned users can access the endpoint:

# tests/test_views.py
from django.test import TestCase
from django.urls import reverse
from rest_framework import status

from .factories import CompanyFactory, UserFactory


class CompanyViewSetTestCase(TestCase):

      ...

      def test_unauthenticated(self):
          """Unauthenticated users may not use the API."""
          self.client.logout()
          company = CompanyFactory()

          with self.subTest('GET list page'):
              response = self.client.get(self.list_url)
              self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

          with self.subTest('GET detail page'):
              response = self.client.get(self.get_detail_url(company.id))
              self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

          with self.subTest('PUT'):
              data = {
                  'name': 'New name',
                  'description': 'New description',
                  'street_line_1': 'New street_line_1',
                  'city': 'New City',
                  'state': 'NY',
                  'zipcode': '12345',
              }
              response = self.client.put(self.get_detail_url(company.id), data=data)
              self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
              # The company was not updated
              company.refresh_from_db()
              self.assertNotEqual(company.name, data['name'])

          with self.subTest('PATCH):
              data = {'name': 'New name'}
              response = self.client.patch(self.get_detail_url(company.id), data=data)
              self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
              # The company was not updated
              company.refresh_from_db()
              self.assertNotEqual(company.name, data['name'])

          with self.subTest('POST'):
              data = {
                  'name': 'New name',
                  'description': 'New description',
                  'street_line_1': 'New street_line_1',
                  'city': 'New City',
                  'state': 'NY',
                  'zipcode': '12345',
              }
              response = self.client.put(self.list_url, data=data)
              self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

      with self.subTest('DELETE'):
              response = self.client.delete(self.get_detail_url(company.id))
              self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
              # The company was not deleted
              self.assertTrue(Company.objects.filter(id=company.id).exists())

As our project grows, we can edit these permissions, make them more specific, and continue to add more complexity, but for now, these are reasonable defaults to start with.

Conclusion

Adding an API endpoint to a project can take a considerable amount of time, but with the Django Rest Framework tools, it can be done more quickly, and be well-tested. Django Rest Framework provides helpful tools that we’ve used at Caktus to create many endpoints, so our process has become a lot more efficient, while still maintaining good coding practices. Therefore, we’ve been able to focus our efforts in other places in order to expand our abilities to grow sharp web apps.

New Call-to-action
blog comments powered by Disqus
Times
Check

Success!

Times

You're already subscribed

Times