Développer une API REST en Clojure : partie 3

Pour terminer notre API, il est impératif de pouvoir la tester. Nous allons également ajouter des outils pour vérifier les conventions de codage, mais aussi une librairie de migration SQL, pour rendre d’éventuelles évolutions plus simples.

Ajouter des scripts de migration

La librairie ragtime, mentionnée dans nos dépendances, utilise des scripts de migration et les applique sur une base de données. Dans une méthode de chargement de la configuration, il faut définir le datastore, qui contient la chaîne de connexion à la base et le dossier où se trouvent les scripts de migration SQL.

src/clojure_rest/config/database.cl

(defn load-ragtime-config []
  {:datastore  (ragtime-jdbc/sql-database (get (appconf/conf) :ragtime-h2-url))
   :migrations (ragtime-jdbc/load-directory (io/resource "migrations"))})

(defn ragtime-migrate
  "Uses ragtime to apply migration scripts"
  []
  (log/info "Applying ragtime migrations")
  (let [ragtime-config (load-ragtime-config)]
    (log/info (str "Used configuration for ragtime : " ragtime-config))
  (ragtime-repl/migrate ragtime-config)))

(defn ragtime-rollback
  "Uses ragtime to apply migration rollback scripts"
  []
  (log/info "Rollbacking ragtime migrations")
  (ragtime-repl/rollback (load-ragtime-config)))

(defn reinit-database
  "Clears database and recreates it using ragtime"
  []
  (ragtime-rollback)
  (ragtime-migrate))

J’utilise la dernière méthode reinit-database dans mes tests, pour remettre ma base de données de tests à zéro entre chaque test.

Un fois que vous avez ajouté ces méthodes, il reste quelques étapes. Tout d’abord, ajoutez les fichiers de migration suivants :

resources/dev/migrations/001-create-document.up.sql (idem pour test)

-- Creation
CREATE TABLE "document" ("id_document" VARCHAR(36) PRIMARY KEY, "title" varchar(50), "description" varchar(100));

--;;

-- Insertion
INSERT INTO "document" VALUES ('1', 'title1', 'text1');
INSERT INTO "document" VALUES ('2', 'title2', 'text2');

resources/dev/migrations/001-create-document.down.sql (idem pour test)

DROP TABLE "document";

Attention à bien respecter les normes de nommage : .up.sql pour les scripts de migration, .down.sql pour les scripts de rollback associés, ainsi qu’un nom identique. Attention également aux nombres mentionnés au début du nom, l’ordre peut avoir une importance à l’exécution.

Pour lancer les méthodes de migration et rollback directement depuis lein, il faut ajouter les alias suivants :

project.clj

:aliases {"migrate"  ["run" "-m" "clojure-rest.config.database/ragtime-migrate"]
            "rollback" ["run" "-m" "clojure-rest.config.database/ragtime-rollback"]}

Puis pour les exécuter, lancez respectivement lein migrate ou lein rollback. Cela s’appliquera sur la base de dev seulement.

Ajouter des tests

Dans cette partie seuls quelques tests d’intégration sont décrits. Des tests unitaires, notamment sur le modèle, sont disponibles sur le dépôt GitHub du projet, avec l’ensemble des tests d’intégration (tests sur update et delete notamment).

src/test/integration/controllers/document_controller_test.clj

(ns clojure-rest.integration.controllers.document-controller-test
  (:require [clojure.test :refer :all]
            [ring.mock.request :as mock]
            [cheshire.core :as json]
            [clojure.java.jdbc :as jdbc]
            [clojure-rest.handler :refer :all]
            [clojure.tools.logging :as log]
            [clojure-rest.config.database :as database]))

(def expected-headers {"Content-Length" "119", "Content-Type" "application/json; charset=utf-8"})
(def initial-document-list-expected (json/generate-string [{:id_document "1" :title "title1" :description "text1"} {:id_document "2" :title "title2" :description "text2"}]))
 

Il faut d’abord importer toutes les dépendances et définir quelques variables de test. Cheshire permet d’échapper le JSON correctement grâce à la méthode generate-string. Cela évite la confusion avec une map Clojure classique, qui ne serait pas reconnue correctement.

Note: J'ai fait le choix de Cheshire car elle est fortement conseillée par la communauté, étant plus performante que la librairie fournie par défaut.

Puis, avec les macros deftest et testing, on définit un test sur la récupération de tous les documents. La librairie de mocking simule une requête sur l’URL demandée, comme si elle était appelée depuis un navigateur. Reste à tester le code retour, les headers et le corps de la réponse. Avant chaque début de test, la base de données est réinitialisée, de ce fait les tests ne sont pas interdépendants, ce qui serait une mauvaise pratique. Il est d’ailleurs possible d’utiliser des fixtures pour reproduire un comportement de before/after disponible via des annotations sur JUnit.

src/test/integration/controllers/document_controller_test.clj

(deftest test-document-controller-list
  (database/reinit-database)

  (testing "Testing document list route worked"
    (let [response (app (mock/request :get "/documents"))]
      (is (= (:status response) 200))
      (is (= (:headers response) expected-headers))
      (is (= (slurp (:body response)) initial-document-list-expected)))))

Dans le cas du :body, la méthode slurp, appartenant à la librairie java.io, est présente car la réponse est renvoyée sous forme de BufferedInputStream. slurp permet de convertir ce type en une map compréhensible par Clojure. Cette méthode peut également être utilisée pour charger un fichier complet en mémoire, attention donc à ne pas trop surcharger votre base de données pour vos tests d’intégration, dans un souci de performance.

src/test/integration/controllers/document_controller_test.clj

(def document-to-create {:title "newDocumentTitle" :description "newDocumentText"})

(deftest test-document-controller-create
  (database/reinit-database)

  (testing "Testing document creation route worked"
    (let [request (mock/request :post "/documents" (json/generate-string document-to-create))]
    (let [response (app (mock/content-type request "application/json"))]
      (let [response-body (slurp (:body response))]
        (is (= (:status response) 201))
        (is (= (get (json/parse-string response-body) "title") (:title document-to-create)))
        (is (= (get (json/parse-string response-body) "description") (:description document-to-create)))
        (is (= (count (jdbc/query database/db-h2-connection ["select * from \"document\""])) 3)))))))

La différence lors d’un test de création réside dans le dernier “assert”. Celui-ci utilise le JDBC pour bien vérifier que la base de données contient une valeur en plus.

Configurer les linters

Un linter peut avoir plusieurs objectifs. Vous pouvez l’utiliser pour appliquer les mêmes conventions de codage au sein de toute votre équipe, mais également pour identifier des constructions et pratiques “douteuses” dans votre code.

Deux plugins sont disponibles dans notre projet pour faire du linting : eastwood et bikeshed (d’autres existent).

Personnellement, j’utilise surtout eastwood pour mettre en avant les fonctionnalités dépréciées. Bikeshed vous sera plus utile si vous souhaitez imposer un nombre de caractères maximum par ligne, détecter les caractères blancs mal placés, etc …

Il sont utilisables avec le project.clj que vous avez actuellement, mais dans notre cas certaines configurations sont nécessaires :

project.clj

 :eastwood {:exclude-linters [:constant-test]
             :include-linters [:deprecations]
             :exclude-namespaces [clojure-rest.config.routes]
             }

 :bikeshed {:max-line-length 100}

J’ai retiré les routes de l’analyse d’eastwood car celui-ci y étend (c’est à dire déroule) les macros Clojure et détecte des erreurs internes, indépendantes de mon propre code. L’exclusion des constant-test est également nécessaire car elle est provoquée par nos tests d’intégration qui renvoient nécessairement true ou false. J’aurais également pu retirer le namespace concerné.

Pour bikeshed, je me contente de définir la longueur de ligne. À vous de faire votre configuration selon vos besoins, la documentation disponible sur les dépôts GitHub est suffisante.

Note : je n’utilise pas de camelCase volontairement dans mes noms de variables ou fonctions. Ce n’est pas idiomatique en Clojure.

Pour lancer les linter, deux commandes sont disponibles : lein eastwood et lein bikeshed. Vous remarquerez d’ailleurs que bikeshed vous remonte quelques erreurs de mise en forme, notamment sur la taille des lignes.

Ce projet ne fait pas une utilisation très avancée des linters, mais vous pouvez consulter la documentation pour appliquer des conventions plus strictes. Ce guide de style est également disponible pour vous donner quelques idées.

Préparer l’API pour la production

En l’état, notre API n’est pas prête pour la production, il manque quelques éléments :

  • La configuration du dossier de ressources pour la production
  • La modification du port par défaut

Une fois vos développements achevés, ring permet de créer un jar qui embarque toutes les dépendances du projet. Pour le créer, entrez la commande suivante :

lein ring uberjar

Puis, selon les paramètres fournis dans votre project.clj, un .jarterminant par “standalone” est mis à disposition dans le dossier target. L’exécuter suffit alors à lancer votre api. Vous pouvez également rajouter des options pour changer le port comme suit :

env PORT=5000 java -jar votreJar.jar

Je vous recommande tout de même d’indiquer le port directement dans votre project.clj. Il peut être différent selon les profils et vous pouvez également mentionner des options pour la JVM. Pour ce faire, il faut ajouter ces lignes dans votre project.clj :

:profiles {:dev {:jvm-opts ["votre option JVM"] 
                 :ring {:port 8080}} 
:uberjar {:ring {:port 80}}})

Attention cependant, votre port 80 n’est pas forcément autorisé par votre machine, cela dépend de votre configuration.

Enfin, il vous faudra rajouter un dossier prod dans vos resources, et le peupler avec des fichiers de configuration selon votre besoin. N’oubliez pas de le mentionner dans vos :profiles à la fin de votre project.clj.

Ce n’est pas tout

Dans cette série d’articles, nous avons vu ensemble un panel d’outils qui nous ont permis d’implémenter une API REST que nous pourrons maintenir facilement. Il est encore possible de l’améliorer, en la rendant plus idiomatique et fonctionnelle ou en ajoutant d'autres fonctionnalités. Il existe par exemple des moyens de la sécuriser via JWT, ou de faire des tests de charge via un équivalent de gatling. Comme vous l’avez compris, l’écosystème est riche. Il vous permet également de travailler sur du front-end, avec ClojureScript.

J’espère vous avoir donné envie d’essayer Clojure. Même si nous n’en avons pas trop parlé, la programmation fonctionnelle est une gymnastique intéressante, et Clojure peut être une bonne alternative pour se lancer. À vous de jouer !