Tutoriel pour apprendre à développer les Microcservices REST avec Spring Boot et Spring RestTemplate

Projet Spring Boot

Nguimgo Bertrand

Le but de cet article est de présenter comment développer les services REST à partir de Spring Boot et de Spring RestTemplate. Le client et le serveur seront développés dans deux applications distinctes afin de montrer une séparation claire qui existe entre les deux parties. Un accent particulier sera mis sur les tests unitaires et les tests d'intégration.

Note: Une mise à jour de ce tutoriel a été faite afin de prendre en compte les remarques des lecteurs pour une bonne implémentation de Rest API.

Un espace de dialogue est disponible pour apporter votre contribution ici: Commentez

- Titre : Tutoriel pour apprendre à développer les Microcservices REST avec Spring Boot et Spring RestTemplate

- Auteur : Nguimgo Bertrand

- Parution : 7 mars 2018

- Mise à jour : 28 mars 2020

- Public visé :

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2018 Nguimgo Bertrand. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Première partie : Le serveur

- I-A. Introduction

- I-A-1. Cahier des charges

- I-A-2. Technologies utilisées

- I-A-3. Pourquoi utiliser Spring Boot ?

- I-A-4. Présentation des services REST

- I-B. Création de l'application

- I-B-1. Création de l'architecture

- I-B-1-a. Configuration et exécution

- I-B-1-b. Structure de l'application

- I-B-1-c. Création du service

- I-B-1-d. Test de la configuration

- I-B-2. Création des couches

- I-B-2-a. Création du model

- I-B-2-b. Création de la base de données

- I-B-2-c. Création de la couche DAO

- I-B-2-d. Couche de services

- I-B-3. Mise en place du service REST

- I-B-3-a. Création du filtre Cross Domain

- I-B-3-b. Nouveautés dans les requêtes HTTP avec Spring

- I-B-3-c. Création d'un contrôleur par défaut

- I-B-3-d. Service d'extraction de tous les utilisateurs

- I-B-3-e. Service création d'un utilisateur

- I-B-3-f. Service mise à jour utilisateur

- I-B-3-g. Suppression d'un utilisateur

- I-B-3-h. Recherche d'un utilisateur par login/password

- I-B-3-i. API Rest au complet

- I-C. Gestion des exceptions

- I-C-1. Pourquoi utiliser @ControllerAdvice

- I-C-2. Test des services avec exceptions

- I-D. Tests unitaires des couches

- I-D-1. Tests unitaires couche DAO

- I-D-2. Tests unitaires couche service

- I-D-3. Tests unitaires du contrôleur

- I-D-4. Résumé sur les tests unitaires

- I-E. Tests d'intégration services REST

- I-F. Automatisation des tests

- I-F-1. Automatisation des tests unitaires

- I-F-2. Automatisation des tests d'intégration

- I-G. Déploiement automatisé

- I-H. Paramétrage complet

- II. Consommation des services

- II-A. Introduction

- II-B. Création du projet client

- II-C. Création du contrôleur

- II-C-1. Contrôleur par défaut

- II-C-2. Contrôleur LoginController

- II-C-3. Rôle de chaque méthode

- II-C-4. Configuration de l'application

- II-C-5. Création des pages web

- II-D. Test manuel de l'application

- II-D-1. Serveur démarré

- II-D-2. Serveur arrêté

- II-D-3. Création d'un utilisateur

- II-D-4. Test de connexion

- II-D-5. Page de connexion réussie

- III. Conclusion

- III-A. Sources et références

- III-B. Remerciements

I - Première partie : Le serveur

Il est question dans cette partie de développer un service REST, et ensuite de le consommer dans la seconde partie de ce tutoriel.

I-A - Introduction

La plupart des applications d'entreprises sont des applications client/serveur. Pour développer ces applications, les deux solutions les plus dominantes sur le marché sont les web services REST (Representational State Transfer) et web services SOAP (Simple Object Access Protocol). SOAP est un protocole très normé et plus ancien que REST. L'objectif de cet article n'est pas de faire une étude comparée des deux technologies, mais avant de passer des heures à choisir entre SOAP et REST, il faut prendre en compte le fait que certains services web supportent l'un et pas l'autre. Par exemple, SOAP fonctionne en environnement distribué (Point à multipoint) alors que REST nécessite une communication directe point à point, basée sur l'utilisation des URI. De plus en plus d'entreprises préfèrent les services REST pour leur simplicité de mise en œuvre, et c'est l'une des raisons pour lesquelles l'idée m'est venue de rédiger cet article et surtout de montrer comment mettre en œuvre une architecture service REST-API. Pour plus d'informations sur la technologie SOAP et son implémentation, je vous conseille d'excellents tutoriels de Mickael Baron.

À la fin de cet article, vous devrez être capable de développer des web services REST en utilisant Spring Boot, savoir automatiser les tests unitaires et les tests d'intégration, être capable de mieux gérer les exceptions dans une application utilisant l'architecture Rest-API et le tout en Java8.

Pour ceux qui ont déjà les connaissances avancées sur les services REST, vous pouvez passer directement à la pratique.

Toutes les sources (client et serveur) sont disponibles en téléchargement

I-A-1 - Cahier des charges

Il s'agit de développer un portail web d'inscription et de connexion en utilisant les services RESTFul côté serveur et Spring RestTemplate côté client.

Le serveur sera développé dans une application à part et le client dans une autre. Je vais ensuite mettre en relation les deux services grâce à l'URL du serveur et le Framework Spring RestTemplate. Ce choix d'architecture permet de bien mettre en évidence la séparation des services REST et leur consommation par les clients.

Dans cette partie, il n'y aura aucun fichier JSP (Java Server Page) à écrire. Ça se fera côté client. Mais, je peux déjà vous montrer à quoi va ressembler l'écran d'accueil de l'application à travers l'image ci-dessous :

Voici à quoi ressemblera la page de connexion.

Écran de création du compte et de connexion

I-A-2 - Technologies utilisées

Technologies à implémenter dans ce projet

- Spring Boot-2.2.6-RELEASE.

- Java-1.8.

- Frameworks : Maven, SpringMVC, Spring-RestTemplate, Mockito, JSON, Boomerang.

- Base de données embarquée: H2.

- Tomcat-9x (embarqué). Vous pouvez aussi utiliser Tomcat-8.

- IDE : Eclipse-03-2019.

- cargo-maven2-plugin pour le déploiement et l'automatisation des tests d'intégration.

- La plupart des technologies sont embarquées par Spring Boot.

I-A-3 - Pourquoi utiliser Spring Boot ?

Définition : Spring est un Framework de développement d'applications Java, qui apporte plusieurs fonctionnalités comme Spring Security, SpringMVC, Spring Batch, Spring Ioc, Spring Data, etc. Ces Frameworks ont pour objectif de faciliter la tâche aux développeurs. Malheureusement, leurs mises en œuvre deviennent très complexes à travers les fichiers de configuration XML qui ne cessent de grossir, et une gestion des dépendances fastidieuse. C'est pour répondre à cette inquiétude que le projet Spring Boot a vu le jour.

Définition : Spring Boot est un sous projet de Spring qui vise à rendre Spring plus facile d'utilisation en élimant plusieurs étapes de configuration. L'objectif de Spring Boot est de permettre aux développeurs de se concentrer sur des tâches techniques et non des tâches de configurations, de déploiements, etc. Ce qui a pour conséquences un gain de temps et de productivité (avec Spring Boot, il est très facile de démarrer un projet n-tiers).

Spring Boot apporte à Spring une très grande simplicité d'utilisation :

