• Smart Data
  • Publié le 17 janvier 2017

API REST avec Clojure – Part 2/2

Maintenant que vous connaissez Clojure, nous allons écrire notre API. Nous allons utiliser Compojure, une petite libraire de routage pour la librairie Ring, une lib bien conçue (et bien documentée) pour concevoir rapidement des applications web de façon modulaire.

Juste pour rappel:

Cet article explique comment facilement monter une API RESTful basique avec Clojure.

Cet article n’est *pas* un cours de programmation, ni une référence du langage Clojure.


1. Serveur minimaliste

Nous créons un nouveau projet Compojure appelé “clojure-api” avec leiningen:

Leiningen va nous créer un squelette avec une application web minimaliste…

… que je peux lancer en tapant:

Mon serveur écoute sur le port 3000, et est relancé automatiquement à la modification des fichiers sources, du coup je le laisse tourner tranquillement pendant que je développe.

NB: L’argument server-headless nous lance juste le serveur, si j’avais utilisé server, il m’aurait aussi ouvert mon navigateur web pour tester (si c’est pas génial ça!).

Dans mon navigateur web ça donne:

7 mots, et vous voilà avec un serveur qui écoute et se relance même tout seul 🙂 La classe quoi.


2. Structure et fichiers

Une explication très rapide sur les fichiers que leiningen a généré pour nous (parce que c’est plutôt intuitif hein, quand même!):

  • project.clj: Le fichier projet, il décrit notre projet et surtout liste les dépendances.
(defproject clojure-api "0.1.0-SNAPSHOT"
:description "Tutorial: Write a RESTful API with Clojure"
:url "http://localhost:3000"
:min-lein-version "2.0.0"
:dependencies [[org.clojure/clojure "1.8.0"]
[compojure "1.5.1"]
[ring/ring-defaults "0.2.1"]]
:plugins [[lein-ring "0.9.7"]]
:ring {:handler clojure-api.handler/app}
:profiles
{:dev {:dependencies [[javax.servlet/servlet-api "2.5"]
[ring/ring-mock "0.3.0"]]}})
  • README.md: Un fichier README à modifier et compléter
  • resources/ : Les fichiers ressource de notre projet
  • src/ : Ici se trouvent tous les fichiers source de notre projet
  • target/ : Ici se trouvent les classes compilées par leiningen
  • test/ : Ici se trouvent tous les fichiers tests

3. Requêtes et réponses

Passons maintenant au coeur de notre application, et notamment au traitement de nos requêtes et nos réponses qui se trouve dans src/clojure_api/handler.clj. Souvenez-vous juste de la syntaxe (fonction argument argument …) 😉

Comme pour les autres langages de programmation, on commence par importer les librairies nécéssaires.

(ns clojure-api.handler
(:require [compojure.core :refer :all]
[compojure.route :as route]
[ring.middleware.defaults
:refer [wrap-defaults api-defaults]]))

Grâce à Compojure, c’est ultra simple et rapide de construire notre app. A la création du projet, 2 routes ont été créées. defroutes définit notre handler (app-routes) et les routes que celui-ci doit gérer.

(defroutes app-routes
(GET "/" [] "Hello World")
(route/not-found "Not Found"))

C’est ici que sont définis nos endpoints, la partie du code qui renvoie “Hello World” à toutes les requêtes GET (sans paramètres). Compojure transforme automatiquement les strings en réponse 200 pour nous.

Avec Ring, les handlers sont tout simplement des fonctions qui acceptent des requêtes, et renvoient des réponses. Et comme vous allez voir, c’est vraiment super facile d’écrire du middleware pour enrichir notre traitement.

(def app
(wrap-defaults app-routes site-defaults))

Un middleware c’est une fonction plus haut-niveau qui va ajouter des fonctionnalités à nos handlers. Ca prend en 1er argument le handler en question, et ça renvoie une nouvelle fonction qui va appeler le handler d’origine.

ring-defaults permet de gérer automatiquement pas mal de choses. Ici wrap-defaults va créer un middleware par-dessus notre handler app-routes avec la conf site-defaults (qui permet de gérer paramètres, upload de fichiers, ressources statiques, …).

Ainsi def app définit le handler app, qui se compose du handler app-routes wrappé dans le middleware site-defaults.

NB: En Clojure, def est évaluée (éxécutée) qu’une seule fois, defn sera évaluée à chaque appel de la fonction.

Et… c’est tout!


4. Test-driven development

