Créer une API versionnée avec Django Tastypie

Tastypie est très bien pour construire une API à partir de modèle
Django. Elle propose tout un tas d'authentification et les sources sont
bien écrites, de sorte que lorsqu'on à un besoin non répertorié dans la
doc, on peut quand même se débrouiller.

Il y a quand même un petit défaut : le versionnement. Alors peut être
que j'ai loupé quelque chose, mais je n'ai rien vu qui permette de
versionner correctement l'Api construire avec cette lib. C'est dommage
parce que ça partait bien : on peut en effet créer plusieurs Api avec
des noms (ou dans notre cas des versions) différentes, mais rien n'est
très clair dans la doc quant au comportement des resources.

Du coup, voici une solution que j'utilise qui permet de maintenir
plusieurs versions d'Api, sans trop de problèmes :

On commence par l'arborescence de notre application :

- CookBook /
- cookbook /
    - __init__.py
    - settings.py
    - urls
    - wsgy.py
- manage.py
- recipes /
    - __init__.py
    - admin.py
    - migrations /
        __init__.py
    - models.py
    - tests.py
    - views.py

On va faire une bête application de snippets de code. Le fichier
recipes/models.py ressemble à ça :

# -*- coding: utf8 -*-
from django.db import models

class Recipe(models.Model):
    ''' '''
    snippet = models.TextField()
    name = models.CharField(max_length=255)
    language = models.CharField(max_length=255)
    edited = models.DateTimeField(auto_now=True)
    created = models.DateTimeField(auto_now_add=True)


class FileCheatSheet(models.Model):
    ''' '''
    name = models.CharField(max_length=255)
    sheet = models.FileField(upload_to="cheat_sheets")

Voilà, c'est pas bien compliqué, mais c'est pas le but. Je vous passe
les étapes de migrations de DB et tout ce qui s'en suit.

Bon, maintenant qu'on a notre modèle, on va créer la ressource pour
l'api. (Oubliez pas d'ajouter tastypie dans le tuple INSTALLED_APPS)

On va commencer par créer un module api dans l'app recipes. Il
contiendra un module v1 qui lui même contiendra un fichier
resources.py

- CookBook /
- cookbook /
    - ...
- recipes /
    - ...
    - api /
        - __init__.py
        - v1 /
            - __init__.py
            - resources.py
- ...

Voilà, on à la base de notre api. L'avantage de cette arborescence,
c'est que l'on sépare bien les ressources pour chaque version.

Notre fichier de ressources pour la v1 pourrai ressembler à ça :

# api/v1/resources.py
# -*- coding: utf8 -*-
from tastypie.resources import ModelResource
from tastypie.authorization import Authorization
from recipes.models import Recipe, FileCheatSheet


class RecipeResource(ModelResource):
    ''' The resource exposing the Recipe model '''

    class Meta:
        queryset = Recipe.objects.all()
        resource_name = "recipes"
        list_allowed_method = ['get', 'post', 'put', 'delete', 'patch']
        detail_allowed_method = ['get', 'post', 'put', 'delete', 'patch']
        authorization = Authorization()


class FileCheatSheetResource(ModelResource):
    ''' The resource exposing the FileCheatSheet model '''

    class Meta:
        queryset = FileCheatSheet.objects.all()
        resource_name = "cheatsheets"
        list_allowed_method = ['get', 'post', 'put', 'delete', 'patch']
        detail_allowed_method = ['get', 'post', 'put', 'delete', 'patch']
        authorization = Authorization()

def deserialize(self, request, data, format=None):
    format = format.lower()
    if format is None:
        format = request.META.get('CONTENT_TYPE', 'application/json')
    if format == 'application/x-www-form-urlencoded':
        return request.POST
    elif format.startswith('multipart'):
        data = request.POST.copy()
        data.update(request.FILES)
        return data
    return super(FileCheatSheetResource, self).deserialize(request, data, format)



# api/v1/__init__.py
# -*- coding: utf8 -*-
from tastypie.api import Api
from recipes.api.v1.resources import RecipeResource, FileCheatSheetResource

# define v1 api
api = Api(api_name="v1")
# register urls
api.register(RecipeResource())
api.register(FileCheatSheetResource())

