Model Relationships in Django REST Framework
Model Relationships in Django REST Framework
I’ve spent the last couple of months working on an API written using Python, Django, and Django REST Framework (DRF). The latter is a popular, well-established framework for building APIs in Python, so I assumed it would have decent documentation surrounding a fairly common situation in any model-view-controller (MVC) framework: relational fields.
It turns out that was a faulty assumption.
Unfortunately, the documentation on working with relational fields in serializers is sparse, and many online answers and solutions are incomplete, case-specific, overengineered, or just plain wrong. It took me entirely too long to find out how to make these relationship fields to work with DRF. My solution even changed while writing this article!
In hopes of sparing other developers that pain, here’s everything I discovered about Django relational fields, and how to work with them in the Django REST Framework. In exploring this topic, I will build out a small-but-complete example, so you can experiment with these concepts yourself.
If you want to follow along, take a minute and create a Django project for yourself, with an app called pizzaria
. The examples below work in Django 2.2/DRF 3.11 through at least Django 4.0/DRF 3.13, and probably beyond. If you’ve never set up a Django project before, take a look at this guide, which you can adapt to your purposes.
In addition to your favorite Python IDE, I also recommend you use Hoppscotch for crafting and testing calls to your example API, and DBeaver Community Edition for viewing your database. (Alternatively, Postman and JetBrains DataGrip work well too!)
Understanding Relationships in Django
Before we bring Django REST Framework into the mix, let’s start with an explanation of how relationships work in MVC frameworks like Django.
The first thing to know is that your Django models define how your app’s database is structured.
There are three major kinds of relationship between database entries:
- One-to-many (a.k.a. a
ForeignKey
): for example, aPizza
is associated with exactly oneOrder
, but anOrder
can have more than onePizza
. - One-to-one: for example, a
Pizza
has exactly oneBox
, and eachBox
belongs to onePizza
. - Many-to-many: for example, a
Pizza
can have more than oneTopping
, and a singleTopping
can be on more than onePizza
.
In Django, we represent these relationships as fields on models, using ForeignKey
, OneToOneField
, and ManyToManyField
to represent them.
In the case of ForeignKey
in a one-to-many relationship, you’d put that field on the model that represents the “one” side relationship: the Pizza
model would have an order
field, not the other way around.
When defining all three, you must specify the model that the relationship is to. See the following code; the comments will explain what different parts are for:
from django.db import models
import uuid
class Order(models.Model):
# In this example, I'll use UUIDs for primary keys
id = models.UUIDField(
primary_key=True,
default=uuid.uuid4,
editable=False
)
customer = models.CharField(max_length=256, blank=False, null=False)
address = models.CharField(max_length=512, blank=True, null=False)
class Box(models.Model):
id = models.UUIDField(
primary_key=True,
default=uuid.uuid4,
editable=False
)
color = models.CharField(
max_length=32, default="white", blank=False, null=False
)
class Topping(models.Model):
name = models.CharField(
primary_key=True,
max_length=64
)
class Pizza(models.Model):
id = models.UUIDField(
primary_key=True,
default=uuid.uuid4,
editable=False
)
order = models.ForeignKey(
"pizzaria.Order",
on_delete=models.CASCADE,
related_name="pizzas",
null=False
)
box = models.OneToOneField(
"pizzaria.Box",
on_delete=models.SET_NULL,
related_name="contents",
null=True
)
toppings = models.ManyToManyField(
"pizzaria.Topping",
related_name='+'
)
There are a few interesting arguments on the relational fields:
on_delete
: what should happen to this object when the other object is deleted? In the above, deleting an Order
deletes the associated Pizza
objects. However, deleting a box
does not delete the Pizza
. (This argument is not available on ManyToManyField
.)
related_name
is a sort of “virtual” field that is automatically created on the other object. (It is NOT actually added as a database column.) For example, the Box
model does not actually have a contents
field (and must not, to avoid colliding with the related_name
here). However, within Box
, I can access the associated Pizza
object by accessing contents
as I would any field. In the case of a one-to-many or many-to-many relationship, this will instead refer to a list of objects.
If I do not define related_name
, one will be automatically created. If I set related_name
to +
(or end the name with +
), then no “virtual” field will be created.
null
defines whether the field can be null
, which determines whether it is required (null=False
), or optional (null=True
).
There are additional parameters, which are covered in Django documentation — Model field reference: Relationship fields.
How Relationships Appear in the Database
Before we continue, it’s important to note how these relationships actually manifest in the database.
In the case of a ForeignKey
or OneToOneField
, a column is created. For Pizza
, the table looks like this:
Table: pizza | id: uuid | order_id: uuid | box_id: uuid |
---|
Notice that the columns are not order
and box
, but order_id
and box_id
. The order
and box
fields are simulated by Django, and use these _id
fields to store and retrieve the primary key of the associated object.
You’ll also notice that no column has been defined for toppings
. Rather, we have a separate table…
Table: pizza__toppings | id: int | pizza_id: uuid | topping_id: uuid |
---|
Don’t worry, Django knows how to work with this, so you can still refer to all three via their fields.
Typically, you can let Django work out how to build this table for ManyToManyField
, but if you really need control — or if you need to add additional fields to the relationship, you can specify another model on the through=
parameter of the models.ManyToManyField
. (See the documentation.) However, spoiler alert, a “through” model does NOT play well with DRF.
Meanwhile, in the tables for box
, order
, and toppings
, there is no column for pizzas
, contents
, or the like. Django handles these related_name
references outside of the database.
Serializing Relationships in DRF
In DRF, you have to define four different components to connect your model to your API endpoint:
- The Model itself in
models.py
(ormodels/«whatever».py
), - One or more Serializers in
serializers.py
(orserializers/«whatever».py
), - One or more ViewSets in
views.py
(orviews/«whatever».py
), - The API endpoint specified in
url.py
.
The serializers are where things get interesting.
Basic Serializers
The serializer is responsible for translating data between the API request (or any other source) and the Model/database. You can use your serializer to control which fields are exposed, which are read-only, and even to simulate fields that don’t really exist.
Here is the first part of serializers.py
, containing the serializers for my Order
, Box
, and Topping
models:
from rest_framework import serializers
from pizzaria.models import Box, Order, Pizza, Topping
class BoxSerializer(serializers.ModelSerializer):
class Meta:
model = Box
fields = ('id', 'color')
class OrderSummarySerializer(serializers.ModelSerializer):
class Meta:
model = Order
fields = ('id', 'customer', 'address')
class ToppingSerializer(serializers.ModelSerializer):
class Meta:
model = Topping
fields = ('name')
In each serializer, I must at a minimum specify a Meta
subclass, containing the model
and a tuple of fields
to expose.
Handling Payload
I’ll be adding a serializer for Pizza
shortly, which handles the relational fields. Before I can, however, I need to write a special function to handle the payload, the data sent with the request via the API.
Depending on how the API is used, you may receive Python objects that were deserialized from JSON or some other structured data format, or you may receive a string represention of JSON data. This gets tricky to handle on serializers!
To get around this, I created a function.py
module with a function to handle the payload. It will attempt to deserialize JSON, returning the raw input if it isn’t a string representation of JSON. That way, no matter what is sent to the API, we can work with the data.
import json
def attempt_json_deserialize(data, expect_type=None):
try:
data = json.loads(data)
except (TypeError, json.decoder.JSONDecodeError): pass
if expect_type is not None and not isinstance(data, expect_type):
raise ValueError(f"Got {type(data)} but expected {expect_type}.")
return data
This code also ensures that the data type being returned is the one specified. That is, if you expect a string, the function will raise an exception if you don’t get a string. It helps cut down on silent bugs originating from the data provided via the API being the wrong type.
Serializing Relational Fields
I can now begin to write a serializer for Pizza
:
from rest_framework import serializers
from pizzaria.functions import attempt_json_deserialize
from pizzaria.models import Box, Order, Pizza, Topping
# --snip--
class PizzaSerializer(serializers.ModelSerializer):
order = OrderSummarySerializer(read_only=True)
box = BoxSerializer(read_only=True)
toppings = ToppingSerializer(read_only=True, many=True)
class Meta:
model = Pizza
fields = ('id', 'order', 'box', 'toppings')
I have to explicitly specify which serializers to use for relational fields. We typically serialize the relational field with the serializer for the other model. This is known as a nested serializer.
For any serializer that inherits from serializers.ModelSerializer
, I can pass the read_only=True
parameter to indicate that I won’t be writing to the nested serializer. In most cases, DRF simply doesn’t support writing to a nested serializer.
Actually writing to a relational field is a little tricker. The best way to handle it is to write explicit create()
and update()
methods on the serializer. I’ll start by breaking down the create()
method:
# --snip--
class PizzaSerializer(serializers.ModelSerializer):
# --snip--
def create(self, validated_data):
request = self.context['request']
order_pk = request.data.get('order')
order_pk = attempt_json_deserialize(order_pk, expect_type=str)
validated_data['order_id'] = order_pk
box_data = request.data.get('box')
box_data = attempt_json_deserialize(box_data, expect_type=dict)
box = Box.objects.create(**box_data)
validated_data['box'] = box
toppings_data = request.data.get('toppings')
toppings_data = attempt_json_deserialize(toppings_data, expect_type=list)
validated_data['toppings'] = toppings_data
instance = super().create(validated_data)
return instance
# --snip--
For each of the relational fields, I must intercept and interpret the payload value. That looks different for each field.
For order
, I expect the UUID of an existing Order
as a string. I attempt to deserialize it, in case it’s a string containing a JSON representation of a string (yes, I’ve had that happen, and it’s annoying!) I store the extracted UUID back to validated_data
on the key order_id
, where it will be used later when creating the Pizza
object.
For box
, I want to create a new Box
object each time. I accept a dictionary (or string representation of a JSON object, which deserializes to a dictionary). Then, I create the new box with Box.objects.create()
, unpacking the contents of the box_data
dictionary in as the arguments. Then, I store the created object on validated_data
on the box
key.
Note: Calling
Box.object.create()
will bypass the serializers. If I definedcreate()
orupdate()
inBoxSerializer
, they would never get called.
For toppings
, I expect a list of topping names, which are also the primary keys for Topping
objects. I wind up storing this list on validated_data
on the toppings
key.
As a useful aside, if I wanted to create Topping
objects instead, I would use the following code instead:
toppings_data = request.data.get('toppings')
toppings_data = attempt_json_deserialize(toppings_data, expect_type=list)
toppings_objs = [Topping.objects.create(**data) for data in toppings_data]
validated_data['toppings'] = toppings_objs
Once I’ve finished refining validated_data
for my use, I can create the new instance with instance = super().create(validated_data)
.
Interestingly, the official DRF documentation demonstrates creating the instance first, and then creating and adding items to the ManyToManyField
with instance.toppings.add()
. This is valid, however, I don’t like it as much because any unhandled exception at this stage will still result in the row for the Pizza
being created, but with all data yet to be processed to be quietly dropped.
The update()
method is almost exactly the same, except we call super().update(instance, validated_data)
:
--snip--
class PizzaSerializer(serializers.ModelSerializer):
# --snip--
def update(self, instance, validated_data):
request = self.context['request']
order_data = request.data.get('order')
order_data = attempt_json_deserialize(order_data, expect_type=str)
validated_data['order_id'] = order_data
box_data = request.data.get('box')
box_data = attempt_json_deserialize(box_data, expect_type=dict)
box = Box.objects.create(**box_data)
validated_data['box'] = box
toppings_data = request.data.get('toppings')
toppings_ids = attempt_json_deserialize(toppings_data, expect_type=list)
validated_data['toppings'] = toppings_ids
instance = super().update(instance, validated_data)
return instance
Multiple Serializers
There’s another serializer I want: one that shows the Pizza
objects on an order. I can’t just add pizzas
as a field to OrderSummarySerializer
, as that one is used by the PizzaSerializer
, and I don’t want a circular dependency.
I’ll create OrderDetailsSerializer
, which will show the pizzas on the order. To do this, I also must create PizzaSummarySerializer
, which shows everything in Pizza
except the order (because, again, circular dependency):
class PizzaSummarySerializer(serializers.ModelSerializer):
box = BoxSerializer(read_only=True)
class Meta:
model = Pizza
fields = ('id', 'box', 'toppings')
class OrderDetailSerializer(serializers.ModelSerializer):
pizzas = PizzaSummarySerializer(read_only=True, many=True)
class Meta:
model = Order
fields = ('id', 'customer', 'address', 'pizzas')
Creating the ViewSet
I have to write a ViewSet to expose my serializers to API endpoints, in views.py
:
from rest_framework.viewsets import ModelViewSet
from pizzaria.models import Box, Order, Pizza, Topping
from pizzaria.serializers import (
OrderSummarySerializer,
OrderDetailSerializer,
PizzaSerializer,
ToppingSerializer
)
class ToppingViewSet(ModelViewSet):
queryset = Topping.objects.all()
serializer_class = ToppingSerializer
class PizzaViewSet(ModelViewSet):
queryset = Pizza.objects.all()
serializer_class = PizzaSerializer
class OrderViewSet(ModelViewSet):
queryset = Order.objects.all()
def get_serializer_class(self):
if self.action in ("create", "update", "partial_update"):
return OrderSummarySerializer
return OrderDetailSerializer
I have a ViewSet for Topping
, Pizza
, and Order
; there’s no sense having one for Box
, since I’m only creating new boxes when creating a Pizza
.
One item of note is the OrderViewSet
, where different serializers are needed for different usages. When the user is creating the order, they should not have to specify the pizzas, as that relationship is handled by the Pizza
model instead. However, when the user is viewing the order, they should see all the details about the pizzas.
To handle this, I define get_serializer_class()
, and check for an API action of create
, update
, or partial_update
; for any of those actions, I use OrderSummarySerializer
, which does not include the pizzas
field. However, for everything else, I use OrderDetailSerializer
, which includes pizzas
.
Creating the API Endpoints
Finally, I must expose the ViewSets via the API. In urls.py
, I specify the API endpoints:
from django.urls import path, include
from rest_framework import routers
from pizzaria.views import BoxViewSet, OrderViewSet, PizzaViewSet, ToppingViewSet
router = routers.DefaultRouter()
router.register("orders", OrderViewSet, "orders")
router.register("pizzas", PizzaViewSet, "pizzas")
router.register("toppings", ToppingViewSet, "toppings")
urlpatterns = [
path("pizzaria/", include(router.urls))
]
All this assumes that the pizzaria
app has been configured in my Django Rest Framework project, although I’m omitting that here, since that part is pretty well documented.
Now I can interact with the API at url.to.api/pizzaria/pizzas/
and the other endpoints, and the GET
and POST
operations will work. I can also use url.to.api/pizzaria/pizzas/«uuid-of-a-pizza-object»/
for the PUT
, PATCH
, and DELETE
operations.
Using the API
I sent the following payload via POST
to /pizzaria/orders/
:
{
"customer": "Bob Smith",
"address": "123 Example Road"
}
That created the base order. I received the following response:
{
"id": "e02c46ce-742a-4656-ba9c-e80afed7304c",
"customer": "Bob Smith",
"address": "123 Example Road"
}
Next, I add a pizza to the order via POST
to /pizzaria/pizzas/
:
{
"order": "e02c46ce-742a-4656-ba9c-e80afed7304c",
"box": {
"color": "white"
},
"toppings": [
"sausage",
"olives",
"mushrooms"
]
}
Now if I call GET
on /pizzaria/orders/
, I see the pizza is on the order:
[
{
"id": "e02c46ce-742a-4656-ba9c-e80afed7304c",
"customer": "Bob Smith",
"address": "123 Example Road",
"pizzas": [
{
"id": "92171c46-6526-46d0-a534-fd07fb542611",
"box": {
"id": "59b84a9e-4da9-4d3e-bf5c-b157ab98f2a9",
"color": "red"
},
"toppings": [
"mushrooms",
"olives",
"sausage"
]
}
]
}
]
One mushroom, olive, and sausage pizza, coming right up, Mr. Smith!
Handling a “through” model on ManyToManyField
Our data models are working pretty well, but the API requires us to manually list all the toppings on each pizza. To save time, let’s add support for a menu. There are two considerations here:
-
We need to store extra information, like “medium size”.
-
We need to support modifications atop the menu item, like “add extra cheese”. These modifications should only affect the one order, and not the menu item itself.
In Django, we typically specify a “through” model on a ForeignKey to allow adding extra information to the field, such as in the example pizza = models.ForeignKey("pizzaria.Pizza", through=PizzaInstance)
or something of that sort. However, this doesn’t work here for two reasons: first, Pizza
has the key to Order
, and not the other way around, and second, DRF serializers do not play well with through=
!
The Models
Instead, I will create separate, standalone models for PizzaMenuItem
and Pizza
, where the latter represents a single instance of a pizza, associated with an order.
I’ll start this change by adding a new model for a menu item:
class PizzaMenuItem(models.Model):
name = models.CharField(
primary_key=True,
max_length=128
)
box = models.OneToOneField(
"pizzaria.Box",
on_delete=models.SET_NULL,
related_name="contents",
null=True
)
toppings = models.ManyToManyField(
"pizzaria.Topping",
related_name='+'
)
This is almost the same as a Pizza
, except I don’t have the order
field, and I’ve swapped the id
for a name
as a primary key, like I have for Topping
.
Next, I’ll adjust my Pizza
model to add a size
field, a menu_item
ForeignKey
field, a remove_toppings
field, and to rename toppings
to extra_toppings
…
class Pizza(models.Model):
id = models.UUIDField(
primary_key=True,
default=uuid.uuid4,
editable=False
)
menu_id = models.ForeignKey(
"pizzaria.Pizza",
on_delete=models.SET_NULL,
related_name="+",
null=True
)
order = models.ForeignKey(
"pizzaria.Order",
on_delete=models.CASCADE,
related_name="pizzas",
null=False
)
SIZE_CHOICES = Choices(
'small',
'medium',
'large',
'x-large'
)
size = models.CharField(
max_length=32,
choices=SIZE_CHOICES,
blank=False,
null=False
)
extra_toppings = models.ManyToManyField(
"pizzaria.Topping",
related_name="+"
)
remove_toppings = models.ManyToManyField(
"pizzaria.Topping",
related_name="+"
)
@property
def toppings(self):
toppings = []
if self.menu_id is not None:
toppings += self.menu_id.toppings
toppings += self.extra_toppings
for topping in self.remove_toppings:
try:
toppings.remove(topping)
except ValueError:
pass # don't worry about removing absent toppings
Notice that I’ve provided a toppings
property at the end. My purpose with this is to allow toppings to be added and removed atop the ones defined in the menu, without the menu item actually being modified.
I’ve also changed the toppings
field to extra_toppings
, and added a toppings
property. When I read toppings
, I want to see the toppings from the menu item and the extra toppings I added.
Models for Non-Destructive Editing
One situation where this pattern comes in handy is where you need to allow non-destructive editing. Departing briefly from our example, consider if we had a GreetingCard
model, and we wanted to allow someone to modify the message on one card, without modifying the GreetingCard
itself for everyone:
class GreetingCard(models.Model):
message = models.CharField(max_length=256, blank=True, null=False)
class GreetingCardInstance(models.Model):
# a ForeignKey to the GreetingCard this expands on
card = models.ForeignKey(
'cards.GreetingCard',
on_delete=models.CASCADE,
related_name='instances'
null=False
)
# a ForeignKey to another GreetingCardInstance
on_instance = model.ForeignKey(
'cards.GreetingCardInstance',
on_delete=models.CASCADE,
related_name='modified_by',
null=True
)
# a private field to store local data
_message = models.CharField(max_length=256, blank=True, null=True)
@property
def message(self):
# return the locally defined value, if there is one
if self.message is not None:
return self.message
# otherwise, if this edits another instance...
elif on_instance is not None:
# delegate to that message property
return self.on_instance.message
# if all else fails, get the message from the card
return self.card.message
@message.setter
def _(self, value):
# store the value locally
self._message = value
@message.deleter
def _(self):
# delete the value locally only
del self._message
This results in GreetingCardInstance
acting like it has a message
field that supports CRUD like normal, but in fact, is a bit more nuanced. The read operation checks for a value locally, on any GreetingCardInstance
that this instance is intended to modify (if one exists), and then finally on the Card
. However, the create, update, and delete operations occur locally only.
One interesting but important detail here is that the _message
field supports both null
and blank
. This is usually not recommended, as it leaves the field with two distinct empty states; in this case, however, it’s actually helpful! If _message
is blank, we want to treat that as an override of GreetingCard.message
; however, if _message
is null, we’d want to fall back on the value in GreetingCard.message
instead.
The serializer coming up supports this non-destructive editing pattern, the same as it supports the primary example of the Pizza
and PizzaMenuItem
models.
Handling Properties in Serializers
Back to our pizzaria, the serializer for PizzaMenuItem
isn’t very surprising. Here it is:
class PizzaMenuItemSerializer(serializers.ModelSerializer):
box = BoxSerializer(read_only=True)
toppings = ToppingSerializer(read_only=True, many=True)
def create(self, validated_data):
request = self.context['request']
box_data = request.data.get('box')
box_data = attempt_json_deserialize(box_data, expect_type=dict)
box = Box.objects.create(**box_data)
validated_data['box'] = box
toppings_data = request.data.get('toppings')
toppings_data = attempt_json_deserialize(toppings_data, expect_type=list)
validated_data['toppings'] = toppings_data
instance = super().create(validated_data)
return instance
def update(self, instance, validated_data):
request = self.context['request']
box_data = request.data.get('box')
box_data = attempt_json_deserialize(box_data, expect_type=dict)
box = Box.objects.create(**box_data)
validated_data['box'] = box
toppings_data = request.data.get('toppings')
toppings_ids = attempt_json_deserialize(toppings_data, expect_type=list)
validated_data['toppings'] = toppings_ids
instance = super().update(instance, validated_data)
return instance
class Meta:
model = PizzaMenuItem
fields = ('name', 'box', 'toppings')
The serializer for Pizza
requires a little more work. Since a property isn’t actually a Django field, so it is always necessary to explicitly declare the serializer that must be used with the property.
class PizzaSerializer(serializers.ModelSerializer):
order = OrderSummarySerializer(read_only=True)
toppings = ToppingSerializer(read_only=True, many=True)
box = BoxSerializer(source='menu_item.box', read_only=True)
class Meta:
model = Pizza
fields = ('id', 'order', 'box', 'menu_item', 'toppings', 'size')
The serializer specified is used to interpret the Python data returned by either the field or property; in this case, toppings
works because the property is returning a list of primary keys for Topping
objects.
Another interesting detail here is the box
field, which I don’t define on the Pizza
model! I can use the source=
field to expose a field across a relational field; in this case, I get the box
field from menu_item
and expose that directly here as a read-only property. This requires menu_item
to be required (null=False
).
You’ll notice that I don’t expose extra_toppings
or remove_toppings
in fields
. I plan to support these indirectly in create()
and update()
, but I don’t want to expose these fields directly; read operations should go through toppings
.
Here are those two methods for the serializer:
class PizzaSerializer(serializers.ModelSerializer):
# --snip--
def create(self, validated_data):
request = self.context['request']
menu_item_pk = request.data.get('menu_item')
menu_item_pk = attempt_json_deserialize(menu_item_pk)
if menu_item_pk is not None:
validated_data['menu_item_id'] = menu_item_pk
order_pk = request.data.get('order')
order_pk = attempt_json_deserialize(order_pk, expect_type=str)
validated_data['order_id'] = order_pk
extra_toppings_data = request.data.get('extra_toppings')
extra_toppings_data = attempt_json_deserialize(extra_toppings_data, expect_type=list)
validated_data['extra_toppings'] = extra_toppings_data
remove_toppings_data = request.data.get('remove_toppings')
remove_toppings_data = attempt_json_deserialize(remove_toppings_data, expect_type=list)
validated_data['remove_toppings'] = remove_toppings_data
instance = super().create(validated_data)
return instance
def update(self, instance, validated_data):
request = self.context['request']
menu_item_pk = request.data.get('menu_item')
menu_item_pk = attempt_json_deserialize(menu_item_pk)
if menu_item_pk is not None:
validated_data['menu_item_id'] = menu_item_pk
order_pk = request.data.get('order')
order_pk = attempt_json_deserialize(order_pk, expect_type=str)
validated_data['order_id'] = order_pk
extra_toppings_data = request.data.get('extra_toppings')
extra_toppings_data = attempt_json_deserialize(extra_toppings_data, expect_type=list)
validated_data['extra_toppings'] = extra_toppings_data
remove_toppings_data = request.data.get('remove_toppings')
remove_toppings_data = attempt_json_deserialize(remove_toppings_data, expect_type=list)
validated_data['remove_toppings'] = remove_toppings_data
instance = super().update(instance, validated_data)
return instance
Serializers for Non-Destructive Editing
I’d like to briefly revisit the greeting card non-destructive editing example.
The serializer for GreetingCard
is as simple as it comes. Since message
is just a CharField
, a Django primitive field, I don’t need to do anything special to serialize that field:
class GreetingCardSerializer(serizaliers.ModelSerializer):
class Meta:
model = GreetingCard
fields = ('message',)
The serializer for GreetingCardInstance
requires a bit more work. Because message
is a property, I still have to explicitly specify the serializer to use, despite the fact the property returns a value from a primitive Django field type:
class GreetingCardInstanceSerializer(serializers.ModelSerializer):
message = serializers.CharField(max_length=256)
class Meta:
model = GreetingCardInstance
fields = ('message',)
The CharField
serializer accepts the same arguments as the field in regard to what the data looks like. That’s always the case for primitive serializers: decimal places, length, choices, and the like, all have to be defined on the serializer. I like to think of these as defining the “shape” of the data, which both a field and a serializer would need to know. However, it is not necessary to specify whether null or blank are allowed, nor do you have to repeat the verbose name or other such field metadata.
Admittedly, it gets a little annoying to have to duplicate the arguments defining the “shape” of your data, but it’s worth the extra effort to get this non-destructive editing.
WARNING: If you try to create an object, like
GreetingCardInstance
, viaGreetingCardInstance.objects.create()
, it will bypass the serializer! As such, you would not be able to include a value formessage
in that call; you’d have to specify a value for_message
instead.
Views, URLs, and API usage
Returning once again to the pizzaria example, my update to views.py
is not surprising in the least:
# --snip--
class PizzaViewSet(ModelViewSet):
queryset = Pizza.objects.all()
serializer_class = PizzaSerializer
class PizzaMenuItemViewSet(ModelViewSet):
queryset = PizzaMenuItem.objects.all()
serializer_class = PizzaMenuItemSerializer
# --snip--
Warning: Some solutions online suggest using your
ViewSet
objects to manipulate the create and update payloads before they reach the serializer, but I strongly advise against this. The further the logic is from the model, the more edge cases the logic is going to be unable to elegantly handle.
Finally, I expose the new menu
endpoint in urls.py
:
# --snip--
from pizzaria.views import OrderViewSet, PizzaViewSet, ToppingViewSet, PizzaMenuItemViewSet
router = routers.DefaultRouter()
router.register("orders", OrderViewSet, "orders")
router.register("pizzas", PizzaViewSet, "pizzas")
router.register("toppings", ToppingViewSet, "toppings")
router.register("menu", PizzaMenuItemViewSet, "menu")
# --snip--
Now I can make some API calls. On the api/pizzaria/menu/
endpoint, I call POST
with the following payload:
{
"name": "ansi standard",
"toppings": [
"pepperoni",
"mushrooms"
],
"box": {
"color": "black"
}
}
Now on api/pizzaria/pizzas/
, I call POST
with the following payload:
{
"order": "017eae04-5123-41ac-b944-8f8208d75298",
"menu_item": "ansi standard",
"size": "large",
"extra_toppings": [
"sausage",
"olives"
],
"remove_toppings": [
"mushrooms"
]
}
You’ll notice that I specify extra_toppings
and remove_toppings
as lists of strings (primary keys for Topping
objects).
Calling GET
on the same endpoint shows me the following:
{
{
"id": "166de02c-3753-46db-8cab-451ad0be5a4a",
"order": {
"id": "017eae04-5123-41ac-b944-8f8208d75298",
"customer": "Bob Smith",
"address": "123 Example Road"
},
"box": {
"id": "5f5aafd6-352d-48e9-926a-25d7bb0be9f9",
"color": "black"
},
"menu_item": "ansi standard",
"toppings": [
{
"name": "olives"
},
{
"name": "sausage"
},
{
"name": "pepperoni"
}
],
"size": "large"
}
]
Notice that toppings
is displayed, but extra_toppings
and remove_toppings
are absent. Remember, the “ansi standard” pizza is pepperoni and mushroom, but on this pizza, I asked to remove mushroom (leaving only pepperoni) and to add olives and sausage. Thus, I see pepperoni, which is coming from the PizzaMenuItem
object, and olives and sausage from the Pizza
object. Mushrooms are omitted because of how the toppings
property uses remove_toppings
on the Pizza
object.
I also see the box
field here, which is coming from the PizzaMenuItem
object; it never existed on the Pizza
object.
Summary
Despite the many surprises DRF presents to the developer when working with relational fields for the first time, once you understand the patterns, it’s not terribly difficult.
-
Define relationships in your models with
ForeignKey
,OneToOneField
, andManyToManyField
fields. -
Use properties on your models to simulate fields that store and interpret data from related models.
-
Use your serializers to intercept and interpret data meant for these relational fields, to expose properties as fields, and to expose fields on related models.
-
Use viewsets to expose these serializers. Avoid using a viewset to manipulating the payload being passed to the serializer.
Happy coding!
Want to be alerted when we publish future blogs? Sign up for our newsletter!