Accueil / Blog / Métier / 2016 / Django Rest Framework : versioning avec DRF (partie 7)

Django Rest Framework : versioning avec DRF (partie 7)

Par Yannick Chabbert — publié 15/02/2016, édité le 24/02/2016
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.
Django Rest Framework : versioning avec DRF (partie 7)

Après avoir remué les questions à débats dans ce précédent article, on peut enfin commencer les choses sérieuses. On va tenter dans cet article de compléter la documentation du versioning dans DRF.

Comment ça se passe ?

Pour les curieux, la version est évaluée à chaque appel de vue. Que ça soit pour une APIView ou un Viewset, la fonction génératrice de vue finit par appeler en cascade : dispatch() > initial() > determine_version().

Si une classe de versioning est définie via le setting DEFAULT_VERSIONING_CLASS, elle est instanciée puis elle exécute la méthode du même nom : determine_version(). Cette dernière peut lever une exception ou retourner la version détectée.

De façon générale, chacune des classes adopte un comportement semblable même si la classe parente commune BaseVersioning ne les y oblige pas. Cependant, toutes ont recours à une même méthode parente : is_allowed_version(). Quand on regarde cette méthode de plus près, on s'aperçoit que la version est validée si :

  • la version est la valeur par défaut

  • la version est dans la liste des versions autorisées

  • il n'y a pas de version autorisée. Autrement dit, toutes les versions sont autorisées. Donc la version :

    version=DELETE FROM auth_user;
    

    ça passe sans soucis. On en déduit par ailleurs que c'est pas mal de mettre les versions autorisées avec le setting REST_FRAMEWORK['ALLOWED_VERSIONS'], histoire d'avoir la variable request.version un peu plus safe...

Papier s'il vous plaît

On a dit dans ce précédent article que c'était mieux de forcer le client à fournir la version. Comment fait-on par défaut avec DRF ? Eh bien la réponse est simple : on ne peut pas !

En effet, chacune des classes retourne la version par défaut si aucune n'est fournie. Les plus malins d'entre vous se diront : "Facile ! Suffit de mettre une valeur par défaut qui n'est pas dans la liste des versions autorisées." Bien essayé... J'ai passé pas mal de temps à chercher une solution élégante qui n'impliquerait aucun, voir peu de changements de l'API existante, mais en vain... J'ai le regret de vous annoncer que la seule chose qui vous reste à faire, c'est d'implémenter vos propres classes de versioning. Ouais, j'ai bien dit vos, pas votre car chaque classe à ses propres méthodes de détection. La mutualisation est donc impossible. Et franchement, c'est pas joli joli...

Comme j'aime partager mes frustrations, voici un exemple pas beau :

from rest_framework import exceptions
from rest_framework.utils.mediatypes import _MediaType
from rest_framework.versioning import AcceptHeaderVersioning


class AcceptHeaderVersioningRequired(AcceptHeaderVersioning):

    def determine_version(self, request, *args, **kwargs):
        media_type = _MediaType(request.accepted_media_type)

        version = media_type.params.get(self.version_param, None)
        if version is None:
            raise exceptions.NotAcceptable('A version is required.')

        return super(AcceptHeaderVersioningRequired, self).determine_version(
            request, *args, **kwargs)

Arf... Il ne reste plus qu'à utiliser cette splendide classe de versioning :

REST_FRAMEWORK = {
    'DEFAULT_VERSIONING_CLASS': 'example.versioning.AcceptHeaderVersioningRequired',
    'ALLOWED_VERSIONS': ('1.0', '1.1'),
    'DEFAULT_VERSION': '1.0',
}

Pour les sadiques qui aiment voir des trucs pas beaux, vous pouvez cloner ce dépôt, aller sur la branche versioning_required et exécuter les requêtes HTTP suivantes :

curl -v -X GET http://127.0.0.1:8000/api/bar/ -H 'Accept: application/json'  # 406, version required
curl -v -X GET http://127.0.0.1:8000/api/bar/ -H 'Accept: application/json; version=3.0;'  # 406, version not supported
curl -v -X GET http://127.0.0.1:8000/api/bar/ -H 'Accept: application/json; version=1.0;'  # 200, everything gonna be alright...

Ceci n'est qu'un exemple qu'il conviendra d'adapter pour chacune des classes de versioning souhaitées car chacune a un traitement spécifique.

Quand on voit le résultat, on a le sentiment que beaucoup se reposeront uniquement sur la bonne volonté de leurs clients à fournir une version : "Et si ils ne le font pas, bah tant pis pour eux, faut assumer !". Après tout, peut-on les blâmer ?