Ici, il y a une petite subtilité : comme on va uploader un fichier via
l'api (pour l'attribut sheet), il faut définir un désérialiseur pour
le Content-Type multipart/form-data. Il faut simplement ajouter les
données contenu dans le QueryDict request.FILES à request.POST (on
est obligé de la copier dans une autre variable puisque c'est un objet
immuable).

Dans api/v1/init.py, on va enregistrer nos ressources pour qu'elles
soit exposées :

# api/v1/__init__.py
# -*- coding: utf8 -*-
from tastypie.api import Api
from recipes.api.v1.resources import RecipeResource, FileCheatSheetResource

# define v1 api
api = Api(api_name="v1")
# register urls
api.register(RecipeResource())
api.register(FileCheatSheetResource())

On a plus qu'à injecter les urls de l'api dans nos urls du projet :

# cookbook/urls.py
# -*- coding: utf8 -*-
...
from recipes.api.v1 import api as api_v1
...

app_patterns = patterns('',
    ...
    url(r'^api/', include(api_v1.urls)),
    ...
)

Voilà, notre api est fonctionnelle. J'ai volontairement passé sous
silence la partie authentification et autorisation, ce n'est pas l'objet
ici. On utilise ici la classe Authorization qui autorise tout et
n'importe quoi du moment que les verbes sont disponible dans
list_allowed_method et detail_allowed_method. A bien sûr ne pas
faire en prod...

Bon c'est pas mal ça, mais maintenant, j'aimerai bien pouvoir ajouter un
nouveau snippet depuis l'api en lui envoyant un ficher, plutôt que du
contenu brut, parce qu'avec un CURL, c'est plus pratique. Eh bien c'est
maintenant qu'il va falloir faire une v2.

Du coup on va créer un nouveau module v2 dans notre arborescence :

- CookBook /
- cookbook /
    - ...
- recipes /
    - ...
    - api /
        - __init__.py
        - v1 /
            - ...
        - v2 /
            - __init__.py
            - resources.py
- ...

Maintenant on va définir nos ressources, avec les changements. Mais on
n'a peut être pas envie de dupliquer le code de FileCheatSheetResource
vu qu'on ne va pas le changer. La documentation de Tastypie n'apportant
aucune solution, voici celle que j'ai choisi :

# api/v2/resources.py
# -*- coding: utf8 -*-
from tastypie.resources import ModelResource
from tastypie.authorization import Authorization
from tastypie import fields
from recipes.models import Recipe
from recipes.api.v1.resources import FileCheatSheetResource as V1FileCheatSheetResource


class RecipeResource(ModelResource):
    ''' The resource exposing the Recipe model '''
    snippet_raw = fields.CharField(null=True, blank=True)
    snippet_file = fields.FileField(null=True, blank=True)

    class Meta:
        queryset = Recipe.objects.all()
        resource_name = "recipes"
        list_allowed_method = ['get', 'post', 'put', 'delete', 'patch']
        detail_allowed_method = ['get', 'post', 'put', 'delete', 'patch']
        authorization = Authorization()
        excludes = ('snippet',)

def deserialize(self, request, data, format=None):
    format = format.lower()
    if format is None:
        format = request.META.get('CONTENT_TYPE', 'application/json')
    if format == 'application/x-www-form-urlencoded':
        return request.POST
    elif format.startswith('multipart'):
        data = request.POST.copy()
        data['snippet'] = request.POST.get('snippet_raw', None)
        if "snippet_file" in request.FILES:
            data['snippet'] = request.FILES['snipper_file'].read()
        return data
    return super(RecipeResource, self).deserialize(request, data, format)

def save(self, bundle, skip_errors=False):
    ''' Override the save method to create the snippet object
        from file or raw data
    '''
    snippet_buffer = bundle.data['snippet']
    # create the object
    obj = Recipe(snippet=snippet_buffer)
    # switch objects
    bundle.obj = obj
    return super(RecipeResource, self).save(bundle, skip_errors)

