Django DRY in action: How to correctly enforce validators with DRF
As a seasoned Django app developer, I absolutely love all the rich functionality the framework brings me and how it allows me to be DRY (Don’t Repeat Yourself). However, one thing that drives me absolutely nuts about Django is how it doesn’t always automatically call the validators when saving a model record.
For an example in action, let’s imagine the following model definition below, with 3 fields:
class NumbersModel(models.Model):
num_per_box = models.PositiveIntegerField() # Must always be an even number!
qty_boxes = models.PositiveIntegerField()
total_items = models.PositiveIntegerField() # Must be the product of the other two fields
The two fields, num_per_box
and qty_boxes
, need to be a positive integers, for obvious reasons. (Can’t have -2.3
items in a box or -1.6
boxes). The third field is calculated to be the product of the first two fields. Ordinarily, this should be a model method instead of a model field, but for the purposes of this example we’ll make it a field.
To make this example interesting, let’s decide that the first field, num_per_box
should always be an even number. Next, let’s use the Django framework to try to enforce that rule.
Enforcing values with Django validators
Because we used the PositiveInteger
field for these values, Django will always ensure these values are positive integers, so we get validation logic for free! However, we do still need to write code to enforce num_per_box
is an even number. Most readers will know the best way to do this is via Django validators, for example:
from django.db import models
from django.core.exceptions import ValidationError
def validate_even(value):
if value % 2 != 0:
raise ValidationError('Value must be an even number!')
class NumbersModel(models.Model):
num_per_box = models.PositiveIntegerField(validators=[validate_even])
qty_boxes = models.PositiveIntegerField()
total_items = models.PositiveIntegerField()
While this associates the num_per_box
field tightly with the validate_even
validator, we’ll need to do something a bit different for the total_items
field since it involves all 3 fields: for validating field values that rely on other fields, we’ll need to put that logic in the clean()
method, seen below:
from django.db import models
from django.core.exceptions import ValidationError
class NumbersModel(models.Model):
num_per_box = models.PositiveIntegerField(validators=[validate_even])
qty_boxes = models.PositiveIntegerField()
total_items = models.PositiveIntegerField()
def validate_product(self):
if self.total_items != self.num_per_box * self.qty_boxes:
raise ValidationError('total_items must equal num_per_box times qty_boxes')
def clean(self):
super().clean()
self.validate_product()
Problem solved, right? Any new records of the NumbersModel
class will have all these values enforced, right? Well, not so fast.
Validating the validators
Let’s try writing a simple UpdateView
for this model, to check that our validators are properly being invoked:
from .models import NumbersModel
class NumberUpdateView(UpdateView):
template_name = 'form.html'
model = NumbersModel
fields = '__all__'
With our handy new view, let’s fire up the browser and put our form to the test. Notice how attempting to enter odd numbers for the first field results in an error:
Likewise for the total_items
field, the form prevents invalid values in the total_items
field:
Therefore, any records attempted to be saved via UpdateView
or CreateView
and their descendants are correctly validated, and the errors are gracefully displayed back to the user.
So far, so good. Now, let’s try this via the shell and see what happens:
>>> from .models import NumbersModel
>>> n = NumbersModel.objects.create(num_per_box=1, qty_boxes=5, total_ordered=5)
>>> n
<NumbersModel: NumbersModel object (1)>
Uh oh. The record was created despite the invalid value provided to num_per_box
.
Seasoned Django users will recognize this maddening behaviour of Django. The clean()
method, which is responsible for executing all the validators (as well as the custom logic we’ve written), is not always run by default.
In one final example, let’s check the same behaviour via DRF REST API. Let’s create a new REST endpoint for this model:
from rest_framework import serializers, viewsets
from .models import NumbersModel
class NumbersSerializer(serializers.ModelSerializer):
class Meta:
model = NumbersModel
fields = '__all__'
class NumbersViewSet(viewsets.ModelViewSet):
queryset = NumbersModel.objects.all()
serializer_class = NumbersSerializer
After updating urls.py
let’s try out the new HTTP endpoints using the requests
module:
>>> import requests
>>> data = {
... 'num_per_box': 1,
... 'qty_boxes': 2,
... 'total_items': 10
... }
>>> r = requests.post('http://localhost:8000/api/numbers/', data=data)
>>> r.content
'{"num_per_box":["Value must be an even number!"]}'
>>> r.status_code
400
Okay, so far DRF appears to be properly evaluating the field validators. Let’s check whether the other validator is invoked. This one should also fail:
>>> data = {
... 'num_per_box': 2,
... 'qty_boxes': 5,
... 'total_items': 6
... }
>>> r = requests.post('http://localhost:8000/api/numbers/', data=data)
>>> r.status_code
201
>>> r.content
'{"id":3,"num_per_box":2,"qty_boxes":5,"total_items":6}'
This time, the validator was not invoked. What this tells us is that DRF also does not call the clean()
method by default.
3 different access methods, 3 different results. This begs the question: when exactly is the clean()
method invoked, and if we have a model that requires it, how can we ensure it is always invoked?
Anti-patterns to avoid
Some potential solutions to this problem you may want to avoid are highlighted below:
- Calling
clean()
from insidesave()
: Since the save method is always invoked, it’s tempting to easily fix the problem by overwriting thesave()
method to callclean()
but this is a bad idea because thesave()
method does not catch theValidationError
exceptions, leading to uncaught exceptions when validation rules are broken. - Implementing validation logic directly inside the CBV or DRF: Another tempting solution is to validate the data at the point of ingest. While a cleaner alternative to the solution above, if there are multiple points of ingress for this model, that logic will need to be repeated in each of those views, thus breaking the DRY principle.
- Implementing validation logic directly inside the Form or Serializer
validate
methods: for the same reasons as the previous point, this breaks the DRY principles. The serializer’s validate methods should only be used for validation logic that should only be enforced at APIs.
Although I mention avoiding re-implementing logic in the validate
methods in the last bullet, inheritance can provide us a way out. Let’s explore that.
Mixins to the rescue
Thankfully, Django mixins gives us a very DRY alternative to solve this problem! We can create a base class for our serializers which overwrites the validate
method to call the model’s full_clean
method:
class FullCleanValidatorSerializerMixin():
def validate(self, attrs):
"""
Create an instance of what the record will look like after the REST call, and then validate on that resulting
instance by calling the `full_clean` method.
"""
request = self.context.get('request')
if request.method in ['PUT', 'POST']:
instance = self.Meta.model(**attrs)
elif request.method == 'PATCH':
instance = getattr(self, 'instance', None)
for attr, value in attrs.items():
setattr(instance, attr, value)
if instance:
instance.full_clean()
return super().validate(attrs)
Before we call full_clean
we need to first modify our instance to mimic what it would look like after the user’s inputted changes. For the PUT
and POST
verbs, this is simply constructing a new instance of the object with all the attributes provided by the user. In the case of PATCH
it’s a bit more complicated, as we need to modify the existing object with only the attributes provided by the user. In any case, once the new instance object is created, we call full_clean
to validate it. If any ValidationErrors
are thrown, the framework will gracefully handle them and present them back to the client.
With this new mixin, whenever we have aDjango model where we want all its validators automatically enforced via DRF, all we need to do is have its serializer inherit the mixin:
class NumbersSerializer(serializers.ModelSerializer, FullCleanValidatorSerializerMixin):
class Meta:
model = NumbersModel
fields = '__all__'
While Django’s validation logic is not always consistently enforced, thankfully we can use the framework itself to create a Mixin which we can optionally inherit whenever we want to force validation on specific models via DRF.