Les méthodes de versioning par URL

On a dit dans ce précédent article que l'intégration du versioning par URL de DRF était plutôt galère. Je vous avoue que je ne l'ai pas testée en production. Je vais vous expliquer pourquoi je n'ai pas osé prendre ces risques.

URLPathVersioning

Regardons de plus près la documentation de la classe URLPathVersioning. Sérieux, rien ne vous fait peur ? Vous vous imaginez maintenir une jungle de motifs d'URL en permanence ?

Si on a une petite API REST avec une seule inclusion d'URLconf, alors ça peut se résumer simplement à maintenir :

urlpatterns = [
    url(r'^api/(?P<version>(v1|v2|v3))/', include(router.urls, namespace='api')),
]

C'est tout. Donc si c'est votre cas, aucun problème.

En revanche, si vous ne pouvez pas mettre la version dans les pattern d'inclusion et que vous avez beaucoup de motifs d'URL... Aïe aïe. À maintenir, cela peut vite devenir un vrai cauchemar. Sans compter qu'on peut également jouer avec le setting ALLOWED_VERSIONS pour autoriser seulement quelques versions sur l'ensemble de l'API. Admettons que vous avez ce motif d'URL perdu dans la masse :

# Other url pattern which are updated
# url(r'^api/(?P<version>(v3|v4))/foo', FooView.as_view()),
# url(r'^api/(?P<version>(v3|v4))/bar', BarView.as_view()),

# a lot of stuff ...

# Did you forgot something?
url(r'^api/(?P<version>(v1|v2))/dontforgetme', MyAwesomeView.as_view()),

et que vous avez le setting :

REST_FRAMEWORK = {
    # ...
    'ALLOWED_VERSIONS': ('v3', 'v4'),
}

vous voyez le soucis ? :-)

Pas le droit à l'erreur. Il faut donc penser à garder tout ce petit monde bien synchronisé et modifier en conséquence toutes les URL. Bon courage.

Si vous souhaitez tester un exemple lambda, vous pouvez aller sur la branche versioning_urlpath du dépôt et exécuter des requêtes HTTP comme :

curl -v -X GET http://127.0.0.1:8000/api/v1/bar/open/  # Paf, 404
curl -v -X GET http://127.0.0.1:8000/api/v2/bar/open/  # Ok, c'est parti
curl -v -X GET http://127.0.0.1:8000/api/v3/bar/open/  # Ok, deuxième tournée

NamespaceVersioning

Avec la classe NamespaceVersioning, c'est déjà mieux. À tel point que c'est certainement une solution de repli pour les projets qui historiquement utilisaient la classe URLPathVersioning et qui aujourd'hui ne s'en sortent plus. En effet, cette bascule est envisageable car c'est totalement transparent pour vos clients puisque l'URL est la même.

Bon déjà, faut pas utiliser les namespaces hein ! Ensuite, il faut bien comprendre les avantages et les faiblesses pour l'utiliser à bon escient.

Par exemple, si on réécris l'exemple précédent avec cette technique, on obtient :

urlpatterns = [
    url(r'^api/v1/', include(router.urls, namespace='v1')),
    url(r'^api/v2/', include(router.urls, namespace='v2')),
    url(r'^api/v3/', include(router.urls, namespace='v3')),
]

Positionnez-vous sur la branche versioning_namespace de notre dépôt et préparez-vous à encaisser l'uppercut :

./manage.py show_urls

Et bim !