1. Il facilite notamment la création, la configuration et le déploiement d'une application complète. On n'a plus besoin des fichiers XML à configurer (pas besoin du fichier du descripteur de déploiement web.xml dans le cas d'une application web). Nous verrons plus bas comment cela est possible.

2. Spring Boot permet de déployer très facilement une application dans plusieurs environnements sans avoir à écrire des scripts. Pour ce faire, une simple indication de l'environnement (développement ou production) dans le fichier de propriétés (.properties) suffit à déployer l'application dans l'un ou l'autre environnement. Ceci est rendu possible grâce à la notion de profil à déclarer toujours dans le fichier de propriétés. Je vous présenterai des exemples de cas d'utilisation.

3. Spring Boot possède un serveur d'application Tomcat embarqué afin de faciliter le déploiement d'une application web. Il est possible d'utiliser un serveur autre ou externe, grâce à une simple déclaration dans le fichier pom.xml.

4. Spring Boot permet de mettre en place un suivi métrique de l'application une fois déployée sur le serveur afin de suivre en temps réel l'activité du serveur, ceci grâce à spring-boot-starter-actuator.

5. Un guide complet de l'utilisation de spring Boot est disponible ici.

I-A-4 - Présentation des services REST

Les services REST représentent un style d'architecture pour développer des services web. Une API qui respecte les principes REST est appelée API-RESTful.

Les principes clés de REST impliquent la séparation de l'API en ressources logiques. Ce qui revient à penser à comment obtenir chaque ressource.

Une ressource est un objet ou une représentation d'objets contenant éventuellement des données. Exemple : un employé d'une société est une ressource. La manipulation de ces ressources repose sur le protocole HTTP à travers les méthodes d'actions GET, POST, PUT, PATCH, DELETE.

Pour obtenir une ressource, il faut déjà l'identifier à travers une URL et bien la nommer.

Une URL (Uniform Ressource Locator) indique le chemin vers une ressource. Cette ressource n'est pas toujours disponible. Lorsque l'URL pointe vers une ressource disponible, on parle d'une URI.

Une URI (Uniform Ressource Identifier) est l'identifiant unique de ressource sur un réseau (URI = URL + Ressource). Une URI est donc une URL qui pointe vers une ressource bien identifiée.

Dans l'implémentation des services REST, une bonne identification de la ressource est l'utilisation des noms et non des verbes. Par exemple, pour notre portail de connexion, j'aurai besoin d'une ressource utilisateur nommée User et d'une URL qui pointe vers cette ressource (l'ensemble formera l'URI de la ressource User), ce qui donne par exemple :

http://localhost:8080/springboot-restserver/user

Une fois que la ressource est identifiée, on a besoin de la manipuler. Par exemple la mettre à jour, supprimer, ou en créer une autre.

Pour ce faire on utilise les méthodes HTTP suivantes :

- GET /users -- extraction de tous les utilisateurs.

- GET /users/1 -- extraction de l'utilisateur ayant l'identifiant 1.

- DELETE /users/1 -- suppression de l'utilisateur 1.

- POST /users -- création d'un nouvel utilisateur.

- PUT /users/1 -- mise à jour de l'utilisateur 1.

- PATCH /users/1 -- mise à jour partielle de l'utilisateur 1.

Il est conseillé d'utiliser les noms au pluriel (users et non user) pour un bon nommage, qui n'est certes pas obligatoire, mais apporte une certaine lisibilité entre le mapping de l'URL et la ressource.

I-B - Création de l'application

Dans cette partie, je vais créer l'architecture de base de l'application à partir Spring Boot.

I-B-1 - Création de l'architecture

Pour créer l'application, vous avez plusieurs possibilités:

- Créer à partir de l'IDE Eclipse un projet Maven simple et le compléter avec les dépendances dont vous avez besoin.

- Créer un projet à partir du générateur fourni par Spring Boot à l'adresse : Spring Boot Quick Start.

C'est cette deuxième option que nous allons utiliser. L'avantage, c'est qu'on peut choisir très aisément toutes les dépendances directement.

Pour voir toutes les dépendances proposées par Spring Boot, déroulez le menu en cliquant sur: "Switch to the full version". Vous pourrez aussi changer de version Spring Boot après génération, car Spring Boot ne propose pas la possibilité de choisir les versions antérieures lors de la génération.

Voici la capture web de génération basique d'un projet web avec Spring Boot :

Génération du squelette projet avec Spring Boot

Avant la génération du projet, prenez soin de sélectionner les trois dépendances principales comme le montre la capture ci-dessous:

- Spring Web : pour tout ce qui est projet web (Spring Boot va ramener toutes les dépendances nécessaires pour une application web).

- Spring Data JPA : pour la couche de persistance.

- H2 Database : pour la base de données.

J'ai directement choisi un packaging war puisque notre application sera une application web et va tourner sur un serveur d'application Tomcat.

Comme on peut le constater dans la capture, l'application sera développée en Java8.

Pour importer le projet dans Eclipse, décompressez-le et suivez la démarche ci-dessous:

Fichier --> Import --> Maven --> Existing Maven Projects --> Indiquez le répertoire du pom.xml --> Finish

I-B-1-a - Configuration et exécution

Présentation du contenu du fichier pom.xml

L'élément central dans un projet Maven est le fichier pom.xml. Ci-dessous le fichier pom.xml généré :

contenu du fichier pom.xml généré

1. <?xml version="1.0" encoding="UTF-8"?>

2. <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

3. xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

4. <modelVersion>4.0.0</modelVersion>

6. <groupId>com.bnguimgo</groupId>

7. <artifactId>springboot-restserver</artifactId>

8. <version>0.0.1-SNAPSHOT</version>

9. <packaging>war</packaging>

11. <name>springboot-restserver</name>

12. <description>Serveur Rest utilisant le framework Spring Boot</description>

14. <parent>

15. <groupId>org.springframework.boot</groupId>

16. <artifactId>spring-boot-starter-parent</artifactId>

17. <version>2.2.6.RELEASE</version>

18. <relativePath/> <!-- lookup parent from repository -->

19. </parent>

21. <properties>

22. <start-class>com.bnguimgo.restclient.com.bnguimgo.springbootrestserver.SpringbootRestserverApplication</start-class><!-- cette déclaration est optionnelle  -->

23. <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

24. <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

25. <java.version>1.8</java.version>

26. </properties>

28. <dependencies>

29. <dependency>

30. <groupId>org.springframework.boot</groupId>

31. <artifactId>spring-boot-starter-data-jpa</artifactId>

32. </dependency>

33. <dependency>

34. <groupId>org.springframework.boot</groupId>

35. <artifactId>spring-boot-starter-web</artifactId>

36. </dependency>

38. <dependency>

39. <groupId>com.h2database</groupId>

40. <artifactId>h2</artifactId>

41. <scope>runtime</scope>

42. </dependency>

43. <dependency>

44. <groupId>org.springframework.boot</groupId>

45. <artifactId>spring-boot-starter-tomcat</artifactId>

46. <scope>provided</scope>

47. </dependency>

48. <dependency>

49. <groupId>org.springframework.boot</groupId>

50. <artifactId>spring-boot-starter-test</artifactId>

51. <scope>test</scope>

52. </dependency>

53. </dependencies>

55. <build>

56. <plugins>

57. <plugin>

58. <groupId>org.springframework.boot</groupId>

59. <artifactId>spring-boot-maven-plugin</artifactId>

60. </plugin>

61. </plugins>

62. </build>

64. </project>

Quelques explications :

La dépendance spring-boot-starter-parent permet de rapatrier la plupart des dépendances du projet. Sans elle, le fichier pom.xml serait plus complexe.

La dépendance spring-boot-starter-web indique à Spring Boot qu'il s'agit d'une application web, ce qui permet à Spring Boot de rapatrier les dépendances comme SpringMVC, SpringContext, et même le serveur d'application Tomcat, etc.

Vous avez aussi constaté la présence de la dépendance spring-boot-starter-tomcat qui n'est pas obligatoire, mais comme il s'agit d'une application web, Spring Boot par anticipation intègre un serveur d'application Tomcat afin de faciliter le déploiement de l'application. Cette dépendance n'est pas obligatoire, vous pouvez la supprimer si jamais spring l'a embarqué. Dans notre cas, on va utiliser un serveur Tomcat externe à travers une configuration Eclipse.

Il faut noter aussi l'absence des versions dans les dépendances. Ceci est dû au fait que Spring Boot gère de manière très autonome les versions et nous n'avons plus besoin de les déclarer.

Enfin, j'ai ajouté la déclaration ci-dessous qui permet de spécifier le point d'entrée de l'application. Cette déclaration est optionnelle, car Spring Boot est capable de rechercher tout seul la classe contenant la méthode main(String[] args).

Spécification optionnelle du point d'entrée de l'application

<start-class>com.bnguimgo.restclient.com.bnguimgo.springbootrestserver.SpringbootRestserverApplication</start-class>

Pour utiliser un serveur d'application différent, ou un serveur d'application externe, il faut commenter la dépendance Tomcat et déclarer juste la version cible dans la partie de déclaration des propriétés comme ci-dessous :

1. <properties>

2. <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

3. <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

4. <java.version>1.8</java.version>

5. <tomcat.version>9.0.24</tomcat.version><!-remplace la version embarquée de Tomcat -->

6. </properties>

La dépendance spring-boot-starter-data-jpa est nécessaire pour le développement de la couche de persistance. Les autres dépendances seront ajoutées notamment pour les besoins de tests.

I-B-1-b - Structure de l'application

Ci-dessous la structure du projet généré :

Il n'est pas encore possible de tester l'application, car il manque un contrôleur pour envoyer ce qu'il faut afficher.

Structure de l'application créée par génération

Il faut relever particulièrement l'absence du fichier de descripteur du déploiement web.xml dont on a plus besoin dans une application Spring Boot. Voir ci-dessous les raisons.

Le point d'entrée de l'application est la classe SpringbootRestserverApplication:

Point d'entrée de l'application

1. package com.bnguimgo.springbootrestserver;

3. import org.springframework.boot.SpringApplication;

4. import org.springframework.boot.autoconfigure.SpringBootApplication;

6. @SpringBootApplication

7. public class SpringbootRestserverApplication {

9. public static void main(String[] args) {

10. SpringApplication.run(SpringbootRestserverApplication.class, args);

11. }

12. }

L'application est initialisée par la classe ServletInitializer:

Classe d'initialisation de l'application

1. package com.bnguimgo.springbootrestserver;

3. import org.springframework.boot.builder.SpringApplicationBuilder;

4. import org.springframework.boot.web.support.SpringBootServletInitializer;

6. public class ServletInitializer extends SpringBootServletInitializer {

8. @Override

9. protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {

10. return application.sources(SpringbootRestserverApplication.class);

11. }

12. }

Dans le package principal com.bnguimgo.springbootrestserver, nous avons les deux classes ServletInitializer et SpringbootRestserverApplication. Vous pouvez nommer ces classes comme vous voulez.

La classe ServletInitializer permet l'initialisation de l'application. Elle étend la classe SpringBootServletInitializer qui est à l'origine de cette initialisation et remplace ainsi l'ancien fichier web.xml.

La classe SpringbootRestserverApplication contient la méthode void main(String[] args) nécessaire dans une application Spring Boot, et permet l'exécution de celle-ci : c'est le point d'entrée de l'application.

La classe de démarrage de l'application SpringbootRestserverApplication doit être à la racine du package principal si on veut permettre à Spring de scanner les sous-packages en utilisant l'annotation @SpringBootApplication.

L'annotation @SpringBootApplication est centrale dans une application Spring Boot et permet de scanner le package courant et ses sous-packages. Elle existe depuis SpringBoot-1.2.0 et est équivalente à l'ensemble des annotations @Configuration, @EnableAutoConfiguration et @ComponentScan

Voici les avantages qu'apporte cette annotation :

- Elle remplace l'annotation @Configuration qui permet de configurer une classe comme une source de définition des beans Spring.

- On aurait aussi pu ajouter l'annotation @EnableWebMvc pour indiquer qu'il s'agit d'une application SpringMVC, mais grâce à @SpringBootApplication, cette annotation n'est pas nécessaire, car Spring Boot va automatiquement l'ajouter dès qu'il verra que dans le workspace, il existe une bibliothèque spring-webmvc.

- Elle remplace également l'annotation @ComponentScan qui autorise Spring à rechercher tous les composants, les configurations et autres services de l'application et à initialiser tous les contrôleurs.

Dans le dossier src/main/resources, il y a :

- Le dossier static qui permet de stocker tous les fichiers images, CSS, bref tous les fichiers qui ne fournissent pas un contenu dynamique (son utilisation n'est pas obligatoire).

- Le dossier templates qui permet de stocker des fichiers web si on utilise le Framework Thymeleaf de Spring (Thymeleaf ne sera pas utilisé dans le cadre de ce projet).

- Le fichier application.properties qui nous sera très utile pour configurer le projet. Pour le moment, il est vide, car je vais d'abord utiliser les configurations par défaut de Spring Boot.

I-B-1-c - Création du service

Je vais créer un premier service REST qui permet juste de démarrer et tester l'application. Le service RESt complet sera développé dans le chapitre I-B-3. Voici un petit service qui teste si le serveur a bien démarré (mettre cette classe dans le même package que SpringbootRestserverApplication) :

Un mini service REST (RestServices) pour tester le démarrage de l'application

1. package com.bnguimgo.springbootrestserver;

2. import org.slf4j.Logger;

3. import org.slf4j.LoggerFactory;

4. import org.springframework.http.HttpStatus;

5. import org.springframework.http.ResponseEntity;

6. import org.springframework.stereotype.Controller;

7. import org.springframework.web.bind.annotation.GetMapping;

9. @Controller

10. public class RestServices {

12. private static final Logger logger = LoggerFactory.getLogger(RestServices.class);

14. @GetMapping(value = "/")

15. public ResponseEntity<String> pong()

16. {

17. logger.info("Démarrage des services OK .....");

18. return new ResponseEntity<String>("Réponse du serveur: "+HttpStatus.OK.name(), HttpStatus.OK);

19. }

21. }

La classe est annotée @Controller afin de permettre à Spring d'enregistrer cette classe comme un contrôleur, et surtout de mémoriser les requêtes que cette classe est capable de gérer.

L'annotation @GetMapping(value = "/") est une nouvelle annotation introduite par Spring qui remplace l'annotation classique @RequestMapping et correspond exactement à @RequestMapping(method=RequestMethod.GET, value = "/").

J'ai intégré un logger sans faire aucune configuration et ça marche. Si vous regardez votre console, vous verrez bien les traces de logs lors du démarrage de l'application. C'est l'un des avantages de Spring Boot qui configure par défaut un logger pour l'application. Je vais vous montrer plus loin comment personnaliser le logger par défaut. Le service est prêt à être testé.

I-B-1-d - Test de la configuration

Le service sera testé avec un serveur d'application externe Tomcat. Il faut donc configurer ce serveur dans l'IDE Eclipse. Pour ajouter un nouveau serveur dans Eclipse :

Fichier --> Nouveau Others… --> Server --> Next --> Tomcat v9.0 Server --> Finish

Pour tester l'application dans Eclipse, après compilation : clic droit sur l'application --> Run As --> Run on Server --> Tomcat v8.0 Server at localhost --> Next --> Finish

Résultat dans Eclipse:

Test du service REST dans l'IDE Eclipse

Il est possible de tester directement sur un navigateur web ou un client REST. Voici le résultat dans le navigateur Mozilla:

Test de l'application sur Mozilla

Maintenant que notre environnement est en place, on peut développer un service REST complet.

Je rappelle que dans cette partie, seul le serveur est développé, mais le client sera développé aussi dans la deuxième partie du tutoriel. Le travail à faire consiste à développer un service qui permet à un internaute de créer son compte et de se connecter à l'application, le tout en services REST.

I-B-2 - Création des couches

Dans cette partie, je vais développer un service complet de gestion d'un utilisateur :

- Extraction de tous les utilisateurs.

- Extraction d'un utilisateur à partir de son login et mot de passe.

- Création d'un utilisateur.

- Mise à jour de l'utilisateur.

- Suppression d'un utilisateur.

I-B-2-a - Création du model

Pour mettre en place le service d'extraction, il nous faut un objet utilisateur (User). Nous aimerions aussi connaître les rôles de chaque utilisateur, par exemple s'il est administrateur (ROLE_ADMIN) ou simple utilisateur (ROLE_USER) de l'application. Il faudra donc aussi créer un objet Role pour stocker ces informations. Sachant qu'un utilisateur peut avoir les deux rôles, cette relation donnera lieu à une nouvelle association ManyToMany. Voyons toute de suite comment implémenter.

Il faut créer un nouveau sous-package nommé model dans le package racine com.bnguimgo.springbootrestserver et ajouter dans le model les deux entités ci-dessous :

Création de l'entité User

1. package com.bnguimgo.springbootrestserver.model;

3. import java.io.Serializable;

4. import java.util.HashSet;

5. import java.util.Set;

7. import javax.persistence.CascadeType;

8. import javax.persistence.Column;

9. import javax.persistence.Entity;

10. import javax.persistence.GeneratedValue;

11. import javax.persistence.GenerationType;

12. import javax.persistence.Id;

13. import javax.persistence.JoinColumn;

14. import javax.persistence.JoinTable;

15. import javax.persistence.ManyToMany;

16. import javax.persistence.Table;

18. @Entity

19. @Table(name = "UTILISATEUR")

20. public class User implements Serializable{

22. private static final long serialVersionUID = 1L;

24. @Id

25. @GeneratedValue(strategy = GenerationType.IDENTITY)

26. @Column(name = "USER_ID", updatable = false, nullable = false)

27. private Long id;

29. @Column(name = "LOGIN", unique=true, insertable=true, updatable=true, nullable=false)

30. private String login;

32. @Column(name = "USER_PASSWORD", insertable=true, updatable=true, nullable=false)

33. private String password;

35. @Column(name = "USER_ACTIVE", insertable=true, updatable = true, nullable=false)

36. private Integer active;

38. @ManyToMany(cascade = CascadeType.DETACH)

39. @JoinTable(name = "USER_ROLE", joinColumns = @JoinColumn(name = "USER_ID"), inverseJoinColumns = @JoinColumn(name = "ROLE_ID"))

40. private Set<Role> roles= new HashSet<>();

42. public User() {

43. super();

44. }

46. public User(String login, String password, Integer active) {

47. this.login = login;

48. this.password = password;

49. this.active = active;

50. }

52. public User(Long id, String login, String password, Integer active) {

53. this.id= id;

54. this.login=login;

55. this.password = password;

56. this.active=active;

57. }

59. public User(String login, String password) {

60. this.login=login;

61. this.password = password;

62. }

64. public Long getId() {

65. return id;

66. }

68. public void setId(Long id) {

69. this.id = id;

70. }

72. public String getLogin() {

73. return login;

74. }

75. public void setLogin(String login) {

76. this.login = login;

77. }

79. public String getPassword() {

80. return password;

81. }

83. public void setPassword(String password) {

84. this.password = password;

85. }

87. public Integer getActive() {

88. return active;

89. }

90. public void setActive(Integer active) {

91. this.active = active;

92. }

94. public Set<Role> getRoles() {

95. return roles;

96. }

98. public void setRoles(Set<Role> roles) {

99. this.roles = roles;

100. }

102. @Override

103. public String toString() {

104. return "User [id=" + id + ", login=" + login + ", password=XXXXXXX, active=" + active + ", roles="

105. + roles + "]";

106. }

108. @Override

109. public int hashCode() {

110. final int prime = 31;

111. int result = 1;

112. result = prime * result + ((active == null) ? 0 : active.hashCode());

113. result = prime * result + ((id == null) ? 0 : id.hashCode());

114. result = prime * result + ((login == null) ? 0 : login.hashCode());

115. result = prime * result + ((password == null) ? 0 : password.hashCode());

116. result = prime * result + ((roles == null) ? 0 : roles.hashCode());

117. return result;

118. }

120. @Override

121. public boolean equals(Object obj) {

122. if (this == obj)

123. return true;

124. if (obj == null)

125. return false;

126. if (getClass() != obj.getClass())

127. return false;

128. User other = (User) obj;

129. if (active == null) {

130. if (other.active != null)

131. return false;

132. } else if (!active.equals(other.active))

133. return false;

134. if (id == null) {

135. if (other.id != null)

136. return false;

137. } else if (!id.equals(other.id))

138. return false;

139. if (login == null) {

140. if (other.login != null)

141. return false;

142. } else if (!login.equals(other.login))

143. return false;

144. if (password == null) {

145. if (other.password != null)

146. return false;

147. } else if (!password.equals(other.password))

148. return false;

149. if (roles == null) {

150. if (other.roles != null)

151. return false;

152. } else if (!roles.equals(other.roles))

153. return false;

154. return true;

155. }

156. }

Création de l'entité ROLE

1. package com.bnguimgo.springbootrestserver.model;

2. import java.io.Serializable;

4. import javax.persistence.Column;

5. import javax.persistence.Entity;

6. import javax.persistence.GeneratedValue;

7. import javax.persistence.GenerationType;

8. import javax.persistence.Id;

9. import javax.persistence.Table;

11. @Entity

12. @Table(name = "ROLE")

13. public class Role implements Serializable{

15. private static final long serialVersionUID = 2284252532274015507L;

17. @Id

18. @GeneratedValue(strategy = GenerationType.IDENTITY)

19. @Column(name = "ROLE_ID", updatable = false, nullable = false)

20. private int id;

22. @Column(name="ROLE_NAME", updatable = true, nullable = false)

23. private String roleName;

25. public Role(){

26. super();

27. }

28. public Role(String roleName){

29. super();

30. this.roleName = roleName;

31. }

32. public int getId() {

33. return id;

34. }

35. public void setId(int id) {

36. this.id = id;

37. }

38. public String getRoleName() {

39. return roleName;

40. }

41. public void setRoleName(String roleName) {

42. this.roleName = roleName;

43. }

44. @Override

45. public String toString() {

46. return "Role [id=" + id + ", role=" + roleName + "]";

47. }

48. @Override

49. public int hashCode() {

50. final int prime = 31;

51. int result = 1;

52. result = prime * result + id;

53. result = prime * result + ((roleName == null) ? 0 : roleName.hashCode());

54. return result;

55. }

56. @Override

57. public boolean equals(Object obj) {

58. if (this == obj)

59. return true;

60. if (obj == null)

61. return false;

62. if (getClass() != obj.getClass())

63. return false;

64. Role other = (Role) obj;

65. if (id != other.id)

66. return false;

67. if (roleName == null) {

68. if (other.roleName != null)

69. return false;

70. } else if (!roleName.equals(other.roleName))

71. return false;

72. return true;

73. }

75. public int compareTo(Role role){

76. return this.roleName.compareTo(role.getRoleName());

78. }

79. }

Il faut explicitement créer le constructeur par défaut Role sinon, il y a un risque d'avoir des problème de sérialisation/désérialisation.

L'annotation @Entity permet d'indiquer à l'ORM Hibernate que cette classe sera une table de la base de données et l'annotation @Table(name = "UTILISATEUR") permet de donner le nom UTILISATEUR à la table. Grâce à ces annotations, on n'a plus besoin du fichier de configuration persistence.xml.

@GeneratedValue(strategy = GenerationType.IDENTITY) ==> c'est la base de données qui va générer la clé primaire afin d'éviter les doublons, car la base contient déjà les données à l'initialisation

Il faut éviter de nommer ou de créer une table avec pour nom USER, car dans la plupart des bases de données, il y a déjà une table utilisateur nommée USER.

Une personne peut à la fois avoir le rôle administrateur et le rôle utilisateur. Plusieurs utilisateurs peuvent avoir le même rôle. On peut par exemple avoir plusieurs administrateurs : c'est ce qui explique l'existence de la relation ManyToMany. Au niveau de la base de données, cela donne naissance à une table de jointure que je vais nommer : USER_ROLE, à ne pas confondre avec la table ROLE qui stocke tous les rôles. On a ainsi terminé le model.

I-B-2-b - Création de la base de données

Dans cette section, on va initialiser la base de données par des scripts et se connecter dessus sans faire aucune déclaration ni aucune configuration de la datasource. C'est un des avantages de Spring Boot.

Il faut remarquer que jusqu'à présent, il n'y a eu aucune configuration concernant Hibernate pour le mapping objet relationnel. Le simple fait d'avoir déclaré une base de données H2 dans le pom.xml fait que Spring Boot configure HibernateEntityManager pour nous. Grâce aux annotations appliquées sur chaque objet model, Hibernate va transformer chaque entité ou association en une table et créer dans la foulée la base de données.

Voici le script de l'initialisation de la base de données nommée data.sql. Ce script doit être déposé dans le répertoire de gestion des ressources src/main/resources. Spring Boot va automatiquement l'utiliser pour initialiser la base de données au démarrage de l'application, pas besoin de déclarer une datasource.

Script d'initialisation de la base de données data.sql

1. --INITIALISATION TABLE ROLE

2. INSERT INTO ROLE(ROLE_ID,ROLE_NAME) VALUES (1,'ROLE_ADMIN');

3. INSERT INTO ROLE(ROLE_ID,ROLE_NAME) VALUES (2,'ROLE_USER');

5. --INITIALISATION TABLE UTILISATEURS

6. INSERT INTO UTILISATEUR(USER_ID, LOGIN, USER_PASSWORD, USER_ACTIVE) values (1, 'admin', 'admin', 1);

7. INSERT INTO UTILISATEUR(USER_ID, LOGIN, USER_PASSWORD, USER_ACTIVE) values (2, 'login2', 'user', 1);

8. INSERT INTO UTILISATEUR(USER_ID, LOGIN, USER_PASSWORD, USER_ACTIVE) values (3, 'login3', 'user1', 0);-- 0 signifie user inactif

10. -- TABLE DE JOINTURE

11. INSERT INTO USER_ROLE(USER_ID,ROLE_ID) VALUES (1,1);

12. INSERT INTO USER_ROLE(USER_ID,ROLE_ID) VALUES (1,2);

13. INSERT INTO USER_ROLE(USER_ID,ROLE_ID) VALUES (2,2);

14. INSERT INTO USER_ROLE(USER_ID,ROLE_ID) VALUES (3,2);

16. COMMIT;

Notez bien la table d'association (USER_ROLE) qui fait le lien ManyToMany décrit plus haut.

Nous allons hacher le mot de passe (USER_PASSWORD) en utilisant la classe Spring BCryptPasswordEncoder (voir son utilisation dans la partie service lors de la création d'un utilisateur) et le script d'initialisation de la base de données donnera le résultat ci-dessous:

Voici la classe utilitaire qui permet de créer des mots de passes hachés, juste pour initialiser la base de données

En réalité, nous n'auraons pas besoin de cette classe, car le hachage sera fait au moment de la création ou de la mise à jour de chaque utilisateur par le service.

Classe utilitaire pour hacher le mot de passe

package com.bnguimgo.springbootrestserver.util;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

/**
* Classe permettant de créer des mots de passe hachés pour des besoins de tests
*et d'initialisation de la base de données
*
*/
public class PasswordEncoder {

private static BCryptPasswordEncoder bCryptPasswordEncoder;

public static void main(String[] args) {
bCryptPasswordEncoder = new BCryptPasswordEncoder();
String password ="password2";
String encodedPassword = bCryptPasswordEncoder.encode(password);
System.out.println("Mot de passe en clair : "+password);
System.out.println("Mot de passe haché : "+encodedPassword);
//Pour vérifier que le mot de passe haché correspond bien au mot de passe initial, il utiliser la méthode bCryptPasswordEncoder.matches(x, y)
System.out.println("Le mot de passe est bien haché : "+bCryptPasswordEncoder.matches(password, encodedPassword));
}

}

Script d'initialisation de la base de données data.sql

1. --INITIALISATION TABLE ROLE

3. INSERT INTO ROLE(ROLE_ID,ROLE_NAME) VALUES (1,'ROLE_ADMIN');

4. INSERT INTO ROLE(ROLE_ID,ROLE_NAME) VALUES (2,'ROLE_USER');

6. --INITIALISATION TABLE UTILISATEURS

7. --admin@admin.com/admin

8. --login2/password2

9. --login3/password3

10. INSERT INTO UTILISATEUR(USER_ID, LOGIN, USER_PASSWORD, USER_ACTIVE) values (1, 'admin@admin.com', '$2a$10$ISnv6T5sqpr5YeRKP01xEOLAr/ZWviCp73BC07hMK54GNgQHMemxm', 1);

11. INSERT INTO UTILISATEUR(USER_ID, LOGIN, USER_PASSWORD, USER_ACTIVE) values (2, 'login2', '$2a$10$5TLZBQgB/FOSkccGjKCRDerrD6YzsFznyNURwNHZG8tEwAumfFw1C', 1);

12. INSERT INTO UTILISATEUR(USER_ID, LOGIN, USER_PASSWORD, USER_ACTIVE) values (3, 'login3', '$2a$10$U2NARRA6lp0CSfDF2JEmtOaAbf3bVGx9zGqDbLkW/T59.QdGnlguO', 0);--inactif user

14. -- TABLE DE JOINTURE

15. INSERT INTO USER_ROLE(USER_ID,ROLE_ID) VALUES (1,1);

16. INSERT INTO USER_ROLE(USER_ID,ROLE_ID) VALUES (1,2);

17. INSERT INTO USER_ROLE(USER_ID,ROLE_ID) VALUES (2,2);

18. INSERT INTO USER_ROLE(USER_ID,ROLE_ID) VALUES (3,2);

20. COMMIT;

Pour le test de connexion il faudra utiliser le login et mot de passe =admin@admin.com/admin ou login2/password2.

I-B-2-c - Création de la couche DAO

Le développement de la couche DAO et même de la couche de services ne concerne pas spécifiquement les services REST. La partie spécifique aux services REST est mise en place au niveau des contrôleurs que nous allons voir plus loin.

La dépendance spring-boot-starter-data-jpa que nous avions ajoutée dans le pom.xml permet d'utiliser SpringData qui est une implémentation de JPA (Java Persistence Api). SpringData implémente toutes méthodes du CRUD (Create, Read, Update, Delete) quand on hérite de JpaRepository ou de CrudRepository.

Interface UserRepository (à créer dans un nouveau package nommé dao) :

1. package com.bnguimgo.springboot.rest.server.dao;

3. import java.util.Optional;

5. import org.springframework.data.jpa.repository.JpaRepository;

7. import com.bnguimgo.springbootrestserver.model.User;

9. public interface UserRepository extends JpaRepository<User, Long> {

11. Optional<User> findByLogin(String loginParam);

12. }

On peut ajouter dans l'interface des méthodes qui ne font pas partie du CRUD. C'est le cas de la méthode findByLogin(String login)

Remarque : grâce à SpringData, pas besoin d'implémenter les méthodes createUser(User user), deleteUser(Long id), ni même la méthode findByLogin, etc. À l'exécution, SpringData va créer automatiquement une implémentation de cette interface, et par conséquent, pas besoin d'une classe d'implémentation nommée UserRepositoryImpl. Il faut par ailleurs respecter le nommage des propriétés, exemple: (login -->findByLogin, name -->findByName).

Voici l'interface Role

1. package com.bnguimgo.springbootrestserver.dao;

2. import java.util.stream.Stream;

4. import org.springframework.data.jpa.repository.JpaRepository;

5. import org.springframework.data.jpa.repository.Query;

7. import com.bnguimgo.springbootrestserver.model.Role;

9. public interface RoleRepository extends JpaRepository<Role, Long> {

11. Role findByRoleName(String roleName);

13. @Query("select role from Role role")

14. Stream<Role> getAllRolesStream();// Java8 Stream : on place la liste des rôles dans un Stream

15. }

I-B-2-d - Couche de services

Interface UserService créée dans un nouveau package nommé service

1. package com.bnguimgo.springbootrestserver.service;

2. import java.util.Collection;

3. import java.util.Optional;

5. import com.bnguimgo.springbootrestserver.exception.BusinessResourceException;

6. import com.bnguimgo.springbootrestserver.model.User;

8. public interface UserService {

10. Collection<User> getAllUsers();

12. Optional<User> getUserById(Long id) throws BusinessResourceException;

14. Optional<User> findByLogin(String login) throws BusinessResourceException;

16. User saveOrUpdateUser(User user) throws BusinessResourceException;

18. void deleteUser(Long id) throws BusinessResourceException;

20. Optional<User> findByLoginAndPassword(String login, String password) throws BusinessResourceException;

22. }

Ajoutez l'interface RoleService dans le même package

1. package com.bnguimgo.springbootrestserver.service;

2. import java.util.Collection;

3. import java.util.stream.Stream;

5. import com.bnguimgo.springbootrestserver.model.Role;

7. public interface RoleService {

9. Role findByRoleName(String roleName);

11. Collection<Role> getAllRoles();

13. Stream<Role> getAllRolesStream();

14. }

Les deux méthodes getAllRoles() et getAllRolesStream() font la même chose. Je veux juste montrer à travers ces deux méthodes comment Java8 permet d'implémenter différemment les collections.

Implémentation des services :

Le mot de passe utilisateur sera haché afin d'éviter de le stocker en clair dans la base de données. Pour le faire, je vais utiliser la dépendance spring-boot-starter-security que je vous invite à ajouter dans pom.xml. C'est cette dépendance qui va mettre à disposition la classe BCryptPasswordEncoder pour hacher le mot de passe à travers l'algorithme SHA-1

ajout de la dépendance spring-boot-starter-security

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

Pour utiliser les Collections Apache, il faut ajouter la dépendance ci-dessous :

ajout de la dépendance apache commons-collections4

<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
<version>4.0</version>
</dependency>

Je vais créer aussi une classe de configuration des beans nommée BeanConfiguration qui va permettre de déclarer des beans. Par exemple le bean BCryptPasswordEncoder, ce qui permettra à spring de créer une instance de cet objet pour nous. Je vais ensuite utiliser cet objet pour hacher les mots de passe lors de la persistance d'un objet.

Création de la classe BeanConfiguration dans un nouveau package component

1. package com.bnguimgo.springbootrestserver.component;

2. import org.springframework.context.annotation.Bean;

3. import org.springframework.context.annotation.Configuration;

4. import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

5. import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

7. @Configuration

8. public class BeanConfiguration extends WebMvcConfigurerAdapter {

10. @Bean

11. public BCryptPasswordEncoder passwordEncoder() {

12. BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();

13. return bCryptPasswordEncoder;

14. }

16. }

L'annotation @Configuration indique à Spring que cette classe est une source de configuration des beans. Dans cette classe, j'ai déclaré un seul bean avec l'annotation @Bean. On pourra ainsi créer une instance de BCryptPasswordEncoder en appliquant l'annotation @Autowired de Spring partout où on en a besoin.

lmplémentation de l'interface UserService avec hachage du mot de passe utilisateur

1. package com.bnguimgo.springbootrestserver.service;

3. import java.util.Collection;

4. import java.util.HashSet;

5. import java.util.Optional;

6. import java.util.Set;

7. import java.util.stream.Collectors;

8. import java.util.stream.Stream;

10. import org.apache.commons.collections4.IteratorUtils;

11. import org.slf4j.Logger;

12. import org.slf4j.LoggerFactory;

13. import org.springframework.beans.factory.annotation.Autowired;

14. import org.springframework.dao.DataIntegrityViolationException;

15. import org.springframework.dao.EmptyResultDataAccessException;

16. import org.springframework.http.HttpStatus;

17. import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

18. import org.springframework.stereotype.Service;

19. import org.springframework.transaction.annotation.Transactional;

21. import com.bnguimgo.springbootrestserver.dao.RoleRepository;

22. import com.bnguimgo.springbootrestserver.dao.UserRepository;

23. import com.bnguimgo.springbootrestserver.exception.BusinessResourceException;

24. import com.bnguimgo.springbootrestserver.model.Role;

25. import com.bnguimgo.springbootrestserver.model.User;

27. @Service

28. public class UserServiceImpl implements UserService {

30. private static final Logger logger = LoggerFactory.getLogger(UserServiceImpl.class);

31. private UserRepository userRepository;

32. private RoleRepository roleRepository;

33. private BCryptPasswordEncoder bCryptPasswordEncoder;

35. public UserServiceImpl() {

36. super();

37. }

39. @Autowired

40. public UserServiceImpl(UserRepository userRepository, RoleRepository roleRepository, BCryptPasswordEncoder bCryptPasswordEncoder) {

41. super();

42. this.userRepository = userRepository;

43. this.roleRepository = roleRepository;

44. this.bCryptPasswordEncoder = bCryptPasswordEncoder;

45. }

47. @Override

48. public Optional<User> findByLogin(String login) throws BusinessResourceException {

50. Optional<User> userFound = userRepository.findByLogin(login);

51. if (Boolean.FALSE.equals(userFound.isPresent())) {

52. throw new BusinessResourceException("User Not Found", "L'utilisateur avec ce login n'existe pas :" + login);

53. }

54. return userFound;

55. }

57. @Override

58. public Collection<User> getAllUsers() {

59. return IteratorUtils.toList(userRepository.findAll().iterator());

60. }

62. @Override

63. public Optional<User> findUserById(Long id) throws  BusinessResourceException{

65. Optional<User> userFound = userRepository.findById(id);

66. if (Boolean.FALSE.equals(userFound.isPresent())){

67. throw new BusinessResourceException("User Not Found", "Aucun utilisateur avec l'identifiant :" + id);

68. }

69. return userFound;

70. }

72. @Override

73. @Transactional(readOnly=false)

74. public User saveOrUpdateUser(User user) throws BusinessResourceException{

75. try{

76. if(null ==user.getId()) {//pas d'Id --> création d'un user

77. addUserRole(user);//Ajout d'un rôle par défaut

78. user.setPassword(bCryptPasswordEncoder.encode(user.getPassword()));

79. } else {//sinon, mise à jour d'un user

81. Optional<User> userFromDB = findUserById(user.getId());

82. if(! bCryptPasswordEncoder.matches(user.getPassword(), userFromDB.get().getPassword())) {

83. user.setPassword(bCryptPasswordEncoder.encode(user.getPassword()));//MAJ du mot de passe s'il a été modifié

84. } else {

86. user.setPassword(userFromDB.get().getPassword());//Sinon, on remet le password déjà haché

87. }

88. updateUserRole(user);//On extrait le rôle en cas de mise à jour

89. }

90. User result = userRepository.save(user);

91. return  result;

92. } catch(DataIntegrityViolationException ex){

93. logger.error("Utilisateur non existant", ex);

94. throw new BusinessResourceException("DuplicateValueError", "Un utilisateur existe déjà avec le compte : "+user.getLogin(), HttpStatus.CONFLICT);

95. } catch (BusinessResourceException e) {

96. logger.error("Utilisateur non existant", e);

97. throw new BusinessResourceException("UserNotFound", "Aucun utilisateur avec l'identifiant: "+user.getId(), HttpStatus.NOT_FOUND);

98. } catch(Exception ex){

99. logger.error("Erreur technique de création ou de mise à jour de l'utilisateur", ex);

100. throw new BusinessResourceException("SaveOrUpdateUserError", "Erreur technique de création ou de mise à jour de l'utilisateur: "+user.getLogin(), HttpStatus.INTERNAL_SERVER_ERROR);

101. }

102. }

104. @Override

105. @Transactional(readOnly=false)

106. public void deleteUser(Long id) throws BusinessResourceException {

107. try{

108. userRepository.deleteById(id);

109. }catch(EmptyResultDataAccessException ex){

110. logger.error(String.format("Aucun utilisateur n'existe avec l'identifiant: "+id, ex));

111. throw new BusinessResourceException("DeleteUserError", "Erreur de suppression de l'utilisateur avec l'identifiant: "+id, HttpStatus.NOT_FOUND);

112. }catch(Exception ex){

113. throw new BusinessResourceException("DeleteUserError", "Erreur de suppression de l'utilisateur avec l'identifiant: "+id, HttpStatus.INTERNAL_SERVER_ERROR);

114. }

115. }

117. @Override

118. public Optional<User> findByLoginAndPassword(String login, String password) throws BusinessResourceException{

119. try {

120. Optional<User> userFound = this.findByLogin(login);

121. if(bCryptPasswordEncoder.matches(password, userFound.get().getPassword())) {

122. return userFound;

123. } else {

124. throw new BusinessResourceException("UserNotFound", "Mot de passe incorrect", HttpStatus.NOT_FOUND);

125. }

126. } catch (BusinessResourceException ex) {

127. logger.error("Login ou mot de passe incorrect", ex);

128. throw new BusinessResourceException("UserNotFound", "Login ou mot de passe incorrect", HttpStatus.NOT_FOUND);

129. }catch (Exception ex) {

130. logger.error("Une erreur technique est survenue", ex);

131. throw new BusinessResourceException("TechnicalError", "Une erreur technique est survenue", HttpStatus.INTERNAL_SERVER_ERROR);

132. }

133. }

135. private void addUserRole(User user) {

136. Set<Role> roles= new HashSet<>();

137. Role roleUser = new Role("ROLE_USER");//initialisation du rôle ROLE_USER

138. roles.add(roleUser);

139. user.setActive(0);

141. Set<Role> roleFromDB = extractRole_Java8(roles, roleRepository.getAllRolesStream());

142. user.setRoles(roleFromDB);

143. }

145. private void updateUserRole(User user) {

147. Set<Role> roleFromDB = extractRole_Java8(user.getRoles(), roleRepository.getAllRolesStream());

148. user.setRoles(roleFromDB);

149. }

151. private Set<Role> extractRole_Java8(Set<Role> rolesSetFromUser, Stream<Role> roleStreamFromDB) {

152. // Collect UI role names

153. Set<String> uiRoleNames = rolesSetFromUser.stream()

154. .map(Role::getRoleName)

155. .collect(Collectors.toCollection(HashSet::new));

156. // Filter DB roles

157. return roleStreamFromDB

158. .filter(role -> uiRoleNames.contains(role.getRoleName()))

159. .collect(Collectors.toSet());

160. }

162. @SuppressWarnings("unused")

163. private Set<Role> extractRoleUsingCompareTo_Java8(Set<Role> rolesSetFromUser, Stream<Role> roleStreamFromDB) {

164. return roleStreamFromDB

165. .filter(roleFromDB -> rolesSetFromUser.stream()

166. .anyMatch( roleFromUser -> roleFromUser.compareTo(roleFromDB) == 0))

167. .collect(Collectors.toCollection(HashSet::new));

168. }

170. @SuppressWarnings("unused")

171. private Set<Role>  extractRole_BeforeJava8(Set<Role> rolesSetFromUser, Collection<Role> rolesFromDB) {

172. Set<Role> rolesToAdd = new HashSet<>();

173. for(Role roleFromUser:rolesSetFromUser){

174. for(Role roleFromDB:rolesFromDB){

175. if(roleFromDB.compareTo(roleFromUser)==0){

176. rolesToAdd.add(roleFromDB);

177. break;

178. }

179. }

180. }

181. return rolesToAdd;

182. }

183. }

Il faut noter l'injection des dépendances par l'annotation @Autowired des objets (userRepository, roleRepository, bCryptPasswordEncoder) créées par Spring. Les dépendances userRepository et roleRepository permettent d'avoir accès aux services de la couche DAO (services fournis par l'implémentation Spring de JpaRepository). L'objet bCryptPasswordEncoder permet de hacher les mots de passe pour ne pas les stocker en clair dans la base de données.

Les classes de gestion d'exceptions (BusinessResourceException) et autres seront présentées dans un chapitre dédié à la gestion des exceptions.

Notez la présence de l'@Autowired plutôt sur le constructeur, ce qui va faciliter le développement des tests unitaires sans avoir besoin d'injecter les dépendances ou d'utiliser SpringRunner.class

La méthode saveOrUpdateUser assure à la fois la création et la mise à jour de l'utilisateur. En cas de création, on hache directement le mot de passe avec bCryptPasswordEncoder.encode(xxx). Sinon, on vérifie si le mot de passe a été modifié par bCryptPasswordEncoder.matches(xxx), si oui, on hache aussi le nouveau mot de passe, sinon, on garde le mot de passe haché récupéré de la base.

J'ai ajouté quelques méthodes utilitaires en Java8 et avant Java8 juste pour mieux montrer la différence dans l'utilisation des Collections entre les versions de Java. Ces trois méthodes font strictement la même chose : récupérer les rôles utilisateurs de la base de données. Car en principe, je ne crée pas de rôles directement, mais je fais une requête dans la base de données pour récupérer le rôle à partir de roleName fourni par le User.

La méthode extractRole_Java8() est écrite exclusivement en Java8 et utilise la classe Stream et les filtres pour extraire les rôles utilisateurs. La méthode extractRoleUsingCompareTo_Java8() est pareille que la méthode ci-dessus, mais utilise la méthode de comparaison compareTo() de la classe Role. La méthode extractRole_BeforeJava8() est la méthode classique de parcours d'une Collection.

Chaque utilisateur est crée avec un rôle par défaut, d'où la méthode utilitaire private void addUserRole(User user) { xxx}.

Implémentation de l'interface RoleService pour gérer les rôles utilisateurs

1. package com.bnguimgo.springbootrestserver.service;

2. import java.util.Collection;

3. import java.util.stream.Stream;

5. import org.apache.commons.collections4.IteratorUtils;

6. import org.springframework.beans.factory.annotation.Autowired;

7. import org.springframework.stereotype.Service;

9. import com.bnguimgo.springbootrestserver.dao.RoleRepository;

10. import com.bnguimgo.springbootrestserver.model.Role;

13. @Service

14. public class RoleServiceImpl implements RoleService {

17. private RoleRepository roleRepository;

19. public RoleServiceImpl() {

20. super();

21. }

22. @Autowired //autowired par constructeur pour bénéficier des tests unitaires

23. public RoleServiceImpl(RoleRepository roleRepository) {

24. super();

25. this.roleRepository = roleRepository;

26. }

28. @Override

29. public Collection<Role> getAllRoles() {

30. return IteratorUtils.toList(roleRepository.findAll().iterator());

31. }

33. @Override

34. public Stream<Role> getAllRolesStream() {

35. return roleRepository.getAllRolesStream();

36. }

38. @Override

39. public Role findByRoleName(String roleName) {

40. return roleRepository.findByRoleName(roleName);

41. }

42. }

Notez bien l'implémentation différente des deux méthodes qui renvoient les collections avec getAllRoles() et le Stream avec getAllRolesStream().

I-B-3 - Mise en place du service REST

Il s'agit de développer réellement notre service REST, cette partie représente la couche de contrôle. C'est cette couche qui intercepte et filtre toutes les requêtes utilisateurs. Chaque contrôleur dispose d'un service pour traiter les requêtes: c'est le service REST.

I-B-3-a - Création du filtre Cross Domain

Avant de développer notre service REST, je vous propose de créer la classe utilitaire CrossDomainFilter qui a pour objectif de corriger les problèmes liés au Cross-Domain. En fait, comme le client et le serveur peuvent être hébergés sur deux serveurs distants, il faut penser aux problèmes réseaux qui peuvent entraver la communication. Il faut indiquer au serveur quels sont les types d'en-têtes HTTP à prendre en considération. Plus d'explications sur les filtres Cross-Domain ici:

Classe utilitaire nommée CrossDomainFilter à ajouter dans le package component

1. package com.bnguimgo.springbootrestserver.component;

2. import org.springframework.stereotype.Component;

3. import org.springframework.web.filter.OncePerRequestFilter;

5. import javax.servlet.FilterChain;

6. import javax.servlet.ServletException;

7. import javax.servlet.http.HttpServletRequest;

8. import javax.servlet.http.HttpServletResponse;

9. import java.io.IOException;

11. @Component

12. public class CrossDomainFilter extends OncePerRequestFilter {

13. @Override

14. protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain)

15. throws ServletException, IOException {

16. httpServletResponse.addHeader("Access-Control-Allow-Origin", "*"); //toutes les URI sont autorisées

17. httpServletResponse.addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");

18. httpServletResponse.addHeader("Access-Control-Allow-Headers", "origin, content-type, accept, x-req");

19. filterChain.doFilter(httpServletRequest, httpServletResponse);

20. }

21. }

Grâce à l'annotation @Component, Spring va considérer cette classe comme un composant et pourra utiliser le filtre sans problème.

Une dernière configuration concerne les fichiers application.properties. J'ai mis à disposition trois fichiers de configuration :

- un fichier application-dev.properties à utiliser en phase de développement.

- un fichier application-prod.properties à utiliser en phase de production.

- un fichier application.properties pour initier toutes les configurations de base.

En ajoutant par exemple spring.profiles.active=dev dans application.properties, Spring Boot sait que c'est le profil dev qui est activé et ce sont les fichiers de configuration application-dev.properties et application.properties qui seront chargés. Je vous invite à regarder la configuration de chaque fichier.

Pour désactiver la demande récurrente du mot de passe généré par Spring security en phase de développement, il faut ajouter cette déclaration security.basic.enabled=false dans le fichier application.properties.

Voici le contenu de chacun de ces trois fichiers:

Le fichier application.properties est le fichier de configuration principale

application.properties

1. #spring.profiles.active=dev

2. spring.profiles.active=prod

3. security.basic.enabled=false

5. spring.h2.console.enabled=true

6. spring.h2.console.path=/console

On constate bien que c'est le profil de production (prod) qui est activé, ce qui veut dire que c'est le fichier de propriétés application-prod.properties qui sera chargé. C'est ce profil que nous allons utiliser dans la suite.

application-prod.properties

1. #Charge les proprietes de la production

2. nextpage.message=Salut Vous etes en production

3. error.no.user.id = Aucun utilisateur avec l'identifiant:

4. error.no.resource = Not found

5. technical.error = Erreur technique !!!

7. -Dmaven.test.skip=true

9. ################# BASE DE DONNEES ############################

10. logging.level.org.hibernate.SQL=error

11. # Supprime et re-crée les tables et sequences existantes , charge le script d'initialisation de la base de données data.sql

12. spring.jpa.hibernate.ddl-auto=create-drop

14. ################# GESTION DES LOGS ############################

15. logging.level.org.springframework.web=DEBUG

16. logging.level.com.bnguimgo.springboot.rest.server=DEBUG

17. #

18. # Pattern impression des logs console

19. logging.pattern.console= %d{yyyy-MM-dd HH:mm:ss} - %msg%n

21. # Pattern impression des logs dans un fichier

22. logging.pattern.file= %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n

23. # Redirection des logs vers un fichier du repertoire Temp, exemple sur windows: C:\Users\UnserName\AppData\Local\Temp\

24. logging.file=${java.io.tmpdir}/logs/restServer/applicationRestServer.log

Vous pouvez modifier le paramétrage de ce fichier à votre souhait pour, par exemple, mieux gérer les logs. On remarque qu'avec le profil de production, les tests sont désactivés par le paramétrage : -Dmaven.test.skip=true

application-dev.properties

1. #Chargement des propriétés ci-dessous au démarrage de l'application

2. nextpage.message=Salut vous etes en profile dev sur Rest Server

4. ################# Configuration des Logs #####################

5. logging.level.root= WARN

6. logging.level.org.springframework.security= DEBUG

7. logging.level.org.springframework.web= ERROR

8. logging.level.org.apache.commons.dbcp2= DEBUG

10. # Pattern impression des logs console

11. logging.pattern.console= %d{yyyy-MM-dd HH:mm:ss} - %msg%n

13. # Pattern impression des logs dans un fichier

14. logging.pattern.file= %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n

15. # Redirection des logs vers un fichier du repertoire Temp, exemple sur windows: C:\Users\UnserName\AppData\Local\Temp\

16. logging.file=${java.io.tmpdir}/logs/rest/applicationRestServer.log

18. ################# BASE DE DONNEES ############################

19. logging.level.org.hibernate.SQL=debug

20. # Supprime et recrée les tables et séquences existantes , exécute le script data.sql qui initialise la base de données

21. spring.jpa.hibernate.ddl-auto=create-drop

Il faut noter que jusqu'à présent, nous n'avons développé aucune page JSP, et par conséquent il n'y a rien à mettre dans le dossier webapp, ce qui est normal, car dans cette partie, nous voulons juste exposer des services. La création d'un service REST se passe globalement dans les contrôleurs comme nous allons le voir à travers les implémentations ci-dessous.

I-B-3-b - Nouveautés dans les requêtes HTTP avec Spring

Spring-4.3 a introduit de nouvelles annotations qui facilitent les requêtes HTTP dans des projets SpringMVC. Spring prend actuellement en charge cinq nouveaux types d'annotations intégrées pour la gestion de différents types de requêtes HTTP : GET, POST, PUT, DELETE et PATCH (mise à jour partielle).

Ces annotations sont:

1. @GetMapping = @RequestMapping + Http GET method.

2. @PostMapping = @RequestMapping + Http POST method.

3. @PutMapping = @RequestMapping + Http PUT method.

4. @DeleteMapping = @RequestMapping + Http DELETE method.

Exemple de méthode HTTP GET avec la nouvelle annotation

1. @GetMapping("/user/{userId}")

2. public User findUserById (@PathVariable Long userId){

3. return userService.findUserById(userId);

4. }

Avant la nouvelle annotation : en règle générale, si nous annotons une méthode en utilisant l'annotation traditionnelle @RequestMapping, on ferait quelque chose comme ceci :

Exemple de méthode HTTP GET avec l'ancienne annotation

1. @RequestMapping(method=RequestMethod.GET, value = "/user/{userId}", produces = MediaType.APPLICATION_JSON_VALUE)

2. public User findUserById(@PathVariable Long userId){

3. return userService.findUserById(userId);

4. }

Nous avons spécifié le type de média JSON que le client devrait être capable de traiter. Les avantages des nouvelles annotations résident dans le fait qu'on peut indifféremment produire du XML ou du JSON sans se préoccuper du type de média. Pour avoir cette flexibilité, il est donc conseillé de ne pas spécifier le type de média, et d'utiliser les nouvelles annotations qu'on vient de décrire.

I-B-3-c - Création d'un contrôleur par défaut

Nous avions déjà créé dans la section I-B-1-c un petit service qui permettait de tester la configuration de notre environnement. Je vous propose de renommer cette classe (RestServices) en DefaultController et de la déplacer dans un nouveau package appelé controller. Je vous remets le résultat ici :

contrôleur par défaut DefaultController

1. package com.bnguimgo.springbootrestserver.controller;

2. import org.slf4j.Logger;

3. import org.slf4j.LoggerFactory;

4. import org.springframework.http.HttpStatus;

5. import org.springframework.http.ResponseEntity;

6. import org.springframework.stereotype.Controller;

7. import org.springframework.web.bind.annotation.GetMapping;

8. @Controller

9. public class DefaultController {

10. private static final Logger logger = LoggerFactory.getLogger(DefaultController.class);

11. @GetMapping(value = "/")

12. public ResponseEntity<String> pong() {

13. logger.info("Démarrage des services OK .....");

14. return new ResponseEntity<String>("Réponse du serveur: "+HttpStatus.OK.name(), HttpStatus.OK);

15. }

16. }

La classe DefaultController est juste un contrôleur utilitaire qui va nous renseigner automatiquement dès le démarrage du client si le serveur REST est à l'écoute grâce à la réponse renvoyée par HttpStatus. Si le serveur est actif, le message de réponse sera: Réponse du serveur: OK, avec le code de réponse HTTP = 200. Sinon, une page d'erreur sera affichée.

L'utilisation de la classe ResponseEntity de Spring permet de prendre en compte la gestion des statuts HTTP de réponses. On peut toutefois utiliser la classe ResponseBody pour traiter les réponses si on n'a pas besoin d'exploiter les codes de réponses HTTP. Dans notre cas, je préfère ResponseEntity. On peut donc dire que ResponseEntity = ResponseBody + HttpStatus.

Le service est désormais prêt et vous pouvez le tester. Pour tester l'application après l'avoir déployée sous Tomcat, voici l'URL de test : http://localhost:8080/springboot-restserver/.

On obtient le même résultat que lors du premier test de la configuration vu précédemment dans la section I-B-1-c . N'hésitez pas à regarder de temps en temps les logs dans la console. Dans la classe UserController ci-dessous, je vais développer les services de lecture, de création, de mise à jour et de suppression d'un utilisateur.

I-B-3-d - Service d'extraction de tous les utilisateurs

Il s'agit de créer réellement notre premier service REST d'extraction de tous les utilisateurs encore appelé EndPoint d'extraction de tous les utilisateurs.

service REST d'extraction de tous les utilisateurs dans UserController

1. package com.bnguimgo.springbootrestserver.controller;

3. import java.util.Collection;

5. import org.springframework.beans.factory.annotation.Autowired;

6. import org.springframework.http.HttpStatus;

7. import org.springframework.http.ResponseEntity;

8. import org.springframework.web.bind.annotation.CrossOrigin;

9. import org.springframework.web.bind.annotation.GetMapping;

10. import org.springframework.web.bind.annotation.RequestMapping;

11. import org.springframework.web.bind.annotation.RestController;

13. import com.bnguimgo.springbootrestserver.model.User;

14. import com.bnguimgo.springbootrestserver.service.UserService;

16. @CrossOrigin(origins = "http://localhost:8080", maxAge = 3600)

17. @RestController

18. @RequestMapping("/user/*")

19. public class UserController {

21. @Autowired

22. private UserService userService;

24. @GetMapping(value = "/users")

25. public ResponseEntity<Collection<User>> getAllUsers() {

26. Collection<User> users = userService.getAllUsers();

27. return new ResponseEntity<Collection<User>>(users, HttpStatus.FOUND);

28. }

29. }

L'annotation @CrossOrigin(origins = "http://localhost:8080", maxAge = 3600) permet de favoriser une communication distante entre le client et le serveur, c'est-à-dire lorsque le client et le serveur sont déployés dans deux serveurs distincts, ce qui permet d'éviter des problèmes réseaux.

Compiler et déployer l'application dans Tomcat. Vous pouvez utiliser n'importe quel client REST pour faire les tests. Pour le cas présent, j'utilise Postman. Voici la procédure pour teser:

l'annotation @GetMapping permet de traiter les requêtes HTTP GET.

Test d'extraction des utilisateurs

- Ouvrez votre client Rest préféré.

- Choisir la méthode GET.

- Entrez l'URL qui pointe vers la resource: http://localhost:8080/springboot-restserver/user/users.

- paramétrez le header pour indiquer le type de contenu, dans le cas présent, on aura Content-Type = application/json.

- Exécutez la requête.

Voici le paramétrage en image:

Requête HTTP GET d'extraction utilisateur

Notez bien que lors du paramétrage, nous n'avons rien ajouté dans le corps (contenu ou body) de la requête.

Résultat de la requête HTTP GET

1. [{

2. "id": 1,

3. "login": "admin@admin.com",

4. "password": "$2a$10$ISnv6T5sqpr5YeRKP01xEOLAr/ZWviCp73BC07hMK54GNgQHMemxm",

5. "active": 1,

6. "roles": [{

7. "id": 2,

8. "roleName": "ROLE_USER"

9. }, {

10. "id": 1,

11. "roleName": "ROLE_ADMIN"

12. }]

13. }, {

14. "id": 2,

15. "login": "login2",

16. "password": "$2a$10$5TLZBQgB/FOSkccGjKCRDerrD6YzsFznyNURwNHZG8tEwAumfFw1C",

17. "active": 1,

18. "roles": [{

19. "id": 2,

20. "roleName": "ROLE_USER"

21. }]

22. }, {

23. "id": 3,

24. "login": "login3",

25. "password": "$2a$10$U2NARRA6lp0CSfDF2JEmtOaAbf3bVGx9zGqDbLkW/T59.QdGnlguO",

26. "active": 0,

27. "roles": [{

28. "id": 2,

29. "roleName": "ROLE_USER"

30. }]

31. }]

Cette requête remonte 3 utilisateurs qui sont des données d'initialisation chargées par Spring à partir du script data.sql au démarrage de l'application.

Vérifiez que le code retour HttpStatus est 302, ce qui correspond bien à HttpStatus.FOUND.

I-B-3-e - Service création d'un utilisateur

Endpoint de création d'un utilisateur à ajouter dans la classe UserController

1. @PostMapping(value = "/users")

2. @Transactional

3. public ResponseEntity<User> saveUser(@RequestBody User user) {

5. User userSaved = userService.saveOrUpdateUser(user);

6. return new ResponseEntity<User>(userSaved, HttpStatus.CREATED);

7. }

Essayons maintenant de créer un utilisateur avec une requête POST. Voici l'URI de création d'un utilisateur : http://localhost:8080/springboot-restserver/user/users. Il s'agit toujours de la même URL utilisée ci-dessus pour l'extraction. Ce qui change, c'est le type de méthode HTTP POST au lieu de HTTP GET, et aussi le contenu de la requête.

Comme il s'agit d'une requête HTTP POST, il faut construire le corps de la requête (body), voir ci-dessous:

Requête HTTP POST de création d'un utilisateur

Réponse du serveur après création d'un utilisateur

Réponse création d'un utilisateur

Analyse de la réponse

- Un nouvel utilisateur a bien été bien avec un nouvel identifiant = 4.

- Le login est bien celui de notre utilisateur.

- On constate bien dans la réponse que le mot de passe est haché comme prévu.

- L'utilisateur n'est pas actif par défaut car, active =0.

- Un rôle par défaut a aussi été attribué à cet utilisateur: c'est le rôle ROLE_USER.

I-B-3-f - Service mise à jour utilisateur

Service qui permet de modifier un utilisateur

1. @PutMapping(value = "/users")

2. public ResponseEntity<User> updateUser(@RequestBody User user) {

3. User userUpdated = userService.saveOrUpdateUser(user);

4. return new ResponseEntity<User>(userUpdated, HttpStatus.OK);

5. }

On va modifier l'utilisateur qu'on a créé précédement et dont l'identifiant renvoyé était id=4.

Test mise à jour d'un utilisateur

- Choisir la méthode HTTP PUT.

- Entrez la même URL : http://localhost:8080/springboot-restserver/user/users.

- Construire le body en modifiant le login, mot de passe en lui attribuant le rôle administrateur et en activant son compte.

- Remarque: cet utilisateur doit avoir absolument un identifiant connu, sinon, erreur.

- Voir la requête complète ci-dessous:

Requête HTTP PUT à envoyer au serveur

Réponse à la requête HTTP PUT

Réponse à la requête HTTP PUT

Analyse de la réponse HTTP PUT

- L'utilisateur avec l'identifiant 4 a été mise à jour.

- Le login et mot de passe ont été modifiés.

- Il est devenu administrateur et son compte a été activé.

- Le nouveau mot de passe est bien haché.

Nous venons d'utiliser la même requête http://localhost:8080/springboot-restserver/user/users pour effectuer l'extraction, la création et la mise à jour. La seule chose qui change chaque fois, c'est le type de requête. HTTP GET, HTTP POST, ou HTTP PUT

I-B-3-g - Suppression d'un utilisateur

Méthode HTTP DELETE de suppression d'un utilisateur

1. @DeleteMapping(value = "/users")

2. public ResponseEntity<Void> deleteUser(@RequestParam(value = "id", required = true) Long id) {

3. userService.deleteUser(id);

4. return new ResponseEntity<Void>(HttpStatus.GONE);

5. }

Requête de suppression : http://localhost:8080/springboot-restserver/user/users?id=3. La suppression d'un utilisateur nécessite de connaître son identifiant. Cet identifiant est passée en paramètre à l'URL. Cette requête ne contient pas de contenu (rien dans le body, mais juste l'idientifiant dans l'URL). Ne pas oublier de choisir la méthode HTTP DELETE.

Analyse de la réponse HTTP DELETE

- L'utilisateur avec l'identifiant 3 a été supprimé.

- Il faut vérifier que le code retour est 410, c'est-à-dire HttpStatus.GONE.

I-B-3-h - Recherche d'un utilisateur par login/password

Service de recherche d'un utilisateur par son login

1. @PostMapping(value = "/users/login")

2. public ResponseEntity<User> findUserByLoginAndPassword(@RequestBody User user) {

4. Optional<User> userFound = userService.findByLoginAndPassword(user.getLogin(), user.getPassword());

5. return new ResponseEntity<User>(userFound.get(), HttpStatus.FOUND);

6. }

Recherchons par exemple l'utilisateur précédement crée avec le login : updateUser et le password: updatePassword (requête HTTP GET) http://localhost:8080/springboot-restserver/user/users/login.

On obtient:

Recherche utilisateur par login

Analyse de la réponse HTTP POST, findUserByLoginAndPassword

- On retrouve l'utilisateur dont le login est updateUser, et le mot de passe updateUser.

- Vérifiez que le code retour est 302, c'est-à-dire HttpStatus.FOUND.

On peut aussi chercher un utilisateur par son identifiant.

EndPoint de recherche d'un utilisateur par identifiant

1. @GetMapping(value = "/users/{id}")

2. public ResponseEntity<User> findUserById(@PathVariable(value = "id") Long id) {

3. Optional<User> userFound = userService.findUserById(id);

4. return new ResponseEntity<User>(userFound.get(), HttpStatus.FOUND);

5. }

Cette méthode nous sera nécessaire pour mettre à jour un utilisateur.

Recherchons maintenant un utilisateur qui n'existe pas.

Voici la requête en image:

Recherche d'un utilisateur inconnu par login

Résultat recherche d'un utilisateur inconnu:

Résultat recherche d'un utilisateur inconnu par login

Dans la section suivante, nous allons voir comment construire et personnaliser la gestion des exceptions dans un service REST.

Comme vous pouvez le noter, il faut éviter de mettre la logique métier dans le contrôleur (pas de traitements, pas de calculs), mais, uniquement l'appel des services

I-B-3-i - API Rest au complet

Voici le Endpoint API Rest complet:

API Rest de gestion d'un utilisateur

1. import java.util.Collection;

2. import java.util.Optional;

4. import org.springframework.beans.factory.annotation.Autowired;

5. import org.springframework.http.HttpStatus;

6. import org.springframework.http.ResponseEntity;

7. import org.springframework.transaction.annotation.Transactional;

8. import org.springframework.web.bind.annotation.CrossOrigin;

9. import org.springframework.web.bind.annotation.DeleteMapping;

10. import org.springframework.web.bind.annotation.GetMapping;

11. import org.springframework.web.bind.annotation.PathVariable;

12. import org.springframework.web.bind.annotation.PostMapping;

13. import org.springframework.web.bind.annotation.PutMapping;

14. import org.springframework.web.bind.annotation.RequestBody;

15. import org.springframework.web.bind.annotation.RequestMapping;

16. import org.springframework.web.bind.annotation.RequestParam;

17. import org.springframework.web.bind.annotation.RestController;

19. import com.bnguimgo.springbootrestserver.exception.BusinessResourceException;

20. import com.bnguimgo.springbootrestserver.model.User;

21. import com.bnguimgo.springbootrestserver.service.UserService;

23. @CrossOrigin(origins = "*", maxAge = 3600)

24. @RestController

25. @RequestMapping("/user/*")

26. public class UserController {//NB: pas de logique métier dans le contrôleur, mais, uniquement l'appel des services

28. @Autowired

29. private UserService userService;

31. @GetMapping(value = "/users")

32. public ResponseEntity<Collection<User>> getAllUsers() {

33. Collection<User> users = userService.getAllUsers();

34. return new ResponseEntity<Collection<User>>(users, HttpStatus.FOUND);

35. }

37. @PostMapping(value = "/users")

38. @Transactional

39. public ResponseEntity<User> saveUser(@RequestBody User user) {

41. User userSaved = userService.saveOrUpdateUser(user);

42. return new ResponseEntity<User>(userSaved, HttpStatus.CREATED);

43. }

45. @PutMapping(value = "/users")

46. public ResponseEntity<User> updateUser(@RequestBody User user) {

47. User userUpdated = userService.saveOrUpdateUser(user);

48. return new ResponseEntity<User>(userUpdated, HttpStatus.OK);

49. }

51. @DeleteMapping(value = "/users")

52. public ResponseEntity<Void> deleteUser(@RequestParam(value = "id", required = true) Long id) throws BusinessResourceException {

54. userService.deleteUser(id);

55. return new ResponseEntity<Void>(HttpStatus.GONE);

56. }

58. @PostMapping(value = "/users/login")

59. public ResponseEntity<User> findUserByLoginAndPassword(@RequestBody User user) {

60. Optional<User> userFound = userService.findByLoginAndPassword(user.getLogin(), user.getPassword());

61. return new ResponseEntity<User>(userFound.get(), HttpStatus.FOUND);

62. }

64. @GetMapping(value = "/users/{id}")

65. public ResponseEntity<User> findUserById(@PathVariable(value = "id") Long id) {

66. Optional<User> userFound = userService.findUserById(id);

67. return new ResponseEntity<User>(userFound.get(), HttpStatus.FOUND);

68. }

69. }

I-C - Gestion des exceptions

Dans cette section, je vais mettre en place la gestion des exceptions en utilisant l'annotation @ControllerAdvice.

I-C-1 - Pourquoi utiliser @ControllerAdvice

L'annotation @ControllerAdvice est une spécialisation de l'annotation de @Component introduite depuis Spring-3.2. Elle permet de gérer de façon plus globale les exceptions dans un service REST. Une classe portant cette annotation est détectée automatiquement par Spring au chargement de l'application. Cette annotation prend en charge trois autres annotations très importantes dont @ExceptionHandler, @InitBinder et @ModelAttribute. Un contrôleur annoté par @ControllerAdvice utilise l'annotation @ExceptionHandler pour intercepter des exceptions dites globales, quelles que soient leurs origines. @InitBinder permet une initialisation globale et @ModelAttribute permet de créer un objet model global (par exemple : création et initialisation d'une vue ou d'une page de l'application).

Pour mieux comprendre, je vais créer un nouveau package nommé exception et une classe GlobalHandlerControllerException de gestion globale des exceptions annotée par @ControllerAdvice, et quelques-unes de ses méthodes annotées par @ExceptionHandler, @InitBinder and @ModelAttribute.

Classe global de gestion des exceptions grâce à l'annotation @ControllerAdvice

1. package com.bnguimgo.springbootrestserver.exception;

3. import javax.servlet.http.HttpServletRequest;

5. import org.springframework.http.HttpStatus;

6. import org.springframework.http.ResponseEntity;

7. import org.springframework.ui.Model;

8. import org.springframework.web.bind.WebDataBinder;

9. import org.springframework.web.bind.annotation.ControllerAdvice;

10. import org.springframework.web.bind.annotation.ExceptionHandler;

11. import org.springframework.web.bind.annotation.InitBinder;

12. import org.springframework.web.bind.annotation.ModelAttribute;

13. import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

15. @ControllerAdvice(basePackages = {"com.bnguimgo.springbootrestserver"} )

16. public class GlobalHandlerControllerException extends ResponseEntityExceptionHandler{

18. @InitBinder

19. public void dataBinding(WebDataBinder binder) {

20. //Vous pouvez intialiser n'importe quelle donnée ici

22. }

24. @ModelAttribute

25. public void globalAttributes(Model model) {

26. model.addAttribute("technicalError", "Une erreur technique est survenue !");

27. }

29. @ExceptionHandler(Exception.class)//toutes les autres erreurs non gérées par le service sont interceptées ici

30. public ResponseEntity<BusinessResourceExceptionDTO> unknowError(HttpServletRequest req, Exception ex) {

31. BusinessResourceExceptionDTO response = new BusinessResourceExceptionDTO();

32. response.setErrorCode("Technical Error");

33. response.setErrorMessage(ex.getMessage());

34. response.setRequestURL(req.getRequestURL().toString());

35. return new ResponseEntity<BusinessResourceExceptionDTO>(response, HttpStatus.INTERNAL_SERVER_ERROR);

36. }

38. @ExceptionHandler(BusinessResourceException.class)

39. public ResponseEntity<BusinessResourceExceptionDTO> businessResourceError(HttpServletRequest req, BusinessResourceException ex) {

40. BusinessResourceExceptionDTO businessResourceExceptionDTO = new BusinessResourceExceptionDTO();

41. businessResourceExceptionDTO.setStatus(ex.getStatus());

42. businessResourceExceptionDTO.setErrorCode(ex.getErrorCode());

43. businessResourceExceptionDTO.setErrorMessage(ex.getMessage());

44. businessResourceExceptionDTO.setRequestURL(req.getRequestURL().toString());

45. return new ResponseEntity<BusinessResourceExceptionDTO>(businessResourceExceptionDTO, ex.getStatus());

46. }

47. }

Comme on peut le constater dans la classe ci-dessus, j'ai annoté la classe par @ControllerAdvice. Cette classe est capable d'intercepter et de gérer plusieurs types d'erreurs grâce à l'annotation @ExceptionHandler avec en paramètre le type d'exception. On peut aussi lui passer plusieurs classes d'exceptions pour le même Handler. Il faut aussi noter le passage en paramètre de l'attribut basePackages qui permet d'indiquer dans quels packages se trouvent les contrôleurs à prendre en compte par cette classe (ici, tous les contrôleurs du package com.bnguimgo.springbootrestserver sont concernés et leurs erreurs seront donc gérées par GlobalHandlerControllerException).

Chaque exception traitée est composée de:

- un status: pour avoir le HttpStatus code.

- un code d'erreur: code métier pour en cas de besoin.

- un message d'erreur à renvoyer.

- l'URL de la requête.

Toutes les exceptions non traitées seront interceptées par la méthode

1. @ExceptionHandler(Exception.class)//toutes les autres erreurs non gérées sont interceptées ici

2. public ResponseEntity<BusinessResourceExceptionResponse> unknowError(HttpServletRequest req, Exception ex) {

3. BusinessResourceExceptionResponse response = new BusinessResourceExceptionResponse();

4. response.setErrorCode("Technical Error");

5. response.setErrorMessage(ex.getMessage());

6. response.setRequestURL(req.getRequestURL().toString());

7. return new ResponseEntity<BusinessResourceExceptionResponse>(response, HttpStatus.INTERNAL_SERVER_ERROR);

8. }

L'attribut global nommé technicalError pourra être utilisé partout dans l'application.

Voici la classe de gestion d'exception BusinessResourceException:

Classe d'exception classique BusinessResourceException

1. package com.bnguimgo.springbootrestserver.exception;

3. import org.springframework.http.HttpStatus;

5. public class BusinessResourceException extends RuntimeException {

7. private static final long serialVersionUID = 1L;

8. private Long resourceId;

9. private String errorCode;

10. private HttpStatus status;

12. public BusinessResourceException(String message) {

13. super(message);

14. }

16. public BusinessResourceException(Long resourceId, String message) {

17. super(message);

18. this.resourceId = resourceId;

19. }

20. public BusinessResourceException(Long resourceId, String errorCode, String message) {

21. super(message);

22. this.resourceId = resourceId;

23. this.errorCode = errorCode;

24. }

26. public BusinessResourceException(String errorCode, String message) {

27. super(message);

28. this.errorCode = errorCode;

29. }

31. public BusinessResourceException(String errorCode, String message, HttpStatus status) {

32. super(message);

33. this.errorCode = errorCode;

34. this.status = status;

35. }

37. public Long getResourceId() {

38. return resourceId;

39. }

41. public void setResourceId(Long resourceId) {

42. this.resourceId = resourceId;

43. }

45. public String getErrorCode() {

46. return errorCode;

47. }

49. public void setErrorCode(String errorCode) {

50. this.errorCode = errorCode;

51. }

53. public HttpStatus getStatus() {

54. return status;

55. }

57. public void setStatus(HttpStatus status) {

58. this.status = status;

59. }

60. }

Notez au passage l'existance de plusieurs constructeurs dont celui avec le paramètre HttpStatus permettant de propager le code d'erreur. C'est ce constructeur qui sera préféré: public BusinessResourceException(String errorCode, String message, HttpStatus status){ xxxx }.

Vous pouvez noter aussi que j'ai enveloppé les exceptions dans une classe utilitaire nommée BusinessResourceExceptionResponse. Cette classe va servir à stocker les messages d'erreurs.

Classe POJO BusinessResourceExceptionResponse de persistance des messages d'erreurs

1. package com.bnguimgo.springbootrestserver.exception;

3. import org.springframework.http.HttpStatus;

5. import org.springframework.http.HttpStatus;

7. public class BusinessResourceExceptionDTO {

9. private String errorCode;

10. private String errorMessage;

11. private String requestURL;

12. private HttpStatus status;

14. public BusinessResourceExceptionDTO() {

15. }

17. public String getErrorCode() {

18. return errorCode;

19. }

21. public void setErrorCode(String errorCode) {

22. this.errorCode = errorCode;

23. }

25. public String getErrorMessage() {

26. return errorMessage;

27. }

29. public void setErrorMessage(String errorMessage) {

30. this.errorMessage = errorMessage;

31. }

33. public void setRequestURL(String url) {

34. this.requestURL = url;

36. }

37. public String getRequestURL(){

38. return requestURL;

39. }

41. public HttpStatus getStatus() {

42. return status;

43. }

45. public void setStatus(HttpStatus status) {

46. this.status = status;

47. }

48. }

I-C-2 - Test des services avec exceptions

Pour mettre en évidence l'intérêt de l'annotation de @ControllerAdvice, je vais faire un premier cas de test en cherchant un utilisateur qui n'existe pas et avec l'annotation @ControllerAdvice désactivée (mettre en commentaire). Résultat:

Exception sans l'annotation @ControllerAdvice

On obtient ERROR 500 et un ensemble d'informations pas très personnalisées.

Et voici l'exception renvoyée lorsque l'annotation @ControllerAdvice est activée

Exception lorsque @ControllerAdvice est activée

On obtient 404 Not Found, mais avec une suite d'erreurs plus claire et exploitable. Par exemple : un code d'erreur, le message, et une URL bien précise et complète.

I-D - Tests unitaires des couches

Chaque couche de l'application peut être testée unitairement. Et comme vous pouvez le constater, chaque couche a une dépendance vis-à-vis de la couche immédiatement inférieure. Dans le cas des tests unitaires, l'idée est de s'affranchir de cette dépendance et de tester unitairement chaque méthode de la couche. C'est ce qui explique le plus souvent l'usage des Mocks Objects(Objets mocks = Objets factices ou objets sans existence réelle) dans les tests unitaires (ce qui n'est pas le cas dans les tests d'intégration).

Il faut donc trouver quel Mock Object est adapté pour chaque couche de l'application, car ça change en fonction des besoins et des cas de tests.

Mais, avant de commencer les tests, j'ai ajouté quelques dépendances nécessaires pour les tests unitaires et les tests d'intégration. Voici les deux dépendances :

Dépendances nécessaires pour les tests

1. <dependency>

2. <groupId>com.jayway.jsonpath</groupId>

3. <artifactId>json-path</artifactId>

4. <scope>test</scope>

5. </dependency>

Et voici le pom.xml complété de l'application:

pom.xml complété avec les dépendances pour les tests

1. <?xml version="1.0" encoding="UTF-8"?>

2. <project xmlns="http://maven.apache.org/POM/4.0.0"

3. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

4. xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

5. <modelVersion>4.0.0</modelVersion>

7. <groupId>com.bnguimgo</groupId>

8. <artifactId>springboot-restserver</artifactId>

9. <version>0.0.1-SNAPSHOT</version>

10. <packaging>war</packaging>

12. <name>springboot-restserver</name>

13. <description>Serveur Rest utilisant le framework Spring Boot</description>

15. <parent>

16. <groupId>org.springframework.boot</groupId>

17. <artifactId>spring-boot-starter-parent</artifactId>

18. <version>2.2.6.RELEASE</version>

19. <relativePath /> <!-- lookup parent from repository -->

20. </parent>

22. <properties>

23. <start-class>com.bnguimgo.restclient.com.bnguimgo.springbootrestserver.SpringbootRestserverApplication</start-class><!--

24. cette déclaration est optionnelle -->

25. <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

26. <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

27. <java.version>1.8</java.version>

28. <tomcat.version>9.0.24</tomcat.version><!-- On surcharge la version intégrée par défaut de Tomcat -->

29. </properties>

30. <repositories>

31. <!-- Indiquer le repo maven avec https afin d'eviter l'erreur suivante:

32. Failed to transfer file: http://repo.maven.apache.org/maven2/org/codehaus/cargo/cargo-maven2-plugin/1.7.10/cargo-maven2-plugin-1.7.10.pom.

33. Return code is: 501 , ReasonPhrase:HTTPS Required. -->

34. <repository>

35. <id>central maven repo</id>

36. <name>central maven repo https</name>

37. <url>https://repo.maven.apache.org/maven2</url>

38. </repository>

39. </repositories>

40. <dependencies>

41. <dependency>

42. <groupId>org.springframework.boot</groupId>

43. <artifactId>spring-boot-starter-data-jpa</artifactId>

44. </dependency>

45. <dependency>

46. <groupId>org.springframework.boot</groupId>

47. <artifactId>spring-boot-starter-web</artifactId>

48. </dependency>

50. <!-- Spring Security: juste pour hacher le mot de passe -->

51. <dependency>

52. <groupId>org.springframework.boot</groupId>

53. <artifactId>spring-boot-starter-security</artifactId>

54. </dependency>

56. <!-- Permet l'utilisation des collections Apache -->

57. <dependency>

58. <groupId>org.apache.commons</groupId>

59. <artifactId>commons-collections4</artifactId>

60. <version>4.0</version>

61. </dependency>

63. <!-- Base de données utilisée par l'application -->

64. <dependency>

65. <groupId>com.h2database</groupId>

66. <artifactId>h2</artifactId>

67. <scope>runtime</scope>

68. </dependency>

70. <!-- Dépendances pour les tests uniquement -->

71. <dependency>

72. <groupId>org.springframework.boot</groupId>

73. <artifactId>spring-boot-starter-test</artifactId>

74. </dependency>

75. <dependency>

76. <groupId>com.jayway.jsonpath</groupId>

77. <artifactId>json-path</artifactId>

78. <scope>test</scope>

79. </dependency>

80. </dependencies>

82. <build>

83. <finalName>springboot-restserver</finalName>

84. <plugins>

85. <plugin>

86. <groupId>org.springframework.boot</groupId>

87. <artifactId>spring-boot-maven-plugin</artifactId>

88. </plugin>

89. </plugins>

90. </build>

92. </project>

I-D-1 - Tests unitaires couche DAO

La couche DAO est la couche la plus basse de l'application et, de ce fait, elle communique le plus souvent avec la base de données. Pour la tester, on ne cherche pas à savoir comment se passe l'extraction des données, mais on vérifie plutôt que la couche DAO est capable de fournir les données dont on a besoin : d'où l'utilisation d'un Mock Object. Pour répondre à ce défi, Spring Boot a mis à notre disposition la classe TestEntityManager qui est l'équivalent Mock de JPA EntityManager.

Voici le service d'accès aux données utilisateur à tester

1. public interface UserRepository extends JpaRepository<User, Long> {

2. Optional<User> findByLogin(String loginParam);

3. }

Et ci-dessous l'initialisation des tests

1. @RunWith(SpringRunner.class)//permet d'établir une liaison entre JUnit et Spring

2. @DataJpaTest

3. public class UserRepositoryTest {

5. @Autowired

6. private TestEntityManager entityManager;

7. @Autowired

8. private UserRepository userRepository;

9. User user = new User("Dupont", "password", 1);

11. @Before

12. public void setup(){

13. entityManager.persist(user);//on sauvegarde l'objet user au début de chaque test

14. entityManager.flush();

15. }

16. }

@RunWith(SpringRunner.class) permet d'établir la liaison l'implémentation Spring de JUnit, donc c'est tout naturel qu'on l'utilise pour un test unitaire.

@DataJpaTest est une implémentation Spring de JPA qui fournit une configuration intégrée de la base de données H2, Hibernate, SpringData, et la DataSource. Cette annotation active également la détection des entités annotées par Entity, et intègre aussi la gestion des logs SQL.

Un utilisateur User("Dupont", "password", 1) est enregistré dans le setup avant chaque test.

Test unitaire de tous les services de la couche DAO

1. package com.bnguimgo.springbootrestserver.dao;

3. import org.junit.Before;

4. import org.junit.Test;

5. import org.junit.runner.RunWith;

6. import static org.junit.Assert.*;

8. import java.util.List;

9. import java.util.Optional;

11. import static org.hamcrest.CoreMatchers.*;

12. import org.springframework.beans.factory.annotation.Autowired;

13. import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

14. import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;

15. import org.springframework.test.context.junit4.SpringRunner;

17. import com.bnguimgo.springbootrestserver.model.User;

19. @RunWith(SpringRunner.class)//permet d'établir une liaison entre JUnit et Spring

20. @DataJpaTest

21. public class UserRepositoryTest {

23. @Autowired

24. private TestEntityManager entityManager;

25. @Autowired

26. private UserRepository userRepository;

27. User user = new User("Dupont", "password", 1);

29. @Before

30. public void setup(){

31. entityManager.persist(user);//on sauvegarde l'objet user au début de chaque test

32. entityManager.flush();

33. }

34. @Test

35. public void testFindAllUsers() {

36. List<User> users = userRepository.findAll();

37. assertThat(4, is(users.size()));//on a 3 Users dans le fichier d'initialisation data.sql et un utilisateur ajouté lors du setup du test

38. }

40. @Test

41. public void testSaveUser(){

42. User user = new User("Paul", "password", 1);

43. User userSaved =  userRepository.save(user);

44. assertNotNull(userSaved.getId());

45. assertThat("Paul", is(userSaved.getLogin()));

46. }

47. @Test

48. public void testFindByLogin() {

49. Optional<User> userFromDB = userRepository.findByLogin("login2");

50. assertThat("login2", is(userFromDB.get().getLogin()));//login2 a été crée lors de l'initialisation du fichier data.sql

51. }

53. @Test

54. public void testFindById() {

55. Optional<User> userFromDB = userRepository.findById(user.getId());

56. assertThat(user.getLogin(), is(userFromDB.get().getLogin()));//user a été crée lors du setup

57. }

59. @Test

60. public void testFindBy_Unknow_Id() {

61. Optional<User> userFromDB = userRepository.findById(50L);

62. assertEquals(Optional.empty(), Optional.ofNullable(userFromDB).get());

63. }

65. @Test

66. public void testDeleteUser(){

67. userRepository.deleteById(user.getId());

68. Optional<User> userFromDB = userRepository.findByLogin(user.getLogin());

69. assertEquals(Optional.empty(), Optional.ofNullable(userFromDB).get());

70. }

72. @Test

73. public void testUpdateUser() {//Test si le compte utilisateur est désactivé

74. Optional<User> userToUpdate = userRepository.findByLogin(user.getLogin());

75. userToUpdate.get().setActive(0);

76. userRepository.save(userToUpdate.get());

77. Optional<User> userUpdatedFromDB = userRepository.findByLogin(userToUpdate.get().getLogin());

78. assertNotNull(userUpdatedFromDB);

79. assertThat(0, is(userUpdatedFromDB.get().getActive()));

80. }

81. }

@TestEntityManager permet de persister les données en base lors des tests. Toutes les méthodes ont été testées.

J'ai ajouté le framework hamcrest (fourni par Spring Boot) qui apporte plus de flexibilité pour l'utilisation de la méthode de test assertThat().

I-D-2 - Tests unitaires couche service

La couche de service a une dépendance directe avec la couche DAO. Cependant, on n'a pas besoin de savoir comment se passe la persistance au niveau de la couche DAO. Le plus important c'est que cette couche renvoie les données sollicitées. On peut donc moquer la couche DAO en utilisant le framework Mockito

Voici le service à tester :

1. package com.bnguimgo.springbootrestserver.service;

2. // tous les imports ici

4. @Service

5. public class UserServiceImpl implements UserService {

7. private static final Logger logger = LoggerFactory.getLogger(UserServiceImpl.class);

8. private UserRepository userRepository;

9. private RoleRepository roleRepository;

10. private BCryptPasswordEncoder bCryptPasswordEncoder;

12. public UserServiceImpl() {

13. super();

14. }

16. @Autowired

17. public UserServiceImpl(UserRepository userRepository, RoleRepository roleRepository, BCryptPasswordEncoder bCryptPasswordEncoder) {

18. super();

19. this.userRepository = userRepository;

20. this.roleRepository = roleRepository;

21. this.bCryptPasswordEncoder = bCryptPasswordEncoder;

22. }

24. @Override

25. public Optional<User> findByLogin(String login) throws BusinessResourceException {

27. Optional<User> userFound = userRepository.findByLogin(login);

28. if (Boolean.FALSE.equals(userFound.isPresent())) {

29. throw new BusinessResourceException("User Not Found", "L'utilisateur avec ce login n'existe pas :" + login);

30. }

31. return userFound;

32. }

34. @Override

35. public Collection<User> getAllUsers() {

36. return IteratorUtils.toList(userRepository.findAll().iterator());

37. }

39. @Override

40. public Optional<User> findUserById(Long id) throws  BusinessResourceException{

42. Optional<User> userFound = userRepository.findById(id);

43. if (Boolean.FALSE.equals(userFound.isPresent())){

44. throw new BusinessResourceException("User Not Found", "Aucun utilisateur avec l'identifiant :" + id);

45. }

46. return userFound;

47. }

49. @Override

50. @Transactional(readOnly=false)

51. public User saveOrUpdateUser(User user) throws BusinessResourceException{

52. try{

53. if(null ==user.getId()) {//pas d'Id --> création d'un user

54. addUserRole(user);//Ajout d'un rôle par défaut

55. user.setPassword(bCryptPasswordEncoder.encode(user.getPassword()));

56. } else {//sinon, mise à jour d'un user

58. Optional<User> userFromDB = findUserById(user.getId());

59. if(! bCryptPasswordEncoder.matches(user.getPassword(), userFromDB.get().getPassword())) {

60. user.setPassword(bCryptPasswordEncoder.encode(user.getPassword()));//MAJ du mot de passe s'il a été modifié

61. } else {

63. user.setPassword(userFromDB.get().getPassword());//Sinon, on remet le password déjà haché

64. }

65. updateUserRole(user);//On extrait le rôle en cas de mise à jour

66. }

67. User result = userRepository.save(user);

68. return  result;

69. } catch(DataIntegrityViolationException ex){

70. logger.error("Utilisateur non existant", ex);

71. throw new BusinessResourceException("DuplicateValueError", "Un utilisateur existe déjà avec le compte : "+user.getLogin(), HttpStatus.CONFLICT);

72. } catch (BusinessResourceException e) {

73. logger.error("Utilisateur non existant", e);

74. throw new BusinessResourceException("UserNotFound", "Aucun utilisateur avec l'identifiant: "+user.getId(), HttpStatus.NOT_FOUND);

75. } catch(Exception ex){

76. logger.error("Erreur technique de création ou de mise à jour de l'utilisateur", ex);

77. throw new BusinessResourceException("SaveOrUpdateUserError", "Erreur technique de création ou de mise à jour de l'utilisateur: "+user.getLogin(), HttpStatus.INTERNAL_SERVER_ERROR);

78. }

79. }

81. @Override

82. @Transactional(readOnly=false)

83. public void deleteUser(Long id) throws BusinessResourceException {

84. try{

85. userRepository.deleteById(id);

86. }catch(EmptyResultDataAccessException ex){

87. logger.error(String.format("Aucun utilisateur n'existe avec l'identifiant: "+id, ex));

88. throw new BusinessResourceException("DeleteUserError", "Erreur de suppression de l'utilisateur avec l'identifiant: "+id, HttpStatus.NOT_FOUND);

89. }catch(Exception ex){

90. throw new BusinessResourceException("DeleteUserError", "Erreur de suppression de l'utilisateur avec l'identifiant: "+id, HttpStatus.INTERNAL_SERVER_ERROR);

91. }

92. }

94. @Override

95. public Optional<User> findByLoginAndPassword(String login, String password) throws BusinessResourceException{

96. try {

97. Optional<User> userFound = this.findByLogin(login);

98. if(bCryptPasswordEncoder.matches(password, userFound.get().getPassword())) {

99. return userFound;

100. } else {

101. throw new BusinessResourceException("UserNotFound", "Mot de passe incorrect", HttpStatus.NOT_FOUND);

102. }

103. } catch (BusinessResourceException ex) {

104. logger.error("Login ou mot de passe incorrect", ex);

105. throw new BusinessResourceException("UserNotFound", "Login ou mot de passe incorrect", HttpStatus.NOT_FOUND);

106. }catch (Exception ex) {

107. logger.error("Une erreur technique est survenue", ex);

108. throw new BusinessResourceException("TechnicalError", "Une erreur technique est survenue", HttpStatus.INTERNAL_SERVER_ERROR);

109. }

110. }

112. private void addUserRole(User user) {

113. Set<Role> roles= new HashSet<>();

114. Role roleUser = new Role("ROLE_USER");//initialisation du rôle ROLE_USER

115. roles.add(roleUser);

116. user.setActive(0);

118. Set<Role> roleFromDB = extractRole_Java8(roles, roleRepository.getAllRolesStream());

119. user.setRoles(roleFromDB);

120. }

122. private void updateUserRole(User user) {

124. Set<Role> roleFromDB = extractRole_Java8(user.getRoles(), roleRepository.getAllRolesStream());

125. user.setRoles(roleFromDB);

126. }

128. private Set<Role> extractRole_Java8(Set<Role> rolesSetFromUser, Stream<Role> roleStreamFromDB) {

129. // Collect UI role names

130. Set<String> uiRoleNames = rolesSetFromUser.stream()

131. .map(Role::getRoleName)

132. .collect(Collectors.toCollection(HashSet::new));

133. // Filter DB roles

134. return roleStreamFromDB

135. .filter(role -> uiRoleNames.contains(role.getRoleName()))

136. .collect(Collectors.toSet());

137. }

139. @SuppressWarnings("unused")

140. private Set<Role> extractRoleUsingCompareTo_Java8(Set<Role> rolesSetFromUser, Stream<Role> roleStreamFromDB) {

141. return roleStreamFromDB

142. .filter(roleFromDB -> rolesSetFromUser.stream()

143. .anyMatch( roleFromUser -> roleFromUser.compareTo(roleFromDB) == 0))

144. .collect(Collectors.toCollection(HashSet::new));

145. }

147. @SuppressWarnings("unused")

148. private Set<Role>  extractRole_BeforeJava8(Set<Role> rolesSetFromUser, Collection<Role> rolesFromDB) {

149. Set<Role> rolesToAdd = new HashSet<>();

150. for(Role roleFromUser:rolesSetFromUser){

151. for(Role roleFromDB:rolesFromDB){

152. if(roleFromDB.compareTo(roleFromUser)==0){

153. rolesToAdd.add(roleFromDB);

154. break;

155. }

156. }

157. }

158. return rolesToAdd;

159. }

160. }

Pour éviter d'avoir des problèmes de configuration des tests, il faut créer exactement les mêmes packages dans src/test/java. Par exemple, pour tester le service UserService, créez le package : com.bnguimgo.springbootrestserver.service

Voici les tests unitaires complets de la classe UserServiceImpl

1. package com.bnguimgo.springbootrestserver.service;

3. import static org.hamcrest.CoreMatchers.is;

4. import static org.junit.Assert.assertEquals;

5. import static org.junit.Assert.assertNotNull;

6. import static org.junit.Assert.assertThat;

7. import static org.mockito.ArgumentMatchers.any;

8. import static org.mockito.Mockito.verify;

10. import java.util.Arrays;

11. import java.util.Collection;

12. import java.util.HashSet;

13. import java.util.List;

14. import java.util.Optional;

15. import java.util.Set;

17. import org.junit.Before;

18. import org.junit.Test;

19. import org.mockito.Mockito;

20. import org.springframework.dao.DataIntegrityViolationException;

21. import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

23. import com.bnguimgo.springbootrestserver.dao.RoleRepository;

24. import com.bnguimgo.springbootrestserver.dao.UserRepository;

25. import com.bnguimgo.springbootrestserver.exception.BusinessResourceException;

26. import com.bnguimgo.springbootrestserver.model.Role;

27. import com.bnguimgo.springbootrestserver.model.User;

29. //@RunWith(SpringRunner.class) //pas besoin car on a fait l'autowired par constructeur sur UserServiceImpl

30. public class UserServiceImplTest {

32. private UserService userService;

33. private RoleRepository roleRepository;

34. private UserRepository userRepository;

35. private BCryptPasswordEncoder bCryptPasswordEncoder;

37. @Before

38. public void setup() {

39. userRepository = Mockito.mock(UserRepository.class);

40. roleRepository = Mockito.mock(RoleRepository.class);

41. bCryptPasswordEncoder = Mockito.mock(BCryptPasswordEncoder.class);

42. userService = new UserServiceImpl(userRepository, roleRepository, bCryptPasswordEncoder);

43. }

44. @Test

45. public void testFindAllUsers() throws Exception {

46. User user = new User("Dupont", "password", 1);

47. Role role = new Role("USER_ROLE");

48. Set<Role> roles = new HashSet<>();

49. roles.add(role);

50. user.setRoles(roles);

51. List<User> allUsers = Arrays.asList(user);

52. Mockito.when(userRepository.findAll()).thenReturn(allUsers);

53. Collection<User> users = userService.getAllUsers();

54. assertNotNull(users);

55. assertEquals(users, allUsers);

56. assertEquals(users.size(), allUsers.size());

57. verify(userRepository).findAll();

58. }

60. @Test

61. public void testSaveUser() throws Exception {

62. User user = new User("Dupont", "password", 1);

63. Role role = new Role("USER_ROLE");

64. Set<Role> roles = new HashSet<>();

65. roles.add(role);

66. User userMock = new User(1L,"Dupont", "password", 1);

67. userMock.setRoles(roles);

68. Mockito.when(roleRepository.getAllRolesStream()).thenReturn(roles.stream());

69. Mockito.when(userRepository.save((user))).thenReturn(userMock);

70. User userSaved = userService.saveOrUpdateUser(user);

71. assertNotNull(userSaved);

72. assertEquals(userMock.getId(), userSaved.getId());

73. assertEquals(userMock.getLogin(), userSaved.getLogin());

74. assertEquals(userMock.getLogin(), userSaved.getLogin());

75. assertEquals(userMock.getRoles(), userSaved.getRoles());

76. verify(userRepository).save(any(User.class));

77. }

79. @Test(expected=BusinessResourceException.class)

80. public void testSaveUser_existing_login_throws_error() throws Exception {

81. User user = new User("Dupont", "password", 1);

82. Role role = new Role("USER_ROLE");

83. Set<Role> roles = new HashSet<>();

84. roles.add(role);

85. Mockito.when(roleRepository.getAllRolesStream()).thenReturn(roles.stream());

86. Mockito.when(userRepository.save((user))).thenThrow(new DataIntegrityViolationException("Duplicate Login"));

87. userService.saveOrUpdateUser(user);

88. verify(userRepository).save(any(User.class));

89. }

91. @Test

92. public void testFindUserByLogin() {

93. User user = new User("Dupont", "password", 1);

94. Mockito.when(userRepository.findByLogin(user.getLogin())).thenReturn(Optional.ofNullable(user));

95. Optional<User> userFromDB = userService.findByLogin(user.getLogin());

96. assertNotNull(userFromDB);

97. assertThat(userFromDB.get().getLogin(), is(user.getLogin()));

98. verify(userRepository).findByLogin(any(String.class));

99. }

101. @Test

102. public void testUpdateUser() throws Exception {

103. User userToUpdate = new User(1L, "NewDupont", "newPassword", 1);

104. Role role = new Role("USER_ROLE");

105. Set<Role> roles = new HashSet<>();

106. roles.add(role);

108. User userFoundById = new User(1L, "OldDupont", "oldpassword", 1);

109. userFoundById.setRoles(roles);

111. User userUpdated = new User(1L, "NewDupont", "newPassword", 1);

112. userUpdated.setRoles(roles);

114. Mockito.when(userRepository.findById(1L)).thenReturn(Optional.of(userFoundById));

115. Mockito.when(bCryptPasswordEncoder.matches(any(String.class), any(String.class))).thenReturn(false);

116. Mockito.when(bCryptPasswordEncoder.encode(any(String.class))).thenReturn("newPassword");

117. Mockito.when(userRepository.save((userToUpdate))).thenReturn(userUpdated);

118. User userFromDB = userService.saveOrUpdateUser(userToUpdate);

119. assertNotNull(userFromDB);

120. verify(userRepository).save(any(User.class));

121. assertEquals(new Long(1), userFromDB.getId());

122. assertEquals("NewDupont", userFromDB.getLogin());

123. assertEquals("newPassword", userFromDB.getPassword());

124. assertEquals(new Integer(1), userFromDB.getActive());

125. assertEquals(roles, userFromDB.getRoles());

126. }

128. @Test

129. public void testDelete() throws Exception {

130. User userTodelete = new User(1L,"Dupont", "password", 1);

131. Mockito.doNothing().when(userRepository).deleteById(userTodelete.getId());

132. userService.deleteUser(userTodelete.getId());

134. verify(userRepository).deleteById(any(Long.class));

135. }

136. }

Sachant que nous avons fait l'autowired par constructeur lors du développement de UserServiceImpl, nous n'avons pas plus besoin d'injecter les dépendances. Une simple déclaration est suffisante. De plus, on n'a pas besoin de @RunWith(SpringRunner.class) pour exécuter les tests unitaires du service.

Le framework Mockito a été utilisé pour moquer tout le service DAO, ce qui a permis d'initialiser les tests.

En développant les services, nous avons haché le mot de passe pour ne pas le laisser transiter en clair dans le réseau. Il faut donc ajouter un bean BcryptPasswordEncoder.

Je vous laisse la possibilité de compléter les tests de la couche de service, et surtout le test des exceptions.

Notez l'usage de Mockito.doNothing() qui permet de tester une méthode dont le type de retour est void. exemple de de la méthode public void deleteUser(Long id) {xxx}.

Tests unitaires de la classe RoleServiceImpl

1. package com.bnguimgo.springbootrestserver.service;

3. import java.util.Arrays;

4. import java.util.Collection;

5. import java.util.List;

7. import org.assertj.core.api.Assertions;

8. import org.junit.Test;

9. import org.mockito.Mockito;

11. import com.bnguimgo.springbootrestserver.dao.RoleRepository;

12. import com.bnguimgo.springbootrestserver.model.Role;

14. public class RoleServiceImplTest {

16. RoleRepository roleRepository = Mockito.mock(RoleRepository.class);

18. @Test

19. public void test_getAllRoles(){

21. RoleServiceImpl roleService = new  RoleServiceImpl (roleRepository );

23. Role role = new Role("USER_ROLE");

24. List<Role> roles = Arrays.asList(role);

26. Mockito.when(roleRepository.findAll()).thenReturn(roles);

27. Collection<Role> result = roleService.getAllRoles();

29. Assertions.assertThat(result).isNotNull().hasSize(1);

30. }

32. @Test

33. public void test_getAllRolesStream(){

35. RoleServiceImpl roleService = new  RoleServiceImpl (roleRepository );

37. Role role = new Role("USER_ROLE");

38. List<Role> roles = Arrays.asList(role);

40. Mockito.when(roleRepository.findAll()).thenReturn(roles);

41. Collection<Role> result = roleService.getAllRoles();

43. Assertions.assertThat(result).isNotNull().hasSize(1);

44. }

45. }

I-D-3 - Tests unitaires du contrôleur

Le contrôleur à tester est le suivant :

1. @Controller

3. @CrossOrigin(origins = "*", maxAge = 3600)

4. @RestController

5. @RequestMapping("/user/*")

6. public class UserController {

8. @Autowired

9. private UserService userService;

11. @GetMapping(value = "/users")

12. public ResponseEntity<Collection<User>> getAllUsers() {

13. Collection<User> users = userService.getAllUsers();

14. return new ResponseEntity<Collection<User>>(users, HttpStatus.FOUND);

15. }

17. @PostMapping(value = "/users")

18. @Transactional

19. public ResponseEntity<User> saveUser(@RequestBody User user) {

21. User userSaved = userService.saveOrUpdateUser(user);

22. return new ResponseEntity<User>(userSaved, HttpStatus.CREATED);

23. }

25. @PostMapping(value = "/users/login")

26. public ResponseEntity<User> findUserByLoginAndPassword(@RequestBody User user) {

27. Optional<User> userFound = userService.findByLoginAndPassword(user.getLogin(), user.getPassword());

28. return new ResponseEntity<User>(userFound.get(), HttpStatus.FOUND);

29. }

31. @PutMapping(value = "/users")

32. public ResponseEntity<User> updateUser(@RequestBody User user) {

33. User userUpdated = userService.saveOrUpdateUser(user);

34. return new ResponseEntity<User>(userUpdated, HttpStatus.OK);

35. }

37. @DeleteMapping(value = "/users")

38. public ResponseEntity<Void> deleteUser(@RequestParam(value = "id", required = true) Long id) throws BusinessResourceException {

40. userService.deleteUser(id);

41. return new ResponseEntity<Void>(HttpStatus.GONE);

42. }

43. }

Ce contrôleur dépend de la couche de service. Il ne connait pas la couche DAO. Il faut donc créer un Mock Object de la couche de service grâce à l'annotation déjà vue précédemment @MockBean. Et pour indiquer le contrôleur à tester, il faut utiliser l'annotation @WebMvcTest en lui passant en paramètre la classe à tester. Cette annotation va aussi apporter toutes les dépendances SpringMVC nécessaires pour le cas de test.

Grâce à l'annotation @WebMvcTest, on peut injecter un MockMvc qui servira à construire des URL pour générer des requêtes HTTP. Nous allons tester la méthode du contrôleur qui recherche tous les utilisateurs de la base de données.

Créez le package de tests com.bnguimgo.springbootrestserver.controller et y ajouter le test UserControllerTest

1. package com.bnguimgo.springbootrestserver.controller;

3. import static org.junit.Assert.assertEquals;

4. import static org.junit.Assert.assertNotNull;

5. import static org.mockito.ArgumentMatchers.any;

6. import static org.mockito.Mockito.verify;

7. import static org.mockito.Mockito.when;

8. //for HTTP methods

9. import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;

10. import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;

11. import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

12. //for HTTP status

13. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

15. import java.util.Arrays;

16. import java.util.Collection;

17. import java.util.HashSet;

18. import java.util.List;

19. import java.util.Optional;

20. import java.util.Set;

22. import org.junit.Before;

23. import org.junit.Test;

24. import org.junit.runner.RunWith;

25. import org.springframework.beans.factory.annotation.Autowired;

26. import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;

27. import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;

28. import org.springframework.boot.test.mock.mockito.MockBean;

29. import org.springframework.http.HttpStatus;

30. import org.springframework.http.MediaType;

31. import org.springframework.test.context.junit4.SpringRunner;

32. import org.springframework.test.web.servlet.MockMvc;

33. import org.springframework.test.web.servlet.MvcResult;

34. import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;

36. import com.bnguimgo.springbootrestserver.model.Role;

37. import com.bnguimgo.springbootrestserver.model.User;

38. import com.bnguimgo.springbootrestserver.service.UserService;

39. import com.fasterxml.jackson.core.type.TypeReference;

40. import com.fasterxml.jackson.databind.ObjectMapper;

42. @RunWith(SpringRunner.class)

43. @WebMvcTest(controllers = UserController.class, excludeAutoConfiguration = SecurityAutoConfiguration.class)

44. public class UserControllerTest {

46. @Autowired

47. private MockMvc mockMvc;

48. private ObjectMapper objectMapper;

50. @MockBean

51. private UserService userService;

53. User user;

55. @Before

56. public void setUp() {

57. // Initialisation du setup avant chaque test

58. Role role = new Role("USER_ROLE");// initialisation du role utilisateur

59. Set<Role> roles = new HashSet<>();

60. roles.add(role);

61. user = new User(1L, "Dupont", "password", 1);

62. user.setRoles(roles);

63. List<User> allUsers = Arrays.asList(user);

64. objectMapper = new ObjectMapper();

66. // Mock de la couche de service

67. when(userService.getAllUsers()).thenReturn(allUsers);

68. when(userService.findUserById(any(Long.class))).thenReturn(Optional.of(user));

70. }

72. @Test

73. public void testFindAllUsers() throws Exception {

75. MvcResult result = mockMvc.perform(get("/user/users").contentType(MediaType.APPLICATION_JSON))

76. .andExpect(status().isFound()).andReturn();

78. // ceci est une redondance, car déjà vérifié par: isFound())

79. assertEquals("Réponse incorrecte", HttpStatus.FOUND.value(), result.getResponse().getStatus());

80. verify(userService).getAllUsers();

81. assertNotNull(result);

82. Collection<User> users = objectMapper.readValue(result.getResponse().getContentAsString(),

83. new TypeReference<Collection<User>>() {

84. });

85. assertNotNull(users);

86. assertEquals(1, users.size());

87. User userResult = users.iterator().next();

88. assertEquals(user.getLogin(), userResult.getLogin());

89. assertEquals(user.getPassword(), userResult.getPassword());

91. }

93. @Test

94. public void testSaveUser() throws Exception {

96. User userToSave = new User("Dupont", "password", 1);

97. String jsonContent = objectMapper.writeValueAsString(userToSave);

98. when(userService.saveOrUpdateUser(any(User.class))).thenReturn(user);

99. MvcResult result = mockMvc

100. .perform(post("/user/users").contentType(MediaType.APPLICATION_JSON).content(jsonContent))

101. .andExpect(status().isCreated()).andReturn();

103. assertEquals("Erreur de sauvegarde", HttpStatus.CREATED.value(), result.getResponse().getStatus());

104. verify(userService).saveOrUpdateUser(any(User.class));

105. User userResult = objectMapper.readValue(result.getResponse().getContentAsString(), new TypeReference<User>() {

106. });

107. assertNotNull(userResult);

108. assertEquals(userToSave.getLogin(), userResult.getLogin());

109. assertEquals(userToSave.getPassword(), userResult.getPassword());

111. }

113. @Test

114. public void testFindUserByLogin() throws Exception {

115. when(userService.findByLoginAndPassword("Dupont", "password")).thenReturn(Optional.ofNullable(user));

116. User userTofindByLogin = new User("Dupont", "password");

117. String jsonContent = objectMapper.writeValueAsString(userTofindByLogin);

118. // on execute la requête

119. MvcResult result = mockMvc

120. .perform(post("/user/users/login").contentType(MediaType.APPLICATION_JSON).content(jsonContent))

121. .andExpect(status().isFound()).andReturn();

123. assertEquals("Erreur de sauvegarde", HttpStatus.FOUND.value(), result.getResponse().getStatus());

124. verify(userService).findByLoginAndPassword(any(String.class), any(String.class));

125. User userResult = objectMapper.readValue(result.getResponse().getContentAsString(), new TypeReference<User>() {

126. });

127. assertNotNull(userResult);

128. assertEquals(new Long(1), userResult.getId());

129. assertEquals(userTofindByLogin.getLogin(), userResult.getLogin());

130. assertEquals(userTofindByLogin.getPassword(), userResult.getPassword());

131. }

133. @Test

134. public void testDeleteUser() throws Exception {

136. MvcResult result = mockMvc.perform(delete("/user/users/").param("id", "1"))

137. .andExpect(status().isGone()).andReturn();

138. assertEquals("Erreur de suppression", HttpStatus.GONE.value(), result.getResponse().getStatus());

139. verify(userService).deleteUser(any(Long.class));

140. }

142. @Test

143. public void testUpdateUser() throws Exception {

145. User userToUpdate = new User("Toto", "password", 0);

146. User userUpdated = new User(2L, "Toto", "password", 0);

147. String jsonContent = objectMapper.writeValueAsString(userToUpdate);

148. when(userService.saveOrUpdateUser(userToUpdate)).thenReturn(userUpdated);

149. // on execute la requête

150. MvcResult result = mockMvc

151. .perform(MockMvcRequestBuilders.put("/user/users/").contentType(MediaType.APPLICATION_JSON)

152. .accept(MediaType.APPLICATION_JSON).content(jsonContent))

153. .andExpect(status().isOk()).andReturn();

155. verify(userService).saveOrUpdateUser(any(User.class));

156. User userResult = objectMapper.readValue(result.getResponse().getContentAsString(), new TypeReference<User>() {

157. });

158. assertNotNull(userResult);

159. assertEquals(new Long(2), userResult.getId());

160. assertEquals(userToUpdate.getLogin(), userResult.getLogin());

161. assertEquals(userToUpdate.getPassword(), userResult.getPassword());

162. assertEquals(userToUpdate.getActive(), userResult.getActive());

163. }

165. }

Arrêtons-nous un peu sur le test unitaire deleteUser(@RequestParam(value = "id", required = true) Long id) pour apprécier comment se fait le passage du paramètre id. Comme le passage du paramètre id se fait par @RequestParam, nous devons également utiliser .param("id", "1") pour assigner la valeur à ce paramètre pour les tests, ce qui donne le résultat ci-dessous:

Test avec @RequestParam

@Test
public void testDeleteUser() throws Exception {

MvcResult result = mockMvc.perform(delete("/user/users/")
.param("id", "1"))
.andExpect(status().isGone())
.andReturn();
assertEquals("Erreur de suppression", HttpStatus.GONE.value(), result.getResponse().getStatus());
verify(userService).deleteUser(any(Long.class));
}

Pour faire le même test si on avait plutôt @PathVariable en paramètre, on obtiendrait:

Test avec @PathVariable en paramètre

@Test
public void testDeleteUser() throws Exception {

MvcResult result = mockMvc.perform(delete("/user/users/{id}", new Long(1)))
.andExpect(status().isGone())
.andReturn();
assertEquals("Erreur de suppression", HttpStatus.GONE.value(), result.getResponse().getStatus());
verify(userService).deleteUser(any(Long.class));
}

I-D-4 - Résumé sur les tests unitaires

Ce chapitre sur les tests unitaires a permis de voir que les besoins en matière de tests unitaires diffèrent d'une couche à l'autre.

Couche DAO :

- @DataJpaTest pour configurer la base de données et la Datasource.

- TestEntityManager qui est l'équivalent Mock de JPA EntityManager.

Couche de service :

- Mockito pour créer un Mock de la couche DAO.

Couche contrôleur :

- @WebMvcTest permet d'indiquer le contrôleur à tester.

- @MockMvc pour injecter un mock MVC qui sert à construire des requêtes HTTP.

- @MockBean pour créer un mock de la couche de service.

Il faut bien remarquer que nous n'avons pas utisé l'annotation @RunWith(SpringRunner.class) lors des tests de la couche de service puisque nous appliqué l'@autowired sur le constructeur.

I-E - Tests d'intégration services REST

Les tests d'intégration consistent à tester une fonctionnalité ou un service complet à travers toutes les couches de l'application. Cela suppose qu'il faut penser à un déploiement complet de l'application, d'où la nécessité d'un serveur d'application, d'une base de données et, par conséquent, pas de Mocks Objects. En pratique, pour tester toutes les fonctionnalités d'une application, ça prend beaucoup de temps. C'est pourquoi ces tests sont séparés des tests unitaires et sont généralement programmés à des heures d'activités réduites.

Avant d'écrire le test, voici le paramétrage nécessaire à l'initialisation de la classe de test :

Paramétrage des tests d'intégration

1. @RunWith(SpringRunner.class)

2. @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)

3. public class UserControllerIntegrationTest {

5. @Autowired

6. private TestRestTemplate restTemplate;

7. private static final String URL = "http://localhost:8080";// url du serveur REST. Cet url peut être celle d'un serveur distant

9. private String getURLWithPort(String uri) {

10. return URL + uri;

11. }

13. // Les tests ici

14. }

Il faut garder à l'esprit que, dans le cadre d'un test d'intégration, tous les services du serveur d'application doivent être démarrés.

Quelques explications :

@SpringBootTest(webEnvironment=WebEnvironment.RANDOM_PORT, classes={Controller.class, AnotherController.class})

Cette annotation a pour rôle :

- de déclencher le démarrage du serveur d'application sur un port libre.

- de créer une ApplicationContext pour les tests.

- d'initialiser une servlet embarquée.

- et enfin d'enregistrer un bean TestRestTemplate pour la gestion des requêtes HTTP.

TestRestTemplate est une dépendance nécessaire pour écrire les requêtes HTTP.

Voici les tests d'intégration complets pour tous les services CRUD à créer dans un package com.bnguimgo.springbootrestserver.integrationtest :

Tests d'intégration pour tous les services CRUD

1. package com.bnguimgo.springbootrestserver.integrationtest;

3. import static org.junit.Assert.assertEquals;

4. import static org.junit.Assert.assertNotNull;

6. import java.net.URI;

7. import java.util.Collection;

9. import org.junit.Test;

10. import org.junit.runner.RunWith;

11. import org.springframework.beans.factory.annotation.Autowired;

12. import org.springframework.boot.test.context.SpringBootTest;

13. import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;

14. import org.springframework.boot.test.web.client.TestRestTemplate;

15. import org.springframework.http.HttpEntity;

16. import org.springframework.http.HttpMethod;

17. import org.springframework.http.HttpStatus;

18. import org.springframework.http.ResponseEntity;

19. import org.springframework.test.context.junit4.SpringRunner;

20. import org.springframework.web.util.UriComponentsBuilder;

22. import com.bnguimgo.springbootrestserver.model.Role;

23. import com.bnguimgo.springbootrestserver.model.User;

25. @RunWith(SpringRunner.class)

26. @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)

