Makina Blog

Le blog Makina-corpus

Django Rest Framework : négociation de contenu (partie 6)


Après s'être remis de nos émotions avec les routeurs, on va s'accorder une petite trêve avec un thème qui ne déchaîne pas les passions : la négociation de contenu. Pourtant, c'est un passage obligatoire car c'est un élément essentiel aux API REST pour communiquer avec les clients. Allez, courage !

Soyons franc, c'est certainement pas la première chose qu'on souhaite apprendre quand on débute avec les REST API et DRF. Pourtant croyez-moi, il faut y passer. C'est la partie qui pique. C'est aussi la partie qui vous sort de l'ignorance. Et puis… j'aborde cette thématique dans un but bien précis : préparer le terrain pour le prochain article sur le versioning.

Personne d'autre ne veut négocier ?

La négociation de contenu est un concept qui permet à des clients d'envoyer et de recevoir des données dans des formats souhaités (JSON, XML, YAML, etc) que les serveurs supportent.

Quand un client envoi une requête HTTP avec un contenu, il peut être structuré. Pour que le serveur puisse comprendre ce contenu, il a besoin de connaître comment ce contenu est structuré. Pour cela, le client doit envoyé en théorie un entête HTTP Content-Type: <MEDIA TYPE> qui indique le media type a utilisé. Par exemple un entête HTTP comme Content-Type: application/json indique au serveur que le contenu de la requête est structuré en JSON.

Quand le client envoi une requête qui attend une réponse avec un contenu en retour, il peut (aussi) dire au serveur comment le contenu de cette réponse doit être structurée. Pour cela, le client doit théoriquement envoyer l'entête HTTP Accept: <MEDIA TYPE>. Le serveur sait alors comment il doit répondre à son client pour qu'ils se comprennent.

Dans la négociation de contenu, comprenez bien que le client est roi et que c'est lui qui mène la danse. Le serveur ne fait rien d'autre que de s'adapter. Enfin… il fait ce qu'il peut ! C'est parce que le serveur s'adapte qu'on parle de négociation : le client peut être très évasif sur sa demande, alors c'est le serveur qui tranche. Pas de miracle. Donc en clair, précise bien ta demande, sinon on t'envoi Korbeeeen Dallas !

Media type, MIME type, Content type, c'est quoi la différence ?

Ça embrouille, ça embrouille… Donc pour que les choses soient bien claires : c'est la même chose. L'usage de chacun de ces termes est historique et contextuel : SMTP (MIME type), HTTP (Content-Type), API REST (internet media type). Dans cet article, on emploiera le terme anglophone media type car il semble être le plus approprié.

Composition d'un media type

Sans trop rentrer dans les détails, un media type est composé :

  • d'un type et d'un sous-type séparés par un slash, comme par exemple text/plain.
  • de paramètres optionnels séparés par des points-virgules, par exemple text/html; charset=UTF-8.

Les sous-types peuvent :

  • être préfixés par des tree de type :
    • standard (aucun préfix) : application/json
    • vendor (vnd.) pour enregistrer officiellement ces propres media type : application/vnd.github
    • personal/vanity (prs.) pour partager vos petits secrets au monde entier : application/prs.ilovepony
    • unregistered (x.) si vous avez la flemme d'enregistrer vos media type auprès de l'IANA : application/x.lazymediatype
  • avoir un suffix pour préciser la structure du media type : application/xhtml+xml

En résumé, la forme complète, c'est :

top-level type name / [ tree. ] subtype name [ +suffix ] [ ; parameters ]

On n'en dit pas plus, l'article Wikipedia le fait très bien pour nous. Et pour ceux qui s'ennuient, vous pouvez aussi lire la RFC2048

Correspondance de media type

Le serveur supporte une liste définie de media type. Quand un client lui demande un media type plutôt qu'un autre, le serveur doit savoir si il peut satisfaire cette demande avec ce qu'il a. Il doit parfois même faire un choix entre plusieurs media type. Pour cela, il applique une méthode de correspondance (matching).

