Une solution pour faciliter la conception d'applications web RESTful avec Django

vignette

Voila la solution à laquelle je suis arrivé après avoir testé les solutions existantes : django collection, django crudapi et django restful model views. J'attends beaucoup du GSoC consacré à l'inclusion native de REST dans Django et les choses vont dans le bon sens de ce côté là mais j'avais besoin d'une solution maintenant. Enfin notez bien le une dans le titre qui est très important, il y a énormément d'interprétations de l'architecture REST, cette implémentation essaye de s'inspirer de celle définie dans le livre RESTful Web Services : l'architecture orientée ressource (ROA).

Objectif ressource

Le but principal de cette solution est de simplifier le développement de l'accès à vos ressources. Pour cela une classe générique s'occupe de dispatcher votre requête dans la bonne méthode en fonction du verbe HTTP. Dans le cas d'une application authentifiée, vous pouvez contrôler les accès aux ressources en fonction des permissions de Django. Enfin la notion d'annulation a été incluse car j'apprécie cette approche (cf. en fin de billet).

Préparons le terrain

La première chose à faire est d'utiliser le middleware permettant d'effectuer de fausses requêtes HTTP PUT et DELETE à partir d'un navigateur (ce qui n'est pas possible actuellement) :

_SUPPORTED_TRANSFORMS = ['PUT', 'DELETE']
_MIDDLEWARE_KEY = '_method'

class HttpMethodsMiddleware(object):
    def process_request(self, request):
        if request.POST and _MIDDLEWARE_KEY in request.POST:
            if request.POST[_MIDDLEWARE_KEY].upper() in _SUPPORTED_TRANSFORMS:
                request.method = request.POST[_MIDDLEWARE_KEY].upper()
        return None

Cela permet de passer de convertir le verbe POST en PUT ou DELETE en fonction de l'argument _method (généralement caché dans un formulaire).

Les permissions par défaut de Django n'incluent pas la notion d'affichage, il est donc nécessaire d'ajouter cette permission à tous vos modèles. Comme tout ce qui est long et fastidieux avec Django, il existe une solution élégante utilisant les signals pour automatiser tout ça :

def create_retrieve_permissions(app, created_models, verbosity):
    app_models = get_models(app)
    for klass in app_models:
        ctype = ContentType.objects.get_for_model(klass)
        codename = _get_permission_codename('retrieve', klass._meta)
        name = 'Can %s %s' % ('retrieve', klass._meta.verbose_name)
        p, created = Permission.objects.get_or_create(codename=codename,
            content_type__pk=ctype.id,
            defaults={'name': name, 'content_type': ctype})
        if created and verbosity >= 2:
            print "Adding permission '%s'" % p

dispatcher.connect(create_retrieve_permissions, signal=signals.post_syncdb)

La permission est créée à chaque fin de syncdb pour l'ensemble des modèles existants.

Passons aux choses sérieuses

Je ne vais pas trop coller de code ici, vous le retrouverez commenté dans le paquet. Je vais plus expliquer les concepts pour comprendre comment l'utiliser.

Nous allons implémenter une todo liste basique permettant d'exploiter les différentes fonctionnalités développées. Nos URI devront avoir le modèle suivant (conformément au design définit par Joe Gregorio) :

/{model}/{id};{noun}

Vous savez qu'une URL se construit avec Django de la façon suivante :

urlpatterns = patterns('',
    (expression régulière, fonction de la vue),
)

L'idée ici est de passer non pas une fonction à votre pattern mais une classe, ou plus précisément une instance de classe. En instanciant cette classe, vous allez appeler la méthode __call__ de la classe (ça c'est du python) qui va faire tout le travail de redirection vers la bonne méthode de la classe en fonction du verbe HTTP de la requête.

On va donc avoir une définition des URL qui va ressembler à ça :

urlpatterns = patterns('',
    (r'^/todos/(?:(?P<id>\d+)/?)?(?:;(?P<noun>\w+))?/?$',
        TodoCollection()),
)

Ok, maintenant voyons voir ce qui se cache dans TodoCollection :

class TodoCollection(GenericCollection):
    def __call__(self, request, **kw):
        return super(TodoCollection, self).__call__(request, 'todo', 'todo', **kw)

TodoCollection hérite de GenericCollection qui est la classe de base permettant d'automatiser l'accès aux ressources. Seule la fonction __call__ doit être réécrite pour tenir compte du nom de l'application et du modèle (vous pouvez encore plus automatiser en passant ces argument dans l'URL ou dans un dictionnaire mais nous verrons par la suite que cette nouvelle classe peut être utile pour y placer les fonction spécifiques à nos ressources).

Arrivé dans GenericCollection, la requête va être redirigée vers la fonction appropriée selon les équivalences suivantes (notez le caractère facultatif des paramètres id et noun dans l'expression régulière) :

  • si c'est un GET sans id, redirection vers la fonction list ;
  • si c'est un POST, redirection vers la fonction create ;
  • si c'est un GET avec id, redirection vers la fonction retrieve ;
  • si c'est un PUT avec id, redirection vers la fonction update ;
  • si c'est un DELETE avec id, redirection vers la fonction delete ;

Dans le cas où il y a argument noun, la redirection s'effectue vers la fonction verbeHTTP_noun ce qui permet d'ajouter des vues si vous avez des besoins spécifiques, typiquement pour afficher un formulaire de mise à jour d'une ressource vous devrez pointer vers une URI de ce type :

/todos/{id};update_form

qui redirigera vers la fonction get_update_form de votre classe Collection.

Vous avez compris le principe ? Étudions maintenant une fonctionnalité en particulier (attention la suite est un peu brutale, pour un vrai exemple je vous recommande celui du paquet).

Trêve de confirmation

Un récent article sur A List Apart évoquait les avantages d'une annulation possible d'une action par rapport à une confirmation préalable. Il est vrai que les messages de confirmation sont toujours frustrants et sont de toute manière tellement banalisés aujourd'hui qu'ils ne sont plus lus par l'utilisateur.

Suite à cet article, une discussion intéressante sur son implémentation dans Django chez Simon Willison a aboutit à son implémentation chez Nathan Ostgard m'ont permis d'intégrer cette fonctionnalité à moindre coût.

Elle impose tout de même d'ajouter un champ, deux fonctions et un manager à votre modèle :

class TrashManager(models.Manager):
    def get_query_set(self):
        return super(TrashManager, self) \
            .get_query_set().filter(trashed_at__isnull=True)

    def get_trashed(self):
        return super(TrashManager, self) \
            .get_query_set().filter(trashed_at__isnull=False)


class Todo(models.Model):
    [...]
    trashed_at = models.DateTimeField(blank=True, null=True, editable=False)
    objects = TrashManager()

    def trash(self):
        self.trashed_at = datetime.now()
        self.save()

    def restore(self):
        self.trashed_at = None
        self.save()

Cela vous permet de placer les ressources « supprimées » dans une corbeille temporaire permettant leur récupération après suppression malencontreuse (l'effet Ooops!). La base doit ensuite être régulièrement purgée ce qui est simplifié par l'accès aux éléments de la corbeille avec le manager :

model.objects.get_trashed()

Voyons maintenant ce que ça donne avec ma solution. Ce cas est très particulier et c'est intéressant car on va accéder aux limites de la solution justement. En effet, la suppression doit normalement être effectuée via un DELETE mais dans notre cas une mise dans la corbeille signifie une modification de la ressource et donc un PUT. Cruel dilemme !

Nous allons devoir créer une nouvelle méthode à notre classe gérant ce cas particulier, celle-ci sera accessible via :

/todos/{id};trash

qui redirigera vers la fonction (simplifiée pour l'exemple) :

    def put_trash(self):
        self.item_instance.trash()
        message = _("Item trashed successfully. \
            <a href=\"%s;recover?_method=put\" title=\"\">Restore?</a> \
            " % self.id)
        self.request.user.message_set.create(message=message)

Cette fonction est bien accessible via un PUT ce qui reste correct (si un client à ce service web est implémenté, il utilisera directement DELETE sans avoir besoin de confirmation). Un lien est ensuite proposé à l'utilisateur pour annuler cette « suppression » qui lui est beaucoup moins RESTful...

En effet, pour simplifier la récupération j'ai préféré placer un lien (ce qui n'est pas forcément conforme aux règles d'ergonomie qui stipulent qu'une donnée ne doit être modifiée qu'à travers un formulaire, d'où l'aspect si hideux reconnaissable des boutons de formulaires). Du coup je suis obligé de passer la méthode en argument pour faire un faux PUT, histoire d'être tout de même cohérent au niveau de ma classe :

    def put_recover(self):
        self.model.objects.get_trashed().get(pk=self.id).restore()
        message = _("Item recovered successfully.")
        self.request.user.message_set.create(message=message)
        return HttpResponseRedirect('.')

Ouf, on y est tout de même arrivé (il faut modifier le middleware du début pour qu'il utilise request.REQUEST et non request.POST si vous souhaitez faire du faux PUT avec du GET).

Conclusion et évolutions

La solution permettant de gérer des collections génériques est satisfaisante (même s'il manque encore quelques fonctionnalités) et constitue un bon exemple de ce qui peut être fait à la fois avec Django et avec REST et c'est plus dans cette optique que je la publie.

Il est parfois difficile de s'en tenir à une architecture RESTful dans les cas extrêmes et certains impératifs peuvent nécessiter quelques entorses aux règles que vous vous êtes fixé. Le plus important est d'en être conscient et d'assumer ses choix en connaissance de cause. Dans mon exemple sur l'annulation je sais très bien que c'est une fonctionnalité qui ne sera pas utilisée par un programme tiers.

Enfin deux mots sur ce qu'il resterait à implémenter (je reste dans le conditionnel car je pense que le GSoC permettra prochainement de faire la même chose en mieux, du moins je l'espère et je participe en ce sens) :

  • Gérer différents formats de représentation des ressources (json, xml, etc).
  • Gérer les différentes erreurs possibles avec les codes appropriés (403, etc).
  • Rendre possible l'utilisation d'une classe avec url_reverse, notamment pour utiliser le tag {% url %} dans les templates.

En attendant, vous pouvez toujours télécharger la version actuelle et jouer avec ! (n'oubliez pas d'ajouter le dossier à votre $PYTHONPATH...)

— 07/08/2007

Articles peut-être en rapport

Commentaires

vdemeester le 08/08/2007 :

Salut,

encore un article super intéressant, au moment où je me posais le même genre de question/problème pour mon application django en cours. (ça fait longtemps que j'ai pas commenté dis donc :D)

"* Gérer différents formats de représentation des ressources (json, xml, etc)."

Justement, imaginons que je veuille supporter les différents formats, quelles seraient les solutions pour faire comprendre à nos méthode que c'est tel ou tel format qui est passé ?

Sinon, et atompub alors, on en parle pas ? :P

David, biologeek le 08/08/2007 :

Salut Vincent,

Pour les formats, soit dans les headers (mais les crawlers ne vont avoir qu'une seule représentation), soit dans l'url : avant (/json/lasuite) ou après (/lasuite.json) je sais pas encore ce qui est le plus pratique.

Pour atompub, ça arrive à grand pas : code.google.com/p/django-...

J'en parlerais probablement à ce moment là.

Brice Carpentier le 21/08/2007 :

À noter que le middleware NE FONCTIONNE PAS avec mod_python (request.method y est une propriété uniquement accessible en écriture). Je viens de m'en apercevoir et suis actuellement à la recherche d'un contournement de ce problème.