Accepter un ID mais retourner un objet pour les liens de Django Rest Framework


DRF est une des perles de Django. De Python même. Comme marshmallow, requests, jupyter, pandas, SQLAlchemy ou l’admin Django. Python a tellement d’outils extraordinaires.

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 ?

Solution 1, utiliser un serializer à la place du field

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.

Solution 2, écrire deux serializers

Ben ça marche mais il faut 2 routings, ça duplique l’API, la doc, les tests. Moche. Next.

Solution 3, un petit hack

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

13 thoughts on “Accepter un ID mais retourner un objet pour les liens de Django Rest Framework

  • VALENTIN

    Solution 2, écrire deux serializers
    Ben ça marche mais il faut 2 routings, ça duplique l’API, la doc, les tests. Moche. Next.

    En overridant la get_serializer_class on a un truc quand même propre non ?

    def get_serializer_class(self):

    serializer_class = self.serializer_class

        if self.action in ('retrieve', ):
            serializer_class = FooSerializerRetrieve
    
        if self.action in ('create', 'update', 'partial_update'):
            serializer_class = FooSerializerCreate
    
        return serializer_class
    

  • Sam Post author

    Nan j’ai tenté mais ça marche pas :( Quand tu fais un post, tu veux poster un Id, mais du veux que le résultat retourné soit un objet.

  • Cku

    Petite typo sur le max_length : name = models.CharField(max_lengt=64)

  • Mask

    Ha bah.. Il tombe à pic cet article..

    Je m’embête depuis ce matin à trouver un truc propre, j’étais parti sur la solution 1, mais effectivement, refaire l’écriture à la main, c’est pas la joie.

    Merci pour l’astuce.

  • Fabs

    Pour info, comme soumis dans le ticket, nous utilisons cette solution :

    class FooSerializer(serilizers.ModelSerializer):
        
        bar = BarSerializer(read_only=True)
        bar_id = serializers.PrimaryKeyRelatedField(source='bar',  queryset=Bar.objects.all(), )
    
        class Meta:
    
            model = Foo
            fields = ('bar', 'bar_id', )
    
    
  • Blob

    Bon comme ça ne marchait pas directement chez moi, je poste la version “corrigée”:

    D’un côté dans un helpers.py:

    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):
        return self.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.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.__name__}AsymetricAutoField"
    
        return type(name, (cls,), {"serializer_class": serializer})
    

    Et dans serializers.py:

    class FooSerializer(serializers.ModelSerializer):

    bar = AsymetricRelatedField.from_serializer(BarSerializer)()
    
    class Meta:
        model = Foo
        field=('bar', 'name')
    

  • Sam Post author

    Il faut que tu précise ce qui ne marchait pas et ce que tu as corrigé.

  • Blob

    Oui, j’ajoute de l’information alors ;-)

    Les corrections sont au niveau de la méthode from_serializer et de l’appel dans le serializer.

    Dans la méthode from_serializer, pour respecter la méthode type, il faut que le 2ème argument soit un tuple: [cls] -> (cls,)

    Comme on passe une classe et non une instance, on ne peut pas faire serializer.class.name mais plutôt serializer.__name__

    Enfin au niveau du serializer, on utilise la méthode de classe from_serializer à laquelle on passe la classe du serializer à utiliser, ce qui nous donne une nouvelle classe de laquelle on appelle le constructeur:

    bar = AsymetricRelatedField.from_serializer(BarSerializer)()

    En tout cas, ce que je n’avais pas mis dans mon premier post, l’idée est vraiment très bonne ! En plus elle tombe à pic et solutionne un problème que j’avais la veille.

    Dommage que la proposition ne soit pas passée et même si je comprend les raisons, on fait bien ce qu’on veut avec nos APIS !

    Encore un bon article donc :-)

  • buffalo974

    C’ est quoi ta methodologie quand tu t’attaques à un probleme costaud ?

    Tu passes simplement ta paume sur la doc à la Matrix ?

    Ou tu fais des dessins au crayon, ou tu utilises un UML generator pour visualiser vite fait la tuyauterie ?

    quand je regardes des codes sources sur nullege, arrivé en bas du fichier, ma mémoire et déjà saturée depuis 5 minutes… Alors pour trouver un hack , jte racontes pas le fossé.

    Combien de temps ça t’a pris à cogiter pour ce cas en particulier ?

  • lollo

    Bonjour,

    Trop bien cet article.

    J’ai un truc a faire depuis des mois et je m’y suis pas encore attelé.. surement car j’ai pas encore la BONNE solution.

    J’ai un point API json_rcp assez sale et verbeux auquel je peux pas toucher, car utilisé dans une autre condition.

    Je veux donc faire un bridge API json_rpc qui expose des methodes faisant intervenir plusieurs appel json, changeant la structure et filtrant les data.

    je me demande quel est le top framework REST pour faire ca.. Car les solutions que j’ai trouvé sont assez dependante de l’ORM

  • Pyglouthon

    C’est certes pratique d’avoir ce petit helper dans le projet mais la solution de Fabs me semble simple et efficace.

    Je ne fais plus trop de Django depuis un moment mais sur mes projets Flask + marshmallow je définis aussi mon schema (~serializer) avec des champs en dump_only et d’autres en read_only. T’es pas tant que ça une grosse feignasse vu comme tu te fais chier à aller fouiner le code source pour faire ta tambouille. T’es surtout un bon geek curieux, un macgyver du Python web :-)

    Faut vraiment que je sois bloqué par les limites du package ou que la doc soit inexistantes pour en arriver là. Bon c’est vrai, ça arrive souvent avec les packages pour flask.

  • Sam Post author

    @buffalo974 : crayon et papier d’abord :)

    Après lire le code source d’un projet externe ça demande vraiment d’être expérimenté, faut pas déconner. Souvent on entend des gens dire “lisez les sources ça fait progresser”, mais y a un fossé entre pouvoir jeter un coup d’oeil à un fichier et comprendre toutes les implications dans un projet. Pour ce dernier, il faut avoir codé pas mal de projets soi-même.

    Pour ce cas là ça m’a pris une bonne demi-heure à une heure. Je savais déjà ce que je voulais faire car j’avais cherché longtemps un moyen de le faire. Donc j’ai juste regardé le code source du truc qui me le permettait pas, et j’ai tenté de le modifier jusqu’à ce que ça marche dans un test unitaire à l’arrache. La tricherie c’est que DRF utilise pas mal de design patterns que je connais bien, et ça change tout car quand je le vois faire quelque chose, ça me parle. D’autant que j’avais déjà regardé le code source de DRF dans des missions précédents et ce n’était pas une base non familière.

    @lollo : DRF marchera très bien pour ça, on peut tout faire sans ORM. Mais vue que tu fais un sous appel à un autre système derrière pour des raisons de perfs tu voudras peut être plutôt taper dans un framework asynchrone. Cherche un micro framework tout simple si c’est le cas. L’avantage de DRF c’est qu’il gère toute l’authentification, et c’est pas rien sur une API. Le désavantage c’est que c’est un gros morceau.

    @Pyglouthon : c’est pas faux :)

Comments are closed.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.