Django rest framework

This is part of the Semicolon&Sons Code Diary - consisting of lessons learned on the job. You're in the python category.

Last Updated: 2024-10-12

Django REST Framework

The reasons we use Django REST Framework are because:

a. It generates wonderful docs automatically as well as an interactive area to play with the API b. It is stable, well-tested, and includes all the features we need

How to create CRUD endpoints?

Step 1: Create a "serializer". Think of this as something that maps your endpoint API layer to and from your model layer

from rest_framework import serializers
from core.models import Product


# Even if your product model has 20 fields, Django will figure out (as best it can) how these fields should
# be translated to and from your API layer
class ProductSerializer(serializers.ModelSerializer):
    class Meta:
        model = Product

Step 2: Create view sets based off GenericViewSet

from rest_framework import mixins, viewsets

from core.models import Product
from core.serializers import (
    ProductSerializer,
)


# This viewset base class does everything, as you can see by the variety. But you can remove certain mixins, e.g.
# `DestroyModelMixin` and thereby limit the capabilities of the view set.
class ExperienceViewSet(
    mixins.RetrieveModelMixin, mixins.ListModelMixin, mixins.UpdateModelMixin,
    mixins.CreateModelMixin, mixins.DestroyModelMixin,
    viewsets.GenericViewSet):
):
    # We need to connect the serializer here, as well as provide a base queryset (i.e. a way of getting all relevant
    # objects as if the list action was called -- even though in fact many actions are possible)
    serializer_class = ProductSerializer
    queryset = Product.objects.all()

Step 3: Create routes

Go to urls.py


from rest_framework import routers
from core.views import ExperienceViewSet
from django.conf.urls import include

# This must come before URL patterns due a dependency
router = routers.DefaultRouter(trailing_slash=False)
# The last parameter is `basename`. You might get complaints if missing
router.register("playlists", ExperienceViewSet, "Experience")

urlpatterns = [
    # This route ensures that the view sets from DRF work
    path("api/v1/", include(router.urls)),
]

How to show primary key from a related model?

class XSerializer():
  # many may be False as the situation dictates
  learning_item = serializers.PrimaryKeyRelatedField(many=True, read_only=True)


How to show a field from a related model?

Say that an AudioGuide has an AudioGuideThread connection. How can the AudioGuideThread access the AudioGuide track_number field?

Use a ReadOnlyField in the serializer to reference a custom method on the AudioGuideThread model that calls the underlying AudioGuide attribute.

# serializers
class AudioGuideThreadSerializer(serializers.ModelSerializer):
    audio_guide = AudioGuideSerializer()
    track_number = serializers.ReadOnlyField()

    class Meta:
        model = AudioGuideThread


# models
class AudioGuideThread(BaseModel):
    audio_guide = models.ForeignKey("AudioGuide", on_delete=models.CASCADE)

    def track_number(self):
        return self.audio_guide.track_number

Note that in order to avoid N+1 issues, the ViewSet queryset should be changed to have a select_related

class AudioGuideThreadViewSet(viewsets.ModelViewSet):
    queryset = (
        AudioGuideThread.objects
        .select_related(
            'audio_guide',
        )
    )
    serializer_class = AudioGuideThreadSerializer

And if it were a many=True related field, you'd want prefetch_related instead

How to translate

Add the middleware to settings.py of

