Serializers: If You Like Django REST Framework, You Have Come to the Right Place!
When I dived into the world of web apps, I first started using Django only as a framework to develop with Python. But later on, REST APIs became popular, and the DRF plug-in was developed to implement them with Django. It was not easy to find user-friendly documentation, but after much research and data collection from different sources, I was able to solve the different problems as they appeared. So, if you know how to work with Python, Django, DRF and REST APIs or if you have back-end programming skills, then, keep reading because you have come to the right place!
Serializers
One way to understand how serializers work is to view them as “translators” between data understood by Python and data understood by JavaScript. In this sense, they will translate Python dictionaries and/or lists into JSON objects.
In our context, we will try to serialize a dictionary with native Python data (such data could come from services that are external to our API -and not from Django models- or from a file or another source). Let’s suppose that in our DRF view, we have a pokemon’s information. In a real context, we will probably want to process the data and then provide an appropriate response, but to simplify the example, we will skip this.
pokemon = {
'name': 'charizard',
'weight': 905,
'creation_date': datetime.datetime.now()
}
In the pokemon variable, we will have a dictionary with Charizard’s data including two keys: name and weight, among other keys.
Now, we are going to have a serializer that will use the pokemon data as input values. In order to obtain the output or response, we will have to access its data attribute:
from rest_framework import serializers
class PokemonSerializer(serializers.Serializer):
name = serializers.CharField()
class PokemonesView(APIView):
def get(self, request):
pokemon = {
'name': 'charizard',
'weight': 905,
'creation_date': datetime.datetime.now()
}
serializer = PokemonSerializer(pokemon)
return Response(
serializer.data,
status=200
)
We will find the following output of this view:
{
"name": "charizard"
}
Graphically, this was what happened:
This suggests that, with serializers, we can implement some kind of filter, since we had a dictionary with several keys about the pokemon variable, and we only serialized the name key. Once we have done this, the generated data will be ready to be rendered as JSON in our API responses.
This becomes clearer if we modify our serializer (just as it is shown below) so that it also serializes the DateTime info:
class PokemonSerializer(serializers.Serializer):
name = serializers.CharField()
creation_date = serializers.DateTimeField(format="%Y-%m-%d")
As a result, we will obtain the following output in which the DateTime type is formatted as a string following the JSON notation.
{
"name": "charizard",
"creation_date": "2022-09-13"
}
Deserialize
One of the most powerful aspects of serializers is that we can translate information in JSON format into data that can be understood by Python. This is known as deserialization.
A very common example of this occurs when we obtain data from an external API. Hereunder, you will find an example of the result of a request to
https://api.nasa.gov/planetary/apod?api_key=<DEMO_KEY>
{'copyright': 'Chris Willocks', 'date': '2022-09-20', 'explanation': "What's happening in the Statue of Liberty nebula? Bright stars and interesting molecules are forming and being liberated. The complex nebula resides in the star forming region called RCW 57, and besides the iconic monument, to some looks like a flying superhero or a weeping angel. By digitally removing the stars, this re-assigned color image showcases dense knots of dark interstellar dust, fields of glowing hydrogen gas ionized by these stars, and great loops of gas expelled by dying stars. A detailed study of NGC 3576, also known as NGC 3582 and NGC 3584, uncovered at least 33 massive stars in the end stages of formation, and the clear presence of the complex carbon molecules known as polycyclic aromatic hydrocarbons (PAHs). PAHs are thought to be created in the cooling gas of star forming regions, and their development in the Sun's formation nebula five billion years ago may have been an important step in the development of life on Earth. Your Sky Surprise: What picture did APOD feature on your birthday? (post 1995)", 'hdurl': 'https://apod.nasa.gov/apod/image/2209/NGC3576_Willocks_3300_Starless.jpg', 'media_type': 'image', 'service_version': 'v1', 'title': 'Star Forming Region NGC 3582 without Stars', 'url': 'https://apod.nasa.gov/apod/image/2209/NGC3576_Willocks_960_Starless.jpg'}
Our initial serializer and view will be as follows:
from rest_framework import status
from rest_framework.generics import ListAPIView
from rest_framework.response import Response
class NasaSerializer(serializers.Serializer):
copyright = serializers.CharField()
date = serializers.DateTimeField(input_formats=["%Y-%m-%d"])
class NasaView(ListAPIView):
def get(self, request):
url = "https://api.nasa.gov/planetary/apod?api_key=<DEMO_KEY>"
response = requests.get(url)
nasa_data = response.json()
serializer = NasaSerializer(data=nasa_data)
if serializer.is_valid():
return Response(
serializer.data,
status=status.HTTP_200_OK
)
else:
return Response(
serializer.errors,
status=status.HTTP_400_BAD_REQUEST
)
Por lo tanto, si nos detenemos con un breakpoint, podremos ver:
>> serializer.data
>> {'copyright': 'Chris Willocks', 'date': '2022-09-20'}
>> serializer.validated_data
OrderedDict([('copyright', 'Chris Willocks'), ('date', datetime.datetime(2022, 9, 21, 0, 0))])
Here, we can notice several things. First, we can observe the data argument used for creating a NasaSerializer instance. Then, we can see the presence of the method “is_valid()”. With this method, we instruct the serializer instance to perform JSON validations.
If all the data is validated, the function returns a true value, and we will have data that will be understood by Python; we will find this data inside serializer.validated_data. In serializer.data, we will find that the filtered data is ready to be sent back in a response. is_valid() returns false values if there is a validation problem. The details of the problematic data will be described in “serializer.errors”.
1st Level Validations
First level validations deal with the existence of a match between each serializer’s attribute name and a key of the JSON response. Therefore, if there was a mismatch between an attribute in our serializer and the JSON response, we would get something like this
class NasaSerializer(serializers.Serializer):
copyright = serializers.CharField()
date = serializers.DateTimeField(input_formats=["%Y-%m-%d"], format="%Y-%m-%d")
nuevo_atributo = serializers.IntegerField()
...
>> serializer.errors
>> {'nuevo_atributo': [ErrorDetail(string='Este campo es requerido.', code='required')]}
On the contrary, if our serializer was expecting a DateTime field with the format “YYYY/MM/DD”, we would find this:
class NasaSerializer(serializers.Serializer):
copyright = serializers.CharField()
date = serializers.DateTimeField(input_formats=["%Y/%m/%d"], format="%Y-%m-%d")
nuevo_atributo = serializers.IntegerField()
...
>> serializer.errors
>> {'date': [ErrorDetail(string='Fecha/hora con formato erróneo. Use uno de los siguientes formatos en su lugar: YYYY/MM/DD.', code='invalid')], 'nuevo_atributo': [ErrorDetail(string='Este campo es requerido.', code='required')]}
To solve this within the first level of validation, we can use field parameters in the serializers (more info on www.django-rest-framework.org/api-guide/fields/). The way to solve this is to validate that new_attribute is NOT required, and that date can come with the format “YYYY/MM/DD” or “YYYY-MM-DD” from that moment onwards.
class NasaSerializer(serializers.Serializer):
copyright = serializers.CharField()
date = serializers.DateTimeField(input_formats=["%Y/%m/%d", "%Y-%m-%d"], format="%Y-%m-%d")
nuevo_atributo = serializers.IntegerField(required=False)
...
>> serializer.data
>> {'copyright': 'Chris Willocks', 'date': '2022-09-20'}
Another Validation Level (Custom)
We can perform custom validations on all the data that enters the serializer. There are other ways to validate data such as validation by defined attributes in the serializer (or by data fields). The DRF convention states that to validate particular attributes, we must create a function under the name validate_name-of-the-attribute. We will see this with the title attribute in the example below in which we will perform a trial validation that verifies that the attribute string is written entirely in lower case. If this is not the case, we can use ValidationError so that when is_valid() is called in the view, we will obtain a false value and we will also get the custom message in serializer.errors.
class NasaSerializer(serializers.Serializer):
title = serializers.CharField()
date = serializers.DateTimeField(input_formats=["%Y/%m/%d", "%Y-%m-%d"], format="%Y-%m-%d")
nuevo_atributo = serializers.IntegerField(required=False)
def validate_title(self, value):
if not value.islower():
raise serializers.ValidationError("No es lowercase")
return value
...
>> serializer.errors
>> {'title': [ErrorDetail(string='No es lowercase', code='invalid')]}
Then, we will be able to validate data as a whole. For example, when we want to make validations that involve more than one attribute:
class NasaSerializer(serializers.Serializer):
title = serializers.CharField()
date = serializers.DateTimeField(input_formats=["%Y/%m/%d", "%Y-%m-%d"], format="%Y-%m-%d")
service_version = serializers.CharField()
def validate_title(self, value):
if len(value) < 5:
raise serializers.ValidationError("No puede tener menos de 5 caracteres.")
return value
def validate(self, attrs):
str_date = str(attrs.get('date').date())
if str_date not in attrs.get('title'):
raise serializers.ValidationError({"validacion_custom": "La fecha no está incluida en el título"})
return attrs
...
>> serializer.errors
>> {'validacion_custom': [ErrorDetail(string='La fecha no está incluida en el título', code='invalid')]}
If what we are looking for is, for example, to validate data to make updates, we can access the validated data within the create methods of the serializer (www.django-rest-framework.org/api-guide/serializers/#saving-instances):
def create(self, validated_data):
...
RANDOM FACT: remember that, just as it happens that it is the is_valid() method of the serializer the one that fires the validate methods and each validate_<:attribute_name>, it is the .save() method of the serializer the one that invokes the create or update method (depending on the view type). Therefore, this will usually be the code that appears in the views.py:
if serializer.is_valid():
instance = serializer.save()
return Response(
{"new_instance_id": instance.id},
status=status.HTTP_201_CREATED
)
Transforming Data before (and after) It Enters the Serializer
In some cases, we may want to transform data after we have serialized and validated the data (for example, when we need to rename a JSON key that the client will receive). In some other cases, we may want the information to arrive in the most convenient way possible to process it in our serializer (for example, if the “first name” and “last name” data come separately while we are expecting them together in our serializer).
For the first example, we possess the function “to_representation()”:
class NasaSerializer(serializers.Serializer):
title = serializers.CharField()
date = serializers.DateTimeField(input_formats=["%Y/%m/%d", "%Y-%m-%d"], format="%Y-%m-%d")
service_version = serializers.CharField()
def to_representation(self, instance):
instance['titulo'] = instance.pop('title')
return instance
...
>> serializer.data
>> {'date': datetime.datetime(2022, 9, 26, 0, 0), 'service_version': 'v1', 'titulo': 'All the Water on Planet Earth'}
In this next example, we want to make sure that media_type and url data (which come from the NASA API) get to our serializer in a combined way in the attribute data_image:
class NasaSerializer(serializers.Serializer):
title = serializers.CharField()
date = serializers.DateTimeField(input_formats=["%Y/%m/%d", "%Y-%m-%d"], format="%Y-%m-%d")
service_version = serializers.CharField()
imagen_data = serializers.CharField()
def to_internal_value(self, data):
data['imagen_data'] = f"{data['media_type']}: {data['url']}"
return super.to_internal_value(data)
...
>> serializer.data
>> {'date': datetime.datetime(2022, 9, 26, 0, 0), 'service_version': 'v1', 'imagen_data': 'image: https://apod.nasa.gov/apod/image/2209/WaterlessEarth2_woodshole_960.jpg', 'titulo': 'All the Water on Planet Earth'}
This suggests that although data_image is not a JSON input key, we can create it before it enters the serializer in any format needed.
More information on: www.django-rest-framework.org/api-guide/fields/#custom-fields.
The Answer Is: It Depends on the Context!
On many occasions, we need extra information within the serializer that is alien to the data that will be serialized but is still related to it. Another way to put it is the need for extra input parameters in the serializers (www.django-rest-framework.org/api-guide/serializers/#including-extra-context).
... view.py
fluxer = {
"nombre": "Onésimo",
"apellido": "Pascal",
"nro_legajo": 666
}
serializer = NasaSerializer(
data=nasa_data,
context={'fluxer': fluxer}
)
Let’s suppose that we want to include a fluxer’s (a person who works at Flux IT) data into our NasaSerializer. In order to do so, we must set the context attribute with the desired data. Then, within any serializer method, we will be able to gain access (for example) as follows:
...
def validate(self, attrs):
fluxer = self.context.get('fluxer')
...
Saving & Updating in the Serializers
As mentioned beforehand, when we call the save() method of a serializer, we can either create a new instance (we call the create() method which is defined in the serializer class), or update an existing one (we call the update() method which is defined in the serializer class).
# se crea una nueva instancia
serializer = PersonaSerializer(data=data)
serializer.save() # se invoca método create definido en PersonaSerializer
...
# se hacen updates en una instancia que ya existe
persona = Persona.objects.get(pk=1)
serializer = PersonaSerializer(persona, data=data)
serializer.save() # se invoca método update definido en PersonaSerializer
Check out these sites for more information:
- www.django-rest-framework.org/api-guide/serializers/#saving-instances
- www.django-rest-framework.org/api-guide/serializers/#partial-updates
SerializerMethodField
SerializerMethodField (www.django-rest-framework.org/api-guide/fields/#serializermethodfield) refers to read-only attributes (not for saving data) that get their value from invoking a method of the serializer with the following nomenclature: get_<name_of_the_attribute>. In this way, any desired information can be added to the output under the name of an attribute.
In our example, we will return a string constituted by the data that arrives through context. It should be noted that, within the obj parameter of the method (get_name_custom) that is invoked to obtain the value in name_custom = serializers. SerializerMethodField(), we will find the deserialized data that entered the serializer and which we could use if we wanted to.
class NasaSerializer(serializers.Serializer):
title = serializers.CharField()
date = serializers.DateTimeField(input_formats=["%Y/%m/%d", "%Y-%m-%d"], format="%Y-%m-%d")
service_version = serializers.CharField()
nombre_custom = serializers.SerializerMethodField()
def get_nombre_custom(self, obj):
# en obj --> OrderedDict([('title', 'DART: Impact on Asteroid Dimorphos'), ('date', datetime.datetime(2022, 9, 27, 0, 0)), ('service_version', 'v1')])
fluxer = self.context.get('fluxer')
return f"{fluxer['apellido']} {fluxer['nombre']} ({fluxer['nro_legajo']})"
...
>> serializer.data
>> {'title': 'DART: Impact on Asteroid Dimorphos', 'date': '2022-09-27', 'service_version': 'v1', 'nombre_custom': 'Pascal Onésimo (666)'}
Final Remarks
Certainly, the official DRF documentation is not very user-friendly for those of us who are new to DRF, but then it becomes easier to find some concrete things once you already know the basics.
The framework features described beforehand make it possible to apply DRF in the best possible way, implementing solutions with essential good practices, such as data validations (each back-end developer must look out for them).
The goal of this article is to provide a shortcut for those who start using DRF so that they gain a quick overview of everything that can be done with it (the most common uses), and to always encourage them to keep accessing and reading official documents and to keep adding information to the documentation that already exists in the community.
It is important to continue learning how to use this tool to always find better solutions to the problems that are presented to us. Learning about it can even help us determine if it is the right tool to achieve the desired result.