Mais comment ça marche exactement ? Vous imaginez peut-être qu'un media type du style application/xhtml+xml peut être considéré comme une sorte de application/xml ? Eh bien détrompez-vous ! En fait, le suffix +xml est considéré comme partie intégrale du sous-type.

On peut voir dans les commentaires de la fonction utilitaire media_type_matches() de DRF que la correspondance peut marcher si :

  • les deux media type sont strictement identiques
  • on supprime les paramètres optionnels du media type souhaité : .. code-block:: python 'application/xml; charset=utf-8' == 'application/xml'
  • on remplace les wildcard du sous-type du media type souhaité: .. code-block:: python 'application/' == 'application/xml' voir carrément du type, allez soyons fou: .. code-block:: python '/*' == 'application/xml'

Et c'est tout ! Tout le reste n'aboutit sur aucune correspondance. En clair :

'application/xhtml+xml' != 'application/xml'

Implémentation par défaut de DRF

DRF propose une implémentation par défaut de la négociation de contenu qui peut être surchargée. C'est la classe DefaultContentNegotiation qui est définie par défaut via le setting DEFAULT_CONTENT_NEGOTIATION_CLASS. Elle se charge de détecter et d'appliquer ce qu'on appelle des parser et des renderer.

Les parser

Vous l'aurez compris, les parsers sont utilisés conjointement avec le header HTTP Content-Type envoyé par le client pour que le serveur interprête correctement le contenu de la requête. Concrètement, si vous avez les parsers qui vont bien, vos clients peuvent vous balancer du JSON, du YAML kikoulol, voire du XML à l'ancienne. Même pas mal.

Déclaration des parser

Les parser peuvent être déclarés avec le setting DEFAULT_PARSER_CLASSES. On peut aussi les surcharger par vue via l'attribut parser_classes ou via le décorateur parser_classes. Bon franchement, ça n'arrive pas tous les quatre matins. Cependant attention, on verra plus loin que l'ordre de définition des parser est important.

Les parser par défaut

Plusieurs parsers sont implémentés par défaut dans DRF, dont l'inévitable JSONParser. Bien souvent, le FormParser est utilisé pour la browsable API (ou autre documentation HTML comme Swagger) et le MultiPartParser pour l'upload de fichier. Eh oui. Donc en réalité, si vous gérez les upload de fichiers, il vous faudra déclarer plusieurs parsers, dont le MultiPartParser en fin de liste par exemple. C'est le cas par défaut rassurez-vous.

Détection des parser

La détection des parsers est lancée quand on tente d'accéder pour la première fois aux données envoyées par le client. Typiquement, quand vous faites : request.data.

Cette détection s'appuie sur l'entête HTTP Content-Type. Si cet entête HTTP n'est pas fourni, c'est la valeur text/plain qui est assumée par défaut. Oui oui, même si votre requête est en POST comme par exemple :

curl -v -X POST http://127.0.0.1:8000/api/bar/

Ça surprend parce que nos réflexes de développeur Web nous font penser au comportement par défaut des navigateurs Web en POST qui envoient un Content-Type: application/x-www-form-urlencoded. J'ai donc décidé de mener mon enquête. L'obtention du content-type repose sur la propriété META de l'objet WSGIRequest du core de Django. De fil en aiguille, on s'aperçoit que ce dictionnaire META obtient tous les entêtes HTTP… à partir d'un objet Message de la librairie email standard de Python ! "Ouais bah en fait, rien de surprenant car finalement, media type, MIME type, tout ça c'est pareil, non ?". Tout à fait. Historiquement, l'usage premier des media type était pour les mails. Et devinez quoi ? Pour les mails, le media type par défaut, c'est text/plain.

