A Guide To Creating An API Endpoint With Django Rest Framework

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='[email protected]')
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.