/api/v1/    rest_framework.routers.APIRoot  v1:api-root
/api/v1/.<format>/  rest_framework.routers.APIRoot  v1:api-root
/api/v1/bar.<format>/       bar.viewsets.BarViewSet v1:bar-list
/api/v1/bar/        bar.viewsets.BarViewSet v1:bar-list
/api/v1/bar/<pk>.<format>/  bar.viewsets.BarViewSet v1:bar-detail
/api/v1/bar/<pk>/   bar.viewsets.BarViewSet v1:bar-detail
/api/v1/bar/open.<format>/  bar.viewsets.BarViewSet v1:bar-open
/api/v1/bar/open/   bar.viewsets.BarViewSet v1:bar-open
/api/v1/baz.<format>/       baz.viewsets.BazViewSet v1:baz-list
/api/v1/baz/        baz.viewsets.BazViewSet v1:baz-list
/api/v1/baz/<pk>.<format>/  baz.viewsets.BazViewSet v1:baz-detail
/api/v1/baz/<pk>/   baz.viewsets.BazViewSet v1:baz-detail
/api/v1/foo.<format>/       foo.viewsets.FooViewSet v1:foo-list
/api/v1/foo/        foo.viewsets.FooViewSet v1:foo-list
/api/v1/foo/<pk>.<format>/  foo.viewsets.FooViewSet v1:foo-detail
/api/v1/foo/<pk>/   foo.viewsets.FooViewSet v1:foo-detail
/api/v2/    rest_framework.routers.APIRoot  v2:api-root
/api/v2/.<format>/  rest_framework.routers.APIRoot  v2:api-root
/api/v2/bar.<format>/       bar.viewsets.BarViewSet v2:bar-list
/api/v2/bar/        bar.viewsets.BarViewSet v2:bar-list
/api/v2/bar/<pk>.<format>/  bar.viewsets.BarViewSet v2:bar-detail
/api/v2/bar/<pk>/   bar.viewsets.BarViewSet v2:bar-detail
/api/v2/bar/open.<format>/  bar.viewsets.BarViewSet v2:bar-open
/api/v2/bar/open/   bar.viewsets.BarViewSet v2:bar-open
/api/v2/baz.<format>/       baz.viewsets.BazViewSet v2:baz-list
/api/v2/baz/        baz.viewsets.BazViewSet v2:baz-list
/api/v2/baz/<pk>.<format>/  baz.viewsets.BazViewSet v2:baz-detail
/api/v2/baz/<pk>/   baz.viewsets.BazViewSet v2:baz-detail
/api/v2/foo.<format>/       foo.viewsets.FooViewSet v2:foo-list
/api/v2/foo/        foo.viewsets.FooViewSet v2:foo-list
/api/v2/foo/<pk>.<format>/  foo.viewsets.FooViewSet v2:foo-detail
/api/v2/foo/<pk>/   foo.viewsets.FooViewSet v2:foo-detail
/api/v3/    rest_framework.routers.APIRoot  v3:api-root
/api/v3/.<format>/  rest_framework.routers.APIRoot  v3:api-root
/api/v3/bar.<format>/       bar.viewsets.BarViewSet v3:bar-list
/api/v3/bar/        bar.viewsets.BarViewSet v3:bar-list
/api/v3/bar/<pk>.<format>/  bar.viewsets.BarViewSet v3:bar-detail
/api/v3/bar/<pk>/   bar.viewsets.BarViewSet v3:bar-detail
/api/v3/bar/open.<format>/  bar.viewsets.BarViewSet v3:bar-open
/api/v3/bar/open/   bar.viewsets.BarViewSet v3:bar-open
/api/v3/baz.<format>/       baz.viewsets.BazViewSet v3:baz-list
/api/v3/baz/        baz.viewsets.BazViewSet v3:baz-list
/api/v3/baz/<pk>.<format>/  baz.viewsets.BazViewSet v3:baz-detail
/api/v3/baz/<pk>/   baz.viewsets.BazViewSet v3:baz-detail
/api/v3/foo.<format>/       foo.viewsets.FooViewSet v3:foo-list
/api/v3/foo/        foo.viewsets.FooViewSet v3:foo-list
/api/v3/foo/<pk>.<format>/  foo.viewsets.FooViewSet v3:foo-detail
/api/v3/foo/<pk>/   foo.viewsets.FooViewSet v3:foo-detail

Ça fait mal hein. Eh oui, dans cet exemple, cette technique va dupliquer les points d'entrées par namespace de versions... Plus de peur que de mal très probablement.

En comparaison avec l'exemple précédent, cette classe n'apporte rien de plus. Non, car l'intéret principal de cette technique, c'est de forcer le versioning d'URL par inclusion de fichier d'URLconf uniquement. Oui, c'est le principe des namespace en fait...

Implicitement, cela peut forcer les développeurs sur un projet à créer des fichiers d'URLconf par version qui seront inclus dans un namespace dédié. Par exemple, on pourrait avoir les fichiers d'URLconf suivants :

  • v1_urls.py
  • v2_urls.py
  • v3_urls.py

et un fichier d'URLconf racine qui ressemblerait à :

urlpatterns = [
    url(r'^api/v1/', include('example.v1_urls', namespace='v1')),
    url(r'^api/v2/', include('example.v2_urls', namespace='v2')),
    url(r'^api/v3/', include('example.v3_urls', namespace='v3')),
]

Évidemment, rien ne vous empêche de faire la même chose avec la classe URLPathVersioning. Cependant, avec les namespaces, vous n'avez pas le choix. Si vous voulez votre point d'entrée dans la v3, il faudra forcément le déclarer dans le fichier v3_urls.py (ou un de ces fichiers enfants).

