Serializers DRF avancés : nested, writable et performances
Les serializers sont le cœur de Django REST Framework. Au-delà de la conversion basique model/JSON, ils permettent de gérer des structures imbriquées, des validations complexes et des transformations de données. Voici les patterns avancés pour les maîtriser.
Nested Serializers : lecture vs écriture
Imbriquer des serializers pour afficher des relations est simple. Les rendre writable demande du travail supplémentaire.
Lecture seule (le cas simple)
api/serializers.py
from rest_framework import serializers
from .models import Order, OrderLine, Product
class ProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = ['id', 'name', 'price']
class OrderLineSerializer(serializers.ModelSerializer):
# Nested en lecture seule
product = ProductSerializer(read_only=True)
class Meta:
model = OrderLine
fields = ['id', 'product', 'quantity', 'unit_price']
class OrderSerializer(serializers.ModelSerializer):
# many=True pour les relations OneToMany
lines = OrderLineSerializer(many=True, read_only=True)
class Meta:
model = Order
fields = ['id', 'customer', 'status', 'lines', 'total']
Résultat JSON :
Réponse API
{
"id": 42,
"customer": "client@example.com",
"status": "pending",
"lines": [
{
"id": 1,
"product": {
"id": 10,
"name": "Widget Pro",
"price": "29.99"
},
"quantity": 2,
"unit_price": "29.99"
}
],
"total": "59.98"
}
Writable Nested Serializers
Pour créer/modifier des objets imbriqués, DRF exige une implémentation explicite de create() et update().
Pourquoi DRF ne le fait pas automatiquement ? Les relations imbriquées impliquent des décisions métier : créer un nouvel objet ou lier un existant ? Que faire si la validation échoue à mi-parcours ? DRF préfère vous laisser décider.
Pattern : création avec nested
api/serializers.py
from django.db import transaction
from rest_framework import serializers
class OrderLineWriteSerializer(serializers.ModelSerializer):
# Accepte l'ID du produit en entrée
product_id = serializers.IntegerField()
class Meta:
model = OrderLine
fields = ['product_id', 'quantity']
class OrderWriteSerializer(serializers.ModelSerializer):
lines = OrderLineWriteSerializer(many=True)
class Meta:
model = Order
fields = ['customer_email', 'lines']
@transaction.atomic
def create(self, validated_data):
# Extraire les données imbriquées AVANT de créer le parent
lines_data = validated_data.pop('lines')
# Créer la commande
order = Order.objects.create(**validated_data)
# Créer les lignes avec le bon prix
for line_data in lines_data:
product = Product.objects.get(id=line_data['product_id'])
OrderLine.objects.create(
order=order,
product=product,
quantity=line_data['quantity'],
unit_price=product.price # Prix figé au moment de la commande
)
return order
@transaction.atomic
Toujours wrapper la création nested dans une transaction. Si la création d'une ligne échoue, tout est annulé : pas de commande orpheline sans lignes.
Pattern : update avec nested (plus complexe)
api/serializers.py
class OrderWriteSerializer(serializers.ModelSerializer):
lines = OrderLineWriteSerializer(many=True)
class Meta:
model = Order
fields = ['customer_email', 'lines']
@transaction.atomic
def update(self, instance, validated_data):
lines_data = validated_data.pop('lines', None)
# Mettre à jour les champs simples
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
# Gérer les lignes si fournies
if lines_data is not None:
# Stratégie : remplacer toutes les lignes
instance.lines.all().delete()
for line_data in lines_data:
product = Product.objects.get(id=line_data['product_id'])
OrderLine.objects.create(
order=instance,
product=product,
quantity=line_data['quantity'],
unit_price=product.price
)
return instance
Stratégies d'update nested :
- Replace all : supprimer et recréer (simple mais destructeur)
- Patch by ID : identifier chaque élément par son ID, créer/modifier/supprimer
- Append only : n'autoriser que l'ajout de nouveaux éléments
Serializers différents pour lecture/écriture
Pattern courant : utiliser des serializers distincts selon l'opération. La lecture retourne plus de données (nested, computed), l'écriture accepte des IDs.
api/views.py
from rest_framework import viewsets
from .serializers import OrderReadSerializer, OrderWriteSerializer
class OrderViewSet(viewsets.ModelViewSet):
queryset = Order.objects.all()
def get_serializer_class(self):
if self.action in ['create', 'update', 'partial_update']:
return OrderWriteSerializer
return OrderReadSerializer
Alternative : to_representation override
Pour un seul serializer qui retourne des données enrichies après écriture :
api/serializers.py
class OrderSerializer(serializers.ModelSerializer):
# Écriture : accepte product_id
lines = OrderLineWriteSerializer(many=True, write_only=True)
class Meta:
model = Order
fields = ['id', 'customer_email', 'lines', 'status']
def to_representation(self, instance):
# Lecture : retourne le serializer complet
return OrderReadSerializer(instance, context=self.context).data
def create(self, validated_data):
# ... logique de création
pass
Validation avancée
DRF offre plusieurs niveaux de validation, du champ individuel à l'objet entier.
Validation au niveau du champ
api/serializers.py
from rest_framework import serializers
class ProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = ['name', 'price', 'sku']
def validate_price(self, value):
"""Validation spécifique au champ price."""
if value <= 0:
raise serializers.ValidationError("Le prix doit être positif")
if value > 99999:
raise serializers.ValidationError("Prix maximum dépassé")
return value
def validate_sku(self, value):
"""SKU doit être alphanumérique."""
if not value.isalnum():
raise serializers.ValidationError("SKU invalide")
return value.upper() # Normalisation
Validation cross-field
api/serializers.py
class PromotionSerializer(serializers.ModelSerializer):
class Meta:
model = Promotion
fields = ['name', 'start_date', 'end_date', 'discount_percent']
def validate(self, attrs):
"""Validation de l'objet entier."""
start = attrs.get('start_date')
end = attrs.get('end_date')
if start and end and start >= end:
raise serializers.ValidationError({
'end_date': "La date de fin doit être après le début"
})
# Vérifier les chevauchements
overlapping = Promotion.objects.filter(
start_date__lt=end,
end_date__gt=start
)
# Exclure l'instance courante en update
if self.instance:
overlapping = overlapping.exclude(pk=self.instance.pk)
if overlapping.exists():
raise serializers.ValidationError(
"Cette promotion chevauche une promotion existante"
)
return attrs
Validators réutilisables
api/validators.py
from rest_framework import serializers
class FutureDateValidator:
"""Valide qu'une date est dans le futur."""
def __init__(self, message=None):
self.message = message or "La date doit être dans le futur"
def __call__(self, value):
from django.utils import timezone
if value <= timezone.now():
raise serializers.ValidationError(self.message)
class UniqueForUserValidator:
"""Valide l'unicité d'un champ pour l'utilisateur courant."""
requires_context = True
def __init__(self, model, field):
self.model = model
self.field = field
def __call__(self, value, serializer_field):
user = serializer_field.context['request'].user
qs = self.model.objects.filter(
user=user,
**{self.field: value}
)
# Exclure l'instance courante en update
instance = serializer_field.parent.instance
if instance:
qs = qs.exclude(pk=instance.pk)
if qs.exists():
raise serializers.ValidationError(
f"Vous avez déjà un élément avec ce {self.field}"
)
Utilisation
class EventSerializer(serializers.ModelSerializer):
start_date = serializers.DateTimeField(
validators=[FutureDateValidator()]
)
name = serializers.CharField(
validators=[UniqueForUserValidator(Event, 'name')]
)
class Meta:
model = Event
fields = ['name', 'start_date', 'description']
Champs dynamiques et contextuels
Les serializers peuvent adapter leurs champs selon le contexte (utilisateur, action, permissions).
Champs conditionnels
api/serializers.py
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'is_staff', 'last_login']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request = self.context.get('request')
if not request:
return
# Seuls les admins voient is_staff et last_login
if not request.user.is_staff:
self.fields.pop('is_staff', None)
self.fields.pop('last_login', None)
# L'email n'est visible que par le propriétaire
if self.instance and self.instance != request.user:
self.fields.pop('email', None)
Pattern : DynamicFieldsSerializer
api/mixins.py
class DynamicFieldsMixin:
"""
Permet de spécifier les champs via query params.
GET /users/?fields=id,username,email
"""
def __init__(self, *args, **kwargs):
# Champs explicitement demandés
fields = kwargs.pop('fields', None)
super().__init__(*args, **kwargs)
if fields is None:
request = self.context.get('request')
if request:
fields = request.query_params.get('fields')
if fields:
fields = fields.split(',')
allowed = set(fields)
existing = set(self.fields)
# Supprimer les champs non demandés
for field_name in existing - allowed:
self.fields.pop(field_name)
class UserSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'first_name', 'last_name']
Optimisation des performances
Les nested serializers sont gourmands en requêtes. Sans précaution, vous tombez vite dans le piège N+1.
Le problème
Mauvais : N+1 queries
# Pour chaque commande, DRF va charger les lignes
# Pour chaque ligne, DRF va charger le produit
# 1 + N + N*M requêtes !
class OrderViewSet(viewsets.ModelViewSet):
queryset = Order.objects.all() # Pas de prefetch
serializer_class = OrderSerializer
La solution : select_related et prefetch_related
api/views.py
class OrderViewSet(viewsets.ModelViewSet):
serializer_class = OrderSerializer
def get_queryset(self):
return Order.objects.select_related(
'customer' # ForeignKey
).prefetch_related(
'lines', # OneToMany
'lines__product', # Nested ForeignKey
'lines__product__category' # Encore plus profond
)
select_related vs prefetch_related
select_related : JOIN SQL, pour ForeignKey et OneToOne.
prefetch_related : requête séparée, pour ManyToMany et reverse FK.
Pattern : setup_eager_loading
Centralisez la logique de prefetch dans le serializer :
api/serializers.py
class OrderSerializer(serializers.ModelSerializer):
lines = OrderLineSerializer(many=True)
class Meta:
model = Order
fields = ['id', 'customer', 'lines', 'total']
@staticmethod
def setup_eager_loading(queryset):
"""Optimise le queryset pour ce serializer."""
return queryset.select_related(
'customer'
).prefetch_related(
'lines__product'
)
api/views.py
class OrderViewSet(viewsets.ModelViewSet):
serializer_class = OrderSerializer
def get_queryset(self):
qs = Order.objects.filter(user=self.request.user)
return OrderSerializer.setup_eager_loading(qs)
SerializerMethodField pour les données calculées
Pour des données qui ne viennent pas directement du modèle :
api/serializers.py
class ProductSerializer(serializers.ModelSerializer):
# Champ calculé
is_available = serializers.SerializerMethodField()
discount_price = serializers.SerializerMethodField()
stock_status = serializers.SerializerMethodField()
class Meta:
model = Product
fields = [
'id', 'name', 'price',
'is_available', 'discount_price', 'stock_status'
]
def get_is_available(self, obj):
return obj.stock > 0 and obj.status == 'active'
def get_discount_price(self, obj):
# Utilise le contexte pour la promotion active
promotion = self.context.get('active_promotion')
if promotion and obj in promotion.products.all():
return obj.price * (1 - promotion.discount / 100)
return None
def get_stock_status(self, obj):
if obj.stock == 0:
return 'out_of_stock'
elif obj.stock < 5:
return 'low_stock'
return 'in_stock'
Attention aux N+1 dans SerializerMethodField ! Si votre méthode fait une requête DB, elle sera exécutée pour chaque objet. Préférez les annotations ou le prefetch.
Alternative : annotation dans le queryset
Plus performant
from django.db.models import Count, Avg, Case, When, Value
class ProductViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return Product.objects.annotate(
review_count=Count('reviews'),
avg_rating=Avg('reviews__rating'),
stock_status=Case(
When(stock=0, then=Value('out_of_stock')),
When(stock__lt=5, then=Value('low_stock')),
default=Value('in_stock')
)
)
class ProductSerializer(serializers.ModelSerializer):
# Champs annotés (pas de requête supplémentaire)
review_count = serializers.IntegerField(read_only=True)
avg_rating = serializers.FloatField(read_only=True)
stock_status = serializers.CharField(read_only=True)
class Meta:
model = Product
fields = ['id', 'name', 'review_count', 'avg_rating', 'stock_status']
Bonnes pratiques résumées
- Nested writable : toujours explicite via
create()/update()avec@transaction.atomic - Lecture vs écriture : serializers séparés ou
to_representationoverride - Validation :
validate_field()pour un champ,validate()pour cross-field - Performance :
select_related/prefetch_relatedobligatoires avec nested - Données calculées : préférer les annotations aux
SerializerMethodField - Contexte : utiliser
self.contextpour accéder à la request et aux données externes
Besoin d'aide sur votre projet ?
Je peux vous accompagner dans le développement ou l'optimisation de votre application.
Me contacter