Versioning tools for use with Django Rest Framework
Project description
Django Rest Framework Versioning
Project description
This project aims to make it easy to support many different API versions in a Django REST Framework (DRF) project.
DRF supports several versioning schemes but (perhaps wisely) completely sidesteps the issue of how to deal with the different versions in your code. To quote the docs: "How you vary the API behavior is up to you".
Django Rest Framework Versioning aims to provide some out-of-the box tools to handle versioning in the code. It is inspired by Stripe's API version "compatibility layer", as described in blog posts by Brandur Leach and Amber Feng. I used Ryan Kaneshiro's excellent Django sketch as a starting point.
Installation quick start
This section is intended for those who want to install DRF Versioning into an existing Django project.
1. Create a versioning module: ./manage.py startapp versioning
or mkdir versioning
Suggested structure:
└── versioning
├── __init__.py
├── version_list.py # maintain the list of supported Versions here
└── versions.py # define your Version instances here
with versions.py
containing:
from drf_versioning.versions import Version
VERSION_1_0_0 = Version(
"1.0.0",
notes=["Initial version"],
)
and version_list.py
containing:
from . import versions
VERSIONS = [
versions.VERSION_1_0_0,
]
2. Update project settings
In your project settings.py
add:
REST_FRAMEWORK = {
..., # any other rest_framework settings
"DEFAULT_VERSIONING_CLASS": "drf_versioning.middleware.AcceptHeaderVersioning",
}
DRF_VERSIONING_SETTINGS = {
"VERSION_LIST": "versioning.version_list.VERSIONS",
"DEFAULT_VERSION": "latest",
}
3. (Optional) add versioning urls
In your project urls.py
:
urlpatterns = [
..., # your other urls
path("version/", include("drf_versioning.urls")),
]
Tutorial
Django project setup
To showcase the features of this library, we will set up a basic Django Rest Framework project. If you want to install DRF Versioning into an existing project, feel free to skip to the DRF versioning installation section. The Django tutorial may also be helpful if you are doing this for the first time.
Create a project directory and a virtual environment, and inside it create requirements.txt
with the following contents:
django
djangorestframework
djangorestframework-versioning
and run
pip install -r requirements.txt
django-admin startproject mysite
start doggies app
./manage.py startapp doggies
Add "doggies"
to settings.INSTALLED_APPS
Create doggies/models.py
with contents:
from django.db import models
from datetime import date
class Dog(models.Model):
name = models.CharField(max_length=50)
birthday = models.DateField(default=date.today)
def __str__(self):
return self.name.title()
Create doggies/serializers.py
with contents:
from rest_framework import serializers
from doggies.models import Dog
class DogSerializer(serializers.ModelSerializer):
class Meta:
model = Dog
fields = (
"id",
"name",
"birthday",
)
Create doggies/admin.py
with contents:
from django.contrib import admin
from doggies.models import Dog
@admin.register(Dog)
class DogAdmin(admin.ModelAdmin):
pass
Create doggies/views.py
with contents:
from rest_framework import viewsets, mixins
from doggies.models import Dog
from doggies.serializers import DogSerializer
class DoggieViewSet(viewsets.GenericViewSet, mixins.ListModelMixin):
serializer_class = DogSerializer
queryset = Dog.objects.all()
Create doggies/urls.py
with contents:
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register("", views.DoggieViewSet, basename="doggies")
urlpatterns = router.urls
Register our doggies app urls in the global project urls mysite/urls.py
:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("doggies/", include("doggies.urls")),
]
Your project directory should now look like this:
├── db.sqlite3
├── doggies
│ ├── admin.py
│ ├── apps.py
│ ├── __init__.py
│ ├── migrations
│ │ ├── 0001_initial.py
│ │ └── __init__.py
│ ├── models.py
│ ├── serializers.py
│ ├── tests.py
│ ├── urls.py
│ └── views.py
├── manage.py
├── mysite
│ ├── asgi.py
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
└── requirements.txt
Create the new Dog table in the database:
./manage.py makemigrations
./manage.py migrate
create superuser
./manage.py createsuperuser
create some dogs in the admin site
Now if we navigate to localhost:8000/doggies/
we should see the following output:
DRF versioning installation
Create a versioning module. This can be a django app, but it doesn't have to be, since we don't require any models.
In the project root, do:
mkdir versioning
In versioning/versions.py
:
from drf_versioning.versions import Version
VERSION_1_0_0 = Version(
"1.0.0",
notes=["Initial version :)"],
)
in versioning.version_list.py
:
from . import versions
VERSIONS = [
versions.VERSION_1_0_0,
]
Add the following line to your mysite/settings.py
.
# Here we are telling rest_framework to use drf_versioning's AcceptHeaderVersioning class. It
# inherits from rest_framework's AcceptHeaderVersioning class, and does almost the same thing,
# but it adds the ability to choose a default version if the version is not specified in the
# request.
REST_FRAMEWORK = {
"DEFAULT_VERSIONING_CLASS": "drf_versioning.middleware.AcceptHeaderVersioning",
}
# Here we are telling drf_versioning where to find our list of supported versions (
# `VERSION_LIST`). We also specify a default version that we would like to attach to requests
# that do not specify a version. We have selected "latest" which will use the most recent Version
# it can find in the supported versions list. Other acceptable values are "earliest" or a version
# string e.g. "1.0.0"
DRF_VERSIONING_SETTINGS = {
"VERSION_LIST": "versioning.version_list.VERSIONS",
"DEFAULT_VERSION": "latest",
}
In mysite/urls.py
, add drf_versioning
s default urls. Your urlpatterns
should now look like this:
urlpatterns = [
path("admin/", admin.site.urls),
path("doggies/", include("doggies.urls")),
path("version/", include("drf_versioning.urls")),
]
If we navigate to http://localhost:8000/version/
, we should see a list of available versions, with a description of the changes in each version. The notes
that we passed to the Version instance are also serialized here.
[
{
"version": "1.0.0",
"notes": [
"Initial version :)"
],
"models": [],
"views": {
"endpoints_introduced": [],
"endpoints_removed": [],
"actions_introduced": [],
"actions_removed": []
}
}
]
If we navigate to http://localhost:8000/version/my_version/
, we should see which version was assigned to our request. Since we did not specify a versoin, we have been assigned the latest version -- 1.0.0 (which is also the only version).
{
"version": "1.0.0",
"notes": [
"Initial version :)"
],
"models": [],
"views": {
"endpoints_introduced": [],
"endpoints_removed": [],
"actions_introduced": [],
"actions_removed": []
}
}
The tutorial begins in earnest
Now that we have completed the setup, we can start the interesting part -- making changes to our API and supporting multiple versions!
Versioning views
View actions / methods
Let's say we want to add a new action to the Dogs viewset -- a view for individual dogs. Paste the following code into your doggies/views.py
:
from drf_versioning.decorators import versioned_view
from rest_framework import viewsets, mixins
from doggies.models import Dog
from doggies.serializers import DogSerializer
from versioning import versions
class DoggieViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, mixins.RetrieveModelMixin):
serializer_class = DogSerializer
queryset = Dog.objects.all()
@versioned_view(introduced_in=versions.VERSION_2_0_0)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
Here we have
- Added the RetrieveModelMixin to the viewset, which allows us to see the detail view at
/doggies/<dog-id>/
- Overridden the
retrieve
method and applied theversioned_view
decorator, specifying the version from which this view should become available.
Version 2.0.0 doesn't exist yet, so let's create it. Add this to your versioning/versions.py
:
VERSION_2_0_0 = Version(
"2.0.0",
notes=["Added doggie detail view"],
)
and add it to the list of supported versions in versioning/version_list.py
:
VERSIONS = [
versions.VERSION_2_0_0,
versions.VERSION_1_0_0,
]
Now if we ping the /version/
endpoint, we should see the new Version. Note that in addition to the notes
which we added to the Version instance by hand, the versioned_view
decorator has also informed the Version instance about the new view, and it is described in the views.actions_introduced
list.
[
{
"version": "2.0.0",
"notes": [
"Added doggie detail view"
],
"models": [],
"views": {
"endpoints_introduced": [],
"endpoints_removed": [],
"actions_introduced": [
"DoggieViewSet.retrieve"
],
"actions_removed": []
}
},
{
"version": "1.0.0",
"notes": [
"Initial version :)"
],
"models": [],
"views": {
"endpoints_introduced": [],
"endpoints_removed": [],
"actions_introduced": [],
"actions_removed": []
}
}
]
The versioned_view
decorator hides the view for requests with version < 2.0.0. We can demonstrate this by requesting GET /doggies/1/
with Accept: application/json; version=1.0.0
in Postman. We get a 404 response with the following body:
{
"detail": "Not found."
}
If we repeat the same request with Accept: application/json; version=2.0.0
, we are given access to the view:
{
"id": 1,
"name": "Biko",
"birthday": "2023-01-30"
}
The versioned_view
decorator also accepts a removed_in
argument. If this is present, the view will be hidden for all requests whose version is greater.
ViewSets
If we want to introduce / remove a whole endpoint, we can achieve this by inheriting from the VersionedViewSet
class. In this case the introduced_in
and removed_in
versions are set as class attributes, which also apply to any of the ViewSet's methods:
class CatViewSet(VersionedViewSet, viewsets.ReadOnlyModelViewSet):
serializer_class = CatSerializer
queryset = Cat.objects.all()
introduced_in = versions.VERSION_1_0_0
removed_in = versions.VERSION_5_0_0
@versioned_view(introduced_in=versions.VERSION_3_0_0)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@versioned_view(removed_in=versions.VERSION_4_0_0)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
However, individual methods can be further limited by using the versioned_view
decorator. The most restrictive combination of viewset / method versions will be chosen. In this example this results in:
CatViewSet
is available from 1.0.0 to 5.0.0retrieve
is available from 3.0.0 to 5.0.0list
is available from 1.0.0 to 4.0.0
The VersionedViewSet
class also informs the relevant Version instances about its introduction and removal. It appears under views.endpoints_introduced
/ views.endpoints_removed
in a serialized Version:
{
"version": "1.0.0",
"notes": [
"Initial version :)"
],
"models": [],
"views": {
"endpoints_introduced": [
"CatViewSet"
],
"endpoints_removed": [],
"actions_introduced": [],
"actions_removed": []
}
}
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 (TODO: link Stripe article).
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"
}
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Hashes for djangorestframework_versioning-2.0.5.tar.gz
Algorithm | Hash digest | |
---|---|---|
SHA256 | 0e535353a3e4f1467b5d3a197f1643aaec9df1266da4d5ad6eacfddaa9771751 |
|
MD5 | 3e86c913719e9d1e907c13954345b8a0 |
|
BLAKE2b-256 | 80523ee8d5399af977b3d5b33e2450f2027fe4afeb2c951ce10a115c88073b57 |
Hashes for djangorestframework_versioning-2.0.5-py3-none-any.whl
Algorithm | Hash digest | |
---|---|---|
SHA256 | 17d32ecb2ac348c721df61ebc9158bed835d69f18bf61be631194b18a2870c0f |
|
MD5 | e0b6d856f5cebccb91ed5fde9c38b1ca |
|
BLAKE2b-256 | cfef97e6c43d43d5a2432e41a8e0f2755f4075b6036b78e077bcce71cebc76a2 |