27. public class UserControllerIntegrationTest {

29. @Autowired

30. private TestRestTemplate restTemplate;

31. private static final String URL = "http://localhost:8080";// url du serveur REST. Cette url peut être celle d'un //

32. // serveur distant

34. private String getURLWithPort(String uri) {

35. return URL + uri;

36. }

38. @Test

39. public void testFindAllUsers() throws Exception {

41. ResponseEntity<Object> responseEntity = restTemplate

42. .getForEntity(getURLWithPort("/springboot-restserver/user/users"), Object.class);

43. assertNotNull(responseEntity);

44. @SuppressWarnings("unchecked")

45. Collection<User> userCollections = (Collection<User>) responseEntity.getBody();

46. assertEquals("Réponse inattendue", HttpStatus.FOUND.value(), responseEntity.getStatusCodeValue());

47. assertNotNull(userCollections);

48. assertEquals(4, userCollections.size());

49. // on a bien 3 utilisateurs initialisés par les scripts data.sql + un nouvel

50. // utilisateur crée dans testSaveUser

51. }

53. @Test

54. public void testSaveUser() throws Exception {

56. User user = new User("PIPO", "newPassword", 1);

57. ResponseEntity<User> userEntitySaved = restTemplate

58. .postForEntity(getURLWithPort("/springboot-restserver/user/users"), user, User.class);

59. assertNotNull(userEntitySaved);

60. // On vérifie le code de réponse HTTP est celui attendu

61. assertEquals("Réponse inattendue", HttpStatus.CREATED.value(), userEntitySaved.getStatusCodeValue());

62. User userSaved = userEntitySaved.getBody();

63. assertNotNull(userSaved);

64. assertEquals(user.getLogin(), userSaved.getLogin());

65. assertEquals("ROLE_USER", userSaved.getRoles().iterator().next().getRoleName());// on vérifie qu'un role par

66. }

68. @Test

69. public void testFindByLoginAndPassword() throws Exception {

71. User userTofindByLoginAndPassword = new User("admin@admin.com", "admin");

72. ResponseEntity<User> responseEntity = restTemplate.postForEntity(

73. getURLWithPort("/springboot-restserver/user/users/login"), userTofindByLoginAndPassword, User.class);

74. assertNotNull(responseEntity);

75. User userFound = responseEntity.getBody();

77. assertEquals("Réponse inattendue", HttpStatus.FOUND.value(), responseEntity.getStatusCodeValue());

78. assertNotNull(userFound);

79. assertEquals(new Long(1), userFound.getId());

80. }

82. @Test

83. public void testFindByLoginAndPassword_notFound() throws Exception {

85. User userTofindByLoginAndPassword = new User("unknowUser", "password3");

86. ResponseEntity<User> responseEntity = restTemplate.postForEntity(

87. getURLWithPort("/springboot-restserver/user/users/login"), userTofindByLoginAndPassword, User.class);

88. assertNotNull(responseEntity);

89. assertEquals("Réponse inattendue", HttpStatus.NOT_FOUND.value(), responseEntity.getStatusCodeValue());

90. }

92. @Test

93. public void testUpdateUser() throws Exception {

95. ResponseEntity<User> responseEntityToUpdate = restTemplate.postForEntity(

96. getURLWithPort("/springboot-restserver/user/users/login"), new User("login3", "password3"), User.class);

97. User userFromDBtoUpdate = responseEntityToUpdate.getBody();

98. // on met à jour l'utilisateur en lui attribuant le role admin, nouveau login et mot de passe

99. userFromDBtoUpdate.setLogin("newLogin");

100. userFromDBtoUpdate.setPassword("newPassword");

101. userFromDBtoUpdate.setActive(1);

102. Role role = new Role("ROLE_ADMIN");

103. userFromDBtoUpdate.getRoles().add(role);

105. URI uri = UriComponentsBuilder.fromHttpUrl(URL).path("/springboot-restserver/user/users").build().toUri();

107. HttpEntity<User> requestEntity = new HttpEntity<User>(userFromDBtoUpdate);

109. ResponseEntity<User> responseEntity = restTemplate.exchange(uri, HttpMethod.PUT, requestEntity, User.class);

110. assertNotNull(responseEntity);

111. User userUpdated = responseEntity.getBody();

112. assertNotNull(userUpdated);

113. assertEquals("Réponse inattendue", HttpStatus.OK.value(), responseEntity.getStatusCodeValue());

114. assertEquals(userFromDBtoUpdate.getLogin(), userUpdated.getLogin());

115. }

117. @Test

118. public void testDeleteUser() throws Exception {

120. URI uri = UriComponentsBuilder.fromHttpUrl(URL).path("/springboot-restserver/user/users")

121. .queryParam("id", new Long(2)).build().toUri();

123. ResponseEntity<Void> responseEntity = restTemplate.exchange(uri, HttpMethod.DELETE, null, Void.class);

124. assertNotNull(responseEntity);

125. assertEquals("Réponse inattendue", HttpStatus.GONE.value(), responseEntity.getStatusCodeValue());

126. }

127. }