Il en ressort un design plus clair car la maintenance de vos versions est uniquement dans votre fichier d'URLconf racine et ça ne part pas dans tous les sens. Enfin...rien ne vous interdit de faire des trucs tordus avec des inclusions d'inclusions sans fin mais là, vous abusez.

La maintenance des versions en tant que telle est donc simplifiée. Par contre, le regroupement de ces points d'entrées dans les fichiers inclus l'est sûrement beaucoup moins... Sur des projets avec de nombreux points d'entrées, cette classe semblerait effectivement bien plus pérenne pour la maintenance des versions.

Pour un exemple plus concret, je vous invite à vous positionner sur la branche versioning_namespace_split du dépôt, de regarder de plus près les modules Python :

  • example.urls
  • example.v(1|2|3)_urls
  • bar.views

puis d'exécuter la commande :

./manage.py show_urls

et enfin d'exécuter les requêtes HTTP suivantes :

curl -v -X GET http://127.0.0.1:8000/api/v1/bar/        # 200
curl -v -X GET http://127.0.0.1:8000/api/v1/bar/open/   # 404 not yet implemented
curl -v -X GET http://127.0.0.1:8000/api/v1/bar/close/  # 404 not yet implemented

curl -v -X GET http://127.0.0.1:8000/api/v2/bar/        # 200 with new changes
curl -v -X GET http://127.0.0.1:8000/api/v2/bar/open/   # 200 implemented since v2 : happy hours!
curl -v -X GET http://127.0.0.1:8000/api/v2/bar/close/  # 404 not yet implemented

curl -v -X GET http://127.0.0.1:8000/api/v3/bar/        # 200 with new changes again
curl -v -X GET http://127.0.0.1:8000/api/v3/bar/open/   # 200
curl -v -X GET http://127.0.0.1:8000/api/v3/bar/close/  # 200 implemented since v3 because it was a real mess !

QueryParameterVersioning

On ne va rien se cacher, la classe QueryParameterVersioning est la plus moche. Mais c'est aussi la plus simple à maintenir.

Moche parce que si vous souhaitez forcer le versioning, un paramètre GET n'est par design pas obligatoire. Pour un client, il doit implémenter des trucs tordus pour toujours mettre le paramètre GET de version dans chacune des URL. Horrible. Et puis, on aime tous avoir de jolies URL sans paramètre GET... non ?

Simple parce qu'il n'y a rien à faire. Zéro maintenance de fichier d'URLconf. Voyez par vous-même en vous positionnant sur la branche versioning_queryparam et exécutez des requêtes moches comme :

curl -v -X GET http://127.0.0.1:8000/api/bar/open/?version=v1  # Paf, 404
curl -v -X GET http://127.0.0.1:8000/api/bar/open/?version=v2  # Ok, 200
curl -v -X GET http://127.0.0.1:8000/api/bar/open/?version=v3  # Ok, 200

En conclusion, si vous avez très peu de points d'entrées, que votre version n'est pas obligatoire (tant pis pour vous !) et que vous avez la flemme, c'est une bonne solution.

Entête HTTP Accept et Vendor Media Type

Si vous retenez la solution de l'entête HTTP Accept, vous opterez sûrement pour un vendor media type personnalisé.

Vendor media type

"Euh...rapidement c'est quoi déjà ce truc ?". Si vous aviez lu ce magnifique article sur la négociation de contenu, vous sauriez que les vendor media type (préfix vnd.) permettent de déclarer vos propres media type de façon officielle. Il existe aussi une façon officieuse pour les plus pressés (unregistered, soit le préfix x.).

Voici par exemple un media type enregistré pour notre API de livres :

Accept: application/vnd.makinacorpus.books+json; version=1.0

Un autre exemple pour la version petit budget :

Accept: application/x.books+json; version=1.0

Qu'on se le dise, c'est quand même la grande classe : "Attends mec, on a notre propre vendor media type, tu piges ??". Non, évidemment, ça n'est pas tellement pour le côté bling-bling que l'on implémente son propre media type. C'est avant tout pour le respect des normes HTTP. Par exemple, le mimetype JSON standard n'accepte pas de paramètre version tout simplement parce que ça n'est pas prévu. En théorie, on ne peut pas faire :

Accept: application/json; version=1.0

C'est la raison pour laquelle on demande à nos clients de préciser notre propre vendor media type avec notre propre paramètre pour la version d'API.

Implémentation d'un vendor media type

