Je suis Charlie

Autres trucs

Accueil

Seulement les RFC

Seulement les fiches de lecture

Mon livre « Cyberstructure »

Ève

Une application bidon avec REST, SQL et Unicode, juste comme exemple

Première rédaction de cet article le 25 juillet 2007
Dernière mise à jour le 31 juillet 2007


Je décris ici une application Web qui ne sert à rien mais qui montre l'usage de plusieurs techniques qui sont apparemment souvent demandées : SQL, REST (sur lequel j'ai déjà écrit un article), Unicode, etc, le tout en Python.

Cette application m'a servi à tester différentes techniques que je voulais expérimenter, pour apprendre ou bien pour déployer dans d'autres applications.

Commençons par la décrire : l'application reçoit, via le Web, en suivant le protocole REST (elle a aussi une interface par un formulaire HTML) des chaînes de caractères en Unicode et elle les enregistre dans une base de données. On peut ensuite les extraire, les décomposer en caractères, etc.

Commençons par la base de données. Elle utilise PostgreSQL qui permet de stocker et de récupérer de l'Unicode. Le schéma est constitué d'une seule table, Words (en fait, des chaînes de caractères, elles ne sont pas limitées à un seul mot). Cette table comprend une colonne word qui va stocker notre chaîne de caractères. Vous pouvez voir le schéma complet dans le fichier create-registry-unicode.sql. Il tire profit des triggers de PostgreSQL pour que la colonne updated soit automatiquement mise à jour, libérant le programmeur de l'application de ce souci (et diminuant le risque de bogues).

Maintenant, l'application elle-même. Elle prend la forme d'un seul script Python, rest-registry.py. Ce script est conçu pour utiliser le protocole CGI. Certes, ce protocole est vieux et a des défauts, mais il fonctionne avec tous les serveurs HTTP et il est très simple. On pourrait plutôt utiliser un module Apache écrit avec mod_python ou bien passer par SCGI mais ce sera pour une autre version.

Pour toute application REST, les deux premières questions à se poser sont :

  • Quel est le schéma de nommage des URI ?
  • Quels sont les méthodes (les verbes) HTTP utilisées ?

Ici, l'URI sera du type PREFIX/STRING où PREFIX dépendra de l'hébergement (par exemple, il vaudra http://www.example.net/rest-registry) et STRING sera la chaîne gérée. Si elle est vide, on supposera que l'action s'applique à tout le registre (dans un esprit similaire à celui de APP où l'absence d'une entrée signifie qu'on parle de toute la collection).

L'URI est donc choisi par le client HTTP à la création, comme avec WebDAV (mais contrairement à APP). On dit que le client contrôle l'espace de nommage.

Le fait d'utiliser l'URI pour stocker la chaîne qui nous intéresse entraine des limites pour l'application (relevées par Kim-Minh Kaplan) : on ne peut pas enregistrer de chaîne qui contienne un caractère illégal pour un URL (comme le point d'interrogation ou le dièse).

Les verbes utilisés seront les classiques méthodes HTTP, GET pour récupérer une chaîne ou bien tout le registre, POST pour mettre à jour une châine ou bien le registre, DELETE pour détruire une chaîne (on ne peut pas détruire tout le registre), PUT pour enregistrer une nouvelle chaîne. On notera que POST appliqué à tout le registre permet la création d'une nouvelle chaîne, puisque créer une entrée dans le registre est une modification de celui-ci. Les codes de retour, à trois chiffres, sont également tirés de la norme HTTP? le RFC 2616. Par exemple, nous renverrons 501 pour les méthodes non encore mise en œuvre.

Comme il est toujours préférable d'écrire le code qui utilise un service avant le service lui-même, voici des exemples d'utilisation avec l'excellent logiciel curl. Ils nous serviront de spécification.

Enregistrement d'une nouvelle chaîne (%20 est l'encodage de l'espace, qui est normalement interdit dans les URL) :


% curl -v  --request PUT http://www.example.net/rest/registry/Pierre%20Louÿs              
...
> PUT /rest/registry/Pierre%20Louÿs HTTP/1.1
User-Agent: curl/7.13.2 (i386-pc-linux-gnu) libcurl/7.13.2 OpenSSL/0.9.7e zlib/1.2.2 libidn/0.5.13
Host: www.example.net
Accept: */*

< HTTP/1.1 200 OK
< Date: Wed, 25 Jul 2007 18:49:12 GMT
< Server: Apache/2.0.58 (Gentoo) mod_python/3.2.10 Python/2.4.3 mod_ssl/2.0.58 OpenSSL/0.9.8d PHP/5.2.2-pl1-gentoo mod_perl/2.0.3-dev Perl/v5.8.8
< X-Script: registry running with Python 2.4.4
...

Si la chaîne existe déjà, nous décidons de renvoyer un code 403 (interdiction).

Récupération d'une chaîne existante :


 % curl -v  --request GET http://www.example.net/rest/registry/café
...
> GET /rest/registry/café HTTP/1.1
User-Agent: curl/7.13.2 (i386-pc-linux-gnu) libcurl/7.13.2 OpenSSL/0.9.7e zlib/1.2.2 libidn/0.5.13
Accept: */*

< HTTP/1.1 200 OK
< Date: Wed, 25 Jul 2007 16:26:27 GMT
< Server: Apache/2.0.58 (Gentoo) mod_python/3.2.10 Python/2.4.3 mod_ssl/2.0.58 OpenSSL/0.9.8d PHP/5.2.2-pl1-gentoo mod_perl/2.0.3-dev Perl/v5.8.8
< X-Script: registry running with Python 2.4.4
< Content-Type: text/html; charset=UTF-8

On note que le script ne fait malheureusement pas, à l'heure actuelle, de négociation de contenu. Envoyer du text/plain serait plus logique dans le cas de curl.

Naturellement, une tentative de récupérer par GET une chaîne qui n'existe pas doit se traduire par un code de retour 404.

Détruisons désormais une chaîne (actuellement, il n'y a aucun mécanisme d'autorisation, n'importe qui peut détruire) :


 % curl -v  --request DELETE http://www.example.net/rest/registry/ça%20va                     
> DELETE /rest/registry/ça%20va HTTP/1.1
User-Agent: curl/7.13.2 (i386-pc-linux-gnu) libcurl/7.13.2 OpenSSL/0.9.7e zlib/1.2.2 libidn/0.5.13
...

< HTTP/1.1 200 OK
< Date: Wed, 25 Jul 2007 18:54:27 GMT
< Server: Apache/2.0.58 (Gentoo) mod_python/3.2.10 Python/2.4.3 mod_ssl/2.0.58 OpenSSL/0.9.8d PHP/5.2.2-pl1-gentoo mod_perl/2.0.3-dev Perl/v5.8.8
< X-Script: registry running with Python 2.4.4

Naturellement, à la place de curl, on aurait pu utiliser n'importe quel client HTTP capable d'envoyer des méthodes autre que GET ou bien écrire un programme en s'appuyant sur les nombreuses bibliothèques existantes qui permettent de développer un client HTTP spécifique assez facilement.

Voyons maintenant le code qui va mettre en œuvre ces opérations. Son intégralité est disponible en rest-registry.py. Nous chargeons les module cgi et urllib de Python :

import cgi

et nous analysons la méthode utilisée, que le protocole CGI met dans la variable d'environnement REQUEST_METHOD :

method = os.environ['REQUEST_METHOD']
request = urllib.unquote(os.environ['REQUEST_URI'])
request = unicode(request, web_encoding)
request = request.replace(prefix, "", 1)
form = cgi.FieldStorage()

Une fois que ces variables sont remplies, le programme peut vraiment commencer :

if method == "GET":
...
elif method == "PUT":
...
else: # Unknown method
    print headers("400 unknown method")
    print response("Unknown method %s" % method)

Le petit nombre de méthodes possible fait qu'une succession de if est la technique la plus simple.

Voyons le code complet pour la méthode DELETE :

    try:
        delete(request)
        print headers()
        print response("%s deleted" % request)
    except NotFound:
        print headers("404 Not found")
        print response("%s not found" % request)

Ce code s'appuie sur des fonctions headers et response, qui prennent en charge les détails du protocole HTTP.

Et la fonction delete ? Elle va devoir parler à la base de données, ce qui va être notre prochaine étape. Pour parler au SGBD PostgreSQL, nous utilisons le module psycopg :

import psycopg2
import psycopg2.extras

et nous ouvrons la base au lancement du programme :

connection = psycopg2.connect("dbname=%s user=%s" % (db, db_user))
connection.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_READ_COMMITTED)
cursor = connection.cursor()
cursor.execute("SET client_encoding TO %s;", (web_encoding, ))

Notez que l'encodage que nous utilisons est explicitement spécifié, pour éviter de dépendre de l'environnement.

Nous pouvons alors envoyer des requêtes SQL, par exemple, pour détruire une entrée de la base :

def delete(word):
    cursor.execute("DELETE FROM Words WHERE word = %s", (word, ))
    if cursor.rowcount == 0:
        raise NotFound
    cursor.execute("COMMIT")

Dans une future version, cette application fera peut-être de la négociation de contenu et pourra envoyer par exemple du CSV ou du texte brut. Mais pour l'instant, elle n'envoie que du XHTML. Comment le générer ? On pourrait le faire entièrement à la main, par des print dans le script Python mais il est plus simple d'utiliser un système de gabarits et nous nous servirons de TAL.

On importe le module :

from simpletal import simpleTAL, simpleTALES, simpleTALUtils
...
context = simpleTALES.Context()

Puis on définit les gabarits (ici, la table de toutes les chaînes de caractères) :


all_words_blurb = """
<div>
<h2>All the registry</h2>
<table id="registry">
<tr><th>Word</th><th>Origin</th><th>Creation</th></tr>
<tr tal:repeat="word allwords"><td><a tal:attributes="href word/word" tal:content="word/word"/></td><td tal:content="word/origin"/><td tal:content="word/created"/><td><a tal:attributes="href string:${word/word}?delete=yes">Delete it</a></td></tr>
</table>
</div>

Ce gabarit pourra être incarné lorsqu'on renverra l'information sur une entrée de la base. Ici, on lit la base avec cursor.fetchall(), on met son contenu ans la variable Python words et on dit à TAL que la variable TAL allwords vaudra cette variable words :

all_words_template = simpleTAL.compileXMLTemplate(all_words_blurb)
...
            for tuple in cursor.fetchall():
                words.append({'word': unicode(tuple[0], db_encoding), 'origin': tuple[1],
                              'created': tuple[2], 'updated': tuple[3], 'comments': tuple[4]})
            context.addGlobal("allwords", words)
            print headers()
            print response(title="All the registry", content=all_words_template)

La plupart des utilisateurs n'étant pas enthousiastes à l'idée d'utiliser l'application depuis la ligne de commande avec curl, nous fournissons également une interface avec un formulaire HTML. Voici le code HTML du formulaire :


<form method="POST">
<p>Type a word: <input type="text" name="word" /></p>
<!-- The syntax of the <textarea> element (no content, but a start and an end tag is because of a parsing bug in Firefox 1 -->
<p>Type (optional) comments:<br/> <textarea cols="40" rows="20" name="comments"></textarea></p>
<p><input type="submit" name="register" value="Register it" /></p>
</form>

On note que l'activation du bouton Register it envoie une requête POST. À partir de là, c'est le code ordinaire qui s'en charge. En voici une petite partie. Notez que toutes les chaînes de caractères sont traduites en Unicode tout de suite :

        word = unicode(form.getfirst("word", ""), web_encoding)
        comments = unicode(form.getfirst("comments", ""), web_encoding)
        if comments.strip() == "":
                    comments = None
        client = os.environ["REMOTE_ADDR"]
        cursor.execute("""
                INSERT INTO Words (word, origin, useragent, comments)
                                     VALUES (%s, %s, %s, %s)""",
                               (word, client,
                                os.environ["HTTP_USER_AGENT"], comments))
                cursor.execute("COMMIT")

C'est au client HTTP d'envoyer de l'UTF-8 dans l'URL, l'en-tête HTTP Content-type ne couvre que le corps, pas l'URL. Dans le cas du formulaire, le client HTTP doit normalement le faire spontanément dans le même encodage que la page contenant le formulaire. Si le client ne respecte pas cette convention, l'application plante avec :

UnicodeDecodeError: 'utf8' codec can't decode byte 0xe9 in position 3:
unexpected end of data 

Enfin, pour afficher les caractéristiques de la chaîne de caractères (nom, point de code et catégorie des caractères), nous utilisons le module Python unicode data :

for char in word:
   ochar = ("U+%06x" % ord(char)).upper()
   result.append("%s %s (%s)" % (ochar,
                                 unicodedata.name(char,
                                                  u"Unknown character"),
                                 unicodedata.category(char)))

Version PDF de cette page (mais vous pouvez aussi imprimer depuis votre navigateur, il y a une feuille de style prévue pour cela)

Source XML de cette page (cette page est distribuée sous les termes de la licence GFDL)