def dehydrate(self, bundle):
    ''' Override the dehydrate methode to re-add the snippet attribute
        and remove snipper_raw and snipper_file ones
    '''
    bundle = super(RecipeResource, self).dehydrate(bundle)
    bundle.data.pop('snippet_raw')
    bundle.data.pop('snippet_file')
    bundle.data['snippet'] = bundle.obj.snippet
    return bundle


class FileCheatSheetResource(V1FileCheatSheetResource):
    ''' Nothing to change for this resource in v2 '''
    pass

Quelques explications sont nécessaires. Dans cette version de la
ressource pour les Recipe, on va définir nous même les champs que l'on
veut pouvoir populer via l'api. Comme on définit un FileField, on est
obligé de remettre un désérialiseur pour récupérer les données d'un
formulaire multipart.

Ici on ne se contente pas de copier les données de requests.FILES dans
ce que retourne le désérialiseur. On va renvoyer le contenu du fichier
que l'on a envoyé (si c'est le cas) ou bien le contenu brut.

La méthode save est là pour créer l'objet. On récupère la valeur
snippet_raw et on y va.

Quant à elle, la méthode dehydrate est là pour nettoyer un peu les
données qu'on expose. En effet, comme on demande à envoyer wnippet_raw
ou snippet_file, et qu'on à exclu snippet, les premiers seront
afficher tandis que le dernier ne le sera pas. On utilise dehydrate pour
remettre de l'ordre dans tout ça.

Voilà, on a plus qu'à enregistrer nos ressources pour les exposer.

# api/v2/__init__.py
# -*- coding: utf8 -*-
from tastypie.api import Api
from recipes.api.v2.resources import RecipeResource, FileCheatSheetResource

# define v2 api
api = Api(api_name="v2")
# register urls
api.register(RecipeResource())
api.register(FileCheatSheetResource())

L'inconvénient, de cette manière de faire, c'est que l'ont est obligé
d'importer les ressources inchangées de la v1
pour en créer une
nouvelle, "vide".

En contrepartie, l'attribut resource_uri des objets conserve la
version de l'api utilisée pour faire l'appel. C'est une question
d'harmonie et ça évite de perdre les utilisateurs.

Il y a néanmoins une autre façon de procéder :

# api/v2/resources.py
# -*- coding: utf8 -*-
from tastypie.resources import ModelResource
from tastypie.authorization import Authorization
from tastypie import fields
from recipes.models import Recipe


class RecipeResource(ModelResource):
    ''' The resource exposing the Recipe model '''
    ...

# cette fois, on ne surcharge pas le FileCheatSheetResource de la v1



# api/v1/__init__.py
# -*- coding: utf8 -*-
from tastypie.api import Api
from recipes.api.v1.resources import RecipeResource, FileCheatSheetResource

# define v1 api
api = Api(api_name="v1")
# register urls
api.register(RecipeResource())
api.register(FileCheatSheetResource())

# api/v2/__init__.py
# -*- coding: utf8 -*-
from tastypie.api import Api
from recipes.api.v2.resources import RecipeResource
from recipes.api.v1.resources import FileCheatSheetResource

# define v2 api
api = Api(api_name="v2")
# register urls
api.register(RecipeResource())
api.register(FileCheatSheetResource())

L'avantage de cette façon de procéder, c'est qu'on ne duplique pas de
code
. De plus, le changelog de la v2 est tout fait, puisque qu'il
suffit de regarder le fichier api/v2/resources.py pour voir ce qui a
changé par rapport à la v1.

En revanche, lorsque l'on va interroger l'api sur la v1, les uri des
objects qui sont identiques en v1 et en v2 seront renvoyés avec la v2.
Ca peut être perturbant pour les utilisateurs non-avertis. Ils risquent
de se demander pourquoi ont lui renvoie l'uri en v2 alors qu'il parle à
la v1.

En bref, si votre Api ne contient pas beaucoup de ressources, ça peut
valoir le coup d'utiliser la première version. Si elle en contient
énormément, ça va vite devenir pénible. A vous de voir à ce moment là si
la cohérence des uri est importante où pas.

Rappelons tout de même que, puisque l'objet n'est pas altéré entre
les deux versions, ça ne cassera absoluement rien !

À vous !

Afficher les commentaires