Dans de très nombreux cas, votre media type est juste une extension d'un media type standard (usage d'un suffix comme +json ou +xml). Ça tombe bien, on peut difficilement faire plus simple. Il faut pour cela créer une classe renderer qui hérite du renderer étendu et déclarer notre propre media type. Le client pourra alors fournir tous les paramètres qui lui chantent. Mais bon, le seul qui nous intéresse vraiment, c'est celui qui se prénomme VERSION_PARAM.

Pour un media type avec un entête HTTP comme :

Accept: application/vnd.example.books+json; version=1.0

on pourrait implémenter un renderer comme celui-ci :

from rest_framework.renderers import JSONRenderer


class ExampleJSONRenderer(JSONRenderer):
    media_type = 'application/vnd.example.books+json'

C'est tout. Enfin si, faut pas oublier de configurer DRF pour utiliser cette classe :

REST_FRAMEWORK = {
    'DEFAULT_RENDERER_CLASSES': (
        'example.renderers.ExampleJSONRenderer',
    ),
}

Pffff, trop balèze. Dans cet exemple, remarquez qu'on accepte uniquement notre vendor media type. Rien d'autre. C'est pas un peu tyrannique ?

Support du media type JSON en fallback

Justement. Comme vous êtes favorable à la diversité des formats (en vrai, vous avez surtout peur que vos clients ne fournissent pas le bon media type), vous devez avoir plusieurs renderers. Comme ça, si un client vient et vous dit :

Accept: application/json; version=1.0

au lieu de :

Accept: application/vnd.makinacorpus.books+json; version=1.0

vous pourrez lui dire : "Grrr il te plaît pas mon vendor ? Bon allez tiens, prends ton JSON !".

Vous n'avez pas compris ? Bon j'avoue, c'est pas super trivial. Si on avait juste le renderer de notre media type :

REST_FRAMEWORK = {
    'DEFAULT_RENDERER_CLASSES': (
        'example.renderers.ExampleJSONRenderer',  # application/vnd.makinacorpus.books+json
    ),
}

la correspondance de media type n'aurait pas fonctionné :

'application/json; version=1.0' != 'application/vnd.makinacorpus.books+json'

On aurait très poliment renvoyé un code HTTP 406. Si vous n'avez toujours pas compris,je vous renvoie au précédent article sur la négociation de contenu.

Pour jouer sur tous les fronts, il vous faudra donc ajouter à la variable de configuration REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] le renderer JSONRender par défaut :

REST_FRAMEWORK = {
    'DEFAULT_RENDERER_CLASSES': (
        'rest_framework.renderers.JSONRenderer',  # application/json
        'example.renderers.ExampleJSONRenderer',  # application/vnd.makinacorpus.books+json
    ),
}

Rappel : l'ordre dans la liste est important. En cas de conflit de résolution, c'est le premier qui gagne.

M'enfin bon, tous vos clients vont scrupuleusement respecter la norme HTTP alors je ne vois pas en quoi vous auriez besoin de cette alternative ;-)

Fonction reverse()

Pour finir, simple rappel déjà abordé dans ce précédent article sur les routeurs : même si vous n'utilisez pas le versioning par URL, pensez à toujours utiliser la fonction reverse() de DRF et non celle de Django. Pas d'excuse.

Conclusion

L'intégration dans DRF vous offre un vaste choix qu'il n'est pas toujours facile à faire. La méthode des entêtes HTTP avec un vendor media type est très souvent un bon choix. Il ne faut pas pour autant négliger la méthode par URL.

Au final, qu'importe la solution retenue, il est probable que vous coderez quelques lignes, notamment si la version est obligatoire. Il n'en reste pas moins que c'est peu d'effort compte tenu de la converture fonctionnelle que propose DRF.

ABONNEZ-VOUS À LA NEWSLETTER !
Voir aussi
Python : Bien configurer son environnement de développement Python : Bien configurer son environnement de développement 07/12/2015

Comment utiliser les bonnes pratiques de développement Python.

Formation Django initiation à Toulouse du 13 au 15 mars Formation Django initiation à Toulouse du 13 au 15 mars 26/01/2017

Entrez de plain-pied dans l'univers de Django aux côtés de développeurs ayant une expérience de ...

Alone in the cloud Alone in the cloud 14/12/2016

Thoughts while helping my brother-in-law

Retour sur la PyConFr 2016 Retour sur la PyConFr 2016 18/10/2016

Nous étions présents à Rennes pour PyConFr 2016. Voici notre compte-rendu à chaud.

Wagtail: How to use the Page model and its manager (part 2) Wagtail: How to use the Page model and its manager (part 2) 08/08/2016

The Page model has several methods specific to Wagtail. This is also the case of its manager. We ...