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 !