Pour exécuter les tests depuis Eclipse, déployez l'application dans Tomcat, puis clic droit sur le nom de la classe Run as ... ==> JUnit Test.

I-F - Automatisation des tests

Dans les chapitres précédents, nous avons développé les tests unitaires et les tests d'intégration, mais ces tests ne sont pas automatisés. c'est-à-dire qu'il faut une action humaine pour que ces tests s'exécutent. Nous pouvons encore faire mieux en les automatisant, c'est ce que nous allons voir dans les chapitres suivants.

I-F-1 - Automatisation des tests unitaires

Maven offre un excellent moyen d'automatiser les tests à travers l'utilisation des plugins. Pour automatiser les tests unitaires, nous allons utliser le plugin maven-surefire-plugin. Son avantage c'est de s'arrêter imédiatement dès qu'un test unitaire échoue.

Voici sa configuration à ajouter dans pom.xml, dans la partie plugins:

Plugin d'exécution des tests unitaires

1. <plugin>

2. <groupId>org.apache.maven.plugins</groupId>

3. <artifactId>maven-surefire-plugin</artifactId>

4. <!-- On exclut les tests d'intégration lors de l'exécution des tests

5. unitaires -->

6. <configuration>

7. <excludes>

8. <exclude>**/*IntegrationTest.java</exclude>

9. </excludes>

10. </configuration>

11. </plugin>

pour exécuter les tests unitaires, ouvrir la console, se placer dans le répertoire contenant le pom.xml, puis exécuter mvn clean install.

Lors de l'exécution des tests unitaires, on exclu les tests d'intégration, car ce n'est pas nécessaires.

I-F-2 - Automatisation des tests d'intégration

Nous allons configurer le plugin cargo-maven2-pluginpour automatiser les tests d'intégration, et Tomcat9 pour comme serveur de tests. L'avantage de cette automatisation c'est qu'on pourra facilement intégrer l'application dans un environnement d'intégration continue comme jenkins. Voir le tutoriel de Alexandre-Laurent pour plus de détails.

Toute la configuration se passe dans le pom.xml. Pour ce faire, on va créer un profil d'intégration.

Ce profil permet démarrer, Tomcat9x, de jouer les tests d'intégration et d'arrêter Tomcat à la fin des tests grâce au plugin cargo-maven2-plugin

Cargo s'appuie sur le plugin maven-failsafe-plugin pour éxécuter les tests d'intégration. Nous aurons donc besoin d'intégrer maven-failsafe-plugin et le configurer.

Dans la phase des tests d'intégration, on aimerait pas rejouer les tests unitaires, il faudra donc les exclure en utilisant le plugin maven-surefire-plugin qui a été utilisé pour les tests unitaires

Configuration du profil des tests d'intégration continue

1. <profile>

2. <id>integrationTest</id>

3. <activation>

4. <activeByDefault>false</activeByDefault>

5. </activation>

6. <properties>

7. <skip.surefire.tests>true</skip.surefire.tests>

8. <skip.failsafe.tests>false</skip.failsafe.tests>

9. </properties>

10. <build>

11. <plugins>

12. <plugin>

13. <groupId>org.codehaus.cargo</groupId>

14. <artifactId>cargo-maven2-plugin</artifactId>

15. <version>${cargo.version}</version>

16. <configuration>

17. <wait>false</wait>

18. <container>

19. <containerId>tomcat9x</containerId>

20. <type>embedded</type>

21. </container>

22. <configuration>

23. <properties>

24. <!-- les ports 8080 et 8009 sont à éviter si l'application est déjà déployée -->

25. <cargo.servlet.port>8484</cargo.servlet.port>

26. <cargo.tomcat.ajp.port>8090</cargo.tomcat.ajp.port>

27. </properties>

28. </configuration>

29. </configuration>

31. <executions>

32. <execution>

33. <!-- id obligatoire pour distinguer les deux phases start et stop -->

34. <id>start-server</id>

35. <phase>pre-integration-test</phase>

36. <goals>

37. <goal>start</goal>

38. </goals>

39. </execution>

40. <execution>

41. <id>stop-server</id>

42. <phase>post-integration-test</phase>

43. <goals>

44. <goal>stop</goal>

45. </goals>

46. </execution>

47. </executions>

48. </plugin>

49. <!-- Configuration de l'exécution des tests d'intégration -->

50. <!-- C'est le plugin maven-failsafe-plugin qui va exécuter les tests d'intégration -->

51. <plugin>

52. <artifactId>maven-failsafe-plugin</artifactId>

53. <executions>

54. <execution>

55. <phase>integration-test</phase>

56. <goals>

57. <goal>integration-test</goal>

58. <goal>verify</goal>

59. </goals>

60. </execution>

61. </executions>

62. <configuration>

63. <excludes>

64. <exclude>none</exclude>

65. </excludes>

66. <includes>

67. <include>**/*IntegrationTest.java</include>

