homenowpostsprojects(english version)

API REST avec CHICKEN Scheme

Durée de lecture estimée : 5 minutes

Il y a quelques mois, je suis tombé sur le réseau et protocole Matrix. Un système de chat avec des fonctionnalités plutôt cool comme les canaux de discussion distribués, la réplication des historiques de communication, la VoIP, le chiffrement bout à bout…

Il y a quelques semaines je m’y suis intéressé à nouveau et me suis dit « Hey ! Pourquoi ne pas écrire un client en CHICKEN pour ce réseau ? », malgré que je n’avais jamais eu d’expérience avec les API REST jusque là.

J’ai donc jeté un coup d’œil à la liste des eggs CHICKEN pour voir s’il y avait quelque chose pour m’aider, et devine quoi ! Il y a tout un tas de bonnes choses pour la programmation web !

Comme le réseau Matrix utilise HTTP pour ses requêtes REST, j’en ai conclu que j’allais devoir utiliser l’egg http-client pour s’occuper de toute la partie réseau pour moi. Il gère le HTTPS de façon transparente à travers l’egg openssl, il réutilise les connexions ouvertes autant que possible et est super facile d’utilisation : il suffit de lui donner une URL et deux procédures, pour écrire et lire les corps de la requête et de la réponse.

Les données de Matrix sont sérialisée dans le format JSON, pour ça j’ai utilisé l’egg medea : un lecteur de JSON qui te laisse choisir comment le JSON est traduit en données Scheme.

Le dernier egg que j’ai décidé d’utiliser est rest-bind. Il exporte une macro plutôt géniale appelée define-method qui définit des procédures qui vont exécuter des requêtes HTTP à ta place et retourne leur réponses. Par exemple, cette déclaration définirait une nouvelle procédure versions qui retourne la représentation Scheme des données JSON envoyées par le serveur quand une requête sur l’URL https://matrix.org/_matrix/client/versions est effectuée.

(define-method (versions)
  "https://matrix.org/_matrix/client/versions"
  #f read-json)
(versions) ;; -> ((versions . #("r0.0.1" "r0.1.0" "r0.2.0")))

Cependant, j’ai rencontré un petit problème en utilisant cette macro. L’API Matrix demande que le client ajoute un jeton de session nommé access_token dans chaque requête une fois authentifié, sous forme de paramètre dans l’URL de la requête. Bien sûr, je n’avais pas envie d’ajouter ce paramètre à chaque appel de procédure définie par define-method.

Heureusement, define-method n’utilise pas l’egg http-client directement ! Il s’attend uniquement à ce qu’une procédure call-with-input-request soit définie. Cette procédure vient généralement de l’egg http-client, mais tu peux la définir toi-même si tu veux bidouiller la requête avant de la donner à http-client !

Bien entendu c’est ce que j’ai fait. J’ai défini ma propre version de call-with-input-request qui ajoute le access_token à l’URL de la requête en le récupérant d’un paramètre Scheme. J’en ai aussi profité pour faire de l’URL du serveur un paramètre Scheme également, grâce à ça, l’utilisateur de la bibliothèque peut gérer plusieurs sessions Matrix, vers différents serveurs, dans son programme. Aussi, cette procédure personnalisée ajoute quelques headers HTTP à la requête.

Voici à quoi elle ressemble (où http:call-with-input-request est la vraie procédure venant de http-client) :

(define (call-with-input-request req writer reader)
  (unless (server-uri)
    (error "Server URI not set, use (init!) first"))
  (let* ((uri-rewritten (update-uri (request-uri req)
                                    scheme: (uri-scheme (server-uri))
                                    host: (uri-host (server-uri))
                                    port: (uri-port (server-uri))
                                    query: (append (if (access-token) `((access_token . ,(access-token))) '())
                                                   (uri-query (request-uri req)))))
         (headers-rewritten (headers (cons* '(accept application/json)
                                            (if (member (request-method req) '(PUT POST))
                                                '((content-type application/json))
                                                '()))
                                     (request-headers req)))
         (request-rewritten (update-request req
                                            uri: uri-rewritten
                                            headers: headers-rewritten)))
    (http:call-with-input-request request-rewritten writer reader)))

La dernière chose que j’ai faite a été de définir ma propre petite macro, define-endpoint, parce que je suis flemmard et que je déteste répéter la même chose encore et encore. C’est une macro syntax-rules très simple qui se développe en un appel à define-method.

(define-syntax define-endpoint
  (syntax-rules (GET POST PUT)
    ((define-endpoint GET decl)
     (define-method decl api-uri #f read-json))
    ((define-endpoint POST decl)
     (define-method decl api-uri json->string read-json))
    ((define-endpoint PUT decl)
     (define-method decl (make-request uri: api-uri method: 'PUT) json->string read-json))))

Avec cette macro, le seul code que j’ai à écrire dans ma bibliothèque pour ajouter de nouveaux appels à l’API Matrix ressemble à ça :

(define-endpoint GET (login-schemes "login"))
(define-endpoint POST (login "login"))
(define-endpoint POST (logout "logout"))
(define-endpoint GET (sync "sync" #!key filter since timeout full_state set_presence timeout))
(define-endpoint PUT (room-send "rooms" room-id "send" event-type transaction-id))
(define-endpoint GET (get-filter "user" user-id "filter" filter-id))
(define-endpoint POST (create-filter "user" user-id "filter"))

Et je peux ainsi les utiliser comme des procédures Scheme habituelles, comme ceci :

(sync since: "whatever" timeout: 30000)
(room-send "!vfFxDRtZSSdspfTSEr:matrix.org"
           'm.room.message
           'some-transaction-id
           '((msgtype . "m.text")
             (body . "Hello world!")))