Développer une API Rest en Clojure : partie 2

Maintenant que notre projet est prêt, nous pouvons commencer les développements de notre API REST. Ici nous verrons pas à pas comment mettre vos données à disposition via des endpoints REST avec Clojure.

Structure de l’application

Pas de description détaillée de la structure ici mais quelques explications sur mon choix d’implémentation. Vous remarquerez en parcourant cet article que j’ai choisi une architecture en couches pour mon application : controleur, services, dao, model …

J’attire votre attention sur les DAO, résultat de mes habitudes de développeur Java. DAO signifiant Data Access Object, le principe n’a pas vraiment sa place en Clojure idiomatique car il n’utilise pas vraiment d’objets ou de classes. Ceci dit, cela vous montre que Clojure ne vous contraint pas à adopter une structure en particulier. L’important reste d’organiser vos namespaces par cas d’utilisation, pour plus de clarté.

Attention : même si Clojure est assez flexible, il ne vous permet pas de faire des références circulaires dans vos namespaces. Il faut donc bien séparer les responsabilités dans votre code. Sinon cela pourrait vous poser de gros problèmes, comme dans beaucoup d’autres langages d’ailleurs.

Ajouter le handler

Par convention, il faut définir une variable pour ring qui répertorie toutes les routes. Appelée depuis le project.clj, celle-ci constitue le point d’entrée de l’application. Vous pouvez déclarer vos routes directement dans ce fichier si vous le souhaitez, mais dans notre cas nous procéderons comme suit :

src/clojure_rest/handler.clj

(ns clojure-rest.handler
  (:require [compojure.core :refer :all]
            [compojure.handler :as handler]
            [compojure.route :as route]
            [clojure-rest.config.routes :as config-routes]))

(def app config-routes/app-routes)

Définir le modèle

L’ORM choisi pour ce projet est Korma. Pour les habitués d’Hibernate, il faut donc déclarer la table et ses différentes contraintes. Pas de déclaration de classe en Clojure, c’est la macro defentity de Korma qui vous permet de le faire. Les macros sont une fonctionnalité puissante de Clojure, pour en savoir plus rendez-vous sur la documentation officielle. Ici nous ajoutons la table associée, la clé primaire, ainsi que les champs qui seront ramenés par défaut par un select.

(ns clojure-rest.model.document
  (:use [korma.core :only [defentity table entity-fields pk]])
  (:require [clojure.tools.logging :as log]
            [schema.core :as schema]))

; -- Korma configuration
(defentity document
  (table :document) ; Associated table
  (pk :id_document) ; primary key
  (entity-fields :id_document :title :description))

; -- Validation schema for complete document
(def document-schema
  {:id_document schema/Str
   :title schema/Str
   :description schema/Str})

; -- Validation for input rest document
(def document-schema-rest-in
 {:title schema/Str
  :description schema/Str})

En Clojure, on fonctionne surtout avec des maps pour visualiser notre modèle, il n’y pas de classes à proprement parler, même si on peut simuler ce type de comportement (Notamment avec la macro defrecord ou de l'interop Java). La bibliothèque schema nous permet d’imposer des types dans notre map et de vérifier si une map en entrée respecte ces contraintes. C’est en quelque sorte un mécanisme de Bean Validation.

Deux modèles sont définis : document_schema pour un document complet, et document_schema_rest_in que nous utiliserons pour les documents envoyés par l’utilisateur, pour la création ou la mise à jour. Ces schémas sont validables via la méthode schema/validate, ou directement dans la déclaration des routes.

Implémenter les routes

Nous avons défini le handler et notre modèle, il faut maintenant implémenter nos routes. Grâce à la bibliothèque compojure-api, définir les routes et leur documentation Swagger est très simple. Commençons d’abord par configurer Swagger.

Configurer Swagger

Swagger donne accès à une interface graphique qui liste vos endpoints et vous permet de requêter facilement pour vos tests, ou pour documenter votre API auprès des futurs utilisateurs. Rendez-vous à cette adresse pour voir une démonstration plus explicite. En Clojure, la configuration se fait sous forme de map :

src/clojure_rest/config/swagger.clj

(ns clojure-rest.config.swagger)

(def swagger-routes
  {:swagger
   {:ui "/api-docs"
    :spec "/swagger.json"
    :data {:info {:title "Simple Clojure API"
                  :description "Example Clojure api for Ippon Blog Article"}
           :tags [{:name "api-documents", :description "Api document endpoints"}]}}})

Ici je mentionne l’url “/api-docs” qui nous permettra d’accéder à l’interface ainsi que d’autres descriptifs que nous verrons à l’affichage (Voir le dépôt github pour un aperçu). Reste maintenant à implémenter nos routes.

Ajouter les routes et leur documentation

Grâce à compojure-api, la déclaration des routes et leur documentation Swagger se fait au même endroit. Importons les dépendances nécessaires et définissons nos routes documents et leur contexte :

src/clojure_rest/config/routes.clj

(ns clojure-rest.config.routes
  (:require [compojure.api.sweet :refer :all]
            [ring.util.http-response :refer :all]
            [clojure-rest.config.swagger :as swagger-conf]
            [clojure-rest.controllers.document-controller :as document-controller]
            [clojure-rest.model.document :as document-model]))

(def document-routes
  (context "/documents" [] :tags ["api-documents"]

Toutes les routes situées dans le scope des parenthèses de *context “/documents” *auront pour début d’URL “documents”. Le mot clé :tags est utilisé par Swagger pour définir une description pour ce contexte là.

Nous pouvons maintenant définir la route qui récupère tous les documents présents en base. Dans la route ci-dessous, le mot clé :return indique le type de retour : un vecteur de documents. Les :responsesajoutent un descriptif selon le type de réponse attendu, ainsi que le schéma en sortie.

Enfin, la route fait appel à notre contrôleur, qui devra retourner une réponse HTTP.

src/clojure_rest/config/routes.clj

(GET "/" []
  :summary "Gets all available documents"
  :return [document-model/document-schema]
  :responses {200 {:schema [document-model/document-schema],
                           :description "List of documents"}}
   (document-controller/get-all-documents))

Pour les autres routes, le fonctionnement est le même, à quelques différences près.

Le mot clé :path-params permet d’ajouter des paramètres typés à l’URL. *:query-params *est également disponible.

src/clojure_rest/config/routes.clj

 (GET "/:id" []
   :path-params [id :- String]
   :return document-model/document-schema
   :responses {200 {:schema document-model/document-schema,
                             :description "The document found"}
               404 {:description "No document found for this id"}}
   :summary "Gets a specific document by id"
   (document-controller/get-document id))

Lorsqu’il est nécessaire de mentionner un corps, compojure-api a besoin du mot-clé :body suivi d’un vecteur contenant le modèle recherché. Attention à ne pas confondre avec le :return où le vecteur correspond à un vrai “tableau” de documents. Dans le cas du body, ce sont des bindings, comme pour let. Dans la route de création, nous affectons “document” à son schéma en entrée pour l’API REST. Lorsqu’on appelle notre contrôleur après coup, on peut mentionner “document” comme un paramètre en entrée.

src/clojure_rest/config/routes.clj

(POST "/" []
  :body [document document-model/document-schema-rest-in]
  :return document-model/document-schema
  :responses {201 {:schema document-model/document-schema,
                           :description "Returns the created document"}
              400 {:description "Malformed request body"}}
  :summary "Creates new document"
  (document-controller/create-new-document document))

src/clojure_rest/config/routes.clj

(PUT "/:id" []
  :path-params [id :- String]
  :body [document document-model/document-schema-rest-in]
  :return document-model/document-schema
  :responses {200 {:schema document-model/document-schema,
                             :description "The updated document"}
              400 {:description "Malformed request body"}
              404 {:description "No document found for this id"}}
  :summary "Updates and existing document by id"
  (document-controller/update-document id document))

src/clojure_rest/config/routes.clj

(DELETE "/:id" []
  :path-params [id :- String]
  :responses {204 {:description "Document successfuly deleted"}
              404 {:description "No document found for this id"}}
  :summary "Deletes and existing document by id"
  (document-controller/delete-document id))))

Pour finir, il faut utiliser la macro defapi qui va répertorier l’ensemble de nos routes pour le handler. On mentionne d’abord la configuration Swagger, puis les routes de document définies au dessus. La dernière route permet de rediriger vers une erreur 404 si une route non valide est demandée lors de la requête. Attention, l’ordre des routes non swagger est important ! Si vous mettez la dernière route en premier, vous aurez systématiquement une erreur 404 “Not found”. Lorsque compojure lit ses routes, il s’arrête à la première qui remplit ces critères.

src/clojure_rest/config/routes.clj

(defapi app-routes
   swagger-conf/swagger-routes
   document-routes
  (undocumented (compojure.route/not-found (ok {:not "found"}))))

Mettre en place la configuration

Avant de continuer, nous allons avoir besoin de fichiers de configuration. Grâce à la bibliothèque nomad, nous pouvons définir les deux fichiers de ressources .edn suivants. Ce type de fichier contient simplement des maps, dans lesquelles clojure peut facilement aller chercher ce dont il a besoin.

resources/dev/application.edn

{:hostname "localhost"
 :h2database {:dbtype "h2"
             :dbname "./resources/dev/db/clojure_rest_h2"
             :user "sa"
             :password ""}

  :h2databasekorma {:db "./resources/dev/db/clojure_rest_h2"
                   :user "sa"
                   :password ""}
  :ragtime-h2-url "jdbc:h2:file:./resources/dev/db/clojure_rest_h2;USER=sa;PASSWORD="
}

>resources/test/application.edn

{:hostname "localhost"
 :h2database {:dbtype "h2"
             :dbname "./resources/test/db/clojure_rest_h2"
             :user "sa"
             :password ""}

 :h2databasekorma {:db "./resources/test/db/clojure_rest_h2"
                   :user "sa"
                   :password ""}
 
 :ragtime-h2-url "jdbc:h2:file:./resources/test/db/clojure_rest_h2;USER=sa;PASSWORD="
 }

Il faut maintenant charger la configuration depuis nos ressources. Grâce à notre configuration sur la localisation des ressources selon les profils dans le project.clj, elle sera récupérée au bon endroit selon que l’on lance le serveur en dev, ou les tests. J’utilise un fichier spécifique pour charger cette configuration :

src/clojure_rest/config/app.clj

(ns clojure-rest.config.app
  (:require [clojure.java.io :as io]
            [nomad :refer [defconfig]]))

(defconfig conf (io/resource "application.edn"))

Il suffit ensuite de mentionner le nom du fichier à charger grâce à la macro defconfig.

Nos configurations sont maintenant prêtes, nous allons pouvoir les utiliser pour la base de données.

Se connecter à la base de données

Au début de mes développements, j’ai d’abord travaillé avec du java JDBC. Il en reste quelques vestiges dans la configuration, et je l’utilise toujours dans les tests. Mais pour les “vraies” requêtes, j’ai décidé d’utiliser l’ORM Clojure Korma. C’est une bibliothèque reconnue et très utilisée par la communauté.

Connection avec Korma

Korma facilite la configuration grâce à deux macros : defdb et h2. Avec la méthode get, je récupère tout simplement la valeur nécessaire dans la map de configuration. Korma utilisera cette connection sans avoir à la mentionner manuellement à chaque requête.

Important :  Il est possible de définir la base de données entièrement depuis sa map de configuration, sans utiliser de macro associée à une base de données spécifique. Cela peut être utile si vous changez de base de données en dev et en test. Voir cette partie de la documentation pour plus de détails. Sans scripts de migration, votre API ne fonctionnera pas avec une base h2, vous pouvez en utiliser une autre. Sinon, rendez-vous dans la partie 3.

src/clojure_rest/config/database.clj

(ns clojure-rest.config.database
  (:use [korma.db :only [defdb h2]])
  (:require [clojure.java.io :as io]
            [clojure.java.jdbc :as jdbc]
            [clojure.tools.logging :as log]
            [ragtime.jdbc :as ragtime-jdbc]
            [ragtime.repl :as ragtime-repl]
            [clojure-rest.config.app :as appconf]
            [clojure-rest.utils.database-utils :as dbutils]))

(defdb db-h2-korma (h2 (get (appconf/conf) :h2databasekorma)))

Connection JDBC

Pour le JDBC, pas de macro, je définis une variable. Il faudra l’appeler à chaque requête JDBC.

src/clojure_rest/config/database.clj

(def db-h2-connection (get (appconf/conf) :h2database))

Développer la logique métier de notre API

Il est maintenant possible de se lancer dans le développement du code métier. Il faut pouvoir appeler depuis le contrôleur des méthodes qui s’occupent du CRUD de notre application.

DAO

Les DAO interfacent notre application avec la base de données. Ses fonctions sont appelées depuis les services. Comme je l’avais dit précédemment nul besoin d’importer notre configuration ici, Korma l’a déjà enregistrée et il suffit de faire appel à notre entité “document” pour nos différentes opérations, Korma s’occupe du reste. À noter que la simplicité de Clojure permet de comprendre facilement chaque requête effectuée par Korma.

src/clojure_rest/dao/document_dao.clj

(ns clojure-rest.dao.document-dao
  (:use [korma.core :only [select insert values delete where set-fields]])
  (:require [clojure.tools.logging :as log]
            [clojure-rest.model.document :as document-model]))

(defn get-all-documents
  "Gets all documents from the database"
  []
  (select document-model/document))

(defn get-document-by-id
  "Gets only one document using its id"
  [id-document]
  (log/info (str "get-document-by-id dao, id: " id-document))
  (select document-model/document (where {:id_document id-document})))

(defn create-new-document
  "Adds a new document to the database"
  [document-to-create]
  (log/info (str "create-new-document dao : " document-to-create))
  (insert document-model/document
          (values document-to-create)))

(defn update-document
  "Updates an existing document on the database"
  [id-document-to-update document-to-update]
  (log/info (str "update-document dao, id : " id-document-to-update))
  (log/info (str "New values for update : " document-to-update))
  (korma.core/update document-model/document
          (set-fields document-to-update)
          (where {:id_document id-document-to-update})))

(defn delete-document
  "Deletes an existing document on the database, returns id of deleted document"
  [id-document-to-delete]
  (log/info (str "delete-document dao, id : " id-document-to-delete))
  (delete document-model/document
          (where {:id_document id-document-to-delete})))

Dans le cas de l’update, j’utilise korma.core/update pour éviter un conflit de namespace avec clojure.core qui cause une erreur. Dans les autres cas, le :use ne pose aucun problème et je peux appeler les opérations korma normalement.

Service

Comme cette API est simple, la plupart des méthodes décrites dans le service font office de passe plat vers la DAO. La création génère un UUID et l’associe au futur document, tandis que l’update complète le document à mettre à jour.

src/clojure_rest/services/document_service.clj

(ns clojure-rest.services.document-service
  (:require [clojure.java.jdbc :as jdbc]
            [clojure.tools.logging :as log]
            [clojure-rest.model.document :as document]
            [clojure-rest.config.database :as database]
            [clojure-rest.dao.document-dao :as document-dao]))

(defn uuid [] (str (java.util.UUID/randomUUID)))

(defn get-all-documents
  "Gets all documents from the database"
  []
  (document-dao/get-all-documents))

(defn get-document-by-id
  "Gets only one document using its id"
  [id-document]
  (document-dao/get-document-by-id id-document))

(defn create-new-document
  "Adds a new document to the database, returns the id if ok, null if invalid"
  [document-to-create]
    (let [id (uuid)]
      (let [document (assoc document-to-create :id_document id)]
        (document-dao/create-new-document document)
        id)))

(defn update-document
  "Updates an existing document on the database"
  [id-document-to-update document-to-update]
    (let [document (assoc document-to-update :id_document id-document-to-update)]
      (document-dao/update-document id-document-to-update document-to-update)
      id-document-to-update))

(defn delete-document
  "Deletes an existing document on the database, returns id of deleted document"
  [id-document-to-delete]
  (document-dao/delete-document id-document-to-delete))

Bien entendu, ce découpage serait d’autant plus pertinent avec d’autres services gérant d’autres entités.

Rajouter nos ressources REST

Reste le coeur de notre API REST, la partie qui lie nos routes à nos services : le contrôleur. Celui-ci génère des réponses HTTP selon les paramètres en entrée et les retours des services associés. Encore une fois, j’ai utilisé des structures conditionnelles différentes à titre d’exemple. Dans tous les cas, la méthode response génère notre réponse, avec un statut 200 si tout se passe bien. Pour préciser un retour différent, on peut assembler la map soi-même comme suit : {:status codeChoisi}. D’autres méthodes existent.

src/clojure_rest/controllers/document_controller.clj

(ns clojure-rest.controllers.document-controller
  (:use [ring.util.response :only [response]])
  (:require [compojure.core :refer :all]
            [ring.util.http-response :refer :all]
            [compojure.route :as route]
            [clojure.java.jdbc :as jdbc]
            [clojure.tools.logging :as log]
            [ring.middleware.json :as ring-json]
            [clojure-rest.services.document-service :as document-service]
            [clojure-rest.model.document :as document]
            [clojure-rest.config.database :as database]))

(defn get-all-documents []
  (log/info "get-all-documents")
  (response
      (document-service/get-all-documents)))

(defn get-document [id]
  (log/info (str "get-document with id : " id))
  (let [results (document-service/get-document-by-id id)]
    (cond (empty? results) {:status 404}
      :else (ok (first results)))))

(defn create-new-document [doc]
  (log/info (str "create-new-document : " doc))
  (let [id-document-created (document-service/create-new-document doc)]
    (if (nil? id-document-created) {:status 400}
       (created (str "/documents/" id-document-created) (first (document-service/get-document-by-id id-document-created))))))

(defn update-document [id doc]
  (log/info (str "update-document with id : " id ". New values : " doc))
  (let [id-document-updated (document-service/update-document id doc)]
    (if (nil? id-document-updated) {:status 400}
      (get-document id-document-updated))))

(defn delete-document [id]
  (log/info (str "delete-document with id : " id))
  (let [deleted-document-id (document-service/delete-document id)]
    (log/info (str "id of document deleted : " deleted-document-id))
    (if (= deleted-document-id 0) {:status 404} {:status 204})))

Dans le cas du delete, le service nous renvoie le nombre d’éléments effectivement supprimés, nous faisons donc un test sur cette valeur avant de renvoyer le statut approprié, sans aucun corps.

Si vous avez choisi une option différente de h2 pour la base de données, l’API est maintenant exécutable avec la commande suivante :

lein ring server

Ring embarque son propre serveur. Par défaut, Swagger est accessible à l’adresse http://localhost:3000/api-docs.

On y est presque, et maintenant ?

Même si notre API se lance, il nous reste quelques outils à implémenter pour rendre notre expérience de développement plus simple et industrialisable. De plus la base de données h2 n’est pas initialisée correctement. Sauf si vous avez tablé pour une autre solution et l’avez initialisée manuellement, votre API ne fonctionne pas correctement. Et enfin, nous n’avons toujours pas écrit de tests !

Je vous donne rendez-vous dans la partie 3 pour couvrir notre API de tests et la rendre aisément maintenable. Nous verrons aussi comment peupler notre base de données h2 à la volée, et la migrer facilement.