68. </includes>

69. <skipTests>${skip.failsafe.tests}</skipTests>

70. </configuration>

71. </plugin>

72. <!-- Le plugin maven-surefire-plugin est adapté pour les tests unitaires -->

73. <plugin>

74. <artifactId>maven-surefire-plugin</artifactId>

75. <!-- On exclut les tests unitaires lors des tests d'intégration, car

76. déjà exécutés NB: Si vous n'ajoutez pas cette configuration, les tests unitaires

77. seront reéxécutés lors des tests d'intégration -->

78. <configuration>

79. <skipTests>${skip.surefire.tests}</skipTests>

80. </configuration>

81. </plugin>

83. </plugins>

84. </build>

85. </profile>

Il faut configurer le port de l'application correctement dans la classe de test UserControllerIntegrationTest, ==> private static final String URL = "http://localhost:8484";. Vérifier que c'est le même port à la fois dans le profil d'intégration et dans la classe. J'ai préféré le port 8484 pour éviter que le port 8080 soit déjà utilisé par l'application.

Pour exécuter les tests d'intégration depuis une console, placez-vous dans le répertoire des sources, puis exécuter: mvn clean install -PintegrationTest

Note: il n'est pas conseillé d'utiliser le plugin surfire pour les tests d'intégration, car surfire s'arrête dès la première erreur et ne continue plus les autres tests, il faut préferer plutôt le plugin failsafe.