Bon sinon, la détection est relativement simple : pour chacun des parsers enregistrés pour la vue courante, DRF vérifie successivement si il y a une correspondance du média type. L'ordre des parsers dans la liste a donc son rôle à jouer : le premier qui correspond gagne. Par exemple, admettons que nous avons les trois media types suivant d'actif :

REST_FRAMEWORK = {
    'DEFAULT_PARSER_CLASSES': (
        'rest_framework.parsers.MultiPartParser',  # multipart/form-data
        'rest_framework.parsers.JSONParser',  # application/json
        'rest_framework_xml.parsers.XMLParser',  # application/xml
    ),
}

et que le client stipule Content-Type: application/* (ouais c'est profondément débile), alors DRF va utiliser JSONParser des deux qui correspondent car c'est le premier. Si XMLParser avait été devant, c'est donc lui qui gagnait.

Bon et puis c'est pas la fête non plus. Genre Content-Type: youhooooooou, c'est 415 in your face.

Les renderer

Les renderer sont utilisés pour structurer la réponse renvoyée au client dans le format spécifié par l'entête HTTP Accept. N'oubliez pas, le client est roi. Un serveur REST sans client, c'est comme le H de Hawaï. Donc si ce dernier vous envoie une requête en JSON et qu'il attend du XML en retour… que vos voeux soient exaucés !

Déclaration des renderer

Même combat, les renderer peuvent être déclarés avec le setting DEFAULT_RENDERER_CLASSES et surchargés par vue via l'attribut renderer_classes ou via le décorateur renderer_classes.

Détection des renderer

La détection des renderer est lancée durant la phase d'initialisation de la requête. Contrairement aux parser, cette détection est donc toujours lancée.

Cette détection s'appuie sur l'entête HTTP Accept, qui vaut par défaut /. Comprenez par là: "Allez vas-y c'est bon quoi, je m'en fous de ta réponse !". Voire pire : "Euuuuuuuuuh, bah je sais pas moi !! C'est ton boulot ! Donne-moi c'que t'as !". Mouais.

Pour ceux qui font mumuse avec leur url parce que "c'est trop cool et que les entêtes HTTP, ça craint", vous pouvez également peaufiner le format souhaité en utilisant :

  • les suffixes de formats : .. code-block:: http://127.0.0.1:8000/api/bar.json/
  • un paramètre GET d'url configurable via le setting URL_FORMAT_OVERRIDE : .. code-block:: http://127.0.0.1:8000/api/bar/?format=json

Attention, il s'agit juste de réduire la liste des renderers à un seul fourni. Il ne faut donc aucun entête HTTP Accept :

curl -v -X GET http://127.0.0.1:8000/api/bar/?format=json -H 'Accept: application/xml'

ou :

curl -v -X GET http://127.0.0.1:8000/api/bar.json/ -H 'Accept: application/xml'

ça marche bofbof ! Mais bon… je dis ça, je dis rien hein !

Une fois le media type connu, la détection est un chouillat plus complexe que celle des parsers. Supposons que nous avons déclaré les renderer suivants :

REST_FRAMEWORK = {
    'DEFAULT_RENDERER_CLASSES': (
        'rest_framework.renderers.JSONRenderer',  # application/json
        'rest_framework_xml.renderers.XMLRenderer',  # application/xml
    ),
}

Imaginons ensuite qu'un client récalcitrant, voire carrément dingue, nous envoi un entête comme :

Accept: application/xml, application/*, */*, application/json