MIDDLEWARE = [
     ...
    "django.middleware.locale.LocaleMiddleware",

Now use the Accept-Language: en (etc.) headers in requests

How to write tests for endpoints

Here is how to test status codes:

from django.test import TestCase
from rest_framework.test import APIClient


class BaseApiTest(TestCase):
    def setUp(self):
        self.client = APIClient(format="json")


class PlaylistsTest(BaseApiTest):
    # These methods must start with `test_`
    def test_list_works(self):
        response = self.client.get("/api/v1/playlists")
        self.assertEqual(response.status_code, 200)

Here's how to add custom headers

# Pass them as kwarg to the client.get/patch/post etc. methods. Don't
# forget to precede with 'en'
response = self.client.get(
    "/api/v1/learning-items", **{"HTTP_ACCEPT_LANGUAGE": "en"}
)

Here is to assert something about the JSON response


class ContentTest(BaseApiTest):
    def test_content_shows(self):
        # create something in the DB first
        LearningItem.objects.create(name="English")
        response = self.client.get(
            "/api/v1/learning-items"
        )
        # parse the response as JSON
        json_response = json.loads(response.content)
        # and assert
        self.assertEqual(json_response[0]["name"], "English")

How exclude some fields from the serializer?

from rest_framework import serializers

# This is encapsulated for resuse in many serializers
AUTO_DATETIME_FIELDS = ['created_at', 'updated_at']


class LearningImageSerializer(serializers.ModelSerializer):
    class Meta:
        model = LearningImage
        # List excluded columns here
        exclude = AUTO_DATETIME_FIELDS + ["parent"]

How to get auto-generated docs?

Introspection heavily relies on those two attributes. get_serializer_class() and get_serializer() are also used if available. You can also set those on APIView. Even though this is not supported by DRF, drf-spectacular will pick them up and use them.

from django.urls import path
from drf_spectacular.views import SpectacularSwaggerView
# urls.py
urlpatterns = [
    path(
        "api/v1/docs",
        SpectacularSwaggerView.as_view(url_name="schema"),
        name="api-docs",
    ),
]

And if you want it to be secured and only available to some users:

from django.contrib.auth.mixins import UserPassesTestMixin
from django.urls import path
from drf_spectacular.views import SpectacularSwaggerView

# This gives us access control over the API docs
class ApiDocsAccessControlledView(UserPassesTestMixin, SpectacularSwaggerView):
    login_url = "/admin"

    def test_func(self):
        return self.request.user.is_superuser

urlpatterns = [
    path(
        "api/v1/docs",
        ApiDocsAccessControlledView.as_view(url_name="schema"),
        name="api-docs",
    ),
]

How to extend docs e.g. with parameters from filters that don't get auto-generated docs easily

Use @extend_schema decorator on the ViewSet and pass in OpenApiParameters as its parameters

There is a multitude of override options, but you only need to override what was not properly discovered in the introspection.

from drf_spectacular.utils import OpenApiParameter, extend_schema

@extend_schema(
    parameters=[
        OpenApiParameter(
            name="search_query",
            description="Search by experience or event name or description",
            required=False,
            type=str,
        )
    ]
)
class ExperiencesViewSet(
    mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet
):
  ...

How to make re-usable schema descriptions (applied on a single field instead of whole serializer here)

For simple responses, you might not go through the hassle of writing an explicit serializer class. In those cases, you can simply specify the request/response with a call to inline_serializer. This lets you conveniently define the endpoint’s schema inline without actually writing a serializer class.

from drf_spectacular.utils import extend_schema_field, inline_serializer


ImageSchemaDescription = inline_serializer(
    name='Images',
    fields={'src': serializers.CharField(), 'from': serializers.IntegerField()},
    many=True,
)

class ImageFieldMixin(metaclass=serializers.SerializerMetaclass):
    thumb_image = serializers.SerializerMethodField()

    @extend_schema_field(ImageSchemaDescription)
    def get_thumb_image(self):
        ...

How to change docs to have a different response

Add @extend_schema decorator and then add a responses key with the right serializer. It will do the rest :)

    @extend_schema(
        description="Buyers Data",
        responses=[BuyersSerializer]
    )
    @action(detail=True, methods=['get'])
    def buyers(self, request, pk=None):  # noqa
        response = BuyersSerializer(data={'buyers': buyers})
        response.is_valid(raise_exception=True)
        return Response(response.validated_data)

How to have nested serializer data for related records

By default, you'll just get the PK numerical id of the related record. But what if you want the nested info instead?

The trick is to connect the other serializers to the field names in the serializer

You need to pass many=True whenever it is a collection so DRF knows whether to return a single item or a list