Phases d'éxécution du plugin

- On utilise une version embarquée de Tomcat9x.

- Dans la phase pré-intégration, on démarre le serveur.

- On inclut les tests d'intégration et on exclut les tests unitaires.

- Après exécution des tests, dans la phase post-intégration, on arrêt le serveur.

Note: dans l'environnement d'intégration continue Jenkins, ce sont les mêmes commandes qu'il faut configurer, à savoir: mvn clean install -PintegrationTest pour les tests d'intégration ou simplement mvn clean install pour les test unitaires.

I-G - Déploiement automatisé

Il est question de voir dans ce paragraphe comment déployer automatiquement l'application à travers le plugin cargo-maven2-plugin

Un profil springbootServerDeploy a été ajouté au pom.xml afin de faciliter le déploiement.

Mais, on peut également déployer depuis Eclipse par un simple clic droit sur le projet, puis Run As --> Run on Server.

Configuration du profil de déploiement automatisé

1. <profile>

2. <id>springbootServerDeploy</id>

3. <activation>

4. <activeByDefault>false</activeByDefault>

5. </activation>

6. <build>

7. <plugins>

8. <plugin>

9. <groupId>org.codehaus.cargo</groupId>

10. <artifactId>cargo-maven2-plugin</artifactId>

11. <version>${cargo.version}</version>

12. <configuration>

13. <container>

14. <containerId>tomcat9x</containerId>

15. <type>embedded</type>

16. </container>

17. <configuration>

18. <home>${project.build.directory}/tomcat9x</home>

19. <properties>

20. <cargo.servlet.port>8080</cargo.servlet.port>

21. </properties>

22. </configuration>

23. <deployables>

24. <deployable>

25. <groupId>com.bnguimgo</groupId>

26. <artifactId>springboot-restserver</artifactId>

27. <type>war</type>

28. <pingURL>http://localhost:8080/springboot-restserver</pingURL>

29. </deployable>

30. </deployables>

31. </configuration>

32. </plugin>

33. </plugins>

34. </build>

35. </profile>

I-H - Paramétrage complet

Dans ce paragraphe, il est juste question de vous fournir le paramétrage complet de l'application à travers le fichier pom.xml.

pom.xml au complet de l'application

1. <?xml version="1.0" encoding="UTF-8"?>

2. <project xmlns="http://maven.apache.org/POM/4.0.0"

3. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

4. xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

5. <modelVersion>4.0.0</modelVersion>

7. <groupId>com.bnguimgo</groupId>

8. <artifactId>springboot-restserver</artifactId>

9. <version>0.0.1-SNAPSHOT</version>

10. <packaging>war</packaging>

12. <name>springboot-restserver</name>

13. <description>Serveur Rest utilisant le framework Spring Boot</description>

15. <parent>

16. <groupId>org.springframework.boot</groupId>

17. <artifactId>spring-boot-starter-parent</artifactId>

18. <version>2.2.6.RELEASE</version>

19. <relativePath />

20. </parent>

22. <properties>

23. <start-class>com.bnguimgo.restclient.com.bnguimgo.springbootrestserver.SpringbootRestserverApplication</start-class>

24. <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

25. <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

26. <java.version>1.8</java.version>

27. <cargo.version>1.7.10</cargo.version>

28. <tomcat.version>9.0.24</tomcat.version>

29. </properties>

30. <repositories>

31. <repository>

32. <id>central maven repo</id>

33. <name>central maven repo https</name>

34. <url>https://repo.maven.apache.org/maven2</url>

35. </repository>

36. </repositories>

37. <dependencies>

38. <dependency>

39. <groupId>org.springframework.boot</groupId>

40. <artifactId>spring-boot-starter-data-jpa</artifactId>

41. </dependency>

42. <dependency>

43. <groupId>org.springframework.boot</groupId>

44. <artifactId>spring-boot-starter-web</artifactId>

45. </dependency>

47. <!-- Spring Security: juste pour hacher le mot de passe -->

48. <dependency>

49. <groupId>org.springframework.boot</groupId>

50. <artifactId>spring-boot-starter-security</artifactId>

51. </dependency>

53. <!-- Permet l'utilisation des collections Apache -->

54. <dependency>

55. <groupId>org.apache.commons</groupId>

56. <artifactId>commons-collections4</artifactId>

57. <version>4.0</version>

58. </dependency>

60. <!-- Base de données utilisée par l'application -->

61. <dependency>

62. <groupId>com.h2database</groupId>

63. <artifactId>h2</artifactId>

64. <scope>runtime</scope>

65. </dependency>

67. <!-- Dépendances pour les tests uniquement -->

68. <dependency>

69. <groupId>org.springframework.boot</groupId>

70. <artifactId>spring-boot-starter-test</artifactId>

71. </dependency>

72. <dependency>

73. <groupId>com.jayway.jsonpath</groupId>

74. <artifactId>json-path</artifactId>

75. <scope>test</scope>

76. </dependency>

77. </dependencies>

79. <build>

80. <finalName>springboot-restserver</finalName>

81. <resources>

82. <resource>

83. <directory>src/main/resources</directory>

84. </resource>

85. </resources>

86. <plugins>

87. <plugin>

88. <groupId>org.springframework.boot</groupId>

89. <artifactId>spring-boot-maven-plugin</artifactId>

90. </plugin>

91. <plugin>

92. <groupId>org.apache.maven.plugins</groupId>

93. <artifactId>maven-surefire-plugin</artifactId>

94. <configuration>

95. <excludes>

96. <exclude>**/*IntegrationTest.java</exclude>

97. </excludes>

98. </configuration>

99. </plugin>

100. </plugins>

101. </build>

102. <profiles>

103. <profile>

104. <id>integrationTest</id>

105. <activation>

106. <activeByDefault>false</activeByDefault>

107. </activation>

108. <properties>

109. <skip.surefire.tests>true</skip.surefire.tests>

110. <skip.failsafe.tests>false</skip.failsafe.tests>

111. </properties>

112. <build>

113. <plugins>

114. <plugin>

115. <groupId>org.codehaus.cargo</groupId>

116. <artifactId>cargo-maven2-plugin</artifactId>

117. <version>${cargo.version}</version>

118. <configuration>

119. <wait>false</wait>

120. <container>

121. <containerId>tomcat9x</containerId>

122. <type>embedded</type>

123. </container>

124. <configuration>

125. <properties>

126. <cargo.servlet.port>8484</cargo.servlet.port>

127. <cargo.tomcat.ajp.port>8090</cargo.tomcat.ajp.port>

128. </properties>

129. </configuration>

130. </configuration>

132. <executions>

133. <execution>

134. <id>start-server</id>

135. <phase>pre-integration-test</phase>

136. <goals>

137. <goal>start</goal>

138. </goals>

139. </execution>

140. <execution>

141. <id>stop-server</id>

142. <phase>post-integration-test</phase>

143. <goals>

144. <goal>stop</goal>

145. </goals>

146. </execution>

147. </executions>

148. </plugin>

149. <plugin>

150. <artifactId>maven-failsafe-plugin</artifactId>

151. <executions>

152. <execution>

153. <phase>integration-test</phase>

154. <goals>

155. <goal>integration-test</goal>

156. <goal>verify</goal>

157. </goals>

158. </execution>

159. </executions>

160. <configuration>

161. <excludes>

162. <exclude>none</exclude>

163. </excludes>

164. <includes>

165. <include>**/*IntegrationTest.java</include>

166. </includes>

167. <skipTests>${skip.failsafe.tests}</skipTests>

168. </configuration>

169. </plugin>

170. <plugin>

171. <artifactId>maven-surefire-plugin</artifactId>

172. <configuration>

173. <skipTests>${skip.surefire.tests}</skipTests>

174. </configuration>

175. </plugin>

177. </plugins>

178. </build>

179. </profile>

180. <profile>

181. <id>springbootServerDeploy</id>

182. <activation>

183. <activeByDefault>false</activeByDefault>

184. </activation>

185. <build>

186. <plugins>

187. <plugin>

188. <groupId>org.codehaus.cargo</groupId>

189. <artifactId>cargo-maven2-plugin</artifactId>

190. <version>${cargo.version}</version>

191. <configuration>

192. <container>

193. <containerId>tomcat9x</containerId>

194. <type>embedded</type>

195. </container>

196. <configuration>

197. <home>${project.build.directory}/tomcat9x</home>

198. <properties>

199. <cargo.servlet.port>8080</cargo.servlet.port>

200. </properties>

201. </configuration>

202. <deployables>

203. <deployable>

204. <groupId>com.bnguimgo</groupId>

205. <artifactId>springboot-restserver</artifactId>

206. <type>war</type>

207. <pingURL>http://localhost:8080/springboot-restserver</pingURL>

208. </deployable>

209. </deployables>

210. </configuration>

211. </plugin>

212. </plugins>

213. </build>

214. </profile>

215. </profiles>

216. </project>

Pour déployer en utilisant ce plugin, depuis une console, placez-vous au même niveau que le pom.xml et exécuter la commande: mvn cargo:run -PspringbootServerDeploy

Dans la deuxième partie, je vais développer un client web pour consommer ces services. J'utiliserai pour ce faire Spring RestTemplate pour permettre la communication entre le client et le serveur. C'est d'ailleurs pour cette que j'ai utilisé Spring RestTemplate pour les tests d'intégration.

Je vous invite à lire la deuxième partie de ce tutoriel, car la plupart des tutoriels qui existent sur internet ne montrent pas comment mettre en place le client afin de consommer les services. J'ai tenu à bien séparer les deux parties pour démontrer qu'on peut tout à fait développer et consommer ces services sur un serveur distant.

II - Consommation des services

II-A - Introduction

Dans cette deuxième partie du tutoriel, je vais développer un client REST en utilisant le Framework Spring RestTemplate et SpringMVC. Le service à consommer par le client a été développé dans la première partie et est disponible ici.

Spring RestTemplate est un Framework de Spring qui permet d'établir une communication entre un client et un serveur REST, ceci grâce aux requêtes HTTP.

SpringMVC permet de faire le lien entre le contrôleur et les pages JSP grâce aux mappings des objets Models (Model, Map, ModelAndView).

Le contrôleur intercepte toutes les requêtes et les transmet (grâce à Spring RestTemplate) ensuite au service REST qui lui renvoie une réponse, et c'est cette réponse qui est remise au Model à travers une variable pour affichage dans une page JSP.

II-B - Création du projet client

Le socle projet sera créé par génération depuis le site web de Spring Boot dont voici le lien : https://start.spring.io/. Vous pouvez néanmoins créer manuellement un projet Maven classique et le compléter. Avant de créer le socle projet, nous savons que notre application est une application web.

Voici donc les dépendances nécessaires :

- Web : permet à Spring Boot de rapatrier toutes les dépendances pour une application web (SpringMVC, SpringContext etc.).

- Packaging : War, car l'application sera déployée sur un serveur d'application.

- Java Version 8.

- Pour accéder à tous les modules fournis par Spring Boot, cliquez sur Switch to full version.

Génération Spring Boot Client Rest

Le résultat de la génération est un fichier zip springboot-restclient.zip que vous devez décompresser et importer dans votre IDE préféré (dans mon cas, il s'agit d'Eclipse) en suivant la procédure suivante :

Fichier --> Import … --> Existing Maven Projects --> Next --> Indiquer le chemin au pom.xml --> Finish

Structure projet client généré par Spring Boot

Voici le contenu des deux classes générées :

ServletInitializer

1. package com.bnguimgo.springbootrestclient;

3. import org.springframework.boot.builder.SpringApplicationBuilder;

4. import org.springframework.boot.web.support.SpringBootServletInitializer;

6. public class ServletInitializer extends SpringBootServletInitializer {

8. @Override

9. protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {

10. return application.sources(SpringbootRestclientApplication.class);

11. }

12. }

La classe ServletInitializer étend SpringBootServletInitializer qui permet de déployer l'application en tant qu'application web. Elle initialise également la classe de démarrage.

SpringbootRestclientApplication

1. package com.bnguimgo.springbootrestclient;

3. import org.springframework.boot.SpringApplication;

4. import org.springframework.boot.autoconfigure.SpringBootApplication;

6. @SpringBootApplication

7. public class SpringbootRestclientApplication {

9. public static void main(String[] args) {

10. SpringApplication.run(SpringbootRestclientApplication.class, args);

11. }

12. }

La classe SpringbootRestclientApplication est le point d'entrée de l'application, et possède à cet effet la méthode main(String[] args) qui exécute l'application au démarrage

Il faut compléter le pom.xml avec les propriétés et dépendances manquantes. Dans la partie propriété, j'ai indiqué quel est le point d'entrée de l'application, et la version de Tomcat. Ces deux configurations restent optionnelles, car Spring boot sait se débrouiller tout seul. Mais, comme je vais utiliser un Tomcat externe, il faut renseigner la version de Tomcat. Vous pouvez donc supprimer la dépendance de Tomcat ajoutée par défaut lors de la génération.

1. <properties>

2. <start-class>com.bnguimgo.springbootrestclient.SpringbootRestclientApplication</start-class><!-- cette déclaration est optionnelle  -->

3. <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

4. <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

5. <java.version>1.8</java.version>

6. <tomcat.version>8.0.41</tomcat.version><!-- Permet de modifier la version de Tomcat embarquée  -->

7. </properties>

Comme on va développer les pages web en utilisant la technologie JSP, il faut ajouter la dépendance jstl, car Spring Boot ne sait pas d'avance quel client web il va supporter. Cependant, Spring Boot intègre un Framework de Template Thymeleaf que je ne vais pas utiliser dans le cadre de ce projet.
On va donc ajouter cette dépendance :

1. <dependency>

2. <groupId>javax.servlet</groupId>

3. <artifactId>jstl</artifactId>

4. </dependency>

Pensez à faire de temps en temps une synchronisation des dépendances dans Eclipse en faisant un clic droit --> Maven --> Update Projects …

J'ai supprimé les dépendances spring-boot-starter-tomcat et spring-boot-starter-test puisque nous n'en aurons pas besoin. Il faut aussi supprimer le package de tests. Pour les tests unitaires et tests d'intégration, se référer à la première partie de ce tutoriel.
Voici le pom.xml final de l'application :

pom.xml final

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.bnguimgo</groupId>
<artifactId>springboot-restclient</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>war</packaging>

<name>springboot-restclient</name>
<description>Client REST utilisant le framework Spring Boot</description>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.6.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>

<properties>
<start-class>com.bnguimgo.springbootrestclient.SpringbootRestclientApplication</start-class><!-- cette déclaration est optionnelle  -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<tomcat.version>9.0.24</tomcat.version><!-- optionnelle, permet de modifier la version de Tomcat embarquée  -->
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- obligatoire: JSTL pour les pages JSP, car Spring Boot ne sais pas quel type de client vous allez utiliser (JSP, AngularJs, VueJs, etc.) -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>jstl</artifactId>
</dependency>
<!-- Dépendances pour les tests uniquement -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<finalName>springboot-restclient</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

En résumé, le pom.xml ne contient que deux dépendances :

- Une dépendance spring-boot-starter-web pour la partie web.

- Une dépendance JSTL pour les servlets et pages JSP.

Il faut exécuter un premier build pour vérifier que la configuration est bonne.

II-C - Création du contrôleur

Je vais créer un contrôleur par défaut dont le rôle est d'envoyer une requête ping au serveur pour vérifier si le serveur est démarré et donc disponible, sinon, une page d'erreur est affichée.

II-C-1 - Contrôleur par défaut

C'est le contrôleur qui s'écutera en premier. Dès le chargement de l'application,il va autommatiquement contacter le serveur Rest.

Créez un package controller et ajoutez la classe WelcomeController.

contrôleur WelcomeController

1. package com.bnguimgo.springbootrestclient.controller;

2. import org.slf4j.Logger;

3. import org.slf4j.LoggerFactory;

4. import org.springframework.beans.factory.annotation.Autowired;

5. import org.springframework.http.ResponseEntity;

6. import org.springframework.stereotype.Controller;

7. import org.springframework.web.bind.annotation.GetMapping;

8. import org.springframework.web.client.RestTemplate;

9. import org.springframework.web.servlet.ModelAndView;

11. @Controller

12. public class WelcomeController {

14. private static Logger logger = LoggerFactory.getLogger(WelcomeController.class);

16. @Autowired

17. private RestTemplate restTemplate;

19. @Autowired

20. private PropertiesConfigurationService configurationService;

22. @GetMapping(value = "/")

23. ModelAndView ping(ModelAndView modelAndView) {

24. // On vérifie directement la disponibilité du serveur au lancement de l'application

25. ResponseEntity<String> reponseServeur = restTemplate.getForEntity(configurationService.getUrl(), String.class);

26. int codeReponseServeur = reponseServeur.getStatusCodeValue();

27. String reponsePing = "";

28. if (codeReponseServeur != 200) {

29. logger.error("Réponse du serveur: " + codeReponseServeur+ " ==> Serveur indisponible, votre application ne fonctionnera pas correctement");

30. reponsePing = configurationService.getPingServeurKo();

31. } else {

32. reponsePing = configurationService.getPingServeurOk();

33. logger.info(configurationService.getPingServeur(), reponsePing);

34. }

35. // construction de la vue

36. modelAndView.setViewName("welcome");

37. modelAndView.addObject("urlServeur", configurationService.getUrl());

38. modelAndView.addObject("pingServeur", reponsePing);

39. modelAndView.addObject("profileActif", configurationService.getProfileActif());

40. return modelAndView;

41. }

43. @GetMapping(value = "/error")

44. public String error() {

45. return "error";

46. }

48. @GetMapping("/next")

49. ModelAndView next(ModelAndView modelAndView) {

50. modelAndView.setViewName("next");

51. modelAndView.addObject("message", configurationService.getMessage());

52. return modelAndView;

53. }

54. }

L'injection par @Autowired de RestTemplate va permettre de construire les requêtes HTTP nécessaires pour établir la communication avec le serveur.

Grâce à l'annotation @GetMapping(value="/"), la méthode ModelAndView ping(ModelAndView modelAndView) {xxx } envoie une requête "ping" au serveur à l'adresse app.serveur.url = http://localhost:8080/springboot-restserver/. Cette requête est construite avec RestTemplate

Si la réponse reçue correspond au code HttpStatus = 200, c'est que le serveur est disponible. On construit ensuite la vue welcome.jsp grâce à la classe ModelAndView fournie par Spring en lui passant en paramètres la page à afficher, l'URL du serveur, la réponse du serveur, et enfin le profil utilisateur.

Si le serveur ne répond pas, une page d'erreur error.jsp sera construite et renvoyée grâce à la méthode :

code de la page d'erreur

1. @GetMapping(value = "/error")

2. public String error() {

3. return "error";

4. }

L'adresse du serveur est configurée dans le fichier application.properties et accessible grâce à la classe utilitaire ci-dessous.

Classe utilitaire PropertiesConfigurationService

1. package com.bnguimgo.springbootrestclient.controller;

3. import org.springframework.beans.factory.annotation.Value;

4. import org.springframework.stereotype.Service;

6. @Service