Comme nous aimons tous les bonnes pratiques, on va d’abord commencer par écrire nos tests. Ca permet de bien réfléchir aux comportements et fonctionnalités attendus, et nous aide à mieux concevoir et écrire du code propre et fiable.

Notre API va gérer une liste d’utilisateurs et leurs emails. Elle devra gérer la consultation, l’ajout, la modification et la suppression. Pour cela, je vais définir 1 endpoint à mon API, /users, où un id pourra être fourni en paramètre optionnel. L’id est un identifiant unique par utilisateur, composé de chiffres. Le tout en JSON. J’écris mes 2 tests basiques dans test/clojure_api/handler_test.clj.

J’utilise clojure.test, un framework de tests unitaires qui fait partie de Clojure.

(ns clojure-api.handler-test
(:require [clojure.test :refer :all]
[ring.mock.request :as mock]
[clojure-api.handler :refer :all]))
(deftest testing-routes
(testing "Users endpoint"
(let [response (app (mock/request :get "/users"))]
(is (= (:status response) 200))
(is (= (get-in response [:headers "Content-type"]) "application/json; charset=utf-8"))))
(testing "not-found route"
(let [response (app (mock/request :get "/bogus"))]
(is (= (:status response) 404)))))

Mon 1er test va checker que la méthode GET sur mon endpoint /users renvoie:

  • une réponse en 200
  • une réponse formattée en JSON

Le 2ème test check simplement que mon API renvoie bien une erreur 404 en cas d’URI non-définie.

Allez, on lance nos tests, et si tout va bien, je devrai avoir une erreur puisque je n’ai pas encore fait de modifs sur mon traitement pour respecter ces contraintes.

Nickel! On voit clairement où et pourquoi mes tests ont échoué. Modifions maintenant le fichier src/clojure_api/handler.clj afin que nos tests passent.

(def app
(-> app-routes
wrap-log-request
wrap-json-response
(wrap-json-body {:keywords? true})))

J’ajoute mon middleware pour logger les requêtes (wrap-log-request), du middleware pour renvoyer les réponses en JSON (wrap-json-response), et du middleware pour parser les requêtes JSON (wrap-json-body).

Notez la ->, appelée “thread-first”. Elle permet de prendre mon handler app-routes et de le passer en argument à la 1ère ligne, wrap-log-request. Tout ça sera ensuite passer en argument à la 2ème ligne, etc, jusqu’à la dernière. Souvenez-vous,  un middleware prend en 1er argument le handler, et renvoie une nouvelle fonction qui va appeler le handler d’origine. Je wrap donc mon handler d’origine (app-routes) dans tous mes middlewares… de façon ultra-lisble, merci Clojure!

A titre d’exemple, j’ai écrit un middleware pour logger les requêtes reçues (wrap-log-requests).

(defn wrap-log-request [handler]
(fn [req]
(println req)
(handler req)))

Je définis ma route.

(defroutes app-routes
(context "/users" []
(GET "/" [] (response {:status 200})))
(route/not-found (response {:message "Not Found"})))

context permet de définir un ensemble de routes avec le même préfixe, pour le moment j’ai juste défini mon endpoint /users avec la méthode GET qui renvoie un status 200.

Les tests passent, nickel.


5. Stockons dans une base de données

J’ai créé une petite base de donnée PostgreSQL pour ce tutoriel, testdb, contenant juste une table, users. J’utilise la librairie Korma qui propose un DSL pratique pour les bases (les plus courantes) SQL.

  1. La base de données:

src/clojure_api/db.clj

(ns clojure-api.db
(:use korma.db))
(defdb pg (postgres {:db "testdb"
:user "tinka"
:password "testdb"
:host "localhost"
:port 5432}))

Une simple définition de la base de données et comment s’y connecter.

  1. Le modèle:

src/clojure_api/entities.clj

(ns clojure-api.entities
(:use korma.core
clojure-api.db))
(declare users)
(defentity users
(pk :id)
(table :users)
(entity-fields :id :name :email))

Ici je décris mon modèle de données. Je crée une entité users. Sa primary key est définie par la variable :id (qui est d’ailleurs la valeur par défaut, cette ligne est donc complètement inutile en réalité), le symbole correspondant s’appelle users, et je vais définir 3 champs par défaut pour toutes mes requêtes SELECT:id:name et :email.

Ensuite, comme on est en TDD, j’écris mes tests d’accès à ma base de données.

  1. Petit utilitaire qui va me permettre de faire un rollback après mes TUs (et pas pourrir ma bdd):

test/clojure_api/test_utils.clj

