Versioning models / serializers

At some point we will need to make changes to our models in order to add new features. But we also want to keep supporting older API versions.

drf_versioning acts as a "versioning layer" in this regard.

Adding a new field

Let's add a new age property to the Dog model.

from django.db import models
from datetime import date

from django.utils import timezone


class Dog(models.Model):
    name = models.CharField(max_length=50)
    birthday = models.DateField(default=date.today)

    def __str__(self):
        return self.name.title()

    @property
    def age(self):
        return (timezone.now().date() - self.birthday).days // 365

And add the age field to the DogSerializer in doggies/serializers.py:

class DogSerializer(serializers.ModelSerializer):
    age = serializers.IntegerField()

    class Meta:
        model = Dog
        fields = (
            "id",
            "name",
            "birthday",
            "age",
        )

But we don't want to break old API versions with this unexpected new field. So we create a new Version and only serialize this field if the request.version is greater.

in versions.py:

VERSION_2_1_0 = Version(
    "2.1.0",
    notes=["Added Dog.age property"],
)

Now create a new file doggies/transforms.py, with the following content:

from drf_versioning.transforms import Transform

from versioning import versions


class AddAge(Transform):
    version = versions.VERSION_2_1_0
    description = "Added Dog.age which is auto-calculated based on the Dog's birthday."

    def to_representation(self, data: dict, request, instance):
        """
        Here we downgrade the serializer's output data to make it match older API versions.
        In this case that means removing the new 'age' field.
        """
        data.pop("age", None)
        return data

    def to_internal_value(self, data: dict, request):
        """
        Here we upgrade the request.data to make it match the latest API version.
        In this case the 'age' field is read-only, so no action is required.
        """
        pass

And update the DogSerializer in doggies/serializers.py:

from drf_versioning.serializers import VersionedSerializer
from rest_framework import serializers

from doggies.models import Dog
from . import transforms


class DogSerializer(VersionedSerializer, serializers.ModelSerializer):
    age = serializers.IntegerField()

    transforms = (
        transforms.AddAge,
    )

    class Meta:
        model = Dog
        fields = (
            "id",
            "name",
            "birthday",
            "age",
        )

Here we have done:

  • DogSerializer now inherits from VersionedSerializer
  • We have declared a tuple of Transform objects that apply to this serializer
  • The serializer code reflects the latest behaviour
  • The Transforms downgrade the output for older request versions

In Postman: GET /doggies/1/ with version = 2.0.0:

{
  "id": 1,
  "name": "Biko",
  "birthday": "2014-05-06"
}

In Postman: GET /doggies/1/ with version = 2.1.0:

{
  "id": 1,
  "name": "Biko",
  "birthday": "2014-05-06",
  "age": 8
}

Because adding a new field is bound to a relatively common operation, DRF Versioning provides a special AddField class. Instead of our Transform subclass above, we could also have done this:

from drf_versioning.transforms import AddField

from versioning import versions


class AddAge(AddField):
    version = versions.VERSION_2_1_0
    field_name = "age"
    description = "Added Dog.age which is auto-calculated based on the Dog's birthday."

and it would have had the same effect.

The Transform object adds its description field to the Version instance's models changelog:

    {
  "version": "2.1.0",
  "notes": [],
  "models": [
    "Added Dog.age which is auto-calculated based on the Dog's birthday."
  ],
  "views": {
    "endpoints_introduced": [],
    "endpoints_removed": [],
    "actions_introduced": [],
    "actions_removed": []
  }
},

Mutating fields

Let's say we want to update the Dog model to provide a dog_years property:

class Dog(models.Model):
    ...

    @property
    def dog_years(self):
        return self.age * 7

and we want to group this together with the age property like this:

{
  "age": {
    "human_years": 8,
    "dog_years": 56
  }
}

First let's update the serializers in doggies/serializers.py:

from drf_versioning.serializers import VersionedSerializer
from rest_framework import serializers

from doggies.models import Dog
from . import transforms


class DogAgeSerializer(serializers.Serializer):
    def to_representation(self, instance):
        return {"human_years": instance.age, "dog_years": instance.dog_years}


class DogSerializer(VersionedSerializer, serializers.ModelSerializer):
    age = DogAgeSerializer(source="*")

    transforms = (
        transforms.AddAge,
    )

    class Meta:
        model = Dog
        fields = (
            "id",
            "name",
            "birthday",
            "age",
        )

Our serializer now produces the desired output:

{
  "id": 1,
  "name": "Biko",
  "birthday": "2014-05-06",
  "age": {
    "human_years": 8,
    "dog_years": 56
  }
}

But we need a transform to downgrade this data for older API versions. In doggies/transforms.py, we add:

class GroupAgeAndDogYears(Transform):
    version = versions.VERSION_3_0_0
    description = (
        "Added Dog.dog_years and grouped Dog.age and Dog.dog_years into one 'age' property"
    )

    def to_representation(self, data: dict, request, instance):
        """
        Here we downgrade the serializer's output data to make it match older API versions.
        In this case that means returning the Dog.age value instead of the whole
        {"human_years": 1, "dog_years": 7} dict.
        """
        data["age"] = data["age"]["human_years"]
        return data

    def to_internal_value(self, data: dict, request):
        """
        Here we upgrade the request.data to make it match the latest API version.
        In this case the 'age' field is read-only, so no action is required.
        """
        pass

We add this transform to the DogSerializer:

class DogSerializer(VersionedSerializer, serializers.ModelSerializer):
    age = DogAgeSerializer(source="*")

    transforms = (
        transforms.AddAge,
        transforms.GroupAgeAndDogYears,
    )

    class Meta:
        model = Dog
        fields = (
            "id",
            "name",
            "birthday",
            "age",
        )

Let's test the endpoint's behaviour.

In Postman: GET /doggies/1/ with version = 2.1.0:

{
  "id": 1,
  "name": "Biko",
  "birthday": "2014-05-06",
  "age": 8
}

In Postman: GET /doggies/1/ with version = 3.0.0:

{
  "id": 1,
  "name": "Biko",
  "birthday": "2014-05-06",
  "age": {
    "human_years": 8,
    "dog_years": 56
  }
}

Removing a field

Let's say we've decided to remove the age field altogether, and let the API consumer work it out for themselves based on the birthday field.

In doggies/transforms.py:

class RemoveAge(Transform):
    version = versions.VERSION_4_0_0
    description = "Removed Dog.age field"

    def to_representation(self, data: dict, request, instance):
        """
        Here we downgrade the serializer's output data to make it match older API versions.
        We have removed the field, but older versions are still expecting it. So we add it to the
        serializer output for older versions here.
        """
        data["age"] = {
            "human_years": instance.age,
            "dog_years": instance.dog_years,
        }
        return data

In doggies/serializers.py:

from drf_versioning.serializers import VersionedSerializer
from rest_framework import serializers

from doggies.models import Dog
from . import transforms


class DogSerializer(VersionedSerializer, serializers.ModelSerializer):
    transforms = (
        transforms.AddAge,
        transforms.GroupAgeAndDogYears,
        transforms.RemoveAge,
    )

    class Meta:
        model = Dog
        fields = (
            "id",
            "name",
            "birthday",
            # "age",  # <---- remove this field 
        )

The resulting behaviour of the API is:

In Postman: GET /doggies/1/ with version = 3.0.0:

{
  "id": 1,
  "name": "Biko",
  "birthday": "2014-05-06",
  "age": {
    "human_years": 8,
    "dog_years": 56
  }
}

In Postman: GET /doggies/1/ with version = 4.0.0:

{
  "id": 1,
  "name": "Biko",
  "birthday": "2014-05-06"
}

In this example, we still have access to the Dog.age and Dog.dog_years properties, so we can continue serializing real values for older request versions.

But let's say the property has been removed, and we completely lose access to the source data. We can no longer serialize the dog's age for older versions. In this case we can instead serialize a "null value" that satisfies the type and structure that the older version is expecting. For Dog.age, we could use -1, for example.

DRF Versioning provides another built in Transform subclass for this case: RemoveField. We can recreate the behaviour of our RemoveAge transform like this:

class RemoveAge(RemoveField):
    version = versions.VERSION_4_0_0
    field_name = "age"
    description = "Removed Dog.age field"
    null_value = {"human_years": -1, "dog_years": -1}

Now is a good time to check that our Transforms correctly cascade their changes through all API versions.

In Postman: GET /doggies/1/ with version = 1.0.0:

{
  "detail": "Not found."
}

In Postman: GET /doggies/1/ with version = 2.0.0:

{
  "id": 1,
  "name": "Biko",
  "birthday": "2014-05-06"
}

In Postman: GET /doggies/1/ with version = 2.1.0:

{
  "id": 1,
  "name": "Biko",
  "birthday": "2014-05-06",
  "age": -1
}

In Postman: GET /doggies/1/ with version = 3.0.0:

{
  "id": 1,
  "name": "Biko",
  "birthday": "2014-05-06",
  "age": {
    "human_years": -1,
    "dog_years": -1
  }
}

In Postman: GET /doggies/1/ with version = 4.0.0:

{
  "id": 1,
  "name": "Biko",
  "birthday": "2014-05-06"
}