class LearningItemSerializer(BaseCollectibleSerializer):
    images = LearningImageSerializer(many=True) # defined above but now shown
    audio_guide = AudioGuideSerializer() # ditto ...
    featured_image = LearningImageSerializer()
    narration = NarrationSerializer()

    class Meta:
        model = LearningItem

However this will not always work due to circular dependencies. E.g. what if AudioGuideSerializer itself depended on LearningItemSerializer, the class we were defining?

In this case, you can use the depth field as an alternative:

class LearningItemSerializer(BaseCollectibleSerializer):
    class Meta:
        depth = 1
        model = LearningItem

How to wrap getting a full-related object as a field and just get a select field

This helps you avoid writing serializers for unimportant models

Here we have many tags, but just want an array of tag.name

class BaseCollectibleSerializer(serializers.ModelSerializer):

    tags = serializers.SlugRelatedField(many=True, slug_field="name", read_only=True)

How to define a custom field in the serializer to return a collection

Use a combo of serializers.SerializerMethodField and get_{FIELD_NAME}

For example we define a places relation that returns the result of serialization data. (Note: this approach may have be suboptimal here, but we leave it in for demonstration purposes)

class DiscoveryRouteSerializer(TranslatedModelSerializer):
    places = serializers.SerializerMethodField()

    class Meta:
        model = DiscoveryRoute

    def get_places(self, instance):
        Thread = instance.places.through
        places = [
            t.place for t in Thread.objects.filter(route=instance).order_by("position")
        ]
        return DiscoveryRoutePlaceSerializer(
            places, many=True, context=self.context
        ).data

How to filter all data shown in list endpoints (non-parameterized)

Override get_queryset in your view set in views.py

class ExperienceViewSet(BaseViewSet):
    serializer_class = ExperienceSerializer

    def get_queryset(self):
        return Experience.objects.active()

How to return a key/value hash

from rest_framework import viewsets
from rest_framework.response import Response

class QRCodeMappings(viewsets.ViewSet):
    """
    A mapping from QRCode to collectibles!
    """

    def list(self, request, *args, **kwargs):
        collectibles = Collectible.objects.all()
        qr_code_mappings = {
            c.qr_code: CollectibleSerializer(c).data for c in collectibles
        }
        return Response(qr_code_mappings)

How to protect some field from user modification and overwrite with a server-side default

Here we set publisher to the current user and use a hidden field to avoid the user modifying it themselves.

from rest_framework import serializers

class VenueSerializer(serializers.ModelSerializer):

    publisher = serializers.HiddenField(default=serializers.CurrentUserDefault())

How to show the display value of a ChoiceField instead of the DB value

Explicitly provide a field of a certain type and give get_{FIELD_NAME}_display as the source. This method is already defined on the model automatically.

from rest_framework import serializers

class VenueSerializer(serializers.ModelSerializer):
    # These `source=` bits are required to convert the category and sub-category from the DB
    # representation "must_see" to the consumer-facing one "Must See"
    category = serializers.CharField(source="get_category_display")

How to have differences between what fields are used (or what types they are in GET vs POST)

Basically use the write_only and read_only parameters as needed. This even allows you to define different behavior or types in each direction, as may be convenient.

class ExperienceSerializer(models.ModelSerializer):
    # This accepts base 64 when POSTing
    photo_file = Base64ImageField(
        max_length=None, use_url=True, required=True, write_only=True
    )
    # This gives a URL when GETing
    photo_file_url = serializers.ImageField(
        read_only=True, use_url=True, required=False
    )

How to customize a serializer's updating or creation logic

Override the existing methods in the serializer. Note the differing parameter numbers for create and update

class ExperienceSerializer(models.ModelSerializer):
    )

    def create(self, validated_data):
        ...
        return experience

    def update(self, experience, validated_data):
        venue = experience.venue
        ...

How to access the current user from a serializer

class ExperienceSerializer(models.ModelSerializer):
    )

    def create(self, validated_data):
        user = self.context["request"].user
        ...
        return experience

How to customize validation logic?

Override the validate method. It should either raise serializers.ValidationError or return the data

class PushNotificationsSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = [
            "push_notification_token",
            "subscribed_to_happening_soon_push_notifications",
            "subscribed_to_special_offer_push_notifications",
            "subscribed_to_nearby_push_notifications",
        ]

    def validate(self, data):
        # If no valid field names given
        if len(data) == 0:
            raise serializers.ValidationError("No valid field names in data")
        return data

    def create(self, validated_data):
        for key, value in validated_data.items():
            ...

How to create a custom field

Say you want a rich field (e.g. an image set with variants instead of image)

# NB: This is untested WIP
from rest_framework.fields import Field
class ImageSetField(Field):
    def __init__(self, image_variants_def: List[ImageVariant] = None,**kwargs):
        self.image_variants_def = image_variants_def
        kwargs['read_only'] = True
        # This is needed in order for the open api documentation to know that null is a possible response
        kwargs['allow_null'] = True
        super().__init__(**kwargs)

    def to_internal_value(self, data):
        # This is used for converted incoming JSON to the instance
        # Since in this case, it's a readonly field, we do nothing
        pass

    def to_representation(self, instance):
        # This is used for converted the instance to the output in the JSON
        data = []
        for image_variant in self.image_variants_def:
            data.append(
                {
                    'src': instance.get_image_variant(
                        size=image_variant.size,
                        image_field_name=image_field_name,
                    ).url,
                    'from': image_variant.viewport_from,
                }
            )
        return data

And here is how to use

class PostSerializer(serializers.HyperlinkedModelSerializer)
    thumb_image = ImageSetField(
        image_variants_def=[
            ImageVariant(
                size="small"
                viewport_from=0,
            ),
        ],
        source='image',
    )

How to use a serializer (e.g. in custom POST logic)

Note that in the serializers we might overwrite update or create, but in the viewsets are domain is web requests such as POST

The key steps are:

# views.py
from rest_framework import mixins, status, viewsets
from rest_framework.response import Response

class VoteForExperienceViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet):
    serializer_class = VoteSerializer

    def post(self, request):
        serializer = VoteSerializer(data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

How to add parameterized filters to queries

Create filter backend with a filter_queryset method then pass the class to filter_backends of the viewset you wish to see filtered.

from rest_framework.filters import BaseFilterBackend

class ExperienceSearchFilterBackend(BaseFilterBackend):
    def filter_queryset(self, request, queryset, view):
        """
        Optionally restricts the returned experience
        by filtering against:
        - a `search_query={QUERY}` parameter
        """
        search_query = request.query_params.get("search_query", None)
        if search_query is not None:
            queryset = queryset.search(search_query)

        return queryset


class ExperiencesViewSet(
    mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet
):
    """
    The view on experiences as seen by the public.
    Restricted to active experiences.
    """

    serializer_class = ExperienceSerializer
    queryset = Experience.objects.active()
    filter_backends = (ExperienceSearchFilterBackend,)

How to allow client to upload a list of values of a specific type

We want a list of UUIDs to be posted so we use ListField for the top-level field and then child for the type of each item:

class EmailVehicleSerializer(serializers.ModelSerializer):
    uuids_on_device = serializers.ListField(write_only=True,
                                            required=True,
                                            child=serializers.UUIDField())

How to send files or images as urls?

class VehicleSerializer
    sensor_csv = serializers.FileField(read_only=True)
    image  = serializers.ImageField(read_only=True)

How to create an endpoint that does not map well to existing models?

Use function views along with the decorator @api_view and the Response from DRF. Use serializers if you have any too.

# -- in views.py --
# -----------------
from rest_framework.decorators import api_view
# Use special response
from rest_framework.response import Response

# The `pk` bit gets added due to
def teaching_session_statistical_data(request, pk):
    return Response({"foo": 1})

# -- in urls.py --
# -----------------

# Unlike with viewsets (which use `router.register`), functional views get added to the `urlpatterns` part. Notice how
# the dynamic `pk` (primary key) is fed in here.
urlpatterns = [
  path(
      r"api/v1/teaching-session/<int:pk>/statistical-data",
      views.teaching_session_statistical_data,
  ),
]