A simple and robust structure for your Django REST Framework API (Part 1)

A simple and robust structure for your Django REST Framework API (Part 1)
Photo by Faisal / Unsplash

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

  1. Models
  2. Serializers
  3. APIViews
  4. 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"),
]