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
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
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)),
]
class XSerializer():
# many may be False as the situation dictates
learning_item = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
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
Add the middleware to settings.py of
MIDDLEWARE = [
...
"django.middleware.locale.LocaleMiddleware",
Now use the Accept-Language: en
(etc.) headers in requests
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")
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"]
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",
),
]
Use @extend_schema
decorator on the ViewSet and pass in OpenApiParameter
s 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
):
...
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):
...
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)
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
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)
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
Override get_queryset
in your view set in views.py
class ExperienceViewSet(BaseViewSet):
serializer_class = ExperienceSerializer
def get_queryset(self):
return Experience.objects.active()
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)
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())
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")
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
)
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
...
class ExperienceSerializer(models.ModelSerializer):
)
def create(self, validated_data):
user = self.context["request"].user
...
return experience
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():
...
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',
)
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:
serializer.is_valid()
before accessing the dataserializer.save()
to persist the dataserializer.data
Response
objects with status
that are correct# 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)
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,)
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())
class VehicleSerializer
sensor_csv = serializers.FileField(read_only=True)
image = serializers.ImageField(read_only=True)
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,
),
]