(je sais, je sais, mais c'est possible !). DRF va d'abord effectuer un tri par préférence. Concrètement, on obtiendra la liste :

[
    set([u'application/json', u'application/xml']),
    set([u'application/*']),
    set([u'*/*'])
]

histoire de partir des plus spécifiques aux plus évasifs. Ensuite pour chacun de ces groupes de préférences, il va appliquer dans l'ordre les renderers déclarés dans la liste.

Évidemment, si un client vous demande de répondre avec le minot YAML ou le papi XML et que votre serveur ne le supporte pas, alors il se prendra une 406. Au moins, il pourra faire son kéké… Bon ok, en vrai, c'est ici que ça se passe.

Dans la vraie vie

Vous pouvez vérifier chacune de ces belles paroles avec ce dépôt git en vous positionnant sur la branche content_negociation. Il ne vous reste plus qu'à jouer avec les settings pour activer/désactiver des parser/renderer et effectuer des requêtes HTTP.

Par exemple pour les parser :

curl -v -X POST http://127.0.0.1:8000/api/bar/ -H 'Content-Type: application/vnd.example.books+json' -d '{}'  # trop swagg, notre propre media type 
curl -v -X POST http://127.0.0.1:8000/api/bar/ -H 'Content-Type: skuuuuurt' -d '{}'  # Paf gamin, prends ta 415 

pour les renderer :

curl -v -X GET http://127.0.0.1:8000/api/bar/ -H 'Accept: application/vnd.example.books+json, application/*, */*, application/json'  # Alors, c'est quiqui gagne ?? 
curl -v -X GET http://127.0.0.1:8000/api/bar/ -H 'Accept: application/*'  # Idem, ça dépend de l'ordre 
curl -v -X GET http://127.0.0.1:8000/api/bar/ -H 'Accept: */*' # qui c'est le premier ? 
curl -v -X GET http://127.0.0.1:8000/api/bar/  # comme */*, mais en mode feignasse
curl -v -X GET http://127.0.0.1:8000/api/bar/ -H 'Accept: We tripyyyy mane'  # bon là c'est fin de soirée... 

et pour les deux :

curl -v -X PUT http://127.0.0.1:8000/api/bar/1/ -H 'Content-Type: application/json' -H 'Accept: application/xml'  # sinon c'est pas drôle 

Conclusion

La négociation de contenu permet à un client de préciser à un serveur comment le contenu d'une requête HTTP est (envoi) ou doit être (réponse) structuré. Pour y parvenir, le client doit fournir une liste de media type plus ou moins précise avec des entêtes HTTP. Pour vos clients, la règle d'or à retenir : explicit is better. Obligez-les à toujours fournir ces entêtes HTTP.

L'implémentation par défaut de DRF adopte un comportement classique qui sera rarement surchargé. Cette implémentation choisit et délègue le travail d'entrée à un parser et le travail de sortie à un renderer. Dans cet article, nous avons seulement parlé de leurs implications dans la négociation de contenu. Leurs fonctionnements et leurs personnalisations mériteraient un article dédié.

À venir

Normalement c'est bon ! On est fin prêt pour parler du versioning.

Formations associées

Formations Django

Formation Django Rest Framework

À distance (FOAD) Du 10 au 14 juin 2024

Voir la formation

Formations Django

Formation Django avancé

À distance (FOAD) Du 9 au 13 décembre 2024

Voir la formation

Formations Django

Formation Django initiation

Nantes Du 12 au 14 mars 2024

Voir la formation

Actualités en lien

Image
Django_rest_framework_logo
04/02/2016

Django Rest Framework : versioning avec DRF (partie 7)

DRF remplit vaillamment son devoir et propose une intégration poussée du versioning d'API. À tel point qu'on est vite perdu dans la multitude de méthodes possibles.

Voir l'article
Image
Django_Rest_Framework_routeurs
06/01/2016

Django Rest Framework : personnalisation des routeurs (partie 5)

Dernière ligne droite pour les routeurs : après avoir analysé le fonctionnement et l'intégration, c'est au tour de la personnalisation.

Voir l'article
Image
Django_Rest_Framework_routeurs2
06/01/2016

Django Rest Framework : intégration des routeurs (partie 4)

Comprendre comment les routeurs fonctionnent est une chose. Savoir s'en servir correctement, c'est mieux ! On va voir que ça n'est pas si simple et que c'est probablement une des parties de DRF la plus étonnante.

Voir l'article

Inscription à la newsletter

Nous vous avons convaincus