7. public class PropertiesConfigurationService{

9. @Value("${app.serveur.url}") // injection via application.properties

10. private String url="";

12. @Value("${app.serveur.pingServeur}")

13. private String pingServeur;

15. @Value("${app.serveur.ok}")

16. private String pingServeurOk;

18. @Value("${app.serveur.ko}")

19. private String pingServeurKo;

21. @Value("${nextpage.message}")

22. private String message;

24. @Value("${spring.profiles.active}")

25. private String profileActif;

26. // getters, setters

28. public String getUrl() {

29. return url;

30. }

32. public String getPingServeur() {

33. return pingServeur;

34. }

36. public String getPingServeurOk() {

37. return pingServeurOk;

38. }

40. public String getPingServeurKo() {

41. return pingServeurKo;

42. }

44. public String getMessage() {

45. return message;

46. }

48. public String getProfileActif() {

49. return profileActif;

50. }

51. }

Cette classe utilitaire PropertiesConfigurationService permet de récupérer l'URL du serveur stockée dans le fichier de configuration application.properties. Cette classe permet également de récupérer toutes les autres variables du fichier de configuration. Le principal avantage de cette classe, c'est qu'elle permet d'éviter qu'une même variable soit injectée dans plusieurs classes, ce qui rend la maintenance facile.

Voici les trois fichiers .properties à ajouter dans source/main/resources/

Le fichier application.properties contient les propriétés globales de l'application

1. #Au démarrage de l'application, en cas d'erreur

2. server.error.whitelabel.enabled = false

3. #Chargement du profil par defaut dev

4. spring.profiles.active=dev

Le fichier application-dev-properties contient la configuration nécessaire en phase de développement

1. #URL du serveur REST

2. app.serveur.url = http://localhost:8080/springboot-restserver/

3. nextpage.message = Salut vous &ecirc;tes en profile dev

4. app.serveur.ok = disponible

5. app.serveur.ko = indisponible

6. app.serveur.pingServeur = Le serveur est {}

7. compte.user.exist = Un utilisateur est d&eacute;j&agrave; enregistr&eacute; avec ce compte

9. #GESTION DES LOGS

10. logging.level.org.springframework.web=DEBUG

11. logging.level.com.bnguimgo.restclient=DEBUG

13. # Pattern impression des logs consoles

14. logging.pattern.console= %d{yyyy-MM-dd HH:mm:ss} - %msg%n

16. # Pattern impression des logs dans un fichier

17. logging.pattern.file= %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n

18. # Redirection des logs vers un fichier du repertoire Temp, exemple sur windows: C:\Users\UnserName\AppData\Local\Temp\

19. logging.file= ${java.io.tmpdir}\logs\\restClient\\applicationRestClient.log

Le fichier application-prod-properties contient la configuration nécessaire en phase de production

1. logging.level.org.springframework.web=ERROR

2. logging.level.com.bnguimgo.restclient=ERROR

3. #

4. # Pattern impression des logs console

5. logging.pattern.console= %d{yyyy-MM-dd HH:mm:ss} - %msg%n

7. # Pattern impression des logs dans un fichier

8. logging.pattern.file= %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n

9. # Redirection des logs vers un fichier du repertoire Temp, exemple sur windows: C:\Users\UnserName\AppData\Local\Temp\

10. logging.file=${java.io.tmpdir}/logs/rest/applicationRest.log

II-C-2 - Contrôleur LoginController

Ce contrôleur gère tous les services CRUD relatifs au compte utilisateur.

LoginController

1. package com.bnguimgo.springbootrestclient.controller;

3. import java.util.HashMap;

4. import java.util.Map;

5. import java.util.Set;

7. import javax.validation.Valid;

9. import org.slf4j.Logger;

10. import org.slf4j.LoggerFactory;

11. import org.springframework.beans.factory.annotation.Autowired;

12. import org.springframework.http.HttpEntity;

13. import org.springframework.http.HttpMethod;

14. import org.springframework.http.ResponseEntity;

15. import org.springframework.validation.BindingResult;

16. import org.springframework.web.bind.annotation.GetMapping;

17. import org.springframework.web.bind.annotation.ModelAttribute;

18. import org.springframework.web.bind.annotation.PathVariable;

19. import org.springframework.web.bind.annotation.PostMapping;

20. import org.springframework.web.bind.annotation.RequestParam;

21. import org.springframework.web.bind.annotation.RestController;

22. import org.springframework.web.client.HttpClientErrorException;

23. import org.springframework.web.client.HttpServerErrorException;

24. import org.springframework.web.client.RestTemplate;

25. import org.springframework.web.servlet.ModelAndView;

27. import com.bnguimgo.springbootrestclient.dto.RestClientExceptionDTO;

28. import com.bnguimgo.springbootrestclient.dto.UserDto;

29. import com.bnguimgo.springbootrestclient.entities.User;

30. import com.fasterxml.jackson.core.JsonProcessingException;

31. import com.fasterxml.jackson.core.type.TypeReference;

32. import com.fasterxml.jackson.databind.JsonMappingException;

33. import com.fasterxml.jackson.databind.ObjectMapper;

35. @RestController

36. public class LoginController {

38. private static Logger logger = LoggerFactory.getLogger(LoginController.class);

40. @Autowired

41. private RestTemplate restTemplate;

43. @Autowired

44. private PropertiesConfigurationService configurationService;

46. @GetMapping(value = "/login") // initialisation du login

47. public ModelAndView loginView(ModelAndView modelAndView) {

48. modelAndView.setViewName("loginForm");

49. UserDto userDto = new UserDto();

50. modelAndView.addObject("userForm", userDto);

51. return modelAndView;

52. }

54. @PostMapping(value = "/login")

55. public ModelAndView login(@Valid @ModelAttribute("userForm") UserDto userForm, BindingResult bindingResult,

56. ModelAndView modelAndView) throws JsonMappingException, JsonProcessingException {

58. modelAndView.setViewName("loginForm");

59. if (bindingResult.hasErrors()) {

60. modelAndView.addObject("saveError", "Erreur de saisie");

61. return modelAndView;

62. }

63. try {

64. ResponseEntity<User> userExists = restTemplate

65. .postForEntity(configurationService.getUrl() + "user/users/login", new User(userForm), User.class);

67. modelAndView.addObject("currentUser", new UserDto(userExists.getBody()));

68. modelAndView.setViewName("loginSuccess");

69. return modelAndView;

71. } catch (HttpClientErrorException | HttpServerErrorException ex) {

72. ObjectMapper objectMapper = new ObjectMapper();

73. RestClientExceptionDTO restClientExceptionDTO = objectMapper.readValue(ex.getResponseBodyAsString(),

74. new TypeReference<RestClientExceptionDTO>() {

75. });

76. modelAndView.setViewName("loginForm");

77. modelAndView.addObject("saveError", restClientExceptionDTO.getErrorMessage());

78. modelAndView.addObject("createAccount", " Créez votre compte !");

79. return modelAndView;

80. } catch (Exception ex) {

81. modelAndView.setViewName("loginForm");

82. modelAndView.addObject("saveError", ex.getMessage());

83. modelAndView.addObject("createAccount", " Créez votre compte !");

84. return modelAndView;

85. }

87. }

89. @GetMapping(value = "/registration") // initialisation du formulaire de création du compte

90. public ModelAndView registrationView(ModelAndView modelAndView) {

91. modelAndView.setViewName("registrationForm");

92. UserDto userRegistrationForm = new UserDto();

93. modelAndView.addObject("registrationForm", userRegistrationForm);

94. return modelAndView;

95. }

97. @PostMapping(value = "/registration")

98. public ModelAndView saveUser(@Valid @ModelAttribute("registrationForm") UserDto userDtoForm,

99. BindingResult bindingResult, ModelAndView modelAndView)

100. throws JsonMappingException, JsonProcessingException {

101. modelAndView.setViewName("registrationForm");

102. if (bindingResult.hasErrors()) {

103. return modelAndView;

104. }

105. try {

106. ResponseEntity<User> userEntitySaved = restTemplate.postForEntity(configurationService.getUrl() + "user/users", new User(userDtoForm), User.class);

107. User user = userEntitySaved.getBody();

108. modelAndView.addObject("currentUser", new UserDto(user));

109. modelAndView.setViewName("loginSuccess");

110. return modelAndView;

112. } catch (HttpClientErrorException | HttpServerErrorException ex) {

113. ObjectMapper objectMapper = new ObjectMapper();

114. RestClientExceptionDTO restClientExceptionDTO = objectMapper.readValue(ex.getResponseBodyAsString(),

115. new TypeReference<RestClientExceptionDTO>() {

116. });

117. modelAndView.addObject("saveError", restClientExceptionDTO.getErrorMessage());

118. bindingResult.rejectValue("login", "error.user", restClientExceptionDTO.getErrorMessage());

119. // login doit être un attribut réel du formulaire UserDto

120. return modelAndView;

122. } catch (Exception ex) {

123. logger.info("Erreur technique de création du compte", ex);

124. modelAndView.addObject("saveError", "erreur technique de création du compte");

125. return modelAndView;

127. }

128. }

130. @GetMapping(value = "/{id}")

131. public ModelAndView findUserById(@PathVariable(value = "id") Long id, ModelAndView modelAndView) throws JsonMappingException, JsonProcessingException {

132. modelAndView.setViewName("updateUserForm");

133. Map<String, Long> variables = new HashMap<>(1);

134. variables.put("id", new Long(id));

135. try {

136. ResponseEntity<User> userEntityFound = restTemplate.getForEntity(configurationService.getUrl() + "user/users/"+String.valueOf(id), User.class);

137. UserDto userDto = new UserDto(userEntityFound.getBody());

138. modelAndView.addObject("currentUserForm", userDto);

139. return modelAndView;

140. } catch (HttpClientErrorException | HttpServerErrorException ex) {

141. ObjectMapper objectMapper = new ObjectMapper();

142. RestClientExceptionDTO restClientExceptionDTO = objectMapper.readValue(ex.getResponseBodyAsString(),

143. new TypeReference<RestClientExceptionDTO>() {

144. });

145. modelAndView.addObject("userNotFoundError", restClientExceptionDTO.getErrorMessage());

146. return modelAndView;

147. } catch (Exception ex) {

148. logger.info("Erreur technique de recherche utilisateur", ex);

149. modelAndView.addObject("saveError", "Erreur technique de recherche utilisateur");

150. return modelAndView;

152. }

153. }

155. @PostMapping(value = "/updateuser")

156. public ModelAndView updateUser(@Valid @ModelAttribute("currentUserForm") UserDto userDtoForm, @RequestParam(value ="roles", required=false) Set<String>roles,

157. BindingResult bindingResult, ModelAndView modelAndView)

158. throws JsonMappingException, JsonProcessingException {

159. modelAndView.setViewName("updateUserForm");

160. if (bindingResult.hasErrors()) {

161. return modelAndView;

162. }

163. try {

164. HttpEntity<User> requestEntity = new HttpEntity<User>(new User(userDtoForm));

165. ResponseEntity<User> userEntityUpdated = restTemplate

166. .exchange(configurationService.getUrl() + "user/users", HttpMethod.PUT, requestEntity, User.class);

167. UserDto userDto = new UserDto(userEntityUpdated.getBody());

168. modelAndView.addObject("currentUserForm", userDto);

169. modelAndView.addObject("updateSucess", "Mise à jour Ok !");

170. modelAndView.setViewName("updateUserForm");

171. return modelAndView;

173. } catch (HttpClientErrorException | HttpServerErrorException ex) {

174. ObjectMapper objectMapper = new ObjectMapper();

175. RestClientExceptionDTO restClientExceptionDTO = objectMapper.readValue(ex.getResponseBodyAsString(),

176. new TypeReference<RestClientExceptionDTO>() {

177. });

178. modelAndView.addObject("saveError", restClientExceptionDTO.getErrorMessage());

179. bindingResult.rejectValue("login", "error.user", restClientExceptionDTO.getErrorMessage());// login doit être un attribut réel du formulaire UserDto

180. return modelAndView;

182. } catch (Exception ex) {

183. logger.info("Erreur technique de mise à jour", ex);

184. modelAndView.addObject("saveError", "erreur technique de mise à jour.");

185. return modelAndView;

187. }

189. }

190. }

II-C-3 - Rôle de chaque méthode

Il faut deux méthodes pour gérer le login: une pour initialiser l'objet qui va contenir les informations de login, une autre pour gérer le login proprement dit.

Méthode d'initialisation du login

Méthode d'initialisation du login

1. @GetMapping(value = "/login")

2. public ModelAndView loginView(ModelAndView modelAndView) {

3. modelAndView.setViewName("loginForm");

4. UserDto userDto = new UserDto();

5. modelAndView.addObject("userForm", userDto);

6. return modelAndView;

7. }

La méthode ci-dessus initialise la vue jsp qui sera renvoyée

- @GetMapping(value = "/login") permet d'intercepter toutes les requêtes HTTP GET de la forme: http://localhost:8080/springboot-restclient/login.

- C'est cette requête qui déclenche l'initialisation de la vue.

- La vue va s'appeler loginForm.jsp.

- Les informations de login seront stockées dans userDto.

- L'ensemble loginForm + userDto sera stocké dans un objet modèle spring modelAndView et renvoyer comme réponse à la réquête http://localhost:8080/springboot-restclient/login.

- La classe UserDto et le formulaire loginForm.jsp sont disponibles en téléchargement.

Méthode de gestion du formulaire de connexion:

Méthode de gestion du formulaire du login

1. @PostMapping(value = "/login")

2. public ModelAndView login(@Valid @ModelAttribute("userForm") UserDto userForm, BindingResult bindingResult,

3. ModelAndView modelAndView) throws JsonMappingException, JsonProcessingException {

5. modelAndView.setViewName("loginForm");

6. if (bindingResult.hasErrors()) {

7. modelAndView.addObject("saveError", "Erreur de saisie");

8. return modelAndView;

9. }

10. try {

11. ResponseEntity<User> userExists = restTemplate

12. .postForEntity(configurationService.getUrl() + "user/users/login", new User(userForm), User.class);

14. modelAndView.addObject("currentUser", new UserDto(userExists.getBody()));

15. modelAndView.setViewName("loginSuccess");

16. return modelAndView;

18. } catch (HttpClientErrorException | HttpServerErrorException ex) {

19. ObjectMapper objectMapper = new ObjectMapper();

20. RestClientExceptionDTO restClientExceptionDTO = objectMapper.readValue(ex.getResponseBodyAsString(),

21. new TypeReference<RestClientExceptionDTO>() {

22. });

23. modelAndView.setViewName("loginForm");

24. modelAndView.addObject("saveError", restClientExceptionDTO.getErrorMessage());

25. modelAndView.addObject("createAccount", " Créez votre compte !");

26. return modelAndView;

27. } catch (Exception ex) {

28. modelAndView.setViewName("loginForm");

29. modelAndView.addObject("saveError", ex.getMessage());

30. modelAndView.addObject("createAccount", " Créez votre compte !");

31. return modelAndView;

32. }

34. }

La méthode ci-dessus traite les données du formulaire loginForm.jsp

- @PostMapping(value = "/login") permet d'intercepter toutes les requêtes HTTP POST de la forme: http://localhost:8080/springboot-restclient/login.

- C'est cette requête qui déclenche l'appel du service Rest.

- L'annotation @Valid permet de vérifier et de valider les champs obligatoires de l'objet userForm. Il faut ouvrir la classe UserDto pour voir ces champs à valider, c'est le cas du champ password.

- L'annotation @ModelAttribute("userForm") de spring crée la liaison entre le formulaire et l'objet UserDto.

- La classe BindingResult collecte et gère les erreurs du formulaire en relation avec l'objet UserDto.

- S'il y a une erreur, on la stocke dans la variable saveError qu'on affichera à travers un label dans l'IHM

- On construit la requête HTTP POST grâce à la classe RestTemplate, en lui passant l'URL, le contenu du formulaire, et le type de réponse attendu.

- La réponse à la requête sera stockée dans la variable objet currentUser.

- Si tout se passe bien, une nouvelle page loginSuccess.jsp est construite et renvoyée.

- En cas d'erreur, on parse l'erreur et on stocke le résultat dans l'objet restClientExceptionDTO, puis on renvoie la page initiale loginForm.jsp et on propose à l'utilisateur de créer son compte.

Pour enregistrer un utilisateur, il faut suivre la même démarche ci-dessus, à savoir, initialiser d'abord un objet userRegistrationForm, puis faire la sauvegarde après contrôle des champs obligatoires.

Pour la mise à jour, on utilise plutôt restTemplate.exchange(xxx), car avec restTemplate.put(xxx), on n'a pas assez de flexibilités pour gérer les réponses HTTP

II-C-4 - Configuration de l'application

Dans cette section, je vais créer une classe de configuration MvcConfiguration juste pour indiquer à Spring Boot où se trouvent les pages web à charger, puisque je ne vais pas utiliser la configuration par défaut qui consiste à mettre toutes les pages statiques dans le dossier source/main/resources/static généré. Créez un package config et ajoutez cette classe:

MvcConfiguration

1. package com.bnguimgo.springbootrestclient.config;

3. import java.time.Duration;

5. import org.springframework.boot.web.client.RestTemplateBuilder;

6. import org.springframework.context.annotation.Bean;

7. import org.springframework.context.annotation.ComponentScan;

8. import org.springframework.context.annotation.Configuration;

9. import org.springframework.web.client.RestTemplate;

10. import org.springframework.web.servlet.config.annotation.DefaultServletHandlerConfigurer;

11. import org.springframework.web.servlet.config.annotation.EnableWebMvc;

12. import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;

13. import org.springframework.web.servlet.config.annotation.ViewResolverRegistry;

14. import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

15. import org.springframework.web.servlet.view.InternalResourceViewResolver;

16. import org.springframework.web.servlet.view.JstlView;

18. @Configuration

19. @EnableWebMvc

20. @ComponentScan

21. public class MvcConfiguration implements WebMvcConfigurer {

23. @Override

24. public void configureViewResolvers(ViewResolverRegistry registry) {

25. InternalResourceViewResolver resolver = new InternalResourceViewResolver();

26. resolver.setPrefix("/WEB-INF/views/");

27. resolver.setSuffix(".jsp");

28. resolver.setOrder(1);

29. resolver.setViewClass(JstlView.class);

30. registry.viewResolver(resolver);

31. }

32. @Override

33. public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {

34. configurer.enable();

35. }

37. @Bean

38. public RestTemplate restTemplate(RestTemplateBuilder builder) {

39. builder.setConnectTimeout(Duration.ofSeconds(10));//set timeout connection

40. return builder.build();

41. }

42. //Permet de spécifier le chemin d'accès aux fichiers de resources statiques comme le CSS, les images etc.

43. @Override

44. public void addResourceHandlers(final ResourceHandlerRegistry registry) {

45. registry.addResourceHandler("/resources/**").addResourceLocations("/resources/");

46. }

47. }

D'après cette configuration, toutes les pages web (vues) seront dans le dossier "/WEB-INF/views/".

L'annotation @Configuration indique que cette classe peut être utilisée par le conteneur Spring comme une source de définition des beans. On constate par ailleurs que le bean restTemplate y a été initialisé.

Si on ne déclarait pas le bean restTemplate, il serait impossible d'injecter RestTemplate par @Autowired dans le contrôleur WelcomeController, à défaut il faut faire un new RestTemplate() à chaque appel de RestTemplate.

Depuis Spring-4, on ne peut plus injecter directement RestTemplate par @Autowired car, la classe RestTemplate a été est migrée dans RestTemplateBuilder qui offre plus de méthodes, comme la configuration du timeout, etc.

L'annotation @EnableWebMvc est l'équivalent de mvc:annotation-driven si on utilisait la configuration XML. Cette annotation active le contexte SpringMVC, et permet d'utiliser les annotations @Controller.

@ComponentScan scanne par défaut toutes les classes du même package pour trouver les classes annotées.

II-C-5 - Création des pages web

Créez les dossiers WEB-INF/views/ dans webapp et ajoutez les vues.jsp. Toutes les vues que j'ai créées sont disponibles en téléchargement dans le répertoire WEB-INF/views/.

Les vues (pages web) et toutes les sources sont disponibles en téléchargement.

II-D - Test manuel de l'application

Commencer par démarrer le serveur, ensuite le client (clic droit sur le projet --> Run As --> Run On Server --> Choisir Tomcat-8). Voici l'URL de test : http://localhost:8080/springboot-restclient/.

II-D-1 - Serveur démarré

Serveur REST démarré et à l'écoute

II-D-2 - Serveur arrêté

Si on démarre le client alors que le serveur est arrêté, on aura l'erreur ci-dessous:

Serveur REST arrêté

II-D-3 - Création d'un utilisateur

Écran création compte utilisateur

II-D-4 - Test de connexion

Écran de connexion

II-D-5 - Page de connexion réussie

Connexion avec succès

III - Conclusion

Ce chapitre sur les services REST s'achève. Nous venons de découvrir l'efficacité de Spring Boot qui nous facilite grandement la tâche en matière de configuration des dépendances, facilite l'instanciation des beans, rend le déploiement d'une application transparent pour le développeur, etc. Spring Boot intègre parfaitement les frameworks de tests unitaires et de tests d'intégration. Grâce à Spring RestTemplate, il est très facile de consommer un service REST.

III-A - Sources et références

Projet Spring Boot

- http://projects.spring.io/spring-boot/.

Pour générer un projet Spring Boot

- https://start.spring.io/.

D'excellents tutoriels sur REST et SOAP

- https://mbaron.developpez.com/#page_soa.

Mise en place des filtres pour la gestion des requêtes Cross Domain

- https://sqli.developpez.com/tutoriels/spring/construire-backend-springboot/.

Étude comparée SOAP et REST

- https://www.supinfo.com/articles/single/2441-comprendre-soap-rest-leurs-differences.

III-B - Remerciements

Tous mes remerciements au responsable de la rubrique JAVA (M Mickael Baron) qui m'a suivi et encouragé dès le départ à la rédaction de cet article. La correction orthographique a été assurée par M Cedric Duprez, à qui j'adresse mes remerciements sans fautes. Je remercie également tous les développeurs qui ont travaillé à la mise en place des outils qui facilitent la rédaction et la publication des tutoriels sur www.developpez.com