Mais aucune n’est parfaite, et une chose qui m’a toujours emmerdé avec celle-ci, c’est que si j’ai un modèle du genre:
class Foo(models.Model):
name = models.CharField(max_length=64)
bar = models.ForeignKey(Bar)
Et le serializer:
class FooSerialize(serilizers.ModelSerializer):
class Meta:
model = Foo
J’ai le choix entre soit avoir que des ID…
En lecture (chiant) :
GET /api/foos/1/ { name: "toto", bar: 2 }
Et en écriture (pratique) :
POST /api/foos/ { name: "tata", bar: 2 }
Soit avoir que des objets.
En lecture (pratique):
GET /api/foos/1/ { name: "toto", bar: { // tout l'objet bar disponible en lecture } }
Et en écriture (chiant) : POST /api/foos/ { name: "tata", bar: { // tout l'objet bar à se taper à écrire } }
Il y a aussi la version hypermedia où l’id est remplacé par une URL. Mais vous voyez le genre : mon API REST est soit pratique en lecture mais relou à écrire, soit pratique en écriture (je fournis juste une référence), mais relou en lecture, puisque je dois ensuite fetcher chaque référence.
GraphQL répond particulièrement bien à ce problème, mais bon, la techno est encore jeune, et il y a encore plein d’API REST à coder pour les années à venir.
Comment donc résoudre ce casse-tête, Oh Sam! – sauveur de la pythonitude ?
class FooSerializer(serilizers.ModelSerializer):
bar = BarSerializer()
class Meta:
model = Foo
Et là j’ai bien l’objet complet qui m’est retourné. Mais je suis en lecture seule, et il faut que je fasse l’écriture à la main. Youpi.
Pas la bonne solution donc.
Ben ça marche mais il faut 2 routings, ça duplique l’API, la doc, les tests. Moche. Next.
En lisant le code source de DRF (ouais j’ai conscience que tout le monde à pas la foi de faire ça), j’ai noté que ModelSerializer
génère automatiquement pour les relations un PrimaryKeyRelatedField
, qui lui même fait le lien via l’ID. On a des classes similaires pour la version full de l’objet et celle avec l’hyperlien.
En héritant de cette classe, on peut créer une variante qui fait ce qu’on veut:
from collections import OrderedDict
from rest_framework import serializers
class AsymetricRelatedField(serializers.PrimaryKeyRelatedField):
# en lecture, je veux l'objet complet, pas juste l'id
def to_representation(self, value):
# le self.serializer_class.serializer_class est redondant
# mais obligatoire
return self.serializer_class.serializer_class(value).data
# petite astuce perso et pas obligatoire pour permettre de taper moins
# de code: lui faire prendre le queryset du model du serializer
# automatiquement. Je suis lazy
def get_queryset(self):
if self.queryset:
return self.queryset
return self.serializer_class.serializer_class.Meta.model.objects.all()
# Get choices est utilisé par l'autodoc DRF et s'attend à ce que
# to_representation() retourne un ID ce qui fait tout planter. On
# réécrit le truc pour utiliser item.pk au lieu de to_representation()
def get_choices(self, cutoff=None):
queryset = self.get_queryset()
if queryset is None:
return {}
if cutoff is not None:
queryset = queryset[:cutoff]
return OrderedDict([
(
item.pk,
self.display_value(item)
)
for item in queryset
])
# DRF saute certaines validations quand il n'y a que l'id, et comme ce
# n'est pas le cas ici, tout plante. On désactive ça.
def use_pk_only_optimization(self):
return False
# Un petit constructeur pour générer le field depuis un serializer. lazy,
# lazy, lazy...
@classmethod
def from_serializer(cls, serializer, name=None, args=(), kwargs={}):
if name is None:
name = f"{serializer.__class__.__name__}AsymetricAutoField"
return type(name, (cls,), {"serializer_class": serializer})(*args, **kwargs)
Et du coup:
class FooSerializer(serializers.ModelSerializer):
bar = AsymetricRelatedField.from_serializer(BarSerializer)
class Meta:
model = Foo
Et voilà, on peut maintenant faire:
GET /api/foos/1/ { name: "toto", bar: { // tout l'objet bar disponible en lecture } } POST /api/foos/ { name: "tata", bar: 2 }
Elle est pas belle la vie ?
Ca serait bien cool que ce soit rajouté officiellement dans DRF tout ça. Je crois que je vais ouvrir un ticket…
]]>Par exemple, c’est un projet utilisant l’excellent Django Rest Framework, une app Django très puissante qui permet de créer des API succulentes.
DRF est très flexible, et permet de régler tout un tas de paramètres à l’aide de classes de configuration. Par exemple, il extrait automatiquement tout champ lookup_field
sur ses classes Serializer
afin de choisir sur quel champ filtrer les données.
L’auteur du code que j’ai sous les yeux, je crois, a voulu être vraiment, mais alors vraiment sûr d’avoir un look up:
class FooViewSet(ModelViewSet):
class Meta:
model = Foo
lookup_field = 'pk'
lookup_fields = ('pk', 'data_id')
extra_lookup_fields = None
En soi, c’est très drôle.
Et je pourrais arrêter l’article ici.
Mais non.
En effet, y a pas un truc qui vous choque ?
Je veux dire, autre que la sainte trinité des lookup fields…
Allez, relisez l’article depuis le début, je vous laisse une chance.
…
…
J’ai dit que DRF extrayait un champ lookup_field
sur les classes Serializer
, et comme vous pouvez le constater, l’auteur ici hérite joyeusement de ModelViewSet
, mais pas du tout de Serializer
.
Oui, parce qu’on est en pleine exploration de Fistland (Au fond du fun !), ces 3 champs ne sont en aucun cas exploités automatiquement par DRF… car sur les Viewset, lookup_field est utilisé pour générer des URLs, et mes prédécesseurs ont créé un router custo qui override ceci. Mais si on retire les champs, ça pète tout car il y a des bouts de leur code qui supposent l’existence de ce champ.
Néanmoins, ne soyons pas complètement négatif, certaines classes héritent bien de Serialiser
, et définissent aussi lookup_field
. D’ailleurs une part de mon job est de migrer tout ça. Car la petite touche humoristique finale, c’est que lookup_field
est deprecated depuis 3 releases dans DRF \o/ Mais deprecated sur les Serializer
uniquement hein, pas les Viewset. Enfin je dis ça…