A simple and robust structure for your Django REST Framework API (Part 1)
After working on more than 40 different Django projects in the last 10 years we have found a sweet spot between speed, simplicity and flexibility.
The four core components in building a well structured API with Django REST Framework is stated below
- Models
- Serializers
- APIViews
- URLs
In this blog post we will try to break down a concise example by building an Asset Tracking app for an organisation where we have several relations laid out in models.py
to keep track of what Asset
has been loaned by an Employee
.
The models
The purpose of the models.py
file is to outline a structure for the database table that holds a certain amount of columns to be able to store rows and columns in a form that can later be easily queried with the Django ORM
We will define a few simple models and relationships to be able to track our Employees, Assets and Session for each loan. In this example we expect that every single physical asset will have a row in our Asset
table in our DB.
# <appname>/models.py
from uuid import uuid4
from django.db import models
class BaseAliasTimestampModel(models.Model):
alias = models.UUIDField(default=uuid4, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = (-'created_at')
class Employee(BaseAliasTimestampModel):
user = models.ForeignKey(settings.AUTH_USER_MODEL)
department = models.CharField(max_length=40)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def get_asset_sessions(self):
return self.assetloan_set.filter(
employee=self,
started_at__isnull=False,
returned_at__isnull=True)
def get_administrated_asset_loan_sessions(self):
return self.get_asset_loans_administrated.filter(
administrator=self,
started_at__isnull=False,
returned_at__isnull=True)
def get_supervised_asset_loan_sessions(self):
return self.get_asset_loans_supervised.filter(
supervisor=self,
started_at__isnull=False,
returned_at__isnull=True)
)
class Brand(BaseAliasTimestampModel):
name = models.CharField(max_length=50, help_text="Brand")
slug = models.SlugField(db_index=True, unique=True)
def get_assets(self):
return self.asset_set.filter(brand=self)
class Asset(BaseAliasTimestampModel):
brand = models.ForeignKey(Brand)
model = models.CharField(max_length=50)
name = models.CharField(max_length=50)
description = models.TextField(null=True, blank=True)
serial_no = models.CharField(max_length=40)
remarks = models.TextField()
note = models.TextField(null=True, blank=True)
inventory_no = models.CharField(max_length=40)
identifier = models.CharField(max_length=40,
null=True, blank=True, help_text="External identifier.")
invoice = models.FileField(null=True, blank=True)
warranty = models.FileField(null=True, blank=True)
purchased_at = models.DateTimeField()
depreciation_days = models.PositiveIntegerField(default=0)
def get_active_sessions(self):
return self.assetloansession_set.filter(
started_at__isnull=False,
returned_at__isnull=True)
def get_ended_sessions(self):
return self.assetloansession_set.filter(
started_at__isnull=False,
returned_at__isnull=False)
class AssetLoanSession(BaseAliasTimestampModel):
employee = models.ForeignKey(Employee)
administrator = models.ForeignKey(Employee,
related_name="get_asset_loans_administrated")
supervisor = models.ForeignKey(Employee,
related_name="get_asset_loans_supervised")
asset = models.ForeignKey(Asset)
remarks = models.TextField(null=True, blank=True)
note = models.TextField(null=True, blank=True)
contract = models.FileField(null=True, blank=True)
started_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True)
returned_at = models.DateTimeField(null=True, blank=True)
The serializers
The serializer manages the logic to convert a JSON object into a Python object when data is pushed from the frontend to the backend and then from a Python object into a JSON object when data is to pushed from the backend to the frontend.
- From JSON to Python when data is pushed from frontend to backend
- From Python to JSON when data is pushed from backend to frontend
NOTE: Try to avoid defining fields = '__all__'
in your serializers as this is considered a security risk. Always be specific when it comes to what fields your serializer should expose.
# <appname>/rest/serializers.py
from rest_framework import serializers
from rest_framework.serializers import ModelSerializer
from ..models import Employee, Brand
class EmployeeSerializer(serializers.ModelSerializer):
user = serializers.PrimaryKeyRelatedField()
department = serializers.CharField(min_length=2)
class Meta:
model = Employee
fields = (
'alias',
'user',
'department',
'created_at',
'updated_at')
read_only_fields = (
'alias',
'created_at',
'updated_at')
class BrandSerializer(serializers.ModelSerializer):
name = serializer.CharField(min_length=2)
class Meta:
model = Brand
fields = (
'alias',
'name',
'slug',
'created_at',
'updated_at')
read_only_fields = (
'alias',
'created_at',
'updated_at')
The views
We will define views that follow the standard behavior by DJRF and also adds some extra logic. The purpose of these examples are to demonstrate the flexibility of APIViews.For most apps you only need to define a few views that will encompass all the CRUD logic necessary for you to be able to build a very robust and powerful base structure for your project.We will primary use
We will mostly use a ListView and a DetailView ()
- ListView - A view that can show a list of items and that allows us to create an item
- DetailView = A view that shows us a single instance of an item and that allows us to modify and/or delete that item
# <appname>/rest/views.py
from rest_framework import generics
from rest_framework.permissions import IsAdminUser
from ..models import Brand, Employee
from .serializers import BrandSerializer
class BrandListView(generics.ListCreateAPIView):
queryset = Brand.objects.filter()
serializer_class = BrandSerializer
permission_class = [IsAdminUser]
class BrandDetailView(generics.RetrieveUpdateDestroyAPIView):
queryset = Brand.objects.filter()
serializer_class = BrandSerializer
permission_class = [IsAdminUser]
lookup_field = 'slug'
class EmployeeListView(generics.ListCreateAPIView):
queryset = Employee.objects.filter()
serializer_class = EmployeeSerializer
permission_class = [IsAdminUser]
class EmployeeDetailView(generics.RetrieveUpdateDestroyAPIView):
queryset = Employee.objects.filter()
serializer_class = EmployeeSerializer
permission_class = [IsAdminUser]
lookup_field = 'alias'
If you look at the views above you can clearly see a pattern where we use reuse both ListCreateAPIView
and RetrieveUpdateDestroyAPIView
.
Why APIViews?
The reason why we use APIViews mainly is due to the fact that we get a rigid and concise structure but also the flexibility needed if we want to add extra logic in our views that deviate from the default behaviour.
The extra logic outside the default DJRF behavior could be
- Caching a list or an item in a certain format
- Send tasks to a task queue ie Celery / RQ
- Send email via a third party service when a new Employee is added
The urls
The urls are what exposes your views to the outside world.
The setup here is pretty standard. By creating a url per view you have a very plain and simple urls.py
file that is easily readable and understandable at a glance.
# <appname>/rest/urls.py
from django.urls import path
from .views import BrandList, BrandDetail, EmployeeList, EmployeeDetail
urlpatterns = [
path(r"/brands", BrandList.as_view(), name="brand-list"),
path(r"/brands/<slug:slug>", BrandDetail.as_view(), name="brand-detail"),
path(r"/employees", EmployeeList.as_view(), name="employee-list"),
path(r"/employees/<uuid:alias>", EmployeeDetail.as_view(),
name="employee-detail"),
]