(ns clojure-api.test-utils
(:use clojure.test)
(:require [korma.db :as db]))
(defn with-rollback
"Test fixture for executing a test inside a database transaction
that rolls back at the end so that database tests can remain isolated"
[test-fn]
(db/transaction
(test-fn)
(db/rollback)))
  1. Les tests sur ma bdd:

test/clojure_api/users_test.clj

(ns clojure-api.users-test
(:use clojure.test
clojure-api.test-utils)
(:require [clojure-api.users :as users]
[clojure-api.entities :as e]
[korma.core :as sql]))
(use-fixtures :each with-rollback)
(deftest count-rows-test
(testing "Initial row count"
(let [test-count (users/db-count-users)]
(is (= 0 test-count)))))
(deftest create-user-test
(testing "Create user"
(let [test-count (users/db-count-users)]
(users/db-create-user {:name "andrew"
:email "andrew@domain.org"})
(is (= (inc test-count) (users/db-count-users))))))
(deftest list-users-test
(testing "Find user"
(let [test-user (users/db-create-user {:name "andrew"
:email "andrew@domain.org"})
test-found (users/db-find-user (test-user :id))]
(is (= "andrew" (test-found :name)))
(is (= "andrew@domain.org" (test-found :email)))))
(testing "Find all users"
(doseq [i (range 9)]
(users/db-create-user {:name (str "testuser." i)
:email (str "testuser." i "@bogus.com")}))
(is (= 10 (count (users/db-find-all))))))
(deftest modify-users-test
(testing "Update user"
(let [test-user (users/db-create-user {:name "andrew"
:email "andrew@domain.org"})
test-id (test-user :id)]
(users/db-update-user (assoc test-user :name "tinka"))
(is (= "tinka" (:name (users/db-find-user test-id))))))
(testing "Delete user"
(let [test-user (users/db-create-user {:name "temp"
:email "temp@domain.org"})
test-count (users/db-count-users)]
(users/db-delete-user test-user)
(is (= (dec test-count) (users/db-count-users)))
(is (nil? (users/db-find-user (test-user :id)))))))

Des tests relativement simples, pour vérifier que les différentes opérations que je veux faire sur ma bdd fonctionnent comme je le veux. C’est ici que vous réalisez finalement à quel point le mythe de ‘la syntaxe  (fonction argument argument …), c’est super dur’ est absurde.

  1. Les fonctions:

src/clojure_api/users.clj

(ns clojure-api.users
(:require [korma.core :refer :all]
[clojure-api.entities :as e]))
(defn db-count-users []
(let [agg (select :users
(aggregate (count :*) :cnt))]
(get (first agg) :cnt)))
(defn db-find-all []
(select e/users))
(defn db-find-user [id]
(first
(select e/users
(where {:id id})
(limit 1))))
(defn db-create-user [user]
(insert e/users
(values user)))
(defn db-update-user [userdata]
(update e/users
(set-fields (dissoc userdata :id))
(where {:id (:id userdata)})))
(defn db-delete-user [user]
(delete e/users
(where {:id (user :id)})))

Et c’est tout 🙂 Je peux maintenant lancer mes tests, et… tout passe, c’est la classe!


6. Une API fonctionnelle

Ecrivons maintenant les tests de la partie web de notre API. Ce que je veux, c’est:

  • Un endpoints /users
    • GET appelera la fonction api-get-all pour lister les tous les utilisateurs dans mon carnet
    • POST appelera la fonction api-create avec le contenu envoyé par le client pour créer un nouvel utilisateur

Le 2ème endpoint imbriqué dans le 1er pour des opérations spécifiques à un utilisateur, identifié par un id unique composé de chiffres.

  • Un endpoint /users/id
    • GET appelera la fonction api-get-user avec l’id en paramètre pour consulter un utilisateur spécifique
    • PUT appelera la fonction api-update avec l’id et le contenu en paramètres pour mettre à jour un utilisateur spécifique
    • DELETE appelera la fonction api-delete avec l’id en paramètre pour supprimer un utilisateur spécifique

Je vais juste ajouter une autre fonction à mon utilitaire de tests, test/clojure_api/users_test.clj, pour vérifier mes contenus:

(defn substring? [sub st]
(if (nil? st)
false
(not= (.indexOf st sub) -1)))

test/clojure_api/handler_test.clj

(ns clojure-api.handler-test
(:use clojure.test
clojure-api.test-utils
ring.mock.request
clojure-api.handler)
(:require [cheshire.core :as json]
[clojure-api.users :as users]))
(use-fixtures :each with-rollback)
(deftest testing-routes
(testing "Users endpoint"
(let [response (app (request :get "/users"))]
(is (= (:status response) 200))
(is (= (get-in response [:headers "Content-Type"]) "application/json; charset=utf-8"))))
(testing "not-found route"
(let [response (app (request :get "/bogus"))]
(is (= (:status response) 404)))))
(deftest creating-user
(testing "POST /users"
(let [user-count (users/db-count-users)
response (app (-> (request :post "/users")
(body (json/generate-string {:name "Andrew"
:email "andrew@domain.org"}))
(content-type "application/json")
(header "Accept" "application/json")))]
(is (= (:status response) 201))
(is (substring? "/users/" (get-in response [:headers "Location"])))
(is (= (inc user-count) (users/db-count-users))))))
(deftest getting-user-data
(let [user (users/db-create-user {:name "John Doe" :email "j.doe@mytest.com"})
init-count (users/db-count-users)]
(testing "GET /users"
(doseq [i (range 4)]
(users/db-create-user {:name "fake" :email (str "fake" i "@example.com")}))
(let [response (app (request :get "/users"))
resp-data (json/parse-string (:body response))]
(is (= (:status response 200)))
(is (substring? "fake3@example.com" (:body response)))
(is (= (+ init-count 4) (count (get resp-data "results" []))))
(is (= (+ init-count 4) (get resp-data "count" [])))))
(testing "GET /users/:id"
(let [response (app (request :get (str "/users/" (:id user))))]
(is (= (:body response) (json/generate-string user)))))))
(deftest modifying-user-data
(let [user (users/db-create-user {:name "Andrew" :email "andrew@domain.org"})
init-count (users/db-count-users)]
(testing "PUT /users/:id"
(let [response (app (-> (request :put (str "/users/" (:id user)))
(body (json/generate-string {:name "tinka"}))
(content-type "application/json")))]
(header "Accept" "application/json")))]
(is (= (:status response 200)))
(is (substring? (str "/users/" (:id user)) (get-in response [:headers "Location"])))
(is (= init-count (users/db-count-users)))))))
(deftest deleting-user
(let [user (users/db-create-user {:name "John Doe" :email "j.doe@mytest.com"})]
(testing "DELETE /users/:id"
(let [response (app (request :delete (str "/users/" (:id user))))]
(is (= (:status response) 204))
(is (= "/users" (get-in response [:headers "Location"])))
(is (nil? (users/db-find-user (:id user))))))))

src/clojure_api/handler.clj

(ns clojure-api.handler
(:use ring.util.response
ring.middleware.json)
(:require [compojure.core :refer :all]
[compojure.route :as route]
[compojure.handler :as handler]
[ring.middleware.defaults
:refer [wrap-defaults site-defaults]]
[clojure.java.jdbc :as j]
[clojure-api.users :as users]))
(defn api-get-all [_]
{:status 200
:body {:count (users/db-count-users)
:results (users/db-find-all)}})
(defn api-create [{user :body}]
(let [new-user (users/db-create-user user)]
{:status 201
:headers {"Location" (str "/users/" (:id new-user))}}))
(defn api-get-user [{{:keys [id]} :params}]
(response (users/db-find-user (read-string id))))
(defn api-update [{{:keys [id]} :params userdata :body}]
(if id
(do
(let [user (users/db-find-user (read-string id))]
(users/db-update-user (merge user userdata)))
{:status 200
:headers {"Location" (str "/users/" id)}})
{:status 404
:headers {"Location" "/users"}}))
(defn api-delete [{{:keys [id]} :params}]
(users/db-delete-user {:id (read-string id)})
{:status 204
:headers {"Location" "/users"}})
(defroutes app-routes
(context  "/users" [] 
(GET  "/" [] api-get-all)
(POST "/" [] api-create)
(context    "/:id{[0-9]+}" [id] 
(GET    "/" [] api-get-user)
(PUT    "/" [] api-update)
(DELETE "/" [] api-delete)))
(route/not-found {:message "Not Found"}))
(defn wrap-log-request [handler]
(fn [req]
(println req)
(handler req)))
(def app
(-> app-routes
wrap-log-request
wrap-json-response
(wrap-json-body {:keywords? true})))

Essayons tout ça maintenant!


Le tout en moins de 260 lignes de code, fichier projet et tests unitaires inclus. Alors, convaincu.e.s?!
Et voilà, encore une victoire de canard!

Karine de Pontevès
Karine de Pontevès

Réagissez à cette article

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *