Couchdb : Accès en HTTP avec Python

09/10/2011

La base de données orientée document Apache CouchDB fait désormais partie de mon quotidien en production depuis plusieurs mois et gère plusieurs Go de données sans problème. Outre l'orientation document qui marque une différence fondamentale avec les bases de données relationnelles classiques, l'intérêt majeur de couchdb réside dans son accès basé sur HTTP (RESTful). Cela permet un accès vraiment simple aux données dans n'importe quel langage.

Le but ici est de fournir des exemples d'accès à la base CouchDB, à l'aide du langage Python, en restant au plus près d'HTTP. D'autres bibliothèques sont évidemment disponibles pour avoir une abstraction d'HTTP. La plus connue est sans doute couchdb-python. Le wiki de CouchDB liste quelques projets et fournit également des exemples avec la bibliothèque httplib. Je n'utiliserai que les bibliothèques logging, json et urllib2. Les fonctions présentées ici ont été réalisées pour être relativement indépendantes les unes des autres. En effet, elles pourraient être optimisées. Une fonction unique pourrait se charger de faire la requête HTTP, quel que soit le verbe utilisé. Mais dans ce qui suit, l'aspect didactique a prévalu. Charge à chacun de cloner le programme (voir le dernier paragraphe) pour adapter/améliorer le code.

Le point de départ pour utiliser le protocole HTTP avec CouchDB est bien évidemment la documentation sur l'API HTTP de cette base. Outre les verbes HTTP utilisables, deux notions sont importantes dans cette API : l'identifiant (ID) du document et sa révision.

ID du document

Sans surprise, l'ID du document identifie de manière unique le document dans la base. Sa notation spéciale _id permet de le reconnaître. Cet ID est soit imposé par l'utilisateur ou le programme, soit calculé automatiquement par CouchDB. En fonction de ce choix, il sera nécessaire d'utiliser le verbe HTTP POST ou le verbe HTTP PUT pour créer/modifier le document en question. Si l'ID est choisi, le verbe HTTP PUT devra être utilisé. Si par contre, l'ID est calculé par la base, ce sera le verbe POST.

Révision d'un document

La révision d'un document fait référence à sa version. Ainsi, à la création du document, la révision de celui-ci est la révision 1. À chaque mise à jour, la révision est incrémentée, de sorte que si le document a été mis à jour 3 fois (le même document identifié de façon unique par le même _id), il y a aura 4 révisions : 1 pour la création et 3 de plus pour chaque nouvelle version. À la différence des bases de données relationnelles, il est donc possible d'obtenir pour un même document, chaque version de celui-ci en spécifiant simplement son numéro (de révision). Lorsque l'on veut supprimer tout cet historique et ne garder que la dernière révision du document, il faut compacter la base.

La notion de révision est fondamentale pour la bonne et simple raison qu'il est nécessaire de la fournir pour mettre à jour le document et pour le supprimer.

Exemples d'accès

Au fil des exemples, nous allons voir comment :

  1. obtenir des informations sur le document : HEAD ;
  2. créer le document en base : PUT ;
  3. mettre à jour le document : PUT ;
  4. le supprimer : DELETE ;

Les exemples se basent sur Python 2.6 avec une base CouchDB déployée en local. Pour ce faire, rien de plus simple, il suffit de télécharger sur le site de couchbase une version packagée qui a le bon goût de ne demander aucune configuration ! Il suffit de lancer le programme et votre base est démarrée.

CouchDB est donc :

Obtenir des informations sur le document

Pour obtenir des informations sur une ressource, il suffit de faire une requête HTTP HEAD en précisant son URL.

En utilisant l'outil curl, essayons d'avoir des informations sur le document current de la base test. L'option -I de curl spécifie que la requête doit être un HTTP HEAD :

fred:~ opikanoba$ curl -I http://localhost:5984/test/current

HTTP/1.1 404 Object Not Found
Server: CouchDB/1.0.2 (Erlang OTP/R14B)
Date: Tue, 27 Sep 2011 22:59:40 GMT
Content-Type: text/plain;charset=utf-8
Content-Length: 41
Cache-Control: must-revalidate

Le document n'est pas crée, la base retourne logiquement un code HTTP 404 : Not Found.

En python :

def retrieveRevision(res_url):
	"""
		Test si la ressource existe dans la base (HEAD) et 
		recuperation de sa revision si c'est le cas.
		La revision est mise dans l'entete HTTP Etag

		res_url : URL de la ressource
	"""

	assert res_url, "une ressource doit etre fournie"

	try:
		logger.info("""Recuperation de la revision de la ressource 
			Couchdb : %s"""%res_url)
		request = urllib2.Request(res_url)
		request.get_method = lambda: 'HEAD'
		resp=urllib2.urlopen(request)
		# suppression des " au debut et a la fin de l'etag
		rev=resp.info()["etag"][1:-1]
		logger.info("Document existant : rev %s "%rev)
		return rev
	except urllib2.HTTPError, e:
		logger.error("Existance de la ressource %s ? ERR HTTP (%s)"
			%(res_url,e.code))
	except urllib2.URLError, e:
		logger.error("Existance de la ressource %s ? ERR d'URL (%s)"
			%(res_url,e.code))

	# aucune revision trouvee
	return None

La fonction ci-dessous retourne soit la révision de la resource si elle existe, soit None si ce n'est pas le cas :

>>> print retrieveRevision("http://localhost:5984/test/current")
None

Pour faire une requête avec le verbe HEAD en python, il est nécessaire de modifier la méthode get_method de l'objet Request et de lui affecter une fonction qui, une fois appelée, retournera le verbe HEAD. Le plus simple est donc de lui fournir une lambda expression réduite à sa plus simple expression, puisqu'elle n'a aucun argument et se contente de retourner 'HEAD'.

		request = urllib2.Request(res_url)   # creation de l'objet Request
		request.get_method = lambda: 'HEAD'  # affection du verbe HTTP HEAD
		resp=urllib2.urlopen(request)        # execution de la requete

La revision du document est positionnée dans les données d'en-tête de la réponse, dans l'en-tête ETag.

Créer le document dans Couchdb

Créer un document ou le modifier se résume en une même action : une requête PUT (dans le cas où l'ID du document est connu) ou une requête POST. Deux façons d'opérer :

  1. comportement optimiste : tout va bien se passer, la ressource n'existe pas. Dans ce cas, nul besoin de positionner la révision du document, CouchDB répondra si l'opération a fonctionné ou non. Avec curl, créons la ressource dont le nom (ID) est current dans la base test :
    fred:~ opikanoba$ curl -X PUT http://localhost:5984/test/current \
    > -H "Content-Type: application/json" \
    > -d '{"music":["Chinese Man","Chapelier Fou"], "book":{"author":"Haruki Murakami","title":"Kafka sur le rivage"}}'
    {"ok":true,"id":"current","rev":"1-531a58a6bd9b979bdf3311f920b893d5"}
    
    Tout s'est bien passé, CouchDB répond ok et donne la révision du document. Si l'on retente la même opération (ignorant donc que le document existe déjà) :
    fred:~ opikanoba$ curl -X PUT http://localhost:5984/test/current \
    > -H "Content-Type: application/json" \
    > -d '{"music":["Chinese Man","Chapelier Fou"], "book":{"author":"Haruki Murakami","title":"Kafka sur le rivage"}}'
    {"error":"conflict","reason":"Document update conflict."}
    
    La réponse est tout autre, puisque CouchDB indique un conflit (un statut HTTP 409 : conflict). Le document existant, la requête est considérée comme une mise à jour. Pour voir le code HTTP renvoyé, il suffit de tracer les en-têtes de retour en ajoutant l'option --dump-header /tmp/trace_headers.txt. Les informations se trouvent dans le fichier trace_headers.txt
    fred:~  opikanoba$ curl -X PUT http://localhost:5984/test/current \
    > -H "Content-Type: application/json" \
    > -d '{"music":["Chinese Man","Chapelier Fou"], "book":{"author":"Haruki Murakami","title":"Kafka sur le rivage"}}' \
    > --dump-header /tmp/trace_headers.txt
    {"error":"conflict","reason":"Document update conflict."}
    
    fred:~  opikanoba$ cat /tmp/trace_headers.txt 
    HTTP/1.1 409 Conflict
    Server: CouchDB/1.0.2 (Erlang OTP/R14B)
    Date: Wed, 28 Sep 2011 22:47:09 GMT
    Content-Type: text/plain;charset=utf-8
    Content-Length: 58
    Cache-Control: must-revalidate
    
    fred:~  opikanoba$ 
    
  2. comportement pessimiste : un risque, quant à l'existence de la ressource, existe : une requête HEAD permet de s'en assurer et de retrouver la révision courante. Cette révision doit alors être positionnée dans le prochain PUT/POST.

Dans l'exemple en python, nous adoptons le comportement pessismiste en vérifiant si une revision existe pour le document à créer.

def store(res_url,data):
	"""
		Stockage des donnees dans couchDB
		- test pour savoir si la ressoure existe deja 
			-> recuperation de la revision
		- PUT de la ressource dans couchdb (la ressource a deja l'id)

		res_url : URL de la ressource
		data : donnees a stocker dans la base
	"""

	assert res_url, "une ressource doit etre fournie"
	assert isinstance(data, dict), """des donnees doivent etre 
									de type dictionnaire"""

	try:
		# Test pour savoir si le document existe
		rev=retrieveRevision(res_url)
		if rev:
			logger.info("""Positionnement de la revision actuelle 
				sur le document %s"""%rev)
			data["_rev"]=rev
			
		# Sauvegarde	
		logger.info("Store Couchdb : %s"%res_url)

		opener = urllib2.build_opener(urllib2.HTTPHandler)
		# les donnees sont encodees en JSON
		request = urllib2.Request(res_url, data=jsonify(data))
		# positionnement du Content-Type
		request.add_header('Content-Type', 'application/json')
		# positionnement du verbe HTTP PUT
		request.get_method = lambda: 'PUT'
		f = opener.open(request)
		result=f.read().decode('utf-8')
		
		logger.info("Reponse de Couchdb : %s"%result)
	except urllib2.HTTPError, e:
		logger.error("Erreur HTTP d'acces a la ressource %s (%d"
			%(res_url, e.code))
	except urllib2.URLError, e:
		logger.error("Erreur d'URL  %s (%d)"%(res_url, e.code))

Un simple appel à cette fonction en donnant l'URL de la ressource et les données sous la forme d'un dictionnaire Python, permet de stocker nos informations dans CouchDB :

fred:~  opikanoba$ python
Python 2.6.1 (r261:67515, Jun 24 2010, 21:47:49) 
[GCC 4.2.1 (Apple Inc. build 5646)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from couchette import *
>>> url="http://localhost:5984/test/lastweek"
>>> d={"music":["Monsieur Grandin","Berry Weight"], "book":{"author":"Dino Buzzati","title":"Le K"}}
>>> store(url,d)
>>>
>>> retrieveRevision(url)
'1-5b862d5df3fc84d7cd3971393d5b177e'

Mettons à jour nos données en rajoutant un élément à la liste music. En recupérant la dernière révision, la mise à jour ne pose alors aucun problème. Elle est positionnée dans les données (clé "_rev") avant que les données ne soient renvoyées à la base :

>>> d
{'book': {'title': 'Le K', 'author': 'Dino Buzzati'}, 'music': ['Monsieur Grandin', 'Berry Weight']}
>>> d['music'].append("Wax Tailor")
>>> d
{'book': {'title': 'Le K', 'author': 'Dino Buzzati'}, 'music': ['Monsieur Grandin', 'Berry Weight', 'Wax Tailor']}
>>> store(url, d)
>>> retrieveRevision(url)
'2-48a47a90262d70870c78116eb379a071'
>>> 

Obtenir les données du document stocké dans CouchDB

L'obtention d'un document stocké dans CouchDB est l'opération la plus simple, puisqu'une simple requête GET avec l'adresse de cette ressource permet de l'obtenir. En utilisant curl, l'opération est triviale :

fred:~ opikanoba$ curl http://localhost:5984/test/lastweek
{"_id":"lastweek","_rev":"2-48a47a90262d70870c78116eb379a071","book":{"title":"Le K","author":"Dino Buzzati"},"music":["Monsieur Grandin","Berry Weight","Wax Tailor"]}

L'équivalent Python n'est pas plus complexe :

def retrieve(res_url):
	"""
		Recuperation des donnees d'une ressource

		res_url : URL de la ressource
	"""

	assert res_url, "une ressource doit etre fournie"

	try:
		opener = urllib2.build_opener(urllib2.HTTPHandler)
		logger.info("Load Couchdb : %s"%res_url)

		request = urllib2.Request(res_url)
		f = opener.open(request)
		data=f.read().decode('utf-8')
		# conversion JSON -> dictionnaire Python
		result=json.loads(data)
	except urllib2.HTTPError, e:
		logger.error("Erreur HTTP d'acces a la ressource %s : %d"
			%(res_url, e.code))
	except urllib2.URLError, e:
		logger.error("Erreur d'URL  %s : %d"%(res_url, e.code))
	
	return result

L'opération HTTP GET étant positionnée par défaut, aucun paramétrage n'est nécessaire. La seule action à entreprendre pour pouvoir avoir les données sous forme de dictionnaire Python est la conversion JSON → Python (utilisation de json.loads) :

>>> result=retrieve(url)
>>> import pprint
>>> pp = pprint.PrettyPrinter(indent=4)
>>> pp.pprint(result)
{   u'_id': u'lastweek',
    u'_rev': u'2-48a47a90262d70870c78116eb379a071',
    u'book': {   u'author': u'Dino Buzzati', u'title': u'Le K'},
    u'music': [u'Monsieur Grandin', u'Berry Weight', u'Wax Tailor']}
>>> 

Suppression du document

Comme pour la modification, la suppression d'un document exige que la révision soit fournie. Dans ce cas, celle-ci est passée en paramètre de la requête HTTP DELETE.

Dans l'exemple curl ci-dessous, la première requête GET permet de récupérer la révision courante du document (une requête HEAD aurait été plus efficace). Cette révision est ensuite passée à la deuxième requête dans le paramètre rev=.

fred:~ opikanoba$ curl http://localhost:5984/test/current
{"_id":"current","_rev":"1-531a58a6bd9b979bdf3311f920b893d5","music":["Chinese Man","Chapelier Fou"],"book":{"author":"Haruki Murakami","title":"Kafka sur le rivage"}}
fred:~ opikanoba$ curl -X DELETE http://localhost:5984/test/current?rev=1-531a58a6bd9b979bdf3311f920b893d5
{"ok":true,"id":"current","rev":"2-47d8892807cec72b46ae8e30fd1a0c2c"}

En faisant de nouveau une requête sur la ressource précédemment supprimée (sans révision pour avoir la version actuelle du document), CouchDB renvoit une erreur not_found mais également une raison deleted. Il s'agit du code HTTP 404, que l'on peut constater en traçant les entêtes HTTP avec l'option --dump-header.Si l'on donne la révision précédente du document, c'est-à-dire la révision du document avant sa suppression, le contenu du document supprimé est renvoyé. Cette manipulation n'est pas sans risque, voir la note à ce sujet.

fred:~ opikanoba$ curl http://localhost:5984/test/current
{"error":"not_found","reason":"deleted"}

fred:~ opikanoba$ curl http://localhost:5984/test/current?rev=2-47d8892807cec72b46ae8e30fd1a0c2c
{"_id":"current","_rev":"2-47d8892807cec72b46ae8e30fd1a0c2c","_deleted":true}

fred:~ opikanoba$ curl http://localhost:5984/test/current?rev=1-531a58a6bd9b979bdf3311f920b893d5
{"_id":"current","_rev":"1-531a58a6bd9b979bdf3311f920b893d5","book":{"title":"Kafka sur le rivage","author":"Haruki Murakami"},"music":["Chinese Man","Chapelier Fou"]}

La fonction Python permettant de faire le delete est très ressemblante aux précédentes :

def delete(res_url, revision):
	"""
		Suppression d'un document par sa revision

		res_url : URL de la ressource
		revision : revision du document a supprimer

	"""

	assert res_url, "une ressource doit etre fournie"
	assert revision, "une revision doit etre fournie"
	
	try:
		logger.info("Suppression de ressource %s REV [%s]"
			%(res_url,revision))

		url_rev=res_url+'?rev='+revision
		request = urllib2.Request(url_rev)
		request.add_header('Content-Type', 'application/json')
		request.get_method = lambda: 'DELETE'
		resp=urllib2.urlopen(request)
		rev=resp.info()["etag"][1:-1]

		logger.info("REV supprimee [%s]"%rev)
	except urllib2.HTTPError, e:
		logger.error("ERR HTTP pour le DELETE de la ressource %s (%s)"
			%(res_url,e.code))
	except urllib2.URLError, e:
		logger.error("Erreur d'URL  %s (%d)"%(res_url, e.code))

En poursuivant, l'exécution dans la console Python :

>>> delete(url, result["_rev"])
>>> f=retrieveRevision(url)
>>> assert f is None

En Python, le procédé est le même qu'avec les autres verbes. La lambda expression permet de spécifier le verbe HTTP DELETE à utiliser dans ce cas. La révision de la ressource supprimée se retrouve dans l'entête ETag.

Note sur la suppression

Lorsqu'un document est supprimé, la révision est incrémentée de sorte que la prochaine requête GET, renvoie la révision du document supprimé : l'option "_deleted":true. Le document reste accessible via sa/ses révision(s) précédente(s) pendant un temps dépendant des actions sur la base. En effet, si la base est compactée ou repliquée, les révisions intermédiaires ne seront plus accessibles (le compactage ne garde que la dernière version, et seules les dernières révisions des documents sont répliqués).

Par conséquent, il n'est pas recommandé de travailler sur des révisions historiques à moins de maîtriser parfaitement le processus !

Obtenir le code

Pour obtenir le code source du programme python utilisé dans ce billet :

git clone git://github.com/flrt/couchette