8. Etude de cas
8.1. Introduction
Nous nous proposons d'écrire une application web de prise de rendez-vous pour un cabinet médical. Ce problème a été traité dans le document 'Tutoriel AngularJS / Spring 4' à l'URL [http://tahe.developpez.com/angularjs-spring4/]. L'architecture de cette application était la suivante :
![]() |
- en [1], un serveur web délivre des pages statiques à un navigateur. Ces pages contiennent une application AngularJS construite sur le modèle MVC (Modèle – Vue – Contrôleur). Le modèle ici est à la fois celui des vues et celui du domaine représenté ici par la couche [Services] ;
- l'utilisateur va interagir avec les vues qui lui sont présentées dans le navigateur. Ses actions vont parfois nécessiter l'interrogation du serveur Spring 4 [2]. Celui-ci traitera la demande et rendra une réponse jSON (JavaScript Object Notation) [3]. Celle-ci sera utilisée pour mettre à jour la vue présentée à l'utilisateur.
Nous nous proposons de reprendre cette application et de l'implémenter de bout en bout avec Spring MVC. L'architecture devient alors la suivante :
![]() |
Le navigateur se connectera à une application [Web 1] implémentée par Spring MVC qui ira chercher ses données auprès d'un service web [Web 2] lui aussi implémenté avec Spring MVC.
8.2. Fonctionnalités de l'application
Le lecteur est invité à découvrir les fonctionnalités de l'application en la testant. Nous chargeons dans STS les projets Maven du dossier [etude-de-cas] :
![]() | ![]() |
Tout d'abord nous allons créer la base de données MySQL 5 [dbrdvmedecins] avec l'outil [Wamp Server] (cf paragraphe 9.5) :
![]() |
- en [1], on sélectionne l'outil [phpMyAdmin] de WampServer ;
- en [2], on choisit l'option [Importer] ;
![]() |
- en [3], on sélectionne le fichier [database/dbrdvmedecins.sql] ;
- en [4], on l'exécute ;
- en [5], la base de données créée.
Ensuite, il nous faut lancer le serveur connecté à la base de données. C'est le projet [rdvmedecins-webjson-server]
![]() |
Le serveur va être disponible à l'URL [http://localhost:8080]. Cela peut être changé dans le fichier [application.properties] du projet :
![]() |
server.port=8080
Les caractéristiques d'accès à la base de données sont enregistrées dans la classe [DomainAndPersistenceConfig] du projet [rdvmedecins-metier-dao] :
![]() |
// la source de données MySQL
@Bean
public DataSource dataSource() {
BasicDataSource dataSource = new BasicDataSource();
dataSource.setDriverClassName("com.mysql.jdbc.Driver");
dataSource.setUrl("jdbc:mysql://localhost:3306/dbrdvmedecins");
dataSource.setUsername("root");
dataSource.setPassword("");
return dataSource;
}
Si vous accédez au SGBD MySQL avec d'autres identifiants, c'est là que ça se passe.
On lance ensuite, de la même façon que le serveur précédent, le serveur [rdvmedecins-springthymeleaf-server] :
![]() | ![]() |
Ce serveur est par défaut disponible à l'URL [http://localhost:8081]. De nouveau, c'est configurable dans le fichier [application.properties] du projet :
server.port=8081
Par ailleurs, ce serveur doit connaître l'URL du serveur connecté à la base de données. Cette configuration se trouve dans la classe [AppConfig] ci-dessus :
// admin / admin
private final String USER_INIT = "admin";
private final String MDP_USER_INIT = "admin";
// racine service web / json
private final String WEBJSON_ROOT = "http://localhost:8080";
// timeout en millisecondes
private final int TIMEOUT = 5000;
// CORS
private final boolean CORS_ALLOWED=true;
Si le premier serveur a été lancé sur un autre port que le 8080, il faut modifier la ligne 5.
Ensuite avec un navigateur, on demande l'URL [http://localhost:8081/boot.html] :
![]() |
- en [1], la page d'entrée de l'application ;
- en [2] et [3], l'identifiant et le mot de passe de celui qui veut utiliser l'application. Il y a deux utilisateurs : admin/admin (login/password) avec un rôle (ADMIN) et user/user avec un rôle (USER). Seul le rôle ADMIN a le droit d'utiliser l'application. Le rôle USER n'est là que pour montrer ce que répond le serveur dans ce cas d'utilisation ;
- en [4], le bouton qui permet de se connecter au serveur ;
- en [5], la langue de l'application. Il y en a deux : le français par défaut et l'anglais ;
- en [6], l'URL du serveur [rdvmedecins-springthymeleaf-server] ;
![]() |
- en [1], on se connecte ;
![]() |
- une fois connecté, on peut choisir le médecin avec lequel on veut un rendez-vous [2] et le jour de celui-ci [3]. Dès qu'un médecin et un jour ont été renseignés, l'agenda est automatiquement affiché :
![]() |
- une fois obtenu l'agenda du médecin, on peut réserver un créneau [5] ;
![]() |
- en [6], on choisit le patient pour le rendez-vous et on valide ce choix en [7] ;
![]() |
Une fois le rendez-vous validé, on est ramené automatiquement à l'agenda où le nouveau rendez-vous est désormais inscrit. Ce rendez-vous pourra être ultérieurement supprimé [8].
Les principales fonctionnalités ont été décrites. Elles sont simples. Terminons par la gestion de la langue :

- en [1], on passe du français à l'anglais ;
![]() |
- en [2], la vue est passée en anglais, y-compris le calendrier ;
8.3. La base de données
![]() |
La base de données appelée par la suite [dbrdvmedecins] est une base de données MySQL5 avec les tables suivantes :
![]() |
Les rendez-vous sont gérés par les tables suivantes :
- [medecins] : contient la liste des médecins du cabinet ;
- [clients] : contient la liste des patienst du cabinet ;
- [creneaux] : contient les créneaux horaires de chacun des médecins ;
- [rv] : contient la liste des rendez-vous des médecins.
Les tables [roles], [users] et [users_roles] sont des tables liées à l'authentification. Dans un premier temps, nous n'allons pas nous en occuper. Les relations entre les tables gérant les rendez-vous sont les suivantes :
![]() |
- un créneau horaire appartient à un médecin – un médecin a 0 ou plusieurs créneaux horaires ;
- un rendez-vous réunit à la fois un client et un médecin via un créneau horaire de ce dernier ;
- un client a 0 ou plusieurs rendez-vous ;
- à un créneau horaire est associé 0 ou plusieurs rendez-vous (à des jours différents).
8.3.1. La table [MEDECINS]
Elle contient des informations sur les médecins gérés par l'application [RdvMedecins].
![]() | ![]() |
- ID : n° identifiant le médecin - clé primaire de la table
- VERSION : n° identifiant la version de la ligne dans la table. Ce nombre est incrémenté de 1 à chaque fois qu'une modification est apportée à la ligne.
- NOM : le nom du médecin
- PRENOM : son prénom
- TITRE : son titre (Melle, Mme, Mr)
8.3.2. La table [CLIENTS]
Les clients des différents médecins sont enregistrés dans la table [CLIENTS] :
![]() | ![]() |
- ID : n° identifiant le client - clé primaire de la table
- VERSION : n° identifiant la version de la ligne dans la table. Ce nombre est incrémenté de 1 à chaque fois qu'une modification est apportée à la ligne.
- NOM : le nom du client
- PRENOM : son prénom
- TITRE : son titre (Melle, Mme, Mr)
8.3.3. La table [CRENEAUX]
Elle liste les créneaux horaires où les RV sont possibles :
![]() |
![]() |
- ID : n° identifiant le créneau horaire - clé primaire de la table (ligne 8)
- VERSION : n° identifiant la version de la ligne dans la table. Ce nombre est incrémenté de 1 à chaque fois qu'une modification est apportée à la ligne.
- ID_MEDECIN : n° identifiant le médecin auquel appartient ce créneau – clé étrangère sur la colonne MEDECINS(ID).
- HDEBUT : heure début créneau
- MDEBUT : minutes début créneau
- HFIN : heure fin créneau
- MFIN : minutes fin créneau
La seconde ligne de la table [CRENEAUX] (cf [1] ci-dessus) indique, par exemple, que le créneau n° 2 commence à 8 h 20 et se termine à 8 h 40 et appartient au médecin n° 1 (Mme Marie PELISSIER).
8.3.4. La table [RV]
Elle liste les RV pris pour chaque médecin :
![]() |
- ID : n° identifiant le RV de façon unique – clé primaire
- JOUR : jour du RV
- ID_CRENEAU : créneau horaire du RV - clé étrangère sur le champ [ID] de la table [CRENEAUX] – fixe à la fois le créneau horaire et le médecin concerné.
- ID_CLIENT : n° du client pour qui est faite la réservation – clé étrangère sur le champ [ID] de la table [CLIENTS]
Cette table a une contrainte d'unicité sur les valeurs des colonnes jointes (JOUR, ID_CRENEAU) :
Si une ligne de la table[RV] a la valeur (JOUR1, ID_CRENEAU1) pour les colonnes (JOUR, ID_CRENEAU), cette valeur ne peut se retrouver nulle part ailleurs. Sinon, cela signifierait que deux RV ont été pris au même moment pour le même médecin. D'un point de vue programmation Java, le pilote JDBC de la base lance une SQLException lorsque ce cas se produit.
La ligne d'id égal à 3 (cf [1] ci-dessus) signifie qu'un RV a été pris pour le créneau n° 20 et le client n° 4 le 23/08/2006. La table [CRENEAUX] nous apprend que le créneau n° 20 correspond au créneau horaire 16 h 20 - 16 h 40 et appartient au médecin n° 1 (Mme Marie PELISSIER). La table [CLIENTS] nous apprend que le client n° 4 est Melle Brigitte BISTROU.
8.3.5. Création de la base de données
Pour créer la base de données [dbrdvmedecins], un script [dbrdvmedecins.sql] est fourni avec les exemples de ce document [1-3] :
![]() |
Nous utilisons l'outil [PhpMyAdmin] de WampServer :
![]() |
- en [1], on sélectionne l'outil [phpMyAdmin] de WampServer ;
- en [2], on choisit l'option [Importer] ;
![]() |
- en [3], on sélectionne le fichier [database/dbrdvmedecins.sql] ;
- en [4], on l'exécute ;
- en [5], la base de données créée.
8.4. Le service web / jSON
![]() |
Dans l'architecture ci-dessus, nous abordons maintenant la construction du service web / jSON construit avec le framework Spring MVC. Nous allons l'écrire en plusieurs étapes :
- d'abord les couches [métier] et [DAO] (Data Access Object). Nous utiliserons ici Spring Data ;
- puis le service web jSON sans authentification. Nous utiliserons ici Spring MVC ;
- puis on ajoutera la partie authentification avec Spring Security.
Ce qui suit est une recopie du document [http://tahe.developpez.com/angularjs-spring4/] avec cependant quelques modifications.
8.4.1. Introduction à Spring Data
Nous allons implémenter la couche [DAO] du projet avec Spring Data, une branche de l'écosystème Spring.
![]() |
Sur le site de Spring existent de nombreux tutoriels pour démarrer avec Spring [http://spring.io/guides]. Nous allons utiliser l'un d'eux pour introduire Spring Data. Nous utilisons pour cela Spring Tool Suite (STS).
![]() |
- en [1], nous importons l'un des tutoriels de [spring.io/guides] ;
![]() |
- en [2], on choisit le tutoriel [Accessing Data Jpa] qui montre comment accéder à une base de données avec Spring Data ;
- en [3], on choisit un projet configuré par Maven ;
- en [4], le tutoriel peut être délivré sous deux formes : [initial] qui est une version vide qu'on remplit en suivant le tutoriel ou [complete] qui est la version finale du tutoriel. Nous choisissons cette dernière ;
- en [5], on peut choisir de visualiser le tutoriel dans un navigateur ;
- en [6], le projet final.
8.4.1.1. La configuration Maven du projet
Les dépendances Maven du projet sont configurées dans le fichier [pom.xml] :
<groupId>org.springframework</groupId>
<artifactId>gs-accessing-data-jpa</artifactId>
<version>0.1.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.1.10.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
</dependencies>
<properties>
<!-- use UTF-8 for everything -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<start-class>hello.Application</start-class>
</properties>
- lignes 5-9 : définissent un projet Maven parent. C'est lui qui définit l'essentiel des dépendances du projet. Elles peuvent être suffisantes, auquel cas on n'en rajoute pas, ou pas, auquel cas on rajoute les dépendances manquantes ;
- lignes 12-15 : définissent une dépendance sur [spring-boot-starter-data-jpa]. Cet artifact contient les classes de Spring Data ;
- lignes 16-19 : définissent une dépendance sur le SGBD H2 qui permet de créer et gérer des bases de données en mémoire.
Regardons les classes amenées par ces dépendances :
![]() | ![]() | ![]() |
Elles sont très nombreuses :
- certaines appartiennent à l'écosystème Spring (celles commençant par spring) ;
- d'autres appartiennent à l'écosystème Hibernate (hibernate, jboss) dont on utilise ici l'implémentation JPA ;
- d'autres sont des bibliothèques de tests (junit, hamcrest) ;
- d'autres des bibliothèques de logs (log4j, logback, slf4j) ;
Nous allons les garder toutes. Pour une application en production, il faudrait ne garder que celles qui sont nécessaires.
Ligne 26 du fichier [pom.xml] on trouve la ligne :
<start-class>hello.Application</start-class>
Cette ligne est liée aux lignes suivantes :
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
Lignes 6-9, le plugin [spring-boot-maven-plugin] permet de générer le jar exécutable de l'application. La ligne 26 du fichier [pom.xml] désigne alors la classe exécutable de ce jar.
8.4.1.2. La couche [JPA]
L'accès à la base de données se fait au travers d'une couche [JPA], Java Persistence API :
![]() |
![]() |
L'application est basique et gère des clients [Customer]. La classe [Customer] fait partie de la couche [JPA] et est la suivante :
package hello;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private long id;
private String firstName;
private String lastName;
protected Customer() {
}
public Customer(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
@Override
public String toString() {
return String.format("Customer[id=%d, firstName='%s', lastName='%s']", id, firstName, lastName);
}
}
Un client a un identifiant [id], un prénom [firstName] et un nom [lastName]. Chaque instance [Customer] représente une ligne d'une table de la base de données.
- ligne 8 : annotation JPA qui fait que la persistence des instances [Customer] (Create, Read, Update, Delete) va être gérée par une implémentation JPA. D'après les dépendances Maven, on voit que c'est l'implémentation JPA / Hibernate qui est utilisée ;
- lignes 11-12 : annotations JPA qui associent le champ [id] à la clé primaire de la table des [Customer]. La ligne 12, indique que l'implémentation JPA utilisera la méthode de génération de clé primaire propre au SGBD utilisé, ici H2 ;
Il n'y a pas d'autres annotations JPA. Des valeurs par défaut seront alors utilisées :
- la table des [Customer] portera le nom de la classe, ç-à-d [Customer] ;
- les colonnes de cette table porteront le nom des champs de la classe : [id, firstName, lastName] sachant que la casse n'est pas prise en compte dans le nom d'une colonne de table ;
On notera qu'à aucun moment, l'implémentation JPA utilisée n'est nommée.
8.4.1.3. La couche [DAO]
![]() |
![]() |
La classe [CustomerRepository] implémente la couche [DAO]. Son code est le suivant :
package hello;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
public interface CustomerRepository extends CrudRepository<Customer, Long> {
List<Customer> findByLastName(String lastName);
}
C'est donc une interface et non une classe (ligne 7). Elle étend l'interface [CrudRepository], une interface de Spring Data (ligne 5). Cette interface est paramétrée par deux types : le premier est le type des éléments gérés, ici le type [Customer], le second le type de la clé primaire des éléments gérés, ici un type [Long]. L'interface [CrudRepository] est la suivante :
package org.springframework.data.repository;
import java.io.Serializable;
@NoRepositoryBean
public interface CrudRepository<T, ID extends Serializable> extends Repository<T, ID> {
<S extends T> S save(S entity);
<S extends T> Iterable<S> save(Iterable<S> entities);
T findOne(ID id);
boolean exists(ID id);
Iterable<T> findAll();
Iterable<T> findAll(Iterable<ID> ids);
long count();
void delete(ID id);
void delete(T entity);
void delete(Iterable<? extends T> entities);
void deleteAll();
}
Cette interface définit les opérations CRUD (Create – Read – Update – Delete) qu'on peut faire sur un type JPA T :
- ligne 8 : la méthode save permet de persister une entité T en base. Elle rend l'entité persistée avec la clé primaire que lui a donnée le SGBD. Elle permet également de mettre à jour une entité T identifiée par sa clé primaire id. Le choix de l'une ou l'autre action se fait selon la valeur de la clé primaire id : si celle-ci vaut null c'est l'opération de persistence qui a lieu, sinon c'est l'opération de mise à jour ;
- ligne 10 : idem mais pour une liste d'entités ;
- ligne 12 : la méthode findOne permet de retrouver une entité T identifiée par sa clé primaire id ;
- ligne 22 : la méthode delete permet de supprimer une entité T identifiée par sa clé primaire id ;
- lignes 24-28 : des variantes de la méthode [delete] ;
- ligne 16 : la méthode [findAll] permet de retrouver toutes les entités persistées T ;
- ligne 18 : idem mais limitées aux entités dont on a passé la liste des identifiants ;
Revenons à l'interface [CustomerRepository] :
package hello;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
public interface CustomerRepository extends CrudRepository<Customer, Long> {
List<Customer> findByLastName(String lastName);
}
- la ligne 9 permet de retrouver un [Customer] par son nom [lastName] ;
Et c'est tout pour la couche [DAO]. Il n'y a pas de classe d'implémentation de l'interface précédente. Celle-ci est générée à l'exécution par [Spring Data]. Les méthodes de l'interface [CrudRepository] sont automatiquement implémentées. Pour les méthodes rajoutées dans l'interface [CustomerRepository], ça dépend. Revenons à la définition de [Customer] :
private long id;
private String firstName;
private String lastName;
La méthode de la ligne 9 est implémentée automatiquement par [Spring Data] parce qu'elle référence le champ [lastName] (ligne 3) de [Customer]. Lorsqu'il rencontre une méthode [findBySomething] dans l'interface à implémenter, Spring Data l'implémente par la requête JPQL (Java Persistence Query Language) suivante :
Il faut donc que le type T ait un champ nommé [something]. Ainsi la méthode
va être implémentée par un code ressemblant au suivant :
return [em].createQuery("select c from Customer c where c.lastName=:value").setParameter("value",lastName).getResultList()
où [em] désigne le contexte de persistance JPA. Cela n'est possible que si la classe [Customer] a un champ nommé [lastName], ce qui est le cas.
En conclusion, dans les cas simples, Spring Data nous permet d'implémenter la couche [DAO] avec une simple interface.
8.4.1.4. La couche [console]
![]() |
![]() |
La classe [Application] est la suivante :
package hello;
import java.util.List;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableAutoConfiguration
public class Application {
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(Application.class);
CustomerRepository repository = context.getBean(CustomerRepository.class);
// save a couple of customers
repository.save(new Customer("Jack", "Bauer"));
repository.save(new Customer("Chloe", "O'Brian"));
repository.save(new Customer("Kim", "Bauer"));
repository.save(new Customer("David", "Palmer"));
repository.save(new Customer("Michelle", "Dessler"));
// fetch all customers
Iterable<Customer> customers = repository.findAll();
System.out.println("Customers found with findAll():");
System.out.println("-------------------------------");
for (Customer customer : customers) {
System.out.println(customer);
}
System.out.println();
// fetch an individual customer by ID
Customer customer = repository.findOne(1L);
System.out.println("Customer found with findOne(1L):");
System.out.println("--------------------------------");
System.out.println(customer);
System.out.println();
// fetch customers by last name
List<Customer> bauers = repository.findByLastName("Bauer");
System.out.println("Customer found with findByLastName('Bauer'):");
System.out.println("--------------------------------------------");
for (Customer bauer : bauers) {
System.out.println(bauer);
}
context.close();
}
}
- la ligne 10 : indique que la classe sert à configurer Spring. Les versions récentes de Spring peuvent en effet être configurées en Java plutôt qu'en XML. Les deux méthodes peuvent être utilisées simultanément. Dans le code d'une classe ayant l'annotation [Configuration] on trouve normalement des beans Spring, ç-à-d des définitions de classe à instancier. Ici aucun bean n'est défini. Il faut rappeler ici que lorsqu'on travaille avec un SGBD, divers beans Spring doivent être définis :
- un [EntityManagerFactory] qui définit l'implémentation JPA à utiliser,
- un [DataSource] qui définit la source de données à utiliser,
- un [TransactionManager] qui définit le gestionnaire de transactions à utiliser ;
Ici aucun de ces beans n'est défini.
- la ligne 11 : l'annotation [EnableAutoConfiguration] est une annotation provenant du projet [Spring Boot] (lignes 5-6). Cette annotation demande à Spring Boot via la classe [SpringApplication] (ligne 16) de configurer l'application en fonction des bibliothèques trouvées dans son Classpath. Parce que les bibliothèques Hibernate sont dans le Classpath, le bean [entityManagerFactory] sera implémenté avec Hibernate. Parce que la bibliothèque du SGBD H2 est dans le Classpath, le bean [dataSource] sera implémenté avec H2. Dans le bean [dataSource], on doit définir également l'utilisateur et son mot de passe. Ici Spring Boot utilisera l'administrateur par défaut de H2, sa sans mot de passe. Parce que la bibliothèque [spring-tx] est dans le Classpath, c'est le gestionnaire de transactions de Spring qui sera utilisé.
Par ailleurs, le dossier dans lequel se trouve la classe [Application] va être scanné à la recherche de beans implicitement reconnus par Spring ou définis explicitement par des annotations Spring. Ainsi les classes [Customer] et [CustomerRepository] vont-elles être inspectées. Parce que la première a l'annotation [@Entity] elle sera cataloguée comme entité à gérer par Hibernate. Parce que la seconde étend l'interface [CrudRepository] elle sera enregistrée comme bean Spring.
Examinons les lignes 16-17 du code :
ConfigurableApplicationContext context = SpringApplication.run(Application.class);
CustomerRepository repository = context.getBean(CustomerRepository.class);
- ligne 16 : la méthode statique [run] de la classe [SpringApplication] du projet Spring Boot est exécutée. Son paramètre est la classe qui a une annotation [Configuration] ou [EnableAutoConfiguration]. Tout ce qui a été expliqué précédemment va alors se dérouler. Le résultat est un contexte d'application Spring, ç-à-d un ensemble de beans gérés par Spring ;
- ligne 17 : on demande à ce contexte Spring, un bean implémentant l'interface [CustomerRepository]. Nous récupérons ici, la classe générée par Spring Data pour implémenter cette interface.
Les opérations qui suivent ne font qu'utiliser les méthodes du bean implémentant l'interface [CustomerRepository]. On notera ligne 50, que le contexte est fermé. Les résultats console sont les suivants :
- lignes 1-8 : le logo du projet Spring Boot ;
- ligne 9 : la classe [hello.Application] est exécutée ;
- ligne 10 : [AnnotationConfigApplicationContext] est une classe implémentant l'interface [ApplicationContext] de Spring. C'est un conteneur de beans ;
- ligne 11 : le bean [entityManagerFactory] est implémentée avec la classe [LocalContainerEntityManagerFactory], une classe de Spring ;
- ligne 15 : on voit apparaître [Hibernate]. C'est cette implémentation JPA qui a été choisie ;
- ligne 19 : un dialecte Hibernate est la variante SQL à utiliser avec le SGBD. Ici le dialecte [H2Dialect] montre qu'Hibernate va travailler avec le SGBD H2 ;
- lignes 21-22 : la base de données est créée. La table [CUSTOMER] est créée. Cela signifie qu'Hibernate a été configuré pour générer les tables à partir des définitions JPA, ici la définition JPA de la classe [Customer] ;
- lignes 27-31 : les cinq clients insérés ;
- lignes 33635 : résultat de la méthode [findOne] de l'interface ;
- lignes 37-40 : résultats de la méthode [findByLastName] ;
- lignes 41 et suivantes : logs de la fermeture du contexte Spring.
8.4.1.5. Configuration manuelle du projet Spring Data
Nous dupliquons le projet précédent dans le projet [gs-accessing-data-jpa-2] :
![]() |
Dans ce nouveau projet, nous n'allons pas nous reposer sur la configuration automatique faite par Spring Boot. Nous allons la faire manuellement. Cela peut être utile si les configurations par défaut ne nous conviennent pas.
Tout d'abord, nous allons expliciter les dépendances nécessaires dans le fichier [pom.xml] :
...
<dependencies>
<!-- Spring Core -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<!-- Spring transactions -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-orm</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aop</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<!-- Spring ORM -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<!-- Spring Data -->
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-jpa</artifactId>
<version>1.7.1.RELEASE</version>
</dependency>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
<version>1.1.10.RELEASE</version>
</dependency>
<!-- Hibernate -->
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-entitymanager</artifactId>
<version>4.3.4.Final</version>
</dependency>
<!-- H2 Database -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.178</version>
</dependency>
<!-- Commons DBCP -->
<dependency>
<groupId>commons-dbcp</groupId>
<artifactId>commons-dbcp</artifactId>
<version>1.4</version>
</dependency>
<dependency>
<groupId>commons-pool</groupId>
<artifactId>commons-pool</artifactId>
<version>1.6</version>
</dependency>
</dependencies>
...
</project>
- lignes 2-18 : les bibliothèques de base de Spring ;
- lignes 19-29 : les bibliothèques de Spring pour gérer les transactions avec une base de données ;
- lignes 30-35 : la bibliothèque de Spring pour travailler avec un ORM (Object Relational Mapper) ;
- lignes 36-41 : Spring Data utilisé pour accéder à la base de données ;
- lignes 42-47 : Spring Boot pour lancer l'application ;
- lignes 54-59 : le SGBD H2 ;
- lignes 60-70 : les bases de données sont souvent utilisées avec des pools de connexions ouvertes qui évitent les ouvertures / fermetures de connexion à répétition. Ici, l'implémentation utilisée est celle de [commons-dbcp] ;
Toujours dans [pom.xml], on modifie le nom de la classe exécutable :
<properties>
...
<start-class>demo.console.Main</start-class>
</properties>
Dans le nouveau projet, l'entité [Customer] et l'interface [CustomerRepository] ne changent pas. On va changer la classe [Application] qui va être scindée en deux classes :
- [Config] qui sera la classe de configuration :
- [Main] qui sera la classe exécutable ;
![]() |
La classe exécutable [Main] est la même que précédemment sans les annotations de configuration :
package demo.console;
import java.util.List;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
import demo.config.Config;
import demo.entities.Customer;
import demo.repositories.CustomerRepository;
public class Main {
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(Config.class);
CustomerRepository repository = context.getBean(CustomerRepository.class);
...
context.close();
}
}
- ligne 12 : la classe [Main] n'a plus d'annotations de configuration ;
- ligne 16 : l'application est lancée avec Spring Boot. Le paramètre [Config.class] est la nouvelle classe de configuration du projet ;
La classe [Config] qui configure le projet est la suivante :
package demo.config;
import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import org.apache.commons.dbcp.BasicDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
//@ComponentScan(basePackages = { "demo" })
//@EntityScan(basePackages = { "demo.entities" })
@EnableTransactionManagement
@EnableJpaRepositories(basePackages = { "demo.repositories" })
@Configuration
public class Config {
// la source de données H2
@Bean
public DataSource dataSource() {
BasicDataSource dataSource = new BasicDataSource();
dataSource.setDriverClassName("org.h2.Driver");
dataSource.setUrl("jdbc:h2:./demo");
dataSource.setUsername("sa");
dataSource.setPassword("");
return dataSource;
}
// le provider JPA
@Bean
public JpaVendorAdapter jpaVendorAdapter() {
HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
hibernateJpaVendorAdapter.setShowSql(false);
hibernateJpaVendorAdapter.setGenerateDdl(true);
hibernateJpaVendorAdapter.setDatabase(Database.H2);
return hibernateJpaVendorAdapter;
}
// EntityManagerFactory
@Bean
public EntityManagerFactory entityManagerFactory(JpaVendorAdapter jpaVendorAdapter, DataSource dataSource) {
LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
factory.setJpaVendorAdapter(jpaVendorAdapter);
factory.setPackagesToScan("demo.entities");
factory.setDataSource(dataSource);
factory.afterPropertiesSet();
return factory.getObject();
}
// Transaction manager
@Bean
public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
JpaTransactionManager txManager = new JpaTransactionManager();
txManager.setEntityManagerFactory(entityManagerFactory);
return txManager;
}
}
- ligne 22 : l'annotation [@Configuration] fait de la classe [Config] une classe de configuration Spring ;
- ligne 21 : l'annotation [@EnableJpaRepositories] permet de désigner les dossiers où se trouvent les interfaces Spring Data [CrudRepository]. Ces interfaces vont devenir des composants Spring et être disponibles dans son contexte ;
- ligne 20 : l'annotation [@EnableTransactionManagement] indique que les méthodes des interfaces [CrudRepository] doivent se dérouler à l'intérieur d'une transaction ;
- ligne 19 : l'annotation [@EntityScan] permet de nommer les dossiers où doivent être cherchées les entités JPA. Ici elle a été mise en commentaires, parce que cette information a été donnée explicitement ligne 50. Cette annotation devrait être présente si on utilise le mode [@EnableAutoConfiguration] et que les entités JPA ne sont pas dans le même dossier que la classe de configuration ;
- ligne 18 : l'annotation [@ComponentScan] permet de lister les dossiers où les composants Spring doivent être recherchés. Les composants Spring sont des classes taguées avec des annotations Spring telles que @Service, @Component, @Controller, ... Ici il n'y en a pas d'autres que ceux qui sont définis au sein de la classe [Config], aussi l'annotation a-t-elle été mise en commentaires ;
- lignes 25-33 : définissent la source de données, la base de données H2. C'est l'annotation @Bean de la ligne 25 qui fait de l'objet créé par cette méthode un composant géré par Spring. Le nom de la méthode peut être ici quelconque. Cependant elle doit être appelée [dataSource] si l'EntityManagerFactory de la ligne 47 est absent et défini par autoconfiguration ;
- ligne 29 : la base de données s'appellera [demo] et sera générée dans le dossier du projet ;
- lignes 36-43 : définissent l'implémentation JPA utilisée, ici une implémentation Hibernate. Le nom de la méthode peut être ici quelconque ;
- ligne 39 : pas de logs SQL ;
- ligne 30 : la base de données sera créée si elle n'existe pas ;
- lignes 46-54 : définissent l'EntityManagerFactory qui va gérer la persistance JPA. La méthode doit s'appeler obligatoirement [entityManagerFactory] ;
- ligne 47 : la méthode reçoit deux paramètres ayant le type des deux beans définis précédemment. Ceux-ci seront alors construits puis injectés par Spring comme paramètres de la méthode ;
- ligne 49 : fixe l'implémentation JPA utilisée ;
- ligne 50 : fixent les dossiers où trouver les entités JPA ;
- ligne 51 : fixe la source de données à gérer ;
- lignes 57-62 : le gestionnaire de transactions. La méthode doit s'appeler obligatoirement [transactionManager]. Elle reçoit pour paramètre le bean des lignes 46-54 ;
- ligne 60 : le gestionnaire de transactions est associé à l'EntityManagerFactory ;
Les méthodes précédentes peuvent être définies dans un ordre quelconque.
L'exécution du projet donne les mêmes résultats. Un nouveau fichier apparaît dans le dossier du projet, celui de la base de données H2 :
![]() |
Enfin, on peut se passer de Spring Boot. On crée une seconde classe exécutable [Main2] :
![]() |
La classe [Main2] a le code suivant :
package demo.console;
import java.util.List;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import demo.config.Config;
import demo.entities.Customer;
import demo.repositories.CustomerRepository;
public class Main2 {
public static void main(String[] args) {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Config.class);
CustomerRepository repository = context.getBean(CustomerRepository.class);
....
context.close();
}
}
- ligne 15 : la classe de configuration [Config] est désormais exploitée par la classe Spring [AnnotationConfigApplicationContext]. On peut voir ligne 5 qu'il n'y a maintenant plus de dépendances vis à vis de Spring Boot.
L'exécution donne les mêmes résultats que précédemment.
8.4.1.6. Création d'une archive exécutable
Pour créer une archive exécutable du projet, on peut procéder ainsi :
![]() |
- en [1] : on crée une configuration d'exécution ;
- en [2] : de type [Java Application]
- en [3] : désigne le projet à exécuter (utiliser le bouton Browse) ;
- en [4] : désigne la classe à exécuter ;
- en [5] : le nom de la configuration d'exécution – peut être quelconque ;
![]() |
- en [6] : on exporte le projet ;
- en [7] : sous la forme d'une archive JAR exécutable ;
- en [8] : indique le chemin et le nom du fichier exécutable à créer ;
- en [9] : le nom de la configuration d'exécution créée en [5] ;
Ceci fait, on ouvre une console dans le dossier contenant l'archive exécutable :
L'archive est exécutée de la façon suivante :
.....\dist>java -jar gs-accessing-data-jpa-2.jar
Les résultats obtenus dans la console sont les suivants :
8.4.1.7. Créer un nouveau projet Spring Data
Pour créer un squelette de projet Spring Data, on peut procéder de la façon suivante :
![]() |
- en [1], on crée un nouveau projet ;
- en [2] : de type [Spring Starter Project] ;
- le projet généré sera un projet Maven. En [3], on indique le nom du groupe du projet ;
- en [4] : on indique le nom de l'artifact (un jar ici) qui sera créé par construction du projet ;
- en [5] : on indique le package de la classe exécutable qui va être créée dans le projet ;
- en [6] : le nom Eclipse du projet – peut être quelconque (n'a pas à être identique à [4]) ;
- en [7] : on indique qu'on va créer un projet ayant une couche [JPA]. Les dépendances nécessaires à un tel projet vont alors être incluses dans le fichier [pom.xml] ;
![]() |
- en [8] : le projet créé ;
Le fichier [pom.xml] intègre les dépendances nécessaires à un projet JPA :
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.0.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
- lignes 9-12 : les dépendances nécessaires à JPA – vont inclure [Spring Data] ;
- lignes 13-17 : les dépendances nécessaires aux tests JUnit intégrés avec Spring ;
La classe exécutable [Application] ne fait rien mais est pré-configurée :
package istia.st;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan
@EnableAutoConfiguration
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
La classe de tests [ApplicationTests] ne fait rien mais est pré-configurée :
package istia.st;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
public class ApplicationTests {
@Test
public void contextLoads() {
}
}
- ligne 9 : l'annotation [@SpringApplicationConfiguration] permet d'exploiter le fichier de configuration [Application]. La classe de test bénéficiera ainsi de tous les beans qui seront définis par ce fichier ;
- ligne 8 : l'annotation [@RunWith] permet l'intégration de Spring avec JUnit : la classe va pouvoir être exécutée comme un test JUnit. [@RunWith] est une annotation JUnit (ligne 4) alors que la classe [SpringJUnit4ClassRunner] est une classe Spring (ligne 6) ;
Maintenant que nous avons un squelette d'application JPA, nous pouvons le compléter pour écrire le projet de la couche de persistance du serveur de notre application de gestion de rendez-vous.
8.4.2. Le projet Eclipse du serveur
![]() |
![]() |
Les éléments principaux du projet sont les suivants :
- [pom.xml] : fichier de configuration Maven du projet ;
- [rdvmedecins.entities] : les entités JPA ;
- [rdvmedecins.repositories] : les interfaces Spring Data d'accès aux entités JPA ;
- [rdvmedecins.metier] : la couche [métier] ;
- [rdvmedecins.domain] : les entités manipulées par la couche [métier] ;
- [rdvmdecins.config] : les classes de configuration de la couche de persistance ;
- [rdvmedecins.boot] : une application console basique ;
8.4.3. La configuration Maven
![]() | ![]() | ![]() |
Le fichier [pom.xml] du projet est le suivant :
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.spring4.rdvmedecins</groupId>
<artifactId>rdvmedecins-metier-dao</artifactId>
<version>0.0.1-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.6.RELEASE</version>
</parent>
<dependencies>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Spring test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- pilote JDBC / MySQL -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- Tomcat JDBC -->
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-jdbc</artifactId>
</dependency>
<!-- mappeur jSON -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Googe Guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>16.0.1</version>
</dependency>
</dependencies>
<properties>
<!-- use UTF-8 for everything -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<start-class>rdvmedecins.boot.Boot</start-class>
<java.version>1.8</java.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>http://repo.spring.io/libs-milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>org.jboss.repository.releases</id>
<name>JBoss Maven Release Repository</name>
<url>https://repository.jboss.org/nexus/content/repositories/releases</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>http://repo.spring.io/libs-milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
</project>
- lignes 8-12 : le projet s'appuie sur le projet parent [spring-boot-starter-parent]. Pour les dépendances déjà présentes dans le projet parent, on ne précise pas de version. C'est la version définie dans le parent qui sera utilisée. Pour les autres dépendances, on les déclare normalement ;
- lignes 15-18 : pour Spring Data ;
- lignes 20-24 : pour les tests JUnit ;
- lignes 26-29 : pour la bibliothèque Spring Security dont la couche [DAO] utilise l'une des classes de cryptage de mots de passe ;
- lignes 31-34 : pilote JDBC du SGBD MySQL5 ;
- lignes 36-39 : pool de connexions Tomcat JDBC. Un pool de connexions rassemble des connexions ouvertes vers une base de données. Lorsque le code veut ouvrir une connexion, elle est demandée au pool. Lorsque le code ferme la connexion, elle n'est pas fermée mais rendue au pool. Tout ceci se fait de façon transparente au niveau du code. On gagne en performances car l'ouverture / fermeture répétée d'une connexion a un coût en temps. Ici le pool de connexion établit un certain nombre de connexion avec la base de données dès son instanciation. Ensuite, il n'y a ni ouverture, ni fermeture de connexion, sauf si le nombre de connexions stockées dans le pool s'avère insuffisant. Dans ce cas, le pool crée automatiquement de nouvelles connexions ;
- lignes 41-44 : bibliothèque Jackson de gestion du jSON ;
- lignes 46-50 : bibliothèque Google de gestion des collections ;
8.4.4. Les entités JPA
![]() |
Les entités JPA sont les objets qui vont encapsuler les lignes des tables de la base de données.
![]() |
La classe [AbstractEntity] est la classe parent des entités [Personne, Creneau, Rv]. Sa définition est la suivante :
package rdvmedecins.entities;
import java.io.Serializable;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.MappedSuperclass;
import javax.persistence.Version;
@MappedSuperclass
public class AbstractEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
protected Long id;
@Version
protected Long version;
@Override
public int hashCode() {
int hash = 0;
hash += (id != null ? id.hashCode() : 0);
return hash;
}
// initialisation
public AbstractEntity build(Long id, Long version) {
this.id = id;
this.version = version;
return this;
}
@Override
public boolean equals(Object entity) {
String class1 = this.getClass().getName();
String class2 = entity.getClass().getName();
if (!class2.equals(class1) || entity==null) {
return false;
}
AbstractEntity other = (AbstractEntity) entity;
return this.id.longValue() == other.id.longValue();
}
// getters et setters
..
}
- ligne 11 : l'annotation [@MappedSuperclass] indique que la classe annotée est parente d'entités JPA [@Entity] ;
- lignes 15-17 : définissent la clé primaire [id] de chaque entité. C'est l'annotation [@Id] qui fait du champ [id] une clé primaire. L'annotation [@GeneratedValue(strategy = GenerationType.IDENTITY)] indique que la valeur de cette clé primaire est générée par le SGBD et que le mode de génération [IDENTITY] est imposé. Pour le SGBD MySQL, cela signifie que les clés primaires seront générées par le SGBD avec l'attribut [AUTO_INCREMENT]
- lignes 18-19 : définissent la version de chaque entité. L'implémentation JPA va incrémenter ce n° de version à chaque fois que l'entité sera modifiée. Ce n° sert à empêcher la mise à jour simultanée de l'entité par deux utilisateur différents : deux utilisateurs U1 et U2 lisent l'entité E avec un n° de version égal à V1. U1 modifie E et persiste cette modification en base : le n° de version passe alors à V1+1. U2 modifie E à son tour et persiste cette modification en base : il recevra une exception car il possède une version (V1) différente de celle en base (V1+1) ;
- lignes 29-33 : la méthode [build] permet d'initialiser les deux champs de [AbstractEntity]. Cette méthode rend la référence de l'instance [AbstractEntity] ainsi initialisée ;
- lignes 36-44 : la méthode [equals] de la classe est redéfinie : deux entités seront dites égales si elles ont le même nom de classe et le même identifiant id ;
- lignes 21-26 : lorsqu'on redéfinit la méthode [equals] d'une classe, il faut alors redéfinir sa méthode [hashCode] (lignes 21-26). La règle est que deux entités dites égales par la méthode [equals] doivent alors avoir le même [hashCode]. Ici, le [hashCode] d'une entité est égal à sa clé primaire [id]. Le [hashCode] d'une classe est utilisée notamment dans la gestion des dictionnaires dont les valeurs sont des instances de la classe ;
L'entité [Personne] est la classe parente des entités [Medecin] et [Client] :
package rdvmedecins.entities;
import javax.persistence.Column;
import javax.persistence.MappedSuperclass;
@MappedSuperclass
public class Personne extends AbstractEntity {
private static final long serialVersionUID = 1L;
// attributs d'une personne
@Column(length = 5)
private String titre;
@Column(length = 20)
private String nom;
@Column(length = 20)
private String prenom;
// constructeur par défaut
public Personne() {
}
// constructeur avec paramètres
public Personne(String titre, String nom, String prenom) {
this.titre = titre;
this.nom = nom;
this.prenom = prenom;
}
// toString
public String toString() {
return String.format("Personne[%s, %s, %s, %s, %s]", id, version, titre, nom, prenom);
}
// getters et setters
...
}
- ligne 6 : l'annotation [@MappedSuperclass] indique que la classe annotée est parente d'entités JPA [@Entity] ;
- lignes 10-15 : une personne a un titre (Melle), un prénom (Jacqueline), un nom (Tatou). aucune information n'est donnée sur les colonnes de la table. Elles porteront donc par défaut les mêmes noms que les champs ;
L'entité [Medecin] est la suivante :
package rdvmedecins.entities;
import javax.persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "medecins")
public class Medecin extends Personne {
private static final long serialVersionUID = 1L;
// constructeur par défaut
public Medecin() {
}
// constructeur avec paramètres
public Medecin(String titre, String nom, String prenom) {
super(titre, nom, prenom);
}
public String toString() {
return String.format("Medecin[%s]", super.toString());
}
}
- ligne 6 : la classe est une entité JPA ;
- ligne 7 : associée à la table [MEDECINS] de la base de données ;
- ligne 8 : l'entité [Medecin] dérive de l'entité [Personne] ;
Un médecin pourra être initialisé de la façon suivante :
Si de plus, on veut lui affecter un identifiant et une version on pourra écrire :
où la méthode [build] est celle définie dans [AbstractEntity].
L'entité [Client] est la suivante :
package rdvmedecins.entities;
import javax.persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "clients")
public class Client extends Personne {
private static final long serialVersionUID = 1L;
// constructeur par défaut
public Client() {
}
// constructeur avec paramètres
public Client(String titre, String nom, String prenom) {
super(titre, nom, prenom);
}
// identité
public String toString() {
return String.format("Client[%s]", super.toString());
}
}
- ligne 6 : la classe est une entité JPA ;
- ligne 7 : associée à la table [CLIENTS] de la base de données ;
- ligne 8 : l'entité [Client] dérive de l'entité [Personne] ;
L'entité [Creneau] est la suivante :
package rdvmedecins.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
@Entity
@Table(name = "creneaux")
public class Creneau extends AbstractEntity {
private static final long serialVersionUID = 1L;
// caractéristiques d'un créneau de RV
private int hdebut;
private int mdebut;
private int hfin;
private int mfin;
// un créneau est lié à un médecin
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_medecin")
private Medecin medecin;
// clé étrangère
@Column(name = "id_medecin", insertable = false, updatable = false)
private long idMedecin;
// constructeur par défaut
public Creneau() {
}
// constructeur avec paramètres
public Creneau(Medecin medecin, int hdebut, int mdebut, int hfin, int mfin) {
this.medecin = medecin;
this.hdebut = hdebut;
this.mdebut = mdebut;
this.hfin = hfin;
this.mfin = mfin;
}
// toString
public String toString() {
return String.format("Créneau[%d, %d, %d, %d:%d, %d:%d]", id, version, idMedecin, hdebut, mdebut, hfin, mfin);
}
// clé étrangère
public long getIdMedecin() {
return idMedecin;
}
// setters - getters
...
}
- ligne 10 : la classe est une entité JPA ;
- ligne 11 : associée à la table [CRENEAUX] de la base de données ;
- ligne 12 : l'entité [Creneau] dérive de l'entité [AbstractEntity] et hérite donc de l'identifiant [id] et de la version [version] ;
- ligne 16 : heure de début du créneau (14) ;
- ligne 17 : minutes de début du créneau (20) ;
- ligne 18 : heure de fin du créneau (14) ;
- ligne 19 : minutes de fin du créneau (40) ;
- lignes 22-24 : le médecin propriétaire du créneau. La table [CRENEAUX] a une clé étrangère sur la table [MEDECINS]. Cette relation est matérialisée par les lignes 22-24 ;
- ligne 22 : l'annotation [@ManyToOne] signale une relation plusieurs (créneaux) à un (médecin). L'attribut [fetch=FetchType.LAZY] indique que lorsqu'on demande une entité [Creneau] au contexte de persistance et que celle-ci doit être cherchée dans la base de données, alors l'entité [Medecin] n'est pas ramenée avec elle. L'intérêt de ce mode est que l'entité [Medecin] n'est cherchée que si le développeur le demande. On économise ainsi la mémoire et on gagne en performances ;
- ligne 23 : indique le nom de la colonne clé étrangère dans la table [CRENEAUX] ;
- lignes 27-28 : la clé étrangère sur la table [MEDECINS] ;
- ligne 27 : la colonne [ID_MEDECIN] a déjà été utilisée ligne 23. Cela veut dire qu'elle peut être modifiée par deux voies différentes ce que n'accepte pas la norme JPA. On ajoute donc les attributs [insertable = false, updatable = false], ce qui fait que la colonne ne peut qu'être lue ;
L'entité [Rv] est la suivante :
package rdvmedecins.entities;
import java.util.Date;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;
@Entity
@Table(name = "rv")
public class Rv extends AbstractEntity {
private static final long serialVersionUID = 1L;
// caractéristiques d'un Rv
@Temporal(TemporalType.DATE)
private Date jour;
// un rv est lié à un client
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_client")
private Client client;
// un rv est lié à un créneau
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_creneau")
private Creneau creneau;
// clés étrangères
@Column(name = "id_client", insertable = false, updatable = false)
private long idClient;
@Column(name = "id_creneau", insertable = false, updatable = false)
private long idCreneau;
// constructeur par défaut
public Rv() {
}
// avec paramètres
public Rv(Date jour, Client client, Creneau creneau) {
this.jour = jour;
this.client = client;
this.creneau = creneau;
}
// toString
public String toString() {
return String.format("Rv[%d, %s, %d, %d]", id, jour, client.id, creneau.id);
}
// clés étrangères
public long getIdCreneau() {
return idCreneau;
}
public long getIdClient() {
return idClient;
}
// getters et setters
...
}
- ligne 14 : la classe est une entité JPA ;
- ligne 15 : associée à la table [RV] de la base de données ;
- ligne 16 : l'entité [Rv] dérive de l'entité [AbstractEntity] et hérite donc de l'identifiant [id] et de la version [version] ;
- ligne 21 : la date du rendez-vous ;
- ligne 20 : le type [Date] de Java contient à la fois une date et une heure. Ici on précise que seule la date est utilisée ;
- lignes 24-26 : le client pour lequel ce rendez-vous a été pris. La table [RV] a une clé étrangère sur la table [CLIENTS]. Cette relation est matérialisée par les lignes 24-26 ;
- lignes 29-31 : le créneau horaire du rendez-vous. La table [RV] a une clé étrangère sur la table [CRENEAUX]. Cette relation est matérialisée par les lignes 29-31 ;
- lignes 34-35 : la clé étrangère [idClient] ;
- lignes 36-37 : la clé étrangère [idCreneau] ;
8.4.5. La couche [DAO]
![]() |
Nous allons implémenter la couche [DAO] avec Spring Data :
![]() |
La couche [DAO] est implémentée avec quatre interfaces Spring Data :
- [ClientRepository] : donne accès aux entités JPA [Client] ;
- [CreneauRepository] : donne accès aux entités JPA [Creneau] ;
- [MedecinRepository] : donne accès aux entités JPA [Medecin] ;
- [RvRepository] : donne accès aux entités JPA [Rv] ;
L'interface [MedecinRepository] est la suivante :
package rdvmedecins.repositories;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Medecin;
public interface MedecinRepository extends CrudRepository<Medecin, Long> {
}
- ligne 7 : l'interface [MedecinRepository] se contente d'hériter des méthodes de l'interface [CrudRepository] sans en ajouter d'autres ;
L'interface [ClientRepository] est la suivante :
package rdvmedecins.repositories;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Client;
public interface ClientRepository extends CrudRepository<Client, Long> {
}
- ligne 7 : l'interface [ClientRepository] se contente d'hériter des méthodes de l'interface [CrudRepository] sans en ajouter d'autres ;
L'interface [CreneauRepository] est la suivante :
package rdvmedecins.repositories;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Creneau;
public interface CreneauRepository extends CrudRepository<Creneau, Long> {
// liste des créneaux horaires d'un médecin
@Query("select c from Creneau c where c.medecin.id=?1")
Iterable<Creneau> getAllCreneaux(long idMedecin);
}
- ligne 8 : l'interface [CreneauRepository] hérite des méthodes de l'interface [CrudRepository] ;
- lignes 10-11 : la méthode [getAllCreneaux] permet d'avoir les créneaux horaires d'un médecin ;
- ligne 11 : le paramètre est l'identifiant du médecin. Le résultat est une liste de créneaux horaires sous la forme d'un objet [Iterable<Creneau>] ;
- ligne 10 : l'annotation [@Query] permet de spécifier la requête JPQL (Java Persistence Query Language) qui implémente la méthode. Le paramètre [?1] sera remplacé par le paramètre [idMedecin] de la méthode ;
L'interface [RvRepository] est la suivante :
package rdvmedecins.repositories;
import java.util.Date;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Rv;
public interface RvRepository extends CrudRepository<Rv, Long> {
@Query("select rv from Rv rv left join fetch rv.client c left join fetch rv.creneau cr where cr.medecin.id=?1 and rv.jour=?2")
Iterable<Rv> getRvMedecinJour(long idMedecin, Date jour);
}
- ligne 10 : l'interface [RvRepository] hérite des méthodes de l'interface [CrudRepository] ;
- lignes 12-13 : la méthode [getRvMedecinJour] permet d'avoir les rendez-vous d'un médecin pour un jour donné ;
- ligne 13 : les paramètres sont l'identifiant du médecin et le jour. Le résultat est une liste de rendez-vous sous la forme d'un objet [Iterable<Rv>] ;
- ligne 12 : l'annotation [@Query] permet de spécifier la requête JPQL qui implémente la méthode. Le paramètre [?1] sera remplacé par le paramètre [idMedecin] de la méthode et le paramètre [?2] sera remplacé par le paramètre [jour] de la méthode. On ne peut se contenter de la requête JPQL suivante :
car les champs de la classe Rv, de types [Client] et [Creneau] sont obtenus en mode [FetchType.LAZY], ce qui signifie qu'ils doivent être demandés explicitement pour être obtenus. Ceci est fait dans la requête JPQL avec la syntaxe [left join fetch entité] qui demandent qu'une jointure soit faite avec la table sur laquelle pointe la clé étrangère afin de récupérer l'entité pointée ;
8.4.6. La couche [métier]
![]() |
![]() |
- [IMetier] est l'interface de la couche [métier] et [Metier] son implémentation ;
- [AgendaMedecinJour] et [CreneauMedecinJour] sont deux entités métier ;
8.4.6.1. Les entités
L'entité [CreneauMedecinJour] associe un créneau horaire et le rendez-vous éventuel pris dans ce créneau :
package rdvmedecins.domain;
import java.io.Serializable;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Rv;
public class CreneauMedecinJour implements Serializable {
private static final long serialVersionUID = 1L;
// champs
private Creneau creneau;
private Rv rv;
// constructeurs
public CreneauMedecinJour() {
}
public CreneauMedecinJour(Creneau creneau, Rv rv) {
this.creneau=creneau;
this.rv=rv;
}
// toString
@Override
public String toString() {
return String.format("[%s %s]", creneau, rv);
}
// getters et setters
...
}
- ligne 12 : le créneau horaire ;
- ligne 13 : l'éventuel rendez-vous – null sinon ;
L'entité [AgendaMedecinJour] est l'agenda d'un médecin pour un jour donné, ç-à-d la liste de ses rendez-vous :
package rdvmedecins.domain;
import java.io.Serializable;
import java.text.SimpleDateFormat;
import java.util.Date;
import rdvmedecins.entities.Medecin;
public class AgendaMedecinJour implements Serializable {
private static final long serialVersionUID = 1L;
// champs
private Medecin medecin;
private Date jour;
private CreneauMedecinJour[] creneauxMedecinJour;
// constructeurs
public AgendaMedecinJour() {
}
public AgendaMedecinJour(Medecin medecin, Date jour, CreneauMedecinJour[] creneauxMedecinJour) {
this.medecin = medecin;
this.jour = jour;
this.creneauxMedecinJour = creneauxMedecinJour;
}
public String toString() {
StringBuffer str = new StringBuffer("");
for (CreneauMedecinJour cr : creneauxMedecinJour) {
str.append(" ");
str.append(cr.toString());
}
return String.format("Agenda[%s,%s,%s]", medecin, new SimpleDateFormat("dd/MM/yyyy").format(jour), str.toString());
}
// getters et setters
...
}
- ligne 13 : le médecin ;
- ligne 14 : le jour dans l'agenda ;
- ligne 15 : ses créneaux horaires avec ou sans rendez-vous ;
8.4.6.2. Le service
L'interface de la couche [métier] est la suivante :
package rdvmedecins.metier;
import java.util.Date;
import java.util.List;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
public interface IMetier {
// liste des clients
public List<Client> getAllClients();
// liste des Médecins
public List<Medecin> getAllMedecins();
// liste des créneaux horaires d'un médecin
public List<Creneau> getAllCreneaux(long idMedecin);
// liste des Rv d'un médecin, un jour donné
public List<Rv> getRvMedecinJour(long idMedecin, Date jour);
// trouver un client identifié par son id
public Client getClientById(long id);
// trouver un client identifié par son id
public Medecin getMedecinById(long id);
// trouver un Rv identifié par son id
public Rv getRvById(long id);
// trouver un créneau horaire identifié par son id
public Creneau getCreneauById(long id);
// ajouter un RV
public Rv ajouterRv(Date jour, Creneau créneau, Client client);
// supprimer un RV
public void supprimerRv(Rv rv);
// metier
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour);
}
Les commentaires expliquent le rôle de chacune des méthodes.
L'implémentation de l'interface [IMetier] est la classe [Metier] suivante :
package rdvmedecins.metier;
import java.util.Date;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.domain.CreneauMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
import rdvmedecins.repositories.ClientRepository;
import rdvmedecins.repositories.CreneauRepository;
import rdvmedecins.repositories.MedecinRepository;
import rdvmedecins.repositories.RvRepository;
import com.google.common.collect.Lists;
@Service("métier")
public class Metier implements IMetier {
// répositories
@Autowired
private MedecinRepository medecinRepository;
@Autowired
private ClientRepository clientRepository;
@Autowired
private CreneauRepository creneauRepository;
@Autowired
private RvRepository rvRepository;
// implémentation interface
@Override
public List<Client> getAllClients() {
return Lists.newArrayList(clientRepository.findAll());
}
@Override
public List<Medecin> getAllMedecins() {
return Lists.newArrayList(medecinRepository.findAll());
}
@Override
public List<Creneau> getAllCreneaux(long idMedecin) {
return Lists.newArrayList(creneauRepository.getAllCreneaux(idMedecin));
}
@Override
public List<Rv> getRvMedecinJour(long idMedecin, Date jour) {
return Lists.newArrayList(rvRepository.getRvMedecinJour(idMedecin, jour));
}
@Override
public Client getClientById(long id) {
return clientRepository.findOne(id);
}
@Override
public Medecin getMedecinById(long id) {
return medecinRepository.findOne(id);
}
@Override
public Rv getRvById(long id) {
return rvRepository.findOne(id);
}
@Override
public Creneau getCreneauById(long id) {
return creneauRepository.findOne(id);
}
@Override
public Rv ajouterRv(Date jour, Creneau créneau, Client client) {
return rvRepository.save(new Rv(jour, client, créneau));
}
@Override
public void supprimerRv(Rv rv) {
rvRepository.delete(rv.getId());
}
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour) {
...
}
}
- ligne 24 : l'annotation [@Service] est une annotation Spring qui fait de la classe annotée un composant géré par Spring. On peut ou non donner un nom à un composant. Celui-ci est nommé [métier] ;
- ligne 25 : la classe [Metier] implémente l'interface [IMetier] ;
- ligne 28 : l'annotation [@Autowired] est une annotation Spring. La valeur du champ ainsi annoté sera initialisée (injectée) par Spring avec la référence d'un composant Spring du type ou du nom précisés. Ici l'annotation [@Autowired] ne précise pas de nom. Ce sera donc une injection par type qui sera faite ;
- ligne 29 : le champ [medecinRepository] sera initialisé avec la référence d'un composant Spring de type [MedecinRepository]. Ce sera la référence de la classe générée par Spring Data pour implémenter l'interface [MedecinRepository] que nous avons déjà présentée ;
- lignes 30-35 : ce processus est répété pour les trois autres interfaces étudiées ;
- lignes 39-41 : implémentation de la méthode [getAllClients] ;
- ligne 40 : nous utilisons la méthode [findAll] de l'interface [ClientRepository]. Cette méthode rend un type [Iterable<Client>] que nous transformons en [List<Client>] avec la méthode statique [Lists.newArrayList]. La classe [Lists] est définie dans la bibliothèque Google Guava. Dans [pom.xml] cette dépendance a été importée :
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>16.0.1</version>
</dependency>
- lignes 38-86 : les méthodes de l'interface [IMetier] sont implémentées avec l'aide des classes de la couche [DAO] ;
Seule la méthode de la ligne 88 est spécifique à la couche [métier]. Elle a été placée ici parce qu'elle fait un traitement métier qui n'est pas qu'un simple accès aux données. Sans cette méthode, il n'y avait pas de raison de créer une couche [métier]. La méthode [getAgendaMedecinJour] est la suivante :
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour) {
// liste des créneaux horaires du médecin
List<Creneau> creneauxHoraires = getAllCreneaux(idMedecin);
// liste des réservations de ce même médecin pour ce même jour
List<Rv> reservations = getRvMedecinJour(idMedecin, jour);
// on crée un dictionnaire à partir des Rv pris
Map<Long, Rv> hReservations = new Hashtable<Long, Rv>();
for (Rv resa : reservations) {
hReservations.put(resa.getCreneau().getId(), resa);
}
// on crée l'agenda pour le jour demandé
AgendaMedecinJour agenda = new AgendaMedecinJour();
// le médecin
agenda.setMedecin(getMedecinById(idMedecin));
// le jour
agenda.setJour(jour);
// les créneaux de réservation
CreneauMedecinJour[] creneauxMedecinJour = new CreneauMedecinJour[creneauxHoraires.size()];
agenda.setCreneauxMedecinJour(creneauxMedecinJour);
// remplissage des créneaux de réservation
for (int i = 0; i < creneauxHoraires.size(); i++) {
// ligne i agenda
creneauxMedecinJour[i] = new CreneauMedecinJour();
// créneau horaire
Creneau créneau = creneauxHoraires.get(i);
long idCreneau = créneau.getId();
creneauxMedecinJour[i].setCreneau(créneau);
// le créneau est-il libre ou réservé ?
if (hReservations.containsKey(idCreneau)) {
// le créneau est occupé - on note la résa
Rv resa = hReservations.get(idCreneau);
creneauxMedecinJour[i].setRv(resa);
}
}
// on rend le résultat
return agenda;
}
Le lecteur est invité à lire les commentaires. L'algorithme est le suivant :
- on récupère tous les créneaux horaires du médecin indiqué ;
- on récupère tous ses rendez-vous pour le jour indiqué ;
- avec ces deux informations, on est capable de dire si un créneau horaire est libre ou occupé ;
8.4.7. La configuration du projet Spring
![]() |
La classe [DomainAndPersistenceConfig] configure l'ensemble du projet :
package rdvmedecins.config;
import javax.persistence.EntityManagerFactory;
import org.apache.tomcat.jdbc.pool.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
@EnableJpaRepositories(basePackages = { "rdvmedecins.repositories", "rdvmedecins.security" })
@ComponentScan(basePackages = { "rdvmedecins" })
public class DomainAndPersistenceConfig {
// packages des entités JPA
public final static String[] ENTITIES_PACKAGES = { "rdvmedecins.entities", "rdvmedecins.security" };
// la source de données MySQL
@Bean
public DataSource dataSource() {
// source de données TomcatJdbc
DataSource dataSource = new DataSource();
// configuration JDBC
dataSource.setDriverClassName("com.mysql.jdbc.Driver");
dataSource.setUrl("jdbc:mysql://localhost:3306/dbrdvmedecins");
dataSource.setUsername("root");
dataSource.setPassword("");
// connexions ouvertes initialement
dataSource.setInitialSize(5);
// résultat
return dataSource;
}
// le provider JPA est Hibernate
@Bean
public JpaVendorAdapter jpaVendorAdapter() {
HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
hibernateJpaVendorAdapter.setShowSql(false);
hibernateJpaVendorAdapter.setGenerateDdl(false);
hibernateJpaVendorAdapter.setDatabase(Database.MYSQL);
return hibernateJpaVendorAdapter;
}
// EntityManagerFactory
@Bean
public EntityManagerFactory entityManagerFactory(JpaVendorAdapter jpaVendorAdapter, DataSource dataSource) {
LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
factory.setJpaVendorAdapter(jpaVendorAdapter);
factory.setPackagesToScan(ENTITIES_PACKAGES);
factory.setDataSource(dataSource);
factory.afterPropertiesSet();
return factory.getObject();
}
// Transaction manager
@Bean
public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
JpaTransactionManager txManager = new JpaTransactionManager();
txManager.setEntityManagerFactory(entityManagerFactory);
return txManager;
}
}
- ligne 17 : la classe est une classe de configuration Spring ;
- ligne 18 : les packages où se trouvent les interfaces [CrudRepository] de Spring Data. Celles-ci seront ajoutées au contexte Spring ;
- ligne 19 : ajoute au contexte de Spring toutes les classes du package [rdvmedecins] et ses descendants ayant une annotation Spring. Dans le package [rdvmdecins.metier], la classe [Metier] avec son annotation [@Service] va être trouvée et ajoutée au contexte Spring ;
- lignes 26-39 : configurent le pool de connexion Tomcat JDBC (ligne 5) ;
- ligne 36 : le pool de connexion aura de base 5 connexions ouvertes. Cette ligne est montrée pour l'exemple. Dans notre cas, 1 connexion serait suffisante. Dans le cas où la couche [DAO] serait utilisée par plusieurs threads, cette ligne serait nécessaire. Ce sera le cas ultérieurement, lorsque la couche [DAO] servira de support à une application web qui supporte par nature plusieurs utilisateurs qui sont servis en même temps ;
- lignes 42-49 : l'implémentation JPA utilisée est une implémentation Hibernate ;
- ligne 45 : pas de logs SQL ;
- ligne 46 : pas de régénération des tables ;
- ligne 47 : le SGBD utilisé est MySQL ;
- lignes 53-61 : définissent l'EntityManagerFactory de la couche JPA. A partir de cet objet, on obtient l'objet [EntityManager] qui permet de faire les opérations JPA ;
- ligne 57 : on indique le ou les packages où se trouvent les entités JPA ;
- ligne 58 : indique la source de données à connecter à la couche JPA ;
- lignes 64-69 : le gestionnaire de transactions associé à l'EntityManagerFactory précédent. Par défaut, les méthodes des interfaces [CrudRepository] de Spring Data se déroulent à l'intérieur d'une transaction. La transaction est démarrée avant l'entrée dans la méthode et est terminée (par un commit ou rollback) après sa sortie ;
8.4.8. Les tests de la couche [métier]
![]() |
La classe [rdvmedecins.tests.Metier] est une classe de test Spring / JUnit 4 :
package rdvmedecins.tests;
import java.text.ParseException;
import java.util.Date;
import java.util.List;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import rdvmedecins.config.DomainAndPersistenceConfig;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
import rdvmedecins.metier.IMetier;
@SpringApplicationConfiguration(classes = DomainAndPersistenceConfig.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class Metier {
@Autowired
private IMetier métier;
@Test
public void test1(){
// affichage clients
List<Client> clients = métier.getAllClients();
display("Liste des clients :", clients);
// affichage médecins
List<Medecin> medecins = métier.getAllMedecins();
display("Liste des médecins :", medecins);
// affichage créneaux d'un médecin
Medecin médecin = medecins.get(0);
List<Creneau> creneaux = métier.getAllCreneaux(médecin.getId());
display(String.format("Liste des créneaux du médecin %s", médecin), creneaux);
// liste des Rv d'un médecin, un jour donné
Date jour = new Date();
display(String.format("Liste des rv du médecin %s, le [%s]", médecin, jour), métier.getRvMedecinJour(médecin.getId(), jour));
// ajouter un RV
Rv rv = null;
Creneau créneau = creneaux.get(2);
Client client = clients.get(0);
System.out.println(String.format("Ajout d'un Rv le [%s] dans le créneau %s pour le client %s", jour, créneau,
client));
rv = métier.ajouterRv(jour, créneau, client);
// vérification
Rv rv2 = métier.getRvById(rv.getId());
Assert.assertEquals(rv, rv2);
display(String.format("Liste des Rv du médecin %s, le [%s]", médecin, jour), métier.getRvMedecinJour(médecin.getId(), jour));
// ajouter un RV dans le même créneau du même jour
// doit provoquer une exception
System.out.println(String.format("Ajout d'un Rv le [%s] dans le créneau %s pour le client %s", jour, créneau,
client));
Boolean erreur = false;
try {
rv = métier.ajouterRv(jour, créneau, client);
System.out.println("Rv ajouté");
} catch (Exception ex) {
Throwable th = ex;
while (th != null) {
System.out.println(ex.getMessage());
th = th.getCause();
}
// on note l'erreur
erreur = true;
}
// on vérifie qu'il y a eu une erreur
Assert.assertTrue(erreur);
// liste des RV
display(String.format("Liste des Rv du médecin %s, le [%s]", médecin, jour), métier.getRvMedecinJour(médecin.getId(), jour));
// affichage agenda
AgendaMedecinJour agenda = métier.getAgendaMedecinJour(médecin.getId(), jour);
System.out.println(agenda);
Assert.assertEquals(rv, agenda.getCreneauxMedecinJour()[2].getRv());
// supprimer un RV
System.out.println("Suppression du Rv ajouté");
métier.supprimerRv(rv);
// vérification
rv2 = métier.getRvById(rv.getId());
Assert.assertNull(rv2);
display(String.format("Liste des Rv du médecin %s, le [%s]", médecin, jour), métier.getRvMedecinJour(médecin.getId(), jour));
}
// méthode utilitaire - affiche les éléments d'une collection
private void display(String message, Iterable<?> elements) {
System.out.println(message);
for (Object element : elements) {
System.out.println(element);
}
}
}
- ligne 22 : l'annotation [@SpringApplicationConfiguration] permet d'exploiter le fichier de configuration [DomainAndPersistenceConfig] étudié précédemment. La classe de test bénéficie ainsi de tous les beans définis par ce fichier ;
- ligne 23 : l'annotation [@RunWith] permet l'intégration de Spring avec JUnit : la classe va pouvoir être exécutée comme un test JUnit. [@RunWith] est une annotation JUnit (ligne 9) alors que la classe [SpringJUnit4ClassRunner] est une classe Spring (ligne 12) ;
- lignes 26-27 : injection dans la classe de test d'une référence sur la couche [métier] ;
- beaucoup de tests ne sont que de simples tests visuels :
- lignes 32-33 : liste des clients ;
- lignes 35-36 : liste des médecins ;
- lignes 39-40 : liste des créneaux d'un médecin ;
- ligne 43 : liste des rendez-vous d'un médecin ;
- ligne 50 : ajout d'un nouveau rendez-vous. La méthode [ajouterRv] rend le rendez-vous avec une information supplémentaire, sa clé primaire id ;
- ligne 53 : on utilise cette clé primaire pour rechercher le rendez-vous en base ;
- ligne 54 : on vérifie que le rendez-vous cherché et le rendez-vous trouvé sont les mêmes. On rappelle que la méthode [equals] de l'entité [Rv] a été redéfinie : deux rendez-vous sont égaux s'ils ont le même id. Ici, cela nous montre que le rendez-vous ajouté a bien été mis en base ;
- lignes 61-73 : on essaie d'ajouter une deuxième fois le même rendez-vous. Cela doit être rejeté par le SGBD car on a une contrainte d'unicité :
CREATE TABLE IF NOT EXISTS `rv` (
`ID` bigint(20) NOT NULL AUTO_INCREMENT,
`JOUR` date NOT NULL,
`ID_CLIENT` bigint(20) NOT NULL,
`ID_CRENEAU` bigint(20) NOT NULL,
`VERSION` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`ID`),
UNIQUE KEY `UNQ1_RV` (`JOUR`,`ID_CRENEAU`),
KEY `FK_RV_ID_CRENEAU` (`ID_CRENEAU`),
KEY `FK_RV_ID_CLIENT` (`ID_CLIENT`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_swedish_ci AUTO_INCREMENT=60 ;
La ligne 8 ci-dessus indique que la combinaison [JOUR, ID_CRENEAU] doit être unique, ce qui empêche de mettre deux rendez-vous le même jour dans le même créneau horaire.
- ligne 73 : on vérifie qu'une exception s'est bien produite ;
- ligne 77 : on demande l'agenda du médecin pour lequel on vient d'ajouter un rendez-vous ;
- ligne 79 : on vérifie que le rendez-vous ajouté est bien présent dans son agenda ;
- ligne 82 : on supprime le rendez-vous ajouté ;
- ligne 84 : on va chercher en base le rendez-vous supprimé ;
- ligne 85 : on vérifie qu'on a récupéré un pointeur null, montrant par là que le rendez-vous cherché n'existe pas ;
L'exécution du test réussit :
![]() |
8.4.9. Le programme console
![]() |
![]() |
Le programme console est basique. Il illustre comment récupérer une clé étrangère :
package rdvmedecins.boot;
import java.text.SimpleDateFormat;
import java.util.Date;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
import rdvmedecins.config.DomainAndPersistenceConfig;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Rv;
import rdvmedecins.metier.IMetier;
public class Boot {
// le boot
public static void main(String[] args) {
// on prépare la configuration
SpringApplication app = new SpringApplication(DomainAndPersistenceConfig.class);
app.setLogStartupInfo(false);
// on la lance
ConfigurableApplicationContext context = app.run(args);
// métier
IMetier métier = context.getBean(IMetier.class);
try {
// ajouter un RV
Date jour = new Date();
System.out.println(String.format("Ajout d'un Rv le [%s] dans le créneau 1 pour le client 1", new SimpleDateFormat("dd/MM/yyyy").format(jour)));
Client client = (Client) new Client().build(1L, 1L);
Creneau créneau = (Creneau) new Creneau().build(1L, 1L);
Rv rv = métier.ajouterRv(jour, créneau, client);
System.out.println(String.format("Rv ajouté = %s", rv));
// vérification
créneau = métier.getCreneauById(1L);
long idMedecin = créneau.getIdMedecin();
display("Liste des rendez-vous", métier.getRvMedecinJour(idMedecin, jour));
} catch (Exception ex) {
System.out.println("Exception : " + ex.getCause());
}
// fermeture du contexte Spring
context.close();
}
// méthode utilitaire - affiche les éléments d'une collection
private static <T> void display(String message, Iterable<T> elements) {
System.out.println(message);
for (T element : elements) {
System.out.println(element);
}
}
}
Le programme ajoute un rendez-vous et ensuite vérifie qu'il a été ajouté.
- ligne 19 : la classe [SpringApplication] va exploiter la classe de configuration [DomainAndPersistenceConfig] ;
- ligne 20 : suppression des logs de démarrage de l'application ;
- ligne 22 : la classe [SpringApplication] est exécutée. Elle rend un contexte Spring, ç-à-d la liste des beans enregistrés ;
- ligne 24 : on récupère une référence sur le bean implémentant l'interface [IMetier]. Il s'agit donc d'une référence sur la couche [métier] ;
- lignes 27-31 : ajout d'un nouveau rendez-vous pour aujourd'hui, pour le client n°1 dans le créneau n° 1. Le client et le créneau ont été créés de toute pièce pour montrer que seuls les identifiants sont utilisés. On a initialisé ici la version mais on n'aurait pu mettre n'importe quoi. Elle n'est pas utilisée ici ;
- ligne 34 : on veut connaître le médecin ayant le créneau n° 1. Pour cela on a besoin d'aller en base chercher le créneau n° 1. Parce qu'on est en mode [FetchType.LAZY], le médecin n'est pas ramené avec le créneau. Cependant, on a pris soin de prévoir un champ [idMedecin] dans l'entité [Creneau] pour récupérer la clé primaire du médecin ;
- ligne 35 : on récupère la primaire du médecin ;
- ligne 36 : on affiche la liste des rendez-vous du médecin ;
Les résultats console sont les suivants :
8.4.10. Gestion des logs
Les logs de la console sont configurés par deux fichiers [application.properties] et [logback.xml] [1] :
![]() |
Le fichier [application.properties] est exploité par le framework Spring Boot. On peut y définir de très nombreux paramètres pour changer les valeurs par défaut prises par Spring Boot (http://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html). Ici son contenu est le suivant :
logging.level.org.hibernate=OFF
spring.main.show-banner=false
- ligne 1 : contrôle le niveau de logs d'Hibernate - ici pas de logs
- ligne 2 : contrôle l'affichage de la bannière de Spring Boot - ici pas de bannière
Le fichier [logback.xml] est le fichier de configuration du framework de logs [logback] [2] :
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are by default assigned the type ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- contrôle du niveau des logs -->
<root level="info"> <!-- off, info, debug, warn -->
<appender-ref ref="STDOUT" />
</root>
</configuration>
- le niveau de logs général est contrôlé par la ligne 9 - ici des logs de niveau [info] ;
Cela donne le résultat suivant :
Si on passe le niveau de logs d'Hibernate à [info] (sans rien changer par ailleurs) :
logging.level.org.hibernate=INFO
spring.main.show-banner=false
cela donne le résultat suivant :
Si on passe le niveau de logs à [debug] (sans rien changer par ailleurs) :
logging.level.org.hibernate=DEBUG
spring.main.show-banner=false
cela donne le résultat suivant :
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Eagerly caching bean 'clientRepository' to allow for resolving potential circular references
10:35:13.522 [main] DEBUG o.s.b.f.annotation.InjectionMetadata - Processing injected element of bean 'clientRepository': PersistenceElement for public void org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean.setEntityManager(javax.persistence.EntityManager)
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Creating instance of bean '(inner bean)#6a2eea2a'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Creating instance of bean '(inner bean)#b967222'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Invoking afterPropertiesSet() on bean with name '(inner bean)#b967222'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean '(inner bean)#b967222'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean '(inner bean)#6a2eea2a'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Creating instance of bean '(inner bean)#1ba05e38'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean '(inner bean)#1ba05e38'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Creating instance of bean '(inner bean)#6c298dc'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Returning cached instance of singleton bean 'entityManagerFactory'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean '(inner bean)#6c298dc'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Returning cached instance of singleton bean 'jpaMappingContext'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Invoking afterPropertiesSet() on bean with name 'clientRepository'
10:35:13.522 [main] DEBUG o.s.o.j.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler - Creating new EntityManager for shared EntityManager invocation
10:35:13.522 [main] DEBUG o.s.o.jpa.EntityManagerFactoryUtils - Closing JPA EntityManager
10:35:13.522 [main] DEBUG o.s.o.j.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler - Creating new EntityManager for shared EntityManager invocation
10:35:13.522 [main] DEBUG o.s.o.jpa.EntityManagerFactoryUtils - Closing JPA EntityManager
10:35:13.522 [main] DEBUG o.s.aop.framework.JdkDynamicAopProxy - Creating JDK dynamic proxy: target source is org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$ThreadBoundTargetSource@723ed581
10:35:13.522 [main] DEBUG o.s.aop.framework.JdkDynamicAopProxy - Creating JDK dynamic proxy: target source is SingletonTargetSource for target object [org.springframework.data.jpa.repository.support.SimpleJpaRepository@796065aa]
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean 'clientRepository'
10:35:13.522 [main] DEBUG o.s.b.f.a.AutowiredAnnotationBeanPostProcessor - Autowiring by type from bean name 'métier' to bean named 'clientRepository'
...
8.4.11. La couche [web / jSON]
![]() |
![]() |
Nous allons construire la couche [web / jSON] en plusieurs étapes :
- étape 1 : une couche web opérationnelle sans authentification ;
- étape 2 : mise en place de l'authentification avec Spring Security ;
- étape 3 : mise en place des CORS [Cross-Origin Resource Sharing (CORS) is a mechanism that allows many resources (e.g. fonts, JavaScript, etc.) on a web page to be requested from another domain outside the domain the resource originated from. (Wikipedia)]. Le client de notre service web sera un client web Angular qui n'appartiendra pas nécessairement au même domaine que notre service web. Par défaut, il ne peut alors pas y accéder sauf si le service web l'y autorise. Nous verrons comment ;
8.4.11.1. Configuration Maven
Le fichier [pom.xml] du projet est le suivant :
<?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>istia.st.spring4.mvc</groupId>
<artifactId>rdvmedecins-webjson-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>rdvmedecins-webjson-server</name>
<description>Gestion de RV Médecins</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.6.RELEASE</version>
</parent>
<dependencies>
<!-- couche web spring mvc -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- couche test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- couche DAO -->
<dependency>
<groupId>istia.st.spring4.rdvmedecins</groupId>
<artifactId>rdvmedecins-metier-dao</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
...
</project>
- lignes 12-15 : le projet Maven parent ;
- lignes 19-22 : les dépendances pour un projet Spring MVC ;
- lignes 24-28 : les dépendances pour les tests JUnit / Spring ;
- lignes 30-34 : les dépendances sur le projet des couches [métier, DAO, JPA] ;
8.4.11.2. L'interface du service web
![]() |
- en [1], ci-dessus, le navigateur ne peut demander qu'un nombre restreint d'URL avec une syntaxe précise ;
- en [4], il reçoit une réponse jSON ;
Les réponses de notre service web auront toutes la même forme correspondant à la transformation jSON d'un objet de type [Response] suivant :
package rdvmedecins.web.models;
import java.util.List;
public class Response<T> {
// ----------------- propriétés
// statut de l'opération
private int status;
// les éventuels messages d'erreur
private List<String> messages;
// le corps de la réponse
private T body;
// constructeurs
public Response() {
}
public Response(int status, List<String> messages, T body) {
this.status = status;
this.messages = messages;
this.body = body;
}
// getters et setters
...
}
- ligne 7 : code d'erreur de la réponse 0: OK, autre chose : KO ;
- ligne 11 : une liste de messages d'erreur, si erreur il y a ;
- ligne 13 : le corps de la réponse ;
Nous présentons maintenant les copies d'écran qui illustrent l'interface du service web / jSON :
Liste de tous les patients du cabinet médical [/getAllClients]
![]() |
Liste de tous les médecins du cabinet médical [/getAllMedecins]
![]() |
Liste des créneaux horaires d'un médecin [/getAllCreneaux/{idMedecin}]
![]() |
Liste des rendez-vous d'un médecin [/getRvMedecinJour/{idMedecin}/{aaaa-mm-jj}
![]() |
Agenda d'un médecin [/getAgendaMedecinJour/{idMedecin}/{aaaa-mm-jj}]
![]() |
Pour ajouter / supprimer un rendez-vous nous utilisons le complément Chrome [Advanced Rest Client] car ces opérations se font avec un POST.
Ajouter un rendez-vous [/ajouterRv]
![]() |
- en [0], l'URL du service web ;
- en [1], la méthode POST est utilisée ;
- en [2], le texte jSON des informations tarnsmises au service web sous la forme {jour, idClient, idCreneau} ;
- en [3], le client précise au service web qu'il lui envoie des informations au format jSON ;
La réponse est alors la suivante :
![]() |
- en [4] : le client envoie l'entête signifiant que les données qu'il envoie sont au format jSON ;
- en [5] : le service web répond qu'il envoie lui aussi du jSON ;
- en [6] : la réponse jSON du service web. Le champ [body] contient la forme jSON du rendez-vous ajouté ;
La présence du nouveau rendez-vous peut être vérifié :
![]() |
On notera l'id [50] du rendez-vous. Nous allons supprimer celui-ci.
Supprimer un rendez-vous [/supprimerRv]
![]() |
- en [1], l'URL du service web ;
- en [2], la méthode POST est utilisée;
- en [3], le texte jSON des informations transmises au service web sous la forme {idRv} ;
- en [4], le client précise au service web qu'il lui envoie des informations jSON ;
La réponse est alors la suivante :
![]() |
- en [5] : le champ [status] est à 0, montrant par là que l'opération a réussi ;
La suppression du rendez-vous peut être vérifiée :
![]() |
Ci-dessus, le rendez-vous du patient [Mme GERMAIN] n'est plus présent.
Le service web permet également de récupérer des entités via leur identifiant :
![]() |
![]() |
![]() |
![]() |
Toutes ces URL sont traitées par le contrôleur [RdvMedecinsController] que nous allons présenter prochainement.
8.4.11.3. Configuration du service web
![]() |
La classe de configuration [AppConfig] est la suivante :
package rdvmedecins.web.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import rdvmedecins.config.DomainAndPersistenceConfig;
@Configuration
@ComponentScan(basePackages = { "rdvmedecins.web" })
@Import({ DomainAndPersistenceConfig.class, SecurityConfig.class, WebConfig.class })
public class AppConfig {
}
- ligne 12 : la classe [AppConfig] configure la totalité de l'application ;
- ligne 9 : la classe [AppConfig] est une classe de configuration Spring ;
- ligne 10 : on demande à ce que les composants Spring soient cherchés dans le package [rdvmedecins.web] et ses descendants. C'est ainsi que seront découverts les composants :
- [@RestController RdvMedecinsController] dans le package [rdvmedecins.web.controllers] ;
- [@Component ApplicationModel] dans le package [rdvmedecins.web.models] ;
- ligne 11 : on importe la classe [DomainAndPersistenceConfig] qui configure le projet [rdvmedecins-metier-dao] afin d'avoir accès aux beans de ce projet ;
- ligne 11 : la classe [SecurityConfig] configure la sécurité de l'application web. Nous allons l'ignorer pour l'instant ;
- ligne 11 : la classe [WebConfig] configure la couche [web / jSON] ;
La classe [WebConfig] est la suivante :
package rdvmedecins.web.config;
import org.springframework.boot.context.embedded.EmbeddedServletContainerFactory;
import org.springframework.boot.context.embedded.ServletRegistrationBean;
import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
@Configuration
@EnableWebMvc
public class WebConfig {
// configuration dispatcherservlet pour les headers CORS
@Bean
public DispatcherServlet dispatcherServlet() {
DispatcherServlet servlet = new DispatcherServlet();
servlet.setDispatchOptionsRequest(true);
return servlet;
}
@Bean
public ServletRegistrationBean servletRegistrationBean(DispatcherServlet dispatcherServlet) {
return new ServletRegistrationBean(dispatcherServlet, "/*");
}
@Bean
public EmbeddedServletContainerFactory embeddedServletContainerFactory() {
return new TomcatEmbeddedServletContainerFactory("", 8080);
}
// mappeurs jSON
@Bean
public ObjectMapper jsonMapper() {
return new ObjectMapper();
}
@Bean
public ObjectMapper jsonMapperShortCreneau() {
ObjectMapper jsonMapperShortCreneau = new ObjectMapper();
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperShortCreneau.setFilters(new SimpleFilterProvider().addFilter("creneauFilter", creneauFilter));
return jsonMapperShortCreneau;
}
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(
new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter", creneauFilter));
return jsonMapperLongRv;
}
@Bean
public ObjectMapper jsonMapperShortRv() {
ObjectMapper jsonMapperShortRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("client", "creneau");
jsonMapperShortRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter));
return jsonMapperShortRv;
}
}
- lignes 20-25 : définissent le bean [dispatcherServlet]. La classe [DispatcherServlet] est la servlet du framework Spring MVC. Elle joue le rôle de [FrontController] : elle intercepte les requêtes adressées au site Spring MVC et les fait traiter par un des contrôleurs (Controller) du site ;
- ligne 22 : instanciation de la classe ;
- ligne 23 : cette ligne peut être ignorée pour le moment ;
- lignes 27-30 : la servlet [dispatcherServlet] traite toutes les URL ;
- lignes 27-30 : activent le serveur Tomcat embarqué dans les dépendances du projet. Il fonctionnera sur le port 8080 ;
- lignes 38-67 : quatre mappeurs jSON configurés avec des filtres jSON différents ;
- lignes 38-41 : un mappeur jSON sans filtres ;
- lignes 43-49 : le mappeur jSON [jsonMapperShortCreneau] sérialise / désérialise un objet [Creneau] en ignorant le champ [Creneau.medecin] ;
- lignes 51-59 : le mappeur jSON [jsonMapperLongRv] sérialise / désérialise un objet [Rv] en ignorant le champ [Rv.creneau.medecin] ;
- lignes 61-67 : le mappeur jSON [jsonMapperShortRv] sérialise / désérialise un objet [Rv] en ignorant les champs [Rv.creneau] et [Rv.client] ;
8.4.11.4. La classe [ApplicationModel]
![]() |
La classe [ApplicationModel] va nous servir à deux choses :
- de cache pour stocker les listes de médecins et de patients (clients) ;
- d'interface unique pour les contrôleurs ;
package rdvmedecins.web.models;
import java.util.Date;
import java.util.List;
import javax.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
import rdvmedecins.metier.IMetier;
import rdvmedecins.web.helpers.Static;
@Component
public class ApplicationModel implements IMetier {
// la couche [métier]
@Autowired
private IMetier métier;
// données provenant de la couche [métier]
private List<Medecin> médecins;
private List<Client> clients;
private List<String> messages;
// données de configuration
private boolean CORSneeded = false;
private boolean secured = false;
@PostConstruct
public void init() {
// on récupère les médecins et les clients
try {
médecins = métier.getAllMedecins();
clients = métier.getAllClients();
} catch (Exception ex) {
messages = Static.getErreursForException(ex);
}
}
// getter
public List<String> getMessages() {
return messages;
}
// ------------------------- interface couche [métier]
@Override
public List<Client> getAllClients() {
return clients;
}
@Override
public List<Medecin> getAllMedecins() {
return médecins;
}
@Override
public List<Creneau> getAllCreneaux(long idMedecin) {
return métier.getAllCreneaux(idMedecin);
}
@Override
public List<Rv> getRvMedecinJour(long idMedecin, Date jour) {
return métier.getRvMedecinJour(idMedecin, jour);
}
@Override
public Client getClientById(long id) {
return métier.getClientById(id);
}
@Override
public Medecin getMedecinById(long id) {
return métier.getMedecinById(id);
}
@Override
public Rv getRvById(long id) {
return métier.getRvById(id);
}
@Override
public Creneau getCreneauById(long id) {
return métier.getCreneauById(id);
}
@Override
public Rv ajouterRv(Date jour, Creneau creneau, Client client) {
return métier.ajouterRv(jour, creneau, client);
}
@Override
public void supprimerRv(long idRv) {
métier.supprimerRv(idRv);
}
@Override
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour) {
return métier.getAgendaMedecinJour(idMedecin, jour);
}
// getters et setters
public boolean isCORSneeded() {
return CORSneeded;
}
public boolean isSecured() {
return secured;
}
}
- ligne 19 : l'annotation [@Component] fait de la classe [ApplicationModel] un composant Spring. Comme tous les composants Spring vus jusqu'ici (à l'exception de @Controller), un seul objet de ce type sera instancié (singleton) ;
- ligne 20 : la classe [ApplicationModel] implémente l'interface [IMetier] ;
- lignes 23-24 : une référence sur la couche [métier] est injectée par Spring ;
- ligne 34 : l'annotation [@PostConstruct] fait que la méthode [init] va être exécutée juste après l'instanciation de la classe [ApplicationModel] ;
- lignes 38-39 : on récupère les listes de médecins et de clients auprès de la couche [métier] ;
- ligne 41 : si une exception se produit, on stocke les messages de la pile d'exceptions dans le champ de la ligne 17 ;
L'architecture de la couche web évolue comme suit :
![]() |
- en [2b], les méthodes du ou des contrôleurs communiquent avec le singleton [ApplicationModel] ;
Cette stratégie amène de la souplesse quant à la gestion du cache. Actuellement les créneaux horaires des médecins ne sont pas mis en cache. Pour les y mettre, il suffit de modifier la classe [ApplicationModel]. Cela n'a aucun impact sur le contrôleur qui continuera à utiliser la méthode [List<Creneau> getAllCreneaux(long idMedecin)] comme il le faisait auparavant. C'est l'implémentation de cette méthode dans [ApplicationModel] qui sera changée.
8.4.11.5. La classe Static
La classe [Static] regroupe un ensemble de méthodes statiques utilitaires qui n'ont pas d'aspect "métier" ou "web" :
![]() |
Son code est le suivant :
package rdvmedecins.web.helpers;
import java.util.ArrayList;
import java.util.List;
public class Static {
public Static() {
}
// liste des messages d'erreur d'une exception
public static List<String> getErreursForException(Exception exception) {
// on récupère la liste des messages d'erreur de l'exception
Throwable cause = exception;
List<String> erreurs = new ArrayList<String>();
while (cause != null) {
erreurs.add(cause.getMessage());
cause = cause.getCause();
}
return erreurs;
}
}
- ligne 12 : la méthode [Static.getErreursForException] qui a été utilisée (ligne 8 ci-dessous) dans la méthode [init] de la classe [ApplicationModel] :
@PostConstruct
public void init() {
// on récupère les médecins et les clients
try {
médecins = métier.getAllMedecins();
clients = métier.getAllClients();
} catch (Exception ex) {
messages = Static.getErreursForException(ex);
}
}
La méthode construit un objet [List<String>] avec les messages d'erreur [exception.getMessage()] d'une exception [exception] et de celles qu'elle contient [exception.getCause()].
8.4.11.6. Le squelette du contrôleur [RdvMedecinsController]
![]() |
Nous allons maintenant détailler le traitement des URL du service web. Trois classes principales sont en jeu dans ce traitement :
- le contrôleur [RdvMedecinsController] ;
- la classe de méthodes utilitaires [Static] ;
- la classe de cache [ApplicationModel] ;
![]() |
Le contrôleur [RdvMedecinsController] est le suivant :
package rdvmedecins.web.controllers;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
import rdvmedecins.web.helpers.Static;
import rdvmedecins.web.models.ApplicationModel;
import rdvmedecins.web.models.PostAjouterRv;
import rdvmedecins.web.models.PostSupprimerRv;
import rdvmedecins.web.models.Response;
@Controller
public class RdvMedecinsController {
@Autowired
private ApplicationModel application;
@Autowired
private RdvMedecinsCorsController rdvMedecinsCorsController;
// liste de messages
private List<String> messages;
// mappeurs jSON
@Autowired
private ObjectMapper jsonMapper;
@Autowired
private ObjectMapper jsonMapperShortCreneau;
@Autowired
private ObjectMapper jsonMapperLongRv;
@Autowired
private ObjectMapper jsonMapperShortRv;
@PostConstruct
public void init() {
// messages d'erreur de l'application
messages = application.getMessages();
}
// liste des médecins
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllMedecins() throws JsonProcessingException {...}
// liste des clients
@RequestMapping(value = "/getAllClients", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllClients() throws JsonProcessingException {...}
// liste des créneaux d'un médecin
@RequestMapping(value = "/getAllCreneaux/{idMedecin}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllCreneaux(@PathVariable("idMedecin") long idMedecin) throws JsonProcessingException {...}
// liste des rendez-vous d'un médecin
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour)
throws JsonProcessingException {...}
@RequestMapping(value = "/getClientById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getClientById(@PathVariable("id") long id) throws JsonProcessingException {...}
@RequestMapping(value = "/getMedecinById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getMedecinById(@PathVariable("id") long id) String origin) throws JsonProcessingException {...}
@RequestMapping(value = "/getRvById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvById(@PathVariable("id") long id) throws JsonProcessingException {...}
@RequestMapping(value = "/getCreneauById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getCreneauById(@PathVariable("id") long id) throws JsonProcessingException {...}
@RequestMapping(value = "/ajouterRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String ajouterRv(@RequestBody PostAjouterRv post) throws JsonProcessingException {...}
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String supprimerRv(@RequestBody PostSupprimerRv post) throws JsonProcessingException {...}
@RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAgendaMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour)
throws JsonProcessingException {...}
@RequestMapping(value = "/authenticate", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String authenticate() throws JsonProcessingException {...}
}
- ligne 35 : l'annotation [@Controller] fait de la classe [RdvMedecinsController] un contrôleur Spring, le C du MVC ;
- lignes 38-39 : un objet de type [ApplicationModel] sera injecté ici par Spring. Nous l'avons présenté ;
- lignes 41-42 : un objet de type [RdvMedecinsCorsController] sera injecté ici par Spring. Nous ne présenterons cet objet qu'ultérieurement ;
- lignes 48-58 : les mappeurs jSON définis dans la classe de configuration [WebConfig] ;
- ligne 60 : l'annotation [@PostConstruct] tague une méthode à exécuter juste après l'instanciation de la classe. Lorsqu'elle celle-ci s'exécute, les objets injectés par Spring sont disponibles ;
- ligne 63 : on récupère les éventuels messages d'erreur de l'objet [ApplicationModel]. Cet objet a été instancié au démarrage de l'application et a essayé de mettre en cache, les médecins et les clients. S'il a échoué, alors on a [messages!=null]. Cela va permettre aux méthodes du contrôleur de savoir si l'application s'est initialisée correctement ;
- lignes 67-118 : les URL exposées par le service [web / jSON]. Toutes les méthodes rendent la chaîne jSON d'un objet de type [Response<T>] suivant :
![]() |
package rdvmedecins.web.models;
import java.util.List;
public class Response<T> {
// ----------------- propriétés
// statut de l'opération
private int status;
// les éventuels messages d'erreur
private List<String> messages;
// le corps de la réponse
private T body;
// constructeurs
public Response() {
}
public Response(int status, List<String> messages, T body) {
this.status = status;
this.messages = messages;
this.body = body;
}
// getters et setters
...
}
- ligne 9 : un code d'erreur : 0 signifie pas d'erreur ;
- ligne 11 : si [status!=0], alors [messages] est une liste de messages d'erreur ;
- ligne 13 : un objet T encapsulé dans la réponse. T vaut null en cas d'erreur ;
Cet objet est sérialisé en jSON avant d'être envoyé au navigateur client ;
- ligne 67 : l'URL exposée est [/getAllMedecins]. Le client doit utiliser une méthode [GET] pour faire sa requête (method = RequestMethod.GET). Si cette URL était demandée par un POST, elle serait refusée et Spring MVC enverrait un code HTTP d'erreur au client web. La méthode renvoie elle-même la réponse au client (ligne 68). Ce sera une chaîne de caractères (ligne 67). L'entête HTTP [Content-type : application/json; charset=UTF-8] sera envoyé au client pour lui indiquer qu'il va recevoir une chaîne jSON (ligne 67) ;
- ligne 77 : l'URL est paramétrée par {idMedecin}. Ce paramètre est récupéré avec l'annotation [@PathVariable] ligne 79 ;
- ligne 79 : le paramètre [long idMedecin] reçoit sa valeur du paramètre {idMedecin} de l'URL [@PathVariable("idMedecin")]. Le paramètre dans l'URL et celui de la méthode peuvent porter des noms différents. Il faut noter ici que [@PathVariable("idMedecin")] est de type String (toute l'URL est un String) alors que le paramètre [long idMedecin] est de type [long]. Le changement de type est fait automatiquement. Un code d'erreur HTTP est renvoyé si ce changement de type échoue ;
- ligne 105 : l'annotation [@RequestBody] désigne le corps de la requête. Dans une requête GET, il n'y a quasiment jamais de corps (mais il est possible d'en mettre un). Dans une requête POST, il y en a le plus souvent (mais il est possible de ne pas en mettre). Pour l'URL [ajouterRv], le client web envoie dans son POST la chaîne jSON suivante :
La syntaxe [@RequestBody PostAjouterRv post] (ligne 105) ajoutée au fait que la méthode attend du jSON [consumes = "application/json; charset=UTF-8"] ligne 103 va faire que la chaîne jSON envoyée par le client web va être désérialisée en un objet de type [PostAjouterRv]. Celui-ci est le suivant :
package rdvmedecins.web.models;
public class PostAjouterRv {
// données du post
private String jour;
private long idClient;
private long idCreneau;
// getters et setters
...
}
Là également, les changements de type nécessaires auront lieu automatiquement ;
- lignes 107-109, on trouve un mécanisme similaire pour l'URL [/supprimerRv]. La chaîne jSON postée est la suivante :
et le type [PostSupprimerRv] le suivant :
package rdvmedecins.web.models;
public class PostSupprimerRv {
// données du post
private long idRv;
// getters et setters
...
}
8.4.11.7. L'URL [/getAllMedecins]
L'URL [/getAllMedecins] est traitée par la méthode suivante du contrôleur [RdvMedecinsController] :
// liste des médecins
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllMedecins() throws JsonProcessingException {
// la réponse
Response<List<Medecin>> response;
// état de l'application
if (messages != null) {
response = new Response<>(-1, messages, null);
} else {
// liste des médecins
try {
response = new Response<>(0, null, application.getAllMedecins());
} catch (RuntimeException e) {
response = new Response<>(1, Static.getErreursForException(e), null);
}
}
// réponse
return jsonMapper.writeValueAsString(response);
}
- lignes 9-10 : on regarde si l'application s'est correctement initialisée (messages==null). Si ce n'est pas le cas, on renvoie une réponse avec status=-1 et body=messages ;
- ligne 13 : sinon on demande la liste des médecins à la classe [ApplicationModel] ;
- ligne19 : on envoie la chaîne jSON de la réponse avec le mappeur jSON [jsonMapper] parce que la classe [Medecin] n'a pas de filtre jSON. La réponse peut être sans erreur (ligne 14) ou avec erreur (ligne 16). La méthode [application.getAllMedecins()] ne lance pas d'exception car elle se contente de rendre une liste qui est en cache. Néanmoins on gardera cette gestion d'exception pour le cas où les médecins ne seraient plus mis en cache ;
Nous n'avons pas encore illustré le cas où l'application s'est mal initialisée. Arrêtons le SGBD MySQL5, lançons le service web puis demandons l'URL [/getAllMedecins] :

On obtient bien une erreur. Dans un contexte normal, on obtient la vue suivante :
![]() |
8.4.11.8. L'URL [/getAllClients]
L'URL [/getAllClients] est traitée par la méthode suivante du contrôleur [RdvMedecinsController] :
// liste des clients
@RequestMapping(value = "/getAllClients", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllClients() throws JsonProcessingException {
// la réponse
Response<List<Client>> response;
// état de l'application
if (messages != null) {
response = new Response<>(-1, messages, null);
}
// liste des clients
try {
response = new Response<>(0, null, application.getAllClients());
} catch (RuntimeException e) {
response = new Response<>(1, Static.getErreursForException(e), null);
}
// réponse
return jsonMapper.writeValueAsString(response);
}
Elle est analogue à la méthode [getAllMedecins] déjà étudiée. Les résultats obtenus sont les suivants :
![]() |
8.4.11.9. L'URL [/getAllCreneaux/{idMedecin}]
L'URL [/getAllCreneaux/{idMedecin}] est traitée par la méthode suivante du contrôleur [RdvMedecinsController] :
// liste des créneaux d'un médecin
@RequestMapping(value = "/getAllCreneaux/{idMedecin}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllCreneaux(@PathVariable("idMedecin") long idMedecin) throws JsonProcessingException {
// la réponse
Response<List<Creneau>> response;
// état de l'application
if (messages != null) {
response = new Response<>(-1, messages, null);
}
// on récupère le médecin
Response<Medecin> responseMedecin = getMedecin(idMedecin);
if (responseMedecin.getStatus() != 0) {
response = new Response<>(responseMedecin.getStatus(), responseMedecin.getMessages(), null);
} else {
Medecin médecin = responseMedecin.getBody();
// créneaux du médecin
try {
response = new Response<>(0, null, application.getAllCreneaux(médecin.getId()));
} catch (RuntimeException e1) {
response = new Response<>(3, Static.getErreursForException(e1), null);
}
}
// réponse
return jsonMapperShortCreneau.writeValueAsString(response);
}
- ligne 12 : le médecin identifié par le paramètre [id] est demandé à une méthode locale :
private Response<Medecin> getMedecin(long id) {
// on récupère le médecin
Medecin médecin = null;
try {
médecin = application.getMedecinById(id);
} catch (RuntimeException e1) {
return new Response<Medecin>(1, Static.getErreursForException(e1), null);
}
// médecin existant ?
if (médecin == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le médecin d'id [%s] n'existe pas", id));
return new Response<Medecin>(2, messages, null);
}
// ok
return new Response<Medecin>(0, null, médecin);
}
On revient de cette méthode avec un status dans [0,1,2]. Revenons au code de la méthode [getAllCreneaux] :
- lignes 13-14 : si status!=0, on construit une réponse avec erreur ;
- ligne 16 : on récupère le médecin ;
- ligne 19 : on récupère les créneaux de ce médecin ;
- ligne 25 : on envoie comme réponse un objet [List<Creneau>]. Rappelons la définition de la classe [Creneau] :
@Entity
@Table(name = "creneaux")
public class Creneau extends AbstractEntity {
private static final long serialVersionUID = 1L;
// caractéristiques d'un créneau de RV
private int hdebut;
private int mdebut;
private int hfin;
private int mfin;
// un créneau est lié à un médecin
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_medecin")
private Medecin medecin;
// clé étrangère
@Column(name = "id_medecin", insertable = false, updatable = false)
private long idMedecin;
...
}
- ligne 13 : le médecin est cherché en mode [FetchType.LAZY] ;
Rappelons la requête JPQL qui implémente la méthode [getAllCreneaux] dans la couche [DAO] :
@Query("select c from Creneau c where c.medecin.id=?1")
La notation [c.medecin.id] force la jointure entre les tables [CRENEAUX] et [MEDECINS]. Aussi la requête ramène-t-elle tous les créneaux du médecin avec dans chacun d'eux le médecin. Lorsqu'on sérialise en jSON ces créneaux, on voit apparaître la chaîne jSON du médecin dans chacun d'eux. C'est inutile. Pour contrôler la sérialisation, il nous faut deux choses :
- avoir accès à l'objet qui sérialise ;
- configurer l'objet à sérialiser ;
Le point 1 est vérifié avec l'injection du convertisseur jSON approprié à l'objet dans le contrôleur :
@Autowired
private ObjectMapper jsonMapperShortCreneau;
Le point 2 est obtenu en ajoutant une annotation à la classe [Creneau] définie dans le projet [rdvmedecins-metier-dao] :
![]() |
@Entity
@Table(name = "creneaux")
@JsonFilter("creneauFilter")
public class Creneau extends AbstractEntity {
...
- ligne 3 : une annotation de la bibliothèque jSON Jackson. Elle crée un filtre appelé [creneauFilter]. A l'aide de ce filtre, nous allons pouvoir définir par programmation les champs qui doivent être ou non sérialisés ;
La sérialisation de l'objet [Creneau] se fait dans la ligne suivante de la méthode [getAllCreneaux] :
// réponse
return jsonMapperShortCreneau.writeValueAsString(response);
Le mappeur jSON [jsonMapperShortCreneau] a été défini dans la classe [WebConfig] de la façon suivante :
@Bean
public ObjectMapper jsonMapperShortCreneau() {
ObjectMapper jsonMapperShortCreneau = new ObjectMapper();
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperShortCreneau.setFilters(new SimpleFilterProvider().addFilter("creneauFilter", creneauFilter));
return jsonMapperShortCreneau;
}
- ligne 5 : le filtre nommé [creneauFilter] est associé au filtre [creneauFilter] de la ligne 4. Ce filtre sérialise l'objet [Creneau] sans son champ [medecin] ;
Le résultat rendu par la méthode [getAllCreneaux] est la chaîne jSON d'un type [Response<List<Creneau>].
Les résultats obtenus sont les suivants :
![]() |
ou bien ceux-ci si le créneau n'existe pas :
![]() |
De cet exemple, nous retiendrons la règle suivante :
- les méthodes du serveur web / jSON rendent un objet de type [Response<T>] qui est sérialisé en jSON ;
- si le type T a un ou plusieurs filtres jSON, pour le sérialiser on utilisera un mappeur avec ces mêmes filtres ;
8.4.11.10. L'URL [/getRvMedecinJour/{idMedecin}/{jour}]
L'URL [/getRvMedecinJour/{idMedecin}/{jour}] est traitée par la méthode suivante du contrôleur [RdvMedecinsController] :
// liste des rendez-vous d'un médecin
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvMedecinJour(@PathVariable("idMedecin") long idMedecin)
throws JsonProcessingException {
// la réponse
Response<List<Rv>> response=null;
boolean erreur = false;
// état de l'application
if (messages != null) {
response = new Response<>(-1, messages, null);
erreur = true;
}
// on vérifie la date
Date jourAgenda = null;
if (!erreur) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("La date [%s] est invalide", jour));
response = new Response<List<Rv>>(3, messages, null);
erreur = true;
}
}
Response<Medecin> responseMedecin = null;
if (!erreur) {
// on récupère le médecin
responseMedecin = getMedecin(idMedecin);
if (responseMedecin.getStatus() != 0) {
response = new Response<>(responseMedecin.getStatus(), responseMedecin.getMessages(), null);
erreur = true;
}
}
if (!erreur) {
Medecin médecin = responseMedecin.getBody();
// liste de ses rendez-vous
try {
response = new Response<>(0, null, application.getRvMedecinJour(médecin.getId(), jourAgenda));
} catch (RuntimeException e1) {
response = new Response<>(4, Static.getErreursForException(e1), null);
}
}
// réponse
return jsonMapperLongRv.writeValueAsString(response);
}
- on doit rendre la chaîne jSON d'un type [Response<List<Rv>>]. La classe [Rv] a un champ [Rv.creneau]. Si ce champ est sérialisé, on va rencontrer le filtre jSON [creneauFilter] ;
- ligne 47 : l'objet de type [Response<List<Rv>>] de la ligne 7 est sérialisé en jSON ;
Etudions le cas où la liste des rendez-vous a été obtenue ligne 42. La classe [Rv] dans le projet [rdvmedecins-metier-dao] est définie de la façon suivante :
@Entity
@Table(name = "rv")
public class Rv extends AbstractEntity {
private static final long serialVersionUID = 1L;
// caractéristiques d'un Rv
@Temporal(TemporalType.DATE)
private Date jour;
// un rv est lié à un client
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_client")
private Client client;
// un rv est lié à un créneau
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_creneau")
private Creneau creneau;
// clés étrangères
@Column(name = "id_client", insertable = false, updatable = false)
private long idClient;
@Column(name = "id_creneau", insertable = false, updatable = false)
private long idCreneau;
...
}
- ligne 11 : le client est recherché avec le mode [FetchType.LAZY] ;
- ligne 18 : le créneau est recherché avec le mode [FetchType.LAZY] ;
Rappelons la requête JPQL qui va chercher les rendez-vous :
@Query("select rv from Rv rv left join fetch rv.client c left join fetch rv.creneau cr where cr.medecin.id=?1 and rv.jour=?2")
De jointures sont faites explicitement pour ramener les champs [client] et [creneau]. Par ailleurs à cause de la jointure [cr.medecin.id=?1], nous aurons également le médecin. Le médecin va donc apparaître dans la chaîne jSON de chaque rendez-vous. Or cette information dupliquée est en outre inutile. Nous avons vu comment résoudre ce problème à l'aide d'un filtre jSON sur l'objet [Creneau]. A cause des modes [FetchType.LAZY] des champs [client] et [creneau] de la classe [Rv], nous allons découvrir bientôt la nécessité de poser un filtre jSON sur la classe [RV] du projet [rdvmedecins-metier-dao] :
@Entity
@Table(name = "rv")
@JsonFilter("rvFilter")
public class Rv extends AbstractEntity {
...
Nous contrôlerons la sérialisation de l'objet [Rv] avec le filtre [rvFilter]. Apparemment ici, nous n'avons pas besoin de filtrer car nous avons besoin de tous les champs de l'objet de type [Rv]. Néanmoins, parce que nous avons indiqué que la classe avait un filtre jSON, nous devons définir celui-ci pour toute sérialisation d'un objet de type [Rv] sinon nous récupérons une exception. Pour cela, nous utilisons le mappeur jSON suivant défini dans la classe [rdvMedecinsController] :
@Autowired
private ObjectMapper jsonMapperLongRv;
Ce mappeur est défini de la façon suivante dans la classe de configuration [WebConfig] :
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter",creneauFilter));
return jsonMapperLongRv;
}
- ligne 4 : nous indiquons que tous les champs de l'objet [Rv] doivent être sérialisés ;
- ligne 5 : nous indiquons que dans l'objet [Creneau], il ne faut pas sérialiser le champ [medecin] ;
- ligne 6 : nous ajoutons les deux filtres [rvFilter] et [creneauFilter] aux filtres jSON de l'objet [jsonMapperLongRv] ;
Les résultats obtenus sont les suivants :
![]() |
ou encore ceux-ci avec un jour sans rendez-vous :
![]() |
ou encore ceux-ci avec un jour incorrect :
![]() |
ou encore ceux-ci avec un médecin incorrect :
![]() |
8.4.11.11. L'URL [/getAgendaMedecinJour/{idMedecin}/{jour}]
L'URL [/getAgendaMedecinJour/{idMedecin}/{jour}] est traitée par la méthode suivante du contrôleur [RdvMedecinsController] :
@RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAgendaMedecinJour(@PathVariable("idMedecin") long idMedecin)
throws JsonProcessingException {
// la réponse
Response<AgendaMedecinJour> response = null;
boolean erreur = false;
// état de l'application
if (messages != null) {
response = new Response<>(-1, messages, null);
erreur = true;
}
// on vérifie la date
Date jourAgenda = null;
if (!erreur) {
// on vérifie la date
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
erreur = true;
List<String> messages = new ArrayList<String>();
messages.add(String.format("La date [%s] est invalide", jour));
response = new Response<>(3, messages, null);
}
}
// on récupère le médecin
Medecin médecin = null;
if (!erreur) {
// on récupère le médecin
Response<Medecin> responseMedecin = getMedecin(idMedecin);
if (responseMedecin.getStatus() != 0) {
response = new Response<>(responseMedecin.getStatus(), responseMedecin.getMessages(), null);
} else {
médecin = responseMedecin.getBody();
}
}
// on récupère son agenda
if (!erreur) {
try {
response = new Response<>(0, null, application.getAgendaMedecinJour(médecin.getId(), jourAgenda));
} catch (RuntimeException e1) {
erreur = true;
response = new Response<>(4, Static.getErreursForException(e1), null);
}
}
// réponse
return jsonMapperLongRv.writeValueAsString(response);
}
- lignes 6, 49 : on rend la chaîne jSON d'un type [AgendaMedecinJour] encapsulé dans un objet [Response] ;
Le type [AgendaMedecinJour] est le suivant :
public class AgendaMedecinJour implements Serializable {
// champs
private Medecin medecin;
private Date jour;
private CreneauMedecinJour[] creneauxMedecinJour;
Le type [CreneauMedecinJour] est le suivant :
public class CreneauMedecinJour implements Serializable {
private static final long serialVersionUID = 1L;
// champs
private Creneau creneau;
private Rv rv;
Les champs [creneau] et [rv] ont des filtres jSON qu'il faut configurer. C'est ce que fait la ligne 49 de la méthode [getAgendaMedecinJour] qui utilise le mappeur jSON [jsonMapperLongRv] déjà rencontré :
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(
new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter", creneauFilter));
return jsonMapperLongRv;
}
Les résultats obtenus sont les suivants :
![]() |
Ci-dessus, on voit que le 28/01/2015, le docteur PELISSIER a un rendez-vous avec Mme Brigitte BISTROU à 8h20 ;
ou bien ceux-ci si le jour est erroné :
![]() |
ou bien ceux-ci si le n° du médecin est invalide :
![]() |
8.4.11.12. L'URL [/getMedecinById/{id}]
L'URL [/getMedecinById/{id}] est traitée par la méthode suivante du contrôleur [RdvMedecinsController] :
@RequestMapping(value = "/getMedecinById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getMedecinById(@PathVariable("id") long id) throws JsonProcessingException {
// la réponse
Response<Medecin> response;
// état de l'application
if (messages != null) {
response = new Response<Medecin>(-1, messages, null);
} else {
response = getMedecin(id);
}
// réponse
return jsonMapper.writeValueAsString(response);
}
- lignes 5, 13 : la méthode rend la chaîne jSON d'un type [Medecin]. Ce type n'a pas d'annotation de filtre jSON. Aussi, ligne 14, utilise-t-on le mappeur jSON sans filtres ;
Ligne 10, la méthode [getMedecin] est la suivante :
private Response<Medecin> getMedecin(long id) {
// on récupère le médecin
Medecin médecin = null;
try {
médecin = application.getMedecinById(id);
} catch (RuntimeException e1) {
return new Response<Medecin>(1, Static.getErreursForException(e1), null);
}
// médecin existant ?
if (médecin == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le médecin d'id [%s] n'existe pas", id));
return new Response<Medecin>(2, messages, null);
}
// ok
return new Response<Medecin>(0, null, médecin);
}
Les résultats obtenus sont les suivants :
![]() |
ou bien ceux-ci si le n° du médecin est incorrect :
![]() |
8.4.11.13. L'URL [/getClientById/{id}]
L'URL [/getClientById/{id}] est traitée par la méthode suivante du contrôleur [RdvMedecinsController] :
@RequestMapping(value = "/getClientById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getClientById(@PathVariable("id") long id) throws JsonProcessingException {
// la réponse
Response<Client> response;
// état de l'application
if (messages != null) {
response = new Response<>(-1, messages, null);
} else {
response = getClient(id);
}
// réponse
return jsonMapper.writeValueAsString(response);
}
- lignes 5, 13 : la méthode rend la chaîne jSON d'un type [Client]. Ce type n'a pas d'annotation de filtres jSON. Aussi, ligne 13, utilise-t-on le mappeur jSON sans filtres ;
Ligne 11, la méthode [getClient] est la suivante :
private Response<Client> getClient(long id) {
// on récupère le client
Client client = null;
try {
client = application.getClientById(id);
} catch (RuntimeException e1) {
return new Response<Client>(1, Static.getErreursForException(e1), null);
}
// client existant ?
if (client == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le client d'id [%s] n'existe pas", id));
return new Response<Client>(2, messages, null);
}
// ok
return new Response<Client>(0, null, client);
}
Les résultats obtenus sont les suivants :
![]() |
ou bien ceux-ci si le n° du client est incorrect :
![]() |
8.4.11.14. L'URL [/getCreneauById/{id}]
L'URL [/getCreneauById/{id}] est traitée par la méthode suivante du contrôleur [RdvMedecinsController] :
@RequestMapping(value = "/getCreneauById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getCreneauById(@PathVariable("id") long id) throws JsonProcessingException {
// la réponse
Response<Creneau> response;
// état de l'application
if (messages != null) {
response = new Response<>(-1, messages, null);
} else {
// on rend le créneau
response = getCreneau(id);
}
// réponse
return jsonMapperShortCreneau.writeValueAsString(response);
}
- lignes 5, 14 :la méthode rend la chaîne jSON d'un type [Response<Creneau>] ;
Ligne 8, la méthode [getCreneau] est la suivante :
private Response<Creneau> getCreneau(long id) {
// on récupère le créneau
Creneau créneau = null;
try {
créneau = application.getCreneauById(id);
} catch (RuntimeException e1) {
return new Response<Creneau>(1, Static.getErreursForException(e1), null);
}
// créneau existant ?
if (créneau == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le créneau d'id [%s] n'existe pas", id));
return new Response<Creneau>(2, messages, null);
}
// ok
return new Response<Creneau>(0, null, créneau);
}
Rappelons le code de l'entité [Creneau] :
@Entity
@Table(name = "creneaux")
@JsonFilter("creneauFilter")
public class Creneau extends AbstractEntity {
private static final long serialVersionUID = 1L;
// caractéristiques d'un créneau de RV
private int hdebut;
private int mdebut;
private int hfin;
private int mfin;
// un créneau est lié à un médecin
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_medecin")
private Medecin medecin;
// clé étrangère
@Column(name = "id_medecin", insertable = false, updatable = false)
private long idMedecin;
- lignes 14-16 : parce que le champ [medecin] est en mode [fetch = FetchType.LAZY], il n'est pas ramené lorsqu'on va chercher un créneau via son [id]. Il est donc nécessaire de l'exclure de la sérialisation. Sans cette exclusion, on a une exception. Celle-ci est dûe au fait que l'objet de sérialisation [mapper] va appeler la méthode [getMedecin] pour obtenir le champ [medecin]. Or, avec une implémentation JPA / Hibernate, le mode [fetch = FetchType.LAZY] du champ [medecin] a ramené un objet [Creneau] dont la méthode [getMedecin] est programmée pour aller chercher le médecin dans le contexte JPA. On appelle cela un objet [proxy]. Or rappelons-nous l'architecture de l'application web :
![]() |
Le contrôleur se trouve dans le bloc [Contrôleurs / Actions]. Lorsqu'on est dans ce bloc, il n'y a plus de notion de contexte JPA. Ce dernier est créé le temps des opérations de la couche [DAO]. Il ne vit pas au-delà. Donc lorsque le contrôleur essaie d'avoir accès au contexte JPA, une exception se produit indiquant que celui-ci est fermé. Pour éviter cette exception, il faut empêcher la sérialisation du champ [medecin] de la classe [Rv]. C'est ce que fait le mappeur jSON [jsonMapperShortCreneau] :
@Bean
public ObjectMapper jsonMapperShortCreneau() {
ObjectMapper jsonMapperShortCreneau = new ObjectMapper();
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperShortCreneau.setFilters(new SimpleFilterProvider().addFilter("creneauFilter", creneauFilter));
return jsonMapperShortCreneau;
}
Les résultats obtenus sont les suivants :
![]() |
ou ceux-ci si le n° du créneau est incorrect :
![]() |
8.4.11.15. L'URL [/getRvById/{id}]
L'URL [/getRvById/{id}] est traitée par la méthode suivante du contrôleur [RdvMedecinsController] :
@RequestMapping(value = "/getRvById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvById(@PathVariable("id") long id) throws JsonProcessingException {
// la réponse
Response<Rv> response;
// état de l'application
if (messages != null) {
response = new Response<>(-1, messages, null);
} else {
// on récupère rv
response = getRv(id);
}
// réponse
return jsonMapperShortRv.writeValueAsString(response);
}
- lignes 5, 14 : la méthode renvoie la chaîne jSON d'un type [Response<Rv>] ;
Ligne 11, la méthode [getRv] est la suivante :
private Response<Rv> getRv(long id) {
// on récupère le Rv
Rv rv = null;
try {
rv = application.getRvById(id);
} catch (RuntimeException e1) {
return new Response<Rv>(1, Static.getErreursForException(e1), null);
}
// Rv existant ?
if (rv == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le rendez-vous d'id [%s] n'existe pas", id));
return new Response<Rv>(2, messages, null);
}
// ok
return new Response<Rv>(0, null, rv);
}
La classe [Rv] a deux champ avec l'annotation [fetch = FetchType.LAZY], les champs [creneau] et [client]. Ces champs ne sont donc pas ramenés lorsqu'on va chercher un [Rv] via sa clé primaire. Il faut donc, pour les mêmes raisons que précédemment, les exclure de la sérialisation. C'est ce que fait le mappeur [jsonMapperShortRv] suivant défini dans la classe [WebConfig] :
@Bean
public ObjectMapper jsonMapperShortRv() {
ObjectMapper jsonMapperShortRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("client", "creneau");
jsonMapperShortRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter));
return jsonMapperShortRv;
}
Les résultats obtenus sont les suivants :
![]() |
ou bien ceux-ci si le n° du rendez-vous est incorrect :
![]() |
8.4.11.16. L'URL [/ajouterRv]
L'URL [/ajouterRv] est traitée par la méthode suivante du contrôleur [RdvMedecinsController] :
@RequestMapping(value = "/ajouterRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String ajouterRv(@RequestBody PostAjouterRv post) throws JsonProcessingException {
// la réponse
Response<Rv> response = null;
boolean erreur = false;
// état de l'application
if (messages != null) {
response = new Response<>(-1, messages, null);
erreur = true;
}
// on récupère les valeurs postées
String jour;
long idCreneau = -1;
long idClient = -1;
Date jourAgenda = null;
if (!erreur) {
// on récupère les valeurs postées
jour = post.getJour();
idCreneau = post.getIdCreneau();
idClient = post.getIdClient();
// on vérifie la date
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("La date [%s] est invalide", jour));
response = new Response<>(6, messages, null);
erreur = true;
}
}
// on récupère le créneau
Response<Creneau> responseCréneau = null;
if (!erreur) {
// on récupère le créneau
responseCréneau = getCreneau(idCreneau);
if (responseCréneau.getStatus() != 0) {
erreur = true;
response = new Response<>(responseCréneau.getStatus(), responseCréneau.getMessages(), null);
}
}
// on récupère le client
Response<Client> responseClient = null;
Creneau créneau = null;
if (!erreur) {
créneau = (Creneau) responseCréneau.getBody();
// on récupère le client
responseClient = getClient(idClient);
if (responseClient.getStatus() != 0) {
erreur = true;
response = new Response<>(responseClient.getStatus() + 2, responseClient.getMessages(), null);
}
}
if (!erreur) {
Client client = responseClient.getBody();
// on ajoute le Rv
try {
response = new Response<>(0, null, application.ajouterRv(jourAgenda, créneau, client));
} catch (RuntimeException e1) {
erreur = true;
response = new Response<>(5, Static.getErreursForException(e1), null);
}
}
// réponse
return jsonMapperLongRv.writeValueAsString(response);
}
- lignes 5, 67 : la méthode doit rendre la chaîne jSON d'un type [Response<Rv>] ;
- ligne 3 : l'annotation [@RequestBody PostAjouterRv post] récupère le corps du POST et le met dans le paramètre [PostAjouterRv post]. Ce corps est du jSON [consumes = "application/json; charset=UTF-8"] qui va être désérialisé automatiquement dans le type [PostAjouterRv] suivant :
public class PostAjouterRv {
// données du post
private String jour;
private long idClient;
private long idCreneau;
...
- ensuite il y a du code qui a déjà été rencontré sous une forme ou une autre ;
- ligne 67 : la mise en place des filtres jSON [creneauFilter] et [rvFilter]. La méthode rend la chaîne jSON d'un type [Response<Rv>] où Rv a été obtenu obtenu ligne 61. L'objet [Rv] encapsule un objet [Creneau] ainsi qu'un objet [Client]. L'objet [Creneau] a une dépendance [FetchType.LAZY] sur un objet [Medecin] et a été obtenu lignes 36-44. Il a été cherché dans le contexte JPA via sa clé primaire et a été obtenu sans sa dépendance [FetchType.LAZY]. Au final,
- l'objet [Rv] a toutes ses dépendances. Elles peuvent être sérialisées ;
- l'objet [Creneau] n'a pas sa dépendance [medecin]. Il faut donc que celle-ci ne soit pas sérialisée ;
Le mappeur jSON [jsonMapperLongRv] défini dans la classe [WebConfig] répond à ces contraintes :
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter",creneauFilter));
return jsonMapperLongRv;
}
Les résultats obtenus ressemblent à ceci avec le client [Advanced Rest Client] :
![]() |
- en [1], l'URL du POST ;
- en [2], le POST ;
- en [3], la valeur postée ;
- en [4a], cette valeur postée est du jSON ;
![]() |
- en [4b], le client indique qu'il envoie du jSON ;
- en [5], le serveur indique qu'il renvoie du jSON ;
![]() |
- en [6], la réponse jSON du serveur qui représente le rendez-vous ajouté. On y voit l'identifiant [id] du rendez-vous ajouté ;
On obtient la chose suivante avec un n° de créneau inexistant :
![]() |
8.4.11.17. L'URL [/supprimerRv]
L'URL [/supprimerRv] est traitée par la méthode suivante du contrôleur [RdvMedecinsController] :
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String supprimerRv(@RequestBody PostSupprimerRv post) throws JsonProcessingException {
// la réponse
Response<Void> response = null;
boolean erreur = false;
// entêtes CORS
rdvMedecinsCorsController.sendOptions(origin, httpServletResponse);
// état de l'application
if (messages != null) {
response = new Response<>(-1, messages, null);
erreur = true;
}
// on récupère les valeurs postées
long idRv = post.getIdRv();
// on récupère le rv
if (!erreur) {
Response<Rv> responseRv = getRv(idRv);
if (responseRv.getStatus() != 0) {
response = new Response<>(responseRv.getStatus(), responseRv.getMessages(), null);
erreur = true;
}
}
if (!erreur) {
// suppression du rv
try {
application.supprimerRv(idRv);
response = new Response<Void>(0, null, null);
} catch (RuntimeException e1) {
response = new Response<>(3, Static.getErreursForException(e1), null);
}
}
// réponse
return jsonMapper.writeValueAsString(response);
}
- ligne 5 : le type [Void] est la classe correspondant au type primitif [void] ;
- lignes 5, 34 : la méthode rend la chaîne jSON d'un type [Response<Void>] qui n'a pas de filtres jSON. Aussi utilise-t-on, ligne 34, le mappeur jSON sans filtres ;
- ligne 3 : la méthode a pour paramètre le corps du POST, ç-à-d la valeur postée. Celle-ci est reçue sous forme jSON [consumes = "application/json; charset=UTF-8"] et désérialisée automatiquement dans le type [PostSupprimerRv] suivant :
public class PostSupprimerRv {
// données du post
private long idRv;
- ligne 28 : lorsque la suppression a réussi, on envoie une réponse avec [status=0] ;
Les résultats obtenus sont les suivants :
![]() |
![]() |
- en [5], le champ [status=0] indique que la suppression a réussi ;
Avec un n° de rendez-vous qui n'existe pas, on obtient la chose suivante :
![]() |
Nous en avons terminé avec le contrôleur. Nous voyons maintenant comment exécuter le projet.
8.4.11.18. La classe exécutable du service web
![]() |
La classe [Boot] [1] est la suivante :
package rdvmedecins.web.boot;
import org.springframework.boot.SpringApplication;
import rdvmedecins.web.config.AppConfig;
public class Boot {
public static void main(String[] args) {
SpringApplication.run(AppConfig.class, args);
}
}
Ligne 10, la méthode statique [SpringApplication.run] est exécutée avec comme premier paramètre, la classe [AppConfig] de configuration du projet. Cette méthode va procéder à l'auto-configuration du projet, lancer le serveur Tomcat embarqué dans les dépendances et y déployer le contrôleur [RdvMedecinsController].
Les logs sont contrôlés par les fichiers suivants [2] :
[logback.xml]
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are by default assigned the type ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- contrôle niveau des logs -->
<root level="info"> <!-- off, info, debug, warn -->
<appender-ref ref="STDOUT" />
</root>
</configuration>
- ligne 9 : le niveau de logs général est mis à [info] ;
[application.properties]
logging.level.org.springframework.web=INFO
logging.level.org.hibernate=OFF
spring.main.show-banner=false
Les lignes 1-2 permettent un niveau de logs spécifique pour certains éléments de l'application :
- ligne 1 : on veut les logs de la couche [web] ;
- ligne 2 : on ne veut pas les logs de la couche [JPA] ;
- ligne 3 : pas de bannière Spring Boot ;
Les logs à l'exécution sont les suivants :
11:06:04,279 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Could NOT find resource [logback.groovy]
11:06:04,279 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Could NOT find resource [logback-test.xml]
11:06:04,279 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Found resource [logback.xml] at [file:/D:/data/istia-1516/projets/springmvc-thymeleaf/dvp-final/etude-de-cas/rdvmedecins-webjson-server/target/classes/logback.xml]
11:06:04,279 |-WARN in ch.qos.logback.classic.LoggerContext[default] - Resource [logback.xml] occurs multiple times on the classpath.
11:06:04,279 |-WARN in ch.qos.logback.classic.LoggerContext[default] - Resource [logback.xml] occurs at [file:/D:/data/istia-1516/projets/springmvc-thymeleaf/dvp-final/etude-de-cas/rdvmedecins-metier-dao/target/classes/logback.xml]
11:06:04,279 |-WARN in ch.qos.logback.classic.LoggerContext[default] - Resource [logback.xml] occurs at [file:/D:/data/istia-1516/projets/springmvc-thymeleaf/dvp-final/etude-de-cas/rdvmedecins-webjson-server/target/classes/logback.xml]
11:06:04,342 |-INFO in ch.qos.logback.classic.joran.action.ConfigurationAction - debug attribute not set
11:06:04,342 |-INFO in ch.qos.logback.core.joran.action.AppenderAction - About to instantiate appender of type [ch.qos.logback.core.ConsoleAppender]
11:06:04,342 |-INFO in ch.qos.logback.core.joran.action.AppenderAction - Naming appender as [STDOUT]
11:06:04,357 |-INFO in ch.qos.logback.core.joran.action.NestedComplexPropertyIA - Assuming default type [ch.qos.logback.classic.encoder.PatternLayoutEncoder] for [encoder] property
11:06:04,404 |-INFO in ch.qos.logback.classic.joran.action.RootLoggerAction - Setting level of ROOT logger to INFO
11:06:04,404 |-INFO in ch.qos.logback.core.joran.action.AppenderRefAction - Attaching appender named [STDOUT] to Logger[ROOT]
11:06:04,404 |-INFO in ch.qos.logback.classic.joran.action.ConfigurationAction - End of configuration.
11:06:04,420 |-INFO in ch.qos.logback.classic.joran.JoranConfigurator@56f4468b - Registering current configuration as safe fallback point
11:06:04.732 [main] INFO rdvmedecins.web.boot.Boot - Starting Boot on Gportpers3 with PID 420 (D:\data\istia-1516\projets\springmvc-thymeleaf\dvp-final\etude-de-cas\rdvmedecins-webjson-server\target\classes started by usrlocal in D:\data\istia-1516\projets\springmvc-thymeleaf\dvp-final\etude-de-cas\rdvmedecins-webjson-server)
11:06:04.775 [main] INFO o.s.b.c.e.AnnotationConfigEmbeddedWebApplicationContext - Refreshing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@2ea6137: startup date [Wed Oct 14 11:06:04 CEST 2015]; root of context hierarchy
11:06:05.538 [main] INFO o.s.b.c.e.t.TomcatEmbeddedServletContainer - Tomcat initialized with port(s): 8080 (http)
11:06:05.688 [main] INFO o.a.catalina.core.StandardService - Starting service Tomcat
11:06:05.689 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet Engine: Apache Tomcat/8.0.26
11:06:05.833 [localhost-startStop-1] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
11:06:05.833 [localhost-startStop-1] INFO o.s.web.context.ContextLoader - Root WebApplicationContext: initialization completed in 1061 ms
11:06:06.231 [localhost-startStop-1] INFO o.s.o.j.LocalContainerEntityManagerFactoryBean - Building JPA container EntityManagerFactory for persistence unit 'default'
11:06:09.234 [localhost-startStop-1] INFO o.s.s.web.DefaultSecurityFilterChain - Creating filter chain: org.springframework.security.web.util.matcher.AnyRequestMatcher@1, [org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@12d14fa, org.springframework.security.web.context.SecurityContextPersistenceFilter@29823fb6, org.springframework.security.web.header.HeaderWriterFilter@662d93b2, org.springframework.security.web.authentication.logout.LogoutFilter@2d81ee0, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@52aa47ad, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@60bd7a74, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@5a374232, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@7ddb4452, org.springframework.security.web.session.SessionManagementFilter@2cd9855f, org.springframework.security.web.access.ExceptionTranslationFilter@2263f0a2, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@192ce7f6]
11:06:09.255 [localhost-startStop-1] INFO o.s.b.c.e.ServletRegistrationBean - Mapping servlet: 'dispatcherServlet' to [/*]
11:06:09.255 [localhost-startStop-1] INFO o.s.b.c.e.FilterRegistrationBean - Mapping filter: 'springSecurityFilterChain' to: [/*]
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/authenticate],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.Void> rdvmedecins.web.controllers.RdvMedecinsController.authenticate(javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAgendaMedecinJour/{idMedecin}/{jour}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getAgendaMedecinJour(long,java.lang.String,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllCreneaux/{idMedecin}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getAllCreneaux(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getRvMedecinJour/{idMedecin}/{jour}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getRvMedecinJour(long,java.lang.String,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getMedecinById/{id}],methods=[GET]}" onto public rdvmedecins.web.models.Response<rdvmedecins.entities.Medecin> rdvmedecins.web.controllers.RdvMedecinsController.getMedecinById(long,javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getClientById/{id}],methods=[GET]}" onto public rdvmedecins.web.models.Response<rdvmedecins.entities.Client> rdvmedecins.web.controllers.RdvMedecinsController.getClientById(long,javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/supprimerRv],methods=[POST],consumes=[application/json;charset=UTF-8]}" onto public rdvmedecins.web.models.Response<java.lang.Void> rdvmedecins.web.controllers.RdvMedecinsController.supprimerRv(rdvmedecins.web.models.PostSupprimerRv,javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllClients],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.util.List<rdvmedecins.entities.Client>> rdvmedecins.web.controllers.RdvMedecinsController.getAllClients(javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/ajouterRv],methods=[POST],consumes=[application/json;charset=UTF-8]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.ajouterRv(rdvmedecins.web.models.PostAjouterRv,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getCreneauById/{id}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getCreneauById(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllMedecins],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.util.List<rdvmedecins.entities.Medecin>> rdvmedecins.web.controllers.RdvMedecinsController.getAllMedecins(javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getRvById/{id}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getRvById(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
...
11:06:09.677 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerAdapter - Looking for @ControllerAdvice: org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@2ea6137: startup date [Wed Oct 14 11:06:04 CEST 2015]; root of context hierarchy
11:06:09.770 [main] INFO o.a.coyote.http11.Http11NioProtocol - Initializing ProtocolHandler ["http-nio-8080"]
11:06:09.786 [main] INFO o.a.coyote.http11.Http11NioProtocol - Starting ProtocolHandler ["http-nio-8080"]
11:06:09.802 [main] INFO o.a.tomcat.util.net.NioSelectorPool - Using a shared selector for servlet write/read
11:06:09.817 [main] INFO o.s.b.c.e.t.TomcatEmbeddedServletContainer - Tomcat started on port(s): 8080 (http)
11:06:09.817 [main] INFO rdvmedecins.web.boot.Boot - Started Boot in 5.319 seconds (JVM running for 6.053)
- ligne 18 : le serveur Tomcat est actif ;
- ligne 21 : le contexte Spring est en cours d'initialisation ;
- lignes 27-38 : les URL exposées par le service web sont découvertes ;
- ligne 44 : le serveur Tomcat est prêt et attend des requêtes sur le port 8080 ;
Si on modifie le fichier [application.properties] de la façon suivante :
logging.level.org.springframework.web: OFF
logging.level.org.hibernate:OFF
spring.main.show-banner=false
on obtient les logs suivants :
Si de plus, on modifie le fichier [logback.xml] de la façon suivante :
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are by default assigned the type ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- contrôle niveau des logs -->
<root level="off"> <!-- off, info, debug, warn -->
<appender-ref ref="STDOUT" />
</root>
</configuration>
on obtient les logs suivants :
On voit donc qu'on a un certain contrôle sur les logs qui apparaissent dans la console. Le niveau [info] est souvent le bon niveau de logs.
Nous avons désormais un service web opérationnel interrogeable avec un client web. Nous abordons maintenant la sécurisation de ce service : nous voulons que seules certaines personnes puissent gérer les rendez-vous des médecins. Nous allons utiliser pour cela le framework Spring Security, une branche de l'écosystème Spring.
8.4.12. Introduction à Spring Security
Nous allons de nouveau importer un guide Spring en suivant les étapes 1 à 3 ci-dessous :
![]() |
![]() |
Le projet se compose des éléments suivants :
- dans le dossier [templates], on trouve les pages HTML du projet ;
- [Application] : est la classe exécutable du projet ;
- [MvcConfig] : est la classe de configuration de Spring MVC ;
- [WebSecurityConfig] : est la classe de configuration de Spring Security ;
8.4.12.1. Configuration Maven
Le projet [3] est un projet Maven. Examinons son fichier [pom.xml] pour connaître ses dépendances :
<?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>org.springframework</groupId>
<artifactId>gs-securing-web</artifactId>
<version>0.1.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.1.10.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- tag::security[] -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- end::security[] -->
</dependencies>
<properties>
<start-class>hello.Application</start-class>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- lignes 10-14 : le projet est un projet Spring Boot ;
- lignes 17-20 : dépendance sur le framework [Thymeleaf] ;
- lignes 22-25 : dépendance sur le framework Spring Security ;
8.4.12.2. Les vues Thymeleaf
![]() |
La vue [home.html] est la suivante :
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<h1>Welcome!</h1>
<p>
Click <a th:href="@{/hello}">here</a> to see a greeting.
</p>
</body>
</html>
- ligne 12 : l'attribut [th:href="@{/hello}"] va générer l'attribut [href] de la balise <a>. La valeur [@{/hello}] va générer le chemin [<context>/hello] où [context] est le contexte de l'application web ;
Le code HTML généré est le suivant :
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<h1>Welcome!</h1>
<p>
Click
<a href="/hello">here</a>
to see a greeting.
</p>
</body>
</html>
La vue [hello.html] est la suivante :
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Hello World!</title>
</head>
<body>
<h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
<form th:action="@{/logout}" method="post">
<input type="submit" value="Sign Out" />
</form>
</body>
</html>
- ligne 9 : L'attribut [th:inline="text"] va générer le texte de la balise <h1>. Ce texte contient une expression $ qui doit être évaluée. L'élément [[${#httpServletRequest.remoteUser}]] est la valeur de l'attribut [RemoteUser] de la requête HTTP courante. C'est le nom de l'utilisateur connecté ;
- ligne 10 : un formulaire HTML. L'attribut [th:action="@{/logout}"] va générer l'attribut [action] de la balise [form]. La valeur [@{/logout}] va générer le chemin [<context>/logout] où [context] est le contexte de l'application web ;
Le code HTML généré est le suivant :
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Hello World!</title>
</head>
<body>
<h1>Hello user!</h1>
<form method="post" action="/logout">
<input type="submit" value="Sign Out" />
<input type="hidden" name="_csrf" value="b152e5b9-d1a4-4492-b89d-b733fe521c91" />
</form>
</body>
</html>
- ligne 8 : la traduction de Hello [[${#httpServletRequest.remoteUser}]]!;
- ligne 9 : la traduction de @{/logout} ;
- ligne 11 : un champ caché appelé (attribut name) _csrf ;
La dernière vue [login.html] est la suivante :
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<div th:if="${param.error}">Invalid username and password.</div>
<div th:if="${param.logout}">You have been logged out.</div>
<form th:action="@{/login}" method="post">
<div>
<label> User Name : <input type="text" name="username" />
</label>
</div>
<div>
<label> Password: <input type="password" name="password" />
</label>
</div>
<div>
<input type="submit" value="Sign In" />
</div>
</form>
</body>
</html>
- ligne 9 : l'attribut [th:if="${param.error}"] fait que la balise <div> ne sera générée que si l'URL qui affiche la page de login contient le paramètre [error] (http://context/login?error);
- ligne 10 : l'attribut [th:if="${param.logout}"] fait que la balise <div> ne sera générée que si l'URL qui affiche la page de login contient le paramètre [logout] (http://context/login?logout);
- lignes 11-23 : un formulaire HTML ;
- ligne 11 : le formulaire sera posté à l'URL [<context>/login] où <context> est le contexte de l'application web ;
- ligne 13 : un champ de saisie nommé [username] ;
- ligne 17 : un champ de saisie nommé [password] ;
Le code HTML généré est le suivant :
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example </title>
</head>
<body>
<div>
You have been logged out.
</div>
<form method="post" action="/login">
<div>
<label>
User Name :
<input type="text" name="username" />
</label>
</div>
<div>
<label>
Password:
<input type="password" name="password" />
</label>
</div>
<div>
<input type="submit" value="Sign In" />
</div>
<input type="hidden" name="_csrf" value="ef809b0a-88b4-4db9-bc53-342216b77632" />
</form>
</body>
</html>
On notera ligne 28 que Thymeleaf a ajouté un champ caché nommé [_csrf].
8.4.12.3. Configuration Spring MVC
![]() |
La classe [MvcConfig] configure le framework Spring MVC :
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
@Configuration
public class MvcConfig extends WebMvcConfigurerAdapter {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/home").setViewName("home");
registry.addViewController("/").setViewName("home");
registry.addViewController("/hello").setViewName("hello");
registry.addViewController("/login").setViewName("login");
}
}
- ligne 7 : l'annotation [@Configuration] fait de la classe [MvcConfig] une classe de configuration ;
- ligne 8 : la classe [MvcConfig] étend la classe [WebMvcConfigurerAdapter] pour en redéfinir certaines méthodes ;
- ligne 10 : redéfinition d'une méthode de la classe parent ;
- lignes 11- 16 : la méthode [addViewControllers] permet d'associer des URL à des vues HTML. Les associations suivantes y sont faites :
URL | vue |
/templates/home.html | |
/templates/hello.html | |
/templates/login.html |
Le suffixe [html] et le dossier [templates] sont les valeurs par défaut utilisées par Thymeleaf. Elles peuvent être changées par configuration. Le dossier [templates] doit être à la racine du Classpath du projet :
![]() |
Ci-dessus [1], les dossiers [java] et [resources] sont tous les deux des dossier source (source folders). Cela implique que leur contenu sera à la racine du Classpath du projet. Donc en [2], les dossiers [hello] et [templates] seront à la racine du Classpath.
8.4.12.4. Configuration Spring Security
![]() |
La classe [WebSecurityConfig] configure le framework Spring Security :
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
@Configuration
@EnableWebMvcSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/", "/home").permitAll().anyRequest().authenticated();
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("user").password("password").roles("USER");
}
}
- ligne 9 : l'annotation [@Configuration] fait de la classe [WebSecurityConfig] une classe de configuration ;
- ligne 10 : l'annotation [@EnableWebSecurity] fait de la classe [WebSecurityConfig] une classe de configuration de Spring Security ;
- ligne 11 : la classe [WebSecurity] étend la classe [WebSecurityConfigurerAdapter] pour en redéfinir certaines méthodes ;
- ligne 12 : redéfinition d'une méthode de la classe parent ;
- lignes 13- 16 : la méthode [configure(HttpSecurity http)] est redéfinie pour définir les droits d'accès aux différentes URL de l'application ;
- ligne 14 : la méthode [http.authorizeRequests()] permet d'associer des URL à des droits d'accès. Les associations suivantes y sont faites :
URL | régle | code |
accès sans être authentifié | | |
accès authentifié uniquement |
- ligne 15 : définit la méthode d'authentification. L'authentification se fait via un formulaire d'URL [/login] accessible à tous [http.formLogin().loginPage("/login").permitAll()]. La déconnexion (logout) est également accessible à tous ;
- lignes 19-21 : redéfinissent la méthode [configure(AuthenticationManagerBuilder auth)] qui gère les utilisateurs ;
- ligne 20 : l'autentification se fait avec des utilisateurs définis en " dur " [auth.inMemoryAuthentication()]. Un utilisateur est ici défini avec le login [user], le mot de passe [password] et le rôle [USER]. On peut accorder les mêmes droits à des utilisateurs ayant le même rôle ;
8.4.12.5. Classe exécutable
![]() |
La classe [Application] est la suivante :
package hello;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@EnableAutoConfiguration
@Configuration
@ComponentScan
public class Application {
public static void main(String[] args) throws Throwable {
SpringApplication.run(Application.class, args);
}
}
- ligne 8 : l'annotation [@EnableAutoConfiguration] demande à Spring Boot (ligne 3) de faire la configuration que le développeur n'aura pas fait explicitement ;
- ligne 9 : fait de la classe [Application] une classe de configuration Spring ;
- ligne 10 : demande le scan du dossier de la classe [Application] afin de rechercher des composants Spring. Les deux classes [MvcConfig] et [WebSecurityConfig] vont être ainsi découvertes car elles ont l'annotation [@Configuration] ;
- ligne 13 : la méthode [main] de la classe exécutable ;
- ligne 14 : la méthode statique [SpringApplication.run] est exécutée avec comme paramètre la classe de configuration [Application]. Nous avons déjà rencontré ce processus et nous savons que le serveur Tomcat embarqué dans les dépendances Maven du projet va être lancé et le projet déployé dessus. Nous avons vu que quatre URL étaient gérées [/, /home, /login, /hello] et que certaines étaient protégées par des droits d'accès.
8.4.12.6. Tests de l'application
Commençons par demander l'URL [/] qui est l'une des quatre URL acceptées. Elle est associée à la vue [/templates/home.html] :
![]() |
L'URL demandée [/] est accessible à tous. C'est pourquoi nous l'avons obtenue. Le lien [here] est le suivant :
L'URL [/hello] va être demandée lorsqu'on va cliquer sur le lien. Celle-ci est protégée :
URL | règle | code |
accès sans être authentifié | | |
accès authentifié uniquement |
Il faut être authentifié pour l'obtenir. Spring Security va alors rediriger le navigateur client vers la page d'authentification. D'après la configuration vue, c'est la page d'URL [/login]. Celle-ci est accessible à tous :
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
Nous l'obtenons donc [1] :
![]() |
Le code source de la page obtenue est le suivant :
- ligne 7, un champ caché apparaît qui n'est pas dans la page [login.html] d'origine. C'est Thymeleaf qui l'a ajouté. Ce code appelé CSRF (Cross Site Request Forgery) vise à éliminer une faille de sécurité. Ce jeton doit être renvoyé à Spring Security avec l'authentification pour que cette dernière soit acceptée ;
Nous nous souvenons que seul l'utilisateur user/password est reconnu par Spring Security. Si nous entrons autre chose en [2], nous obtenons la même page avec un message d'erreur en [3]. Spring Security a redirigé le navigateur vers l'URL [http://localhost:8080/login?error]. La présence du paramètre [error] a déclenché l'affichage de la balise :
<div th:if="${param.error}">Invalid username and password.</div>
Maintenant, entrons les valeurs attendues user/password [4] :
![]() |
- en [4], nous nous identifions ;
- en [5], Spring Security nous redirige vers l'URL [/hello] car c'est l'URL que nous demandions lorsque nous avons été redirigés vers la page de login. L'identité de l'utilisateur a été affichée par la ligne suivante de [hello.html] :
<h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
La page [5] affiche le formulaire suivant :
<form th:action="@{/logout}" method="post">
<input type="submit" value="Sign Out" />
</form>
Lorsqu'on clique sur le bouton [Sign Out], un POST va être fait sur l'URL [/logout]. Celle-ci comme l'URL [/login] est accessible à tous :
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
Dans notre association URL / vues, nous n'avons rien défini pour l'URL [/logout]. Que va-t-il se passer ? Essayons :
![]() |
- en [6], nous cliquons sur le bouton [Sign Out] ;
- en [7], nous voyons que nous avons été redirigés vers l'URL [http://localhost:8080/login?logout]. C'est Spring Security qui a demandé cette redirection. La présence du paramètre [logout] dans l'URL a fait afficher la ligne suivante de la vue :
<div th:if="${param.logout}">You have been logged out.</div>
8.4.12.7. Conclusion
Dans l'exemple précédent, nous aurions pu écrire l'application web d'abord puis la sécuriser ensuite. Spring Security n'est pas intrusif. On peut mettre en place la sécurité d'une application web déjà écrite. Par ailleurs, nous avons découvert les points suivants :
- il est possible de définir une page d'authentification ;
- l'authentification doit être accompagnée du jeton CSRF délivré par Spring Security ;
- si l'authentification échoue, on est redirigé vers la page d'authentification avec de plus un paramètre error dans l'URL ;
- si l'authentification réussit, on est redirigé vers la page demandée lorsque l'autentification a eu lieu. Si on demande directement la page d'authentification sans passer par une page intermédiaire, alors Spring Security nous redirige vers l'URL [/] (ce cas n'a pas été présenté) ;
- on se déconnecte en demandant l'URL [/logout] avec un POST. Spring Security nous redirige alors vers la page d'authentification avec le paramètre logout dans l'URL ;
Toutes ces conclusions reposent sur des comportements par défaut de Spring Security. Ces comportements peuvent être changés par configuration en redéfinissant certaines méthodes de la classe [WebSecurityConfigurerAdapter].
Le tutoriel précédent nous aidera peu dans la suite. Nous allons en effet utiliser :
- une base de données pour stocker les utilisateurs, leurs mots de passe et leurs rôles ;
- une authentification par entête HTTP ;
On trouve assez peu de tutoriels pour ce qu'on veut faire ici. La solution qui va être proposée est un assemblage de codes trouvés ici et là.
8.4.13. Mise en place de la sécurité sur le service web de rendez-vous
8.4.13.1. La base de données
La base de données [rdvmedecins] évolue pour prendre en compte les utilisateurs, leurs mots de passe et leur rôles. Trois nouvelles tables apparaissent :

Table [USERS] : les utilisateurs
- ID : clé primaire ;
- VERSION : colonne de versioning de la ligne ;
- IDENTITY : une identité descriptive de l'utilisateur ;
- LOGIN : le login de l'utilisateur ;
- PASSWORD : son mot de passe ;
Dans la table USERS, les mots de passe ne sont pas stockés en clair :
![]() |
L'algorithme qui crypte les mots de passe est l'algorithme BCRYPT.
Table [ROLES] : les rôles
- ID : clé primaire ;
- VERSION : colonne de versioning de la ligne ;
- NAME : nom du rôle. Par défaut, Spring Security attend des noms de la forme ROLE_XX, par exemple ROLE_ADMIN ou ROLE_GUEST ;
![]() |
Table [USERS_ROLES] : table de jointure USERS / ROLES
Un utilisateur peut avoir plusieurs rôles, un rôle peut rassembler plusieurs utilisateurs. On a une relation plusieurs à plusieurs matérialisée par la table [USERS_ROLES].
- ID : clé primaire ;
- VERSION : colonne de versioning de la ligne ;
- USER_ID : identifiant d'un utilisateur ;
- ROLE_ID : identifiant d'un rôle ;
![]() |
Parce que nous modifions la base de données, l'ensemble des couches du projet [métier, DAO, JPA] doit être modifié :
![]() |
8.4.13.2. Le nouveau projet STS du [métier, DAO, JPA]
Le projet [rdvmedecins-metier-dao] évolue de la façon suivante :
![]() |
- en [1] : le nouveau projet ;
- en [2] : les modifications amenées par la prise en compte de la sécurité ont été rassemblées dans un unique paquetage [rdvmedecins.security]. Ces nouveaux éléments appartiennent aux couches [JPA] et [DAO] mais par simplicité elles ont été rassemblées dans un même paquetage.
8.4.13.3. Les nouvelles entités [JPA]
![]() |
La couche JPA définit trois nouvelles entités :
![]() |
La classe [User] est l'image de la table [USERS] :
package rdvmedecins.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "USERS")
public class User extends AbstractEntity {
private static final long serialVersionUID = 1L;
// propriétés
private String identity;
private String login;
private String password;
// constructeur
public User() {
}
public User(String identity, String login, String password) {
this.identity = identity;
this.login = login;
this.password = password;
}
// identité
@Override
public String toString() {
return String.format("User[%s,%s,%s]", identity, login, password);
}
// getters et setters
....
}
- ligne 9 : la classe étend la classe [AbstractEntity] déjà utilisée pour les autres entités ;
- lignes 13-15 : on ne précise pas de nom pour les colonnes parce qu'elles portent le même nom que les champs qui leur sont associés ;
La classe [Role] est l'image de la table [ROLES] :
package rdvmedecins.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "ROLES")
public class Role extends AbstractEntity {
private static final long serialVersionUID = 1L;
// propriétés
private String name;
// constructeurs
public Role() {
}
public Role(String name) {
this.name = name;
}
// identité
@Override
public String toString() {
return String.format("Role[%s]", name);
}
// getters et setters
...
}
La classe [UserRole] est l'image de la table [USERS_ROLES] :
package rdvmedecins.entities;
import javax.persistence.Entity;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
@Entity
@Table(name = "USERS_ROLES")
public class UserRole extends AbstractEntity {
private static final long serialVersionUID = 1L;
// un UserRole référence un User
@ManyToOne
@JoinColumn(name = "USER_ID")
private User user;
// un UserRole référence un Role
@ManyToOne
@JoinColumn(name = "ROLE_ID")
private Role role;
// getters et setters
...
}
- lignes 15-17 : matérialisent la clé étrangère de la table [USERS_ROLES] vers la table [USERS] ;
- lignes 19-21 : matérialisent la clé étrangère de la table [USERS_ROLES] vers la table [ROLES] ;
8.4.13.4. Modifications de la couche [DAO]
![]() |
La couche [DAO] s'enrichit de trois nouveaux [Repository] :
![]() |
L'interface [UserRepository] gère les accès aux entités [User] :
package rdvmedecins.repositories;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Role;
import rdvmedecins.entities.User;
public interface UserRepository extends CrudRepository<User, Long> {
// liste des rôles d'un utilisateur identifié par son id
@Query("select ur.role from UserRole ur where ur.user.id=?1")
Iterable<Role> getRoles(long id);
// liste des rôles d'un utilisateur identifié par son login et son mot de passe
@Query("select ur.role from UserRole ur where ur.user.login=?1 and ur.user.password=?2")
Iterable<Role> getRoles(String login, String password);
// recherche d'un utilisateur via son login
User findUserByLogin(String login);
}
- ligne 9 : l'interface [UserRepository] étend la l'interface [CrudRepository] de Spring Data (ligne 4) ;
- lignes 12-13 : la méthode [getRoles(User user)] permet d'avoir tous les rôles d'un utilisateur identifié par son [id]
- lignes 16-17 : idem mais pour un utilisateur identifié pas ses login / mot de passe ;
- ligne 20 : pour trouver un utilisateur via son login ;
L'interface [RoleRepository] gère les accès aux entités [Role] :
package rdvmedecins.security;
import org.springframework.data.repository.CrudRepository;
public interface RoleRepository extends CrudRepository<Role, Long> {
// recherche d'un rôle via son nom
Role findRoleByName(String name);
}
- ligne 5 : l'interface [RoleRepository] étend l'interface [CrudRepository] ;
- ligne 8 : on peut chercher un rôle via son nom ;
L'interface [userRoleRepository] gère les accès aux entités [UserRole] :
package rdvmedecins.security;
import org.springframework.data.repository.CrudRepository;
public interface UserRoleRepository extends CrudRepository<UserRole, Long> {
}
- ligne 5 : l'interface [UserRoleRepository] se contente d'étendre l'interface [CrudRepository] sans lui ajouter de nouvelles méthodes ;
8.4.13.5. Les classes de gestion des utilisateurs et des rôles
![]() |
Spring Security impose la création d'une classe implémentant l'interface [UsersDetail] suivante :
![]() |
Cette interface est ici implémentée par la classe [AppUserDetails] :
package rdvmedecins.security;
import java.util.ArrayList;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public class AppUserDetails implements UserDetails {
private static final long serialVersionUID = 1L;
// propriétés
private User user;
private UserRepository userRepository;
// constructeurs
public AppUserDetails() {
}
public AppUserDetails(User user, UserRepository userRepository) {
this.user = user;
this.userRepository = userRepository;
}
// -------------------------interface
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
for (Role role : userRepository.getRoles(user.getId())) {
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getLogin();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
// getters et setters
...
}
- ligne 10 : la classe [AppUserDetails] implémente l'interface [UserDetails] ;
- lignes 15-16 : la classe encapsule un utilisateur (ligne 15) et le repository qui permet d'avoir les détails de cet utilisateur (ligne 16) ;
- lignes 22-25 : le constructeur qui instancie la classe avec un utilisateur et son repository ;
- lignes 28-35 : implémentation de la méthode [getAuthorities] de l'interface [UserDetails]. Elle doit construire une collection d'éléments de type [GrantedAuthority] ou dérivé. Ici, nous utilisons le type dérivé [SimpleGrantedAuthority] (ligne 32) qui encapsule le nom d'un des rôles de l'utilisateur de la ligne 15 ;
- lignes 31-33 : on parcourt la liste des rôles de l'utilisateur de la ligne 15 pour construire une liste d'éléments de type [SimpleGrantedAuthority] ;
- lignes 38-40 : implémentent la méthode [getPassword] de l'interface [UserDetails]. On rend le mot de passe de l'utilisateur de la ligne 15 ;
- lignes 38-40 : implémentent la méthode [getUserName] de l'interface [UserDetails]. On rend le login de l'utilisateur de la ligne 15 ;
- lignes 47-50 : le compte de l'utilisateur n'expire jamais ;
- lignes 52-55 : le compte de l'utilisateur n'est jamais bloqué ;
- lignes 57-60 : les identifiants de l'utilisateur n'expirent jamais ;
- lignes 62-65 : le compte de l'utilisateur est toujours actif ;
Spring Security impose également l'existence d'une classe implémentant l'interface [AppUserDetailsService] :
![]() |
Cette interface est implémentée par la classe [AppUserDetailsService] suivante :
package rdvmedecins.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class AppUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String login) throws UsernameNotFoundException {
// on cherche l'utilisateur via son login
User user = userRepository.findUserByLogin(login);
// trouvé ?
if (user == null) {
throw new UsernameNotFoundException(String.format("login [%s] inexistant", login));
}
// on rend les détails de l'utilsateur
return new AppUserDetails(user, userRepository);
}
}
- ligne 9 : la classe sera un composant Spring, donc disponible dans son contexte ;
- lignes 12-13 : le composant [UserRepository] sera injecté ici ;
- lignes 16-25 : implémentation de la méthode [loadUserByUsername] de l'interface [UserDetailsService] (ligne 10). Le paramètre est le login de l'utilisateur ;
- ligne 18 : l'utilisateur est recherché via son login ;
- lignes 20-22 : s'il n'est pas trouvé, une exception est lancée ;
- ligne 24 : un objet [AppUserDetails] est construit et rendu. Il est bien de type [UserDetails] (ligne 16) ;
8.4.13.6. Tests de la couche [DAO]
![]() |
Tout d'abord, nous créons une classe exécutable [CreateUser] capable de créer un utilisateur avec un rôle :
package rdvmedecins.security;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.security.crypto.bcrypt.BCrypt;
import rdvmedecins.config.DomainAndPersistenceConfig;
import rdvmedecins.security.Role;
import rdvmedecins.security.RoleRepository;
import rdvmedecins.security.User;
import rdvmedecins.security.UserRepository;
import rdvmedecins.security.UserRole;
import rdvmedecins.security.UserRoleRepository;
public class CreateUser {
public static void main(String[] args) {
// syntaxe : login password roleName
// il faut trois paramètres
if (args.length != 3) {
System.out.println("Syntaxe : [pg] user password role");
System.exit(0);
}
// on récupère les paramètres
String login = args[0];
String password = args[1];
String roleName = String.format("ROLE_%s", args[2].toUpperCase());
// contexte Spring
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DomainAndPersistenceConfig.class);
UserRepository userRepository = context.getBean(UserRepository.class);
RoleRepository roleRepository = context.getBean(RoleRepository.class);
UserRoleRepository userRoleRepository = context.getBean(UserRoleRepository.class);
// le rôle existe-t-il déjà ?
Role role = roleRepository.findRoleByName(roleName);
// s'il n'existe pas on le crée
if (role == null) {
role = roleRepository.save(new Role(roleName));
}
// l'utilisateur existe-t-il déjà ?
User user = userRepository.findUserByLogin(login);
// s'il n'existe pas on le crée
if (user == null) {
// on hashe le mot de passe avec bcrypt
String crypt = BCrypt.hashpw(password, BCrypt.gensalt());
// on sauvegarde l'utilisateur
user = userRepository.save(new User(login, login, crypt));
// on crée la relation avec le rôle
userRoleRepository.save(new UserRole(user, role));
} else {
// l'utilisateur existe déjà- a-t-il le rôle demandé ?
boolean trouvé = false;
for (Role r : userRepository.getRoles(user.getId())) {
if (r.getName().equals(roleName)) {
trouvé = true;
break;
}
}
// si pas trouvé, on crée la relation avec le rôle
if (!trouvé) {
userRoleRepository.save(new UserRole(user, role));
}
}
// fermeture contexte Spring
context.close();
}
}
- ligne 17 : la classe attend trois arguments définissant un utilisateur : son login, son mot de passe, son rôle ;
- lignes 25-27 : les trois paramètres sont récupérés ;
- ligne 29 : le contexte Spring est construit à partir de la classe de configuration [DomainAndPersistenceConfig]. Cette classe existait déjà dans le projet initial. Elle doit évoluer de la façon suivante :
@EnableJpaRepositories(basePackages = { "rdvmedecins.repositories", "rdvmedecins.security" })
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins" })
@EntityScan(basePackages = { "rdvmedecins.entities", "rdvmedecins.security" })
@EnableTransactionManagement
public class DomainAndPersistenceConfig {
....
}
- ligne 1 : il faut indiquer qu'il y a maintenant des composants [Repository] dans le paquetage [rdvmedecins.security] ;
- ligne 4 : il faut indiquer qu'il y a maintenant des entités JPA dans le paquetage [rdvmedecins.security] ;
Revenons au code de création d'un utilisateur :
- lignes 30-32 : on récupère les références des trois [Repository] qui peuvent nous être utiles pour créer l'utilisateur ;
- ligne 34 : on regarde si le rôle existe déjà ;
- lignes 36-38 : si ce n'est pas le cas, on le crée en base. Il aura un nom du type [ROLE_XX] ;
- ligne 40 : on regarde si le login existe déjà ;
- lignes 42-49 : si le login n'existe pas, on le crée en base ;
- ligne 44 : on crypte le mot de passe. On utilise ici, la classe [BCrypt] de Spring Security (ligne 4). On a donc besoin des archives de ce framework. Le fichier [pom.xml] inclut une nouvelle dépendance :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
- ligne 46 : l'utilisateur est persisté en base ;
- ligne 48 : ainsi que la relation qui le lie à son rôle ;
- lignes 51-57 : cas où le login existe déjà – on regarde alors si parmi ses rôles se trouve déjà le rôle qu'on veut lui attribuer ;
- ligne 59-61 : si le rôle cherché n'a pas été trouvé, on crée une ligne dans la table [USERS_ROLES] pour relier l'utilisateur à son rôle ;
- on ne s'est pas protégé des exceptions éventuelles. C'est une classe de soutien pour créer rapidement un utilisateur avec un rôle.
Lorsqu'on exécute la classe avec les arguments [x x guest], on obtient en base les résultats suivants :
Table [USERS]
![]() |
Table [ROLES]
![]() |
Table [USERS_ROLES]
![]() |
Considérons maintenant la seconde classe [UsersTest] qui est un test JUnit :
![]() |
package rdvmedecins.security;
import java.util.List;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.bcrypt.BCrypt;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import rdvmedecins.config.DomainAndPersistenceConfig;
import com.google.common.collect.Lists;
@SpringApplicationConfiguration(classes = DomainAndPersistenceConfig.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class UsersTest {
@Autowired
private UserRepository userRepository;
@Autowired
private AppUserDetailsService appUserDetailsService;
@Test
public void findAllUsersWithTheirRoles() {
Iterable<User> users = userRepository.findAll();
for (User user : users) {
System.out.println(user);
display("Roles :", userRepository.getRoles(user.getId()));
}
}
@Test
public void findUserByLogin() {
// on récupère l'utilisateur [admin]
User user = userRepository.findUserByLogin("admin");
// on vérifie que son mot de passe est [admin]
Assert.assertTrue(BCrypt.checkpw("admin", user.getPassword()));
// on vérifie le rôle de admin / admin
List<Role> roles = Lists.newArrayList(userRepository.getRoles("admin", user.getPassword()));
Assert.assertEquals(1L, roles.size());
Assert.assertEquals("ROLE_ADMIN", roles.get(0).getName());
}
@Test
public void loadUserByUsername() {
// on récupère l'utilisateur [admin]
AppUserDetails userDetails = (AppUserDetails) appUserDetailsService.loadUserByUsername("admin");
// on vérifie que son mot de passe est [admin]
Assert.assertTrue(BCrypt.checkpw("admin", userDetails.getPassword()));
// on vérifie le rôle de admin / admin
@SuppressWarnings("unchecked")
List<SimpleGrantedAuthority> authorities = (List<SimpleGrantedAuthority>) userDetails.getAuthorities();
Assert.assertEquals(1L, authorities.size());
Assert.assertEquals("ROLE_ADMIN", authorities.get(0).getAuthority());
}
// méthode utilitaire - affiche les éléments d'une collection
private void display(String message, Iterable<?> elements) {
System.out.println(message);
for (Object element : elements) {
System.out.println(element);
}
}
}
- lignes 27-34 : test visuel. On affiche tous les utilisateurs avec leurs rôles ;
- lignes 36-46 : on vérifie que l'utilisateur [admin] a le mot de passe [admin] et le rôle [ROLE_ADMIN] en utilisant le repository [UserRepository] ;
- ligne 41 : [admin] est le mot de passe en clair. En base, il est crypté selon l'algorithme BCrypt. La méthode [BCrypt.checkpw] permet de vérifier que le mot de passe en clair une fois crypté est bien égal à celui qui est en base ;
- lignes 48-59 : on vérifie que l'utilisateur [admin] a le mot de passe [admin] et le rôle [ROLE_ADMIN] en utilisant le service [appUserDetailsService] ;
L'exécution des tests réussit avec les logs suivants :
8.4.13.7. Conclusion intermédiaire
L'ajout des classes nécessaires à Spring Security a pu se faire avec peu de modifications du projet originel. Rappelons-les :
- ajout d'une dépendance sur Spring Security dans le fichier [pom.xml] ;
- création de trois tables supplémentaires dans la base de données ;
- création d'entités JPA et de composants Spring dans le package [rdvmedecins.security] ;
Ce cas très favorable découle du fait que les trois tables ajoutées dans la base de données sont indépendantes des tables existantes. On aurait même pu les mettre dans une base de données séparée. Ceci a été possible parce qu'on a décidé qu'un utilisateur avait une existence indépendante des médecins et des clients. Si ces derniers avaient été des utilisateurs potentiels, il aurait fallu créer des liens entre la table [USERS] et les tables [MEDECINS] et [CLIENTS]. Cela aurait eu alors un impact important sur le projet existant.
8.4.13.8. Le projet STS de la couche [web]
![]() |
Le projet [rdvmedecins-webjson] évolue de la façon suivante[1] :
![]() |
Les principales modifications sont à faire dans le package [rdvmedecins.web.config] où il faut configurer Spring Security. Il y en a d'autres, mineures, dans les classes [AppConfig] et [ApplicationModel]. Nous avons déjà rencontré une classe de configuration de Spring Security :
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
@Configuration
@EnableWebMvcSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/", "/home").permitAll().anyRequest().authenticated();
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("user").password("password").roles("USER");
}
}
Nous allons suivre la même démarche :
- ligne 11 : définir une classe qui étend la classe [WebSecurityConfigurerAdapter] ;
- ligne 13 : définir une méthode [configure(HttpSecurity http)] qui définit les droits d'accès aux différentes URL du service web ;
- ligne 19 : définir une méthode [configure(AuthenticationManagerBuilder auth)] qui définit les utilisateurs et leurs rôles ;
La configuration de Spring Security est assurée par la classe [SecurityConfig] :
package rdvmedecins.web.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import rdvmedecins.security.AppUserDetailsService;
import rdvmedecins.web.models.ApplicationModel;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AppUserDetailsService appUserDetailsService;
@Autowired
private ApplicationModel application;
@Override
protected void configure(AuthenticationManagerBuilder registry) throws Exception {
// l'authentification est faite par le bean [appUserDetailsService]
// le mot de passe est crypté par l'algorithme de hachage BCrypt
registry.userDetailsService(appUserDetailsService).passwordEncoder(new BCryptPasswordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// CSRF
http.csrf().disable();
// application sécurisée ?
if (application.isSecured()) {
// le mot de passe est transmis par le header Authorization: Basic xxxx
http.httpBasic();
// la méthode HTTP OPTIONS doit être autorisée pour tous
http.authorizeRequests() //
.antMatchers(HttpMethod.OPTIONS, "/", "/**").permitAll();
// seul le rôle ADMIN peut utiliser l'application
http.authorizeRequests() //
.antMatchers("/", "/**") // toutes les URL
.hasRole("ADMIN");
// pas de session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
}
- ligne 15 : la classe [SecurityConfig] est une classe de configuration Spring ;
- ligne 16 : pour mettre en place la sécurité du projet ;
- lignes 19-20 : la classe [AppUserDetails] qui donne accès aux utilisateurs de l'application est injectée ;
- lignes 21-22 : la classe [ApplicationModel] qui sert de cache à l'application web est injectée. On décide ici de l'utiliser également, pour configurer l'application web en un unique endroit. C'est elle qui définit le booléen [isSecured] de la ligne 36. Ce booléen sécurise (true) ou non (false) l'application web ;
- lignes 25-29 : la méthode [configure(HttpSecurity http)] définit les utilisateurs et leurs rôles. Elle reçoit en paramètre un type [AuthenticationManagerBuilder]. Ce paramètre est enrichi de deux informations (ligne 28) :
- une référence sur le service [appUserDetailsService] de la ligne 20 qui donne accès aux utilisateurs enregistrés. On notera ici que le fait qu'ils soient enregistrés dans une base de données n'apparaît pas. Ils pourraient donc être dans un cache, délivrés par un service web, ...
- le type de cryptage utilisé pour le mot de passe. On rappelle ici que nous avons utilisé l'algorithme BCrypt ;
- lignes 38-47 : la méthode [configure(HttpSecurity http)] définit les droits d'accès aux URL du service web ;
- ligne 34 : nous avons vu dans le projet d'introduction que par défaut Spring Security gérait un jeton CSRF (Cross Site Request Forgery) que l'utilisateur qui voulait s'authentifier devait renvoyer au serveur. Ici ce mécanisme est désactivé. Ceci allié au booléen (isSecured=false) permet d'utiliser l'application web sans sécurité ;
- ligne 38 : on active le mode d'authentification par entête HTTP. Le client devra envoyer l'entête HTTP suivant :
où code est le codage de la chaîne login:password par l'algorithme Base64. Par exemple, le codage Base64 de la chaîne admin:admin est YWRtaW46YWRtaW4=. Donc l'utilisateur de login [admin] et de mot de passe [admin] enverra l'entête HTTP suivant pour s'authentifier :
- lignes 40-42 : indiquent que toutes les URL du service web sont accessibles aux utilisateurs ayant le rôle [ROLE_ADMIN]. Cela veut dire qu'un utilisateur n'ayant pas ce rôle ne peut accéder au service web ;
- ligne 47 : le mot de passe de l'utilisateur peut être enregistré ou non dans une session. S'il est enregistré, l'utilisateur n'a besoin de s'authentifier que la 1ère fois. Les fois suivantes, ses identifiants ne lui sont pas demandés. Ici, on a choisi un mode sans session. Chaque requête devra être accompagnée des identifiants de sécurité ;
La classe [AppConfig] qui configure l'ensemble de l'application évolue comme suit :
![]() |
package rdvmedecins.web.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import rdvmedecins.config.DomainAndPersistenceConfig;
@Configuration
@ComponentScan(basePackages = { "rdvmedecins.web" })
@Import({ DomainAndPersistenceConfig.class, SecurityConfig.class, WebConfig.class })
public class AppConfig {
}
- la modification a lieu ligne 11 : on ajoute la classe de configuration [SecurityConfig] ;
Enfin la classe [ApplicationModel] s'enrichit d'un booléen :
@Component
public class ApplicationModel implements IMetier {
...
// données de configuration
private boolean secured = false;
public boolean isSecured() {
return secured;
}
- ligne 6 : on positionne le booléen [secured] à [true / false] selon qu'on veut ou non activer la sécurisation.
8.4.13.9. Tests du service web
Nous allons tester le service web avec le client Chrome [Advanced Rest Client]. Nous allons avoir besoin de préciser l'entête HTTP d'authentification :
où [code] est le code Base64 de la chaîne [login:password]. Pour générer ce code, on peut utiliser le programme suivant :
![]() |
package rdvmedecins.helpers;
import org.springframework.security.crypto.codec.Base64;
public class Base64Encoder {
public static void main(String[] args) {
// on attend deux arguments : login password
if (args.length != 2) {
System.out.println("Syntaxe : login password");
System.exit(0);
}
// on récupère les deux arguments
String chaîne = String.format("%s:%s", args[0], args[1]);
// on encode la chaîne
byte[] data = Base64.encode(chaîne.getBytes());
// on affiche son encodage Base64
System.out.println(new String(data));
}
}
Si nous exécutons ce programme avec les deux arguments [admin admin] :
![]() |
nous obtenons le résultat suivant :
Maintenant que nous savons générer l'entête HTTP d'authentification, nous lançons le service web maintenant sécurisé :
@Component
public class ApplicationModel implements IMetier {
...
private boolean secured = true;
Puis avec le client Chrome [Advanced Rest Client], nous demandons la liste des tous les médecins :
![]() |
- en [1], nous demandons l'URL des médecins ;
- en [2], avec une méthode GET ;
- en [3], nous donnons l'entête HTTP de l'authentification. Le code [YWRtaW46YWRtaW4=] est le codage Base64 de la chaîne [admin:admin] ;
- en [4], nous envoyons la commande HTTP ;
La réponse du serveur est la suivante :
![]() |
- en [1], l'entête HTTP d'authentification ;
- en [2], le serveur renvoie une réponse jSON ;
- en [3], une liste d'entêtes HTTP liés à la sécurisation de l'application web ;
On obtient bien la liste des médecins :
![]() |
Tentons maintenant une requête HTTP avec un entête d'authentification incorrect. La réponse est alors la suivante :
![]() |
- en [1] et [3] : l'entête HTTP d'authentification ;
- en [2] : la réponse du service web ;
Maintenant, essayons l'utilisateur user / user. Il existe mais n'a pas accès au service web. Si nous exécutons le programme d'encodage Base64 avec les deux arguments [user user] :
![]() |
nous obtenons le résultat suivant :
![]() |
- en [1] et [3] : l'entête HTTP d'authentification ;
- en [2] : la réponse du service web. Elle est différente de la précédente qui était [401 Unauthorized]. Cette fois-ci, l'utilisateur s'est authentifié correctement mais n'a pas les droits suffisants pour accéder à l'URL ;
Un service web sécurisé est maintenant opérationnel. Nous allons le compléter pour qu'il autorise des requêtes inter-domaines. Ce besoin est apparu dans le document [Tutoriel AngularJS / Spring 4] et bien que ce besoin n'existe pas ici, nous allons quand même y répondre.
8.4.14. Mise en place des requêtes inter-domaines
Examinons le problème des requêtes inter-domaines. Dans le document [Tutoriel AngularJS / Spring 4], on développe une application client / serveur où le client est une application AngularJS :
![]() |
- les pages HTML / CSS / JS de l'application Angular viennent du serveur [1] ;
- en [2], le service [dao] fait une requête à un autre serveur, le serveur [2]. Et bien ça, c'est interdit par le navigateur qui exécute l'application Angular parce que c'est une faille de sécurité. L'application ne peut interroger que le serveur d'où elle vient, ç-à-d le serveur [1] ;
En fait, il est inexact de dire que le navigateur interdit à l'application Angular d'interroger le serveur [2]. Elle l'interroge en fait pour lui demander s'il autorise un client qui ne vient pas de chez lui à l'interroger. On appelle cette technique de partage, le CORS (Cross-Origin Resource Sharing). Le serveur [2] donne son accord en envoyant des entêtes HTTP précis.
Pour montrer les problèmes que l'on peut rencontrer, nous allons créer une application client / serveur où :
- le serveur sera notre serveur web / jSON ;
- le client sera une simple page HTML équipée d'un code Javascript qui fera des requêtes au serveur web / jSON ;
8.4.14.1. Le projet du client
![]() |
Le projet est un projet Maven avec le fichier [pom.xml] suivant :
<?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>istia.st</groupId>
<artifactId>rdvmedecins-webjson-client-cors</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>rdvmedecins-webjson-client-cors</name>
<description>Client for webjson server</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.6.RELEASE</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<start-class>istia.st.rdvmedecins.Client</start-class>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- spring MVC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
</project>
- lignes 14-19 : c'est un projet Spring Boot ;
- lignes 29-32 : on utilise la dépendance [spring-boot-starter-web] qui amène avec elle un serveur Tomcat et Spring MVC ;
La page HTML est la suivante :
![]() |
Elle est générée par le code suivant :
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Spring MVC</title>
<script type="text/javascript" src="/js/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="/js/client.js"></script>
</head>
<body>
<h2>Client du service web / jSON</h2>
<form id="formulaire">
<!-- méthode HTTP -->
Méthode HTTP :
<!-- -->
<input type="radio" id="get" name="method" value="get" checked="checked" />GET
<!-- -->
<input type="radio" id="post" name="method" value="post" />POST
<!-- URL -->
<br /> <br />URL cible : <input type="text" id="url" size="30"><br />
<!-- valeur postée -->
<br /> Chaîne jSON à poster : <input type="text" id="posted" size="50" />
<!-- bouton de validation -->
<br /> <br /> <input type="submit" value="Valider" onclick="javascript:requestServer(); return false;"></input>
</form>
<hr />
<h2>Réponse du serveur</h2>
<div id="response"></div>
</body>
</html>
- ligne 6 : on importe la bibliothèque jQuery ;
- ligne 7 : on importe un code que nous allons écrire ;
Le code [client.js] est le suivant :
// données globales
var url;
var posted;
var response;
var method;
function requestServer() {
// on récupère les informations du formulaire
var urlValue = url.val();
var postedValue = posted.val();
method = document.forms[0].elements['method'].value;
// on fait un appel Ajax à la main
if (method === "get") {
doGet(urlValue);
} else {
doPost(urlValue, postedValue);
}
}
function doGet(url) {
// on fait un appel Ajax à la main
$.ajax({
headers : {
'Authorization' : 'Basic YWRtaW46YWRtaW4='
},
url : 'http://localhost:8080' + url,
type : 'GET',
dataType : 'tex/plain',
beforeSend : function() {
},
success : function(data) {
// résultat texte
response.text(data);
},
complete : function() {
},
error : function(jqXHR) {
// erreur système
response.text(jqXHR.responseText);
}
})
}
function doPost(url, posted) {
// on fait un appel Ajax à la main
$.ajax({
headers : {
'Authorization' : 'Basic YWRtaW46YWRtaW4='
},
url : 'http://localhost:8080' + url,
type : 'POST',
contentType : 'application/json',
data : posted,
dataType : 'tex/plain',
beforeSend : function() {
},
success : function(data) {
// résultat texte
response.text(data);
},
complete : function() {
},
error : function(jqXHR) {
// erreur système
response.text(jqXHR.responseText);
}
})
}
// au chargement du document
$(document).ready(function() {
// on récupère les références des composants de la page
url = $("#url");
posted = $("#posted");
response = $("#response");
});
Nous laissons le lecteur comprendre ce code. Tout a déjà été rencontré à un moment ou à un autre. Certaines lignes méritent cependant une explication :
- ligne 11 :
- [document] désigne le document chargé par le navigateur, ce qu'on appelle le DOM (Document Object Model),
- [document.forms[0]] désigne le 1er formulaire du document, un document pouvant en avoir plusieurs. Ici, il n'y en qu'un,
- [document.forms[0].elements['method']] désigne l'élément du formulaire qui a l'attribut [name='method']. Il y en a deux :
<input type="radio" id="get" name="method" value="get" checked="checked" />GET
<input type="radio" id="post" name="method" value="post" />POST
- ligne 11 :
- [document.forms[0].elements['method'].value] est la valeur qui va être postée pour le composant qui a l'attribut [name='method']. On sait que la valeur postée est la valeur de l'attribut [value] du bouton radio coché. Ici, ce sera donc l'une des chaînes ['get', 'post'] ;
- lignes 23-25 : on s'adresse à un serveur qui exige un entête HTTP [Authorization: Basic code]. Nous créons cette entête pour l'utilisateur [admin / admin] qui est le seul à pouvoir interroger le serveur ;
- ligne 26 : l'utilisateur saisira des URL du type [/getAllMedecins, /supprimerRv, ...]. Il faut donc compléter ces URL ;
- ligne 28 : le serveur renvoie du jSON qui est une forme de texte. On indique le type [text/plain] comme type de résultat afin de l'afficher tel qu'il a été reçu ;
- ligne 33 : affichage de la réponse texte du serveur ;
- ligne 39 : affichage du message d'erreur éventuel au format texte ;
- ligne 52 : pour indiquer que le client envoie du jSON ;
Dans l'application client / serveur construite :
- le client est une application web disponible à l'URL [http://localhost:8081]. C'est l'application que nous sommes en train de construire ;
- le serveur est une application web disponible à l'URL [http://localhost:8080]. C'est notre serveur web / jSON ;
Parce que le client n'est pas obtenu à partir du même port que le serveur, le problème des requêtes inter-domaines surgit. [http://localhost:8080] et [http://localhost:8081] sont deux domaines différents.
L'application Spring Boot est une application console lancée par la classe exécutable [Client] suivante :
package istia.st.rdvmedecins;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.context.embedded.EmbeddedServletContainerFactory;
import org.springframework.boot.context.embedded.ServletRegistrationBean;
import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
@Configuration
@EnableWebMvc
public class Client extends WebMvcConfigurerAdapter {
public static void main(String[] args) {
SpringApplication.run(Client.class, args);
}
// pages statiques
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**").addResourceLocations(new String[] { "classpath:/static/" });
}
// configuration dispatcherServlet
@Bean
public DispatcherServlet dispatcherServlet() {
return new DispatcherServlet();
}
@Bean
public ServletRegistrationBean servletRegistrationBean(DispatcherServlet dispatcherServlet) {
return new ServletRegistrationBean(dispatcherServlet, "/*");
}
// serveur Tomcat embarqué
@Bean
public EmbeddedServletContainerFactory embeddedServletContainerFactory() {
return new TomcatEmbeddedServletContainerFactory("", 8081);
}
}
- ligne 14 : la classe [Client] est une classe de configuration Spring ;
- ligne 15 : on configure une application Spring MVC. Cette annotation amène un certain nombre de configuration automatiques ;
- ligne 16 : pour redéfinir certaines valeurs par défaut du framework Spring MVC, il faut étendre la classe [WebMvcConfigurerAdapter] ;
- lignes 23-26 : la méthode [addResourceHandlers] permet de préciser les dossiers où se trouvent les ressources statiques (html, css, js, ...) de l'application. Ici, on indique le dossier [static] situé dans le Classpath du projet :
![]() |
- lignes 29-37 : configuration du bean [dispatcherServlet] qui désigne la servlet de Spring MVC ;
- lignes 40-43 : le serveur Tomcat embarqué travaillera sur le port 8081 ;
8.4.14.2. L'URL [/getAllMedecins]
Nous lançons :
- le serveur web / json sur le port 8080 ;
- le client de ce serveur sur le port 8081 ;
puis nous demandons l'URL [http://localhost:8081/client.html] [1] :
![]() |
- en [2], nous faisons un GET sur l'URL [http://localhost:8080/getAllMedecins];
Nous n'obtenons pas de réponse du serveur. Lorsqu'on regarde la console de développement (Ctrl-Maj-I) on découvre une erreur :
![]() |
- en [1], on est dans l'onglet [Network] ;
- en [2], on voit que la requête HTTP qui a été faite n'est pas [GET] mais [OPTIONS]. Dans le cas d'une requête inter-domaines, le navigateur vérifie auprès du serveur qu'un certain nombre de conditions sont vérifiées en lui envoyant une requête HTTP [OPTIONS]. En l'occurrence, les requêtes sont celles pointées par les pastilles [5-6] ;
- en [5], le navigateur demande si l'URL cible peut être atteinte avec un GET. L'entête de la requête [Access-Control-Request-Method] demande une réponse avec un entête HTTP [Access-Control-Allow-Methods] indiquant que la méthode demandée est acceptée ;
- en [5], le navigateur envoie l'entête HTTP [Origin: http://localhost:8081]. Cet entête demande une réponse dans un entête HTTP [Access-Control-Allow-Origin] indiquant que l'origine indiquée est acceptée ;
- en [6], le navigateur demande si les entêtes HTTP [accept] et [authorization] sont acceptés. L'entête de la requête [Access-Control-Request-Headers] attend une réponse avec un entête HTTP [Access-Control-Allow-Headers] indiquant que les entêtes demandés sont acceptés ;
- on a une erreur en [3]. En cliquant sur l'icône, on a l'erreur [4] ;
- en [4], le message indique que le serveur n'a pas envoyé l'entête HTTP [Access-Control-Allow-Origin] qui indique si l'origine de la requête est acceptée ;
- en [7], on peut constater que le serveur n'a effectivement pas envoyé cet entête. Du coup le navigateur a refusé de faire la requête HTTP GET demandée initialement ;
Il nous faut modifier le serveur web / jSON. Nous faisons une première modification dans [ApplicationModel] qui est l'un des éléments de configuration du service web :
![]() |
@Component
public class ApplicationModel implements IMetier {
...
// données de configuration
private boolean corsAllowed = true;
private boolean secured = true;
...
public boolean isCorsAllowed() {
return corsAllowed;
}
- ligne 6 : nous créons un booléen qui indique si on accepte ou non les clients étrangers au domaine du serveur ;
- lignes 10-12 : la méthode d'accès à cette information ;
Puis nous créons un nouveau contrôleur Spring MVC :
![]() |
La classe [RdvMedecinsCorsController] est la suivante :
package rdvmedecins.web.controllers;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import rdvmedecins.web.models.ApplicationModel;
@Controller
public class RdvMedecinsCorsController {
@Autowired
private ApplicationModel application;
// envoi des options au client
public void sendOptions(String origin, HttpServletResponse response) {
// Cors allowed ?
if (!application.isCorsAllowed() || origin==null || !origin.startsWith("http://localhost")) {
return;
}
// on fixe le header CORS
response.addHeader("Access-Control-Allow-Origin", origin);
// on autorise certains headers
response.addHeader("Access-Control-Allow-Headers", "accept, authorization");
// on autorise le GET
response.addHeader("Access-Control-Allow-Methods", "GET");
}
// liste des médecins
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.OPTIONS)
public void getAllMedecins(@RequestHeader(value = "Origin", required = false) String origin, HttpServletResponse response) {
sendOptions(origin, response);
}
}
- lignes 12-13 : la classe [RdvMedecinsCorsController] est un contrôleur Spring ;
- lignes 33-36 : définissent une action traitant l'URL [/getAllMedecins] lorsqu'elle est demandée avec la commande HTTP [OPTIONS] ;
- ligne 34 : la méthode [getAllMedecins] admet pour paramètres :
- l'objet [@RequestHeader(value = "Origin", required = false)] qui va récupérer l'entête HTTP [Origin] de la requête. Cet entête a été envoyé par l'émetteur de la requête :
On indique que l'entête HTTP [Origin] est facultatif [required = false]. Dans ce cas, si l'entête est absent, le paramètre [String origin] aura la valeur null. Avec [required = true] qui est la valeur par défaut, une exception est lancée si l'entête est absent. On a voulu éviter ce cas ;
- ligne 34 :
- l'objet [HttpServletResponse response] qui va être envoyé au client qui a fait la demande ;
Ces deux paramètres sont injectés par Spring ;
- ligne 35 : on délègue le traitement de la demande à la méthode des lignes 19-30 ;
- lignes 15-16 : l'objet [ApplicationModel] est injecté ;
- lignes 21-23 : si l'application est configurée pour accepter les requêtes inter-domaines et si l'émetteur a envoyé l'entête HTTP [Origin] et si cette origine commence par [http://localhost], alors on va accepter la requête inter-domaines, sinon on la rejette ;
- lignes 25 : si le client est dans le domaine [http://localhost:port], on envoie l'entête HTTP :
Access-Control-Allow-Origin: http://localhost:port
qui signifie que le serveur accepte l'origine du client ;
- ligne 25 : nous avons signalé deux entêtes HTTP particuliers dans la requête HTTP [OPTIONS] :
A l'entête HTTP [Access-Control-Request-X], le serveur répond avec un entête HTTP [Access-Control-Allow-X] dans lequel il indique ce qui est autorisé. Les lignes 23-26 se contentent de reprendre la demande du client pour indiquer qu'elle est acceptée ;
Nous sommes désormais prêts pour de nouveaux tests. Nous lançons la nouvelle version du service web et nous découvrons que le problème reste entier. Rien n'a changé. Si ligne 35 ci-dessus, on met un affichage console, celui-ci n'est jamais affiché montrant par là que la méthode [getAllMedecins] de la ligne 34 n'est jamais appelée.
Après quelques recherches, on découvre que Spring MVC traite lui-même les commandes HTTP [OPTIONS] avec un traitement par défaut. Aussi c'est toujours Spring qui répond et jamais la méthode [getAllMedecins] de la ligne 34. Ce comportement par défaut de Spring MVC peut être changé. Nous modifions la classe [WebConfig] existante :
![]() |
package rdvmedecins.web.config;
...
import org.springframework.web.servlet.DispatcherServlet;
@Configuration
public class WebConfig {
// configuration dispatcherservlet pour les headers CORS
@Bean
public DispatcherServlet dispatcherServlet() {
DispatcherServlet servlet = new DispatcherServlet();
servlet.setDispatchOptionsRequest(true);
return servlet;
}
// mapping jSON
...
- lignes 10-11 : le bean [dispatcherServlet] sert à définir la servlet qui gère les demandes des clients. Elle est ici de type [DispatcherServlet], la servlet du framework Spring MVC ;
- ligne 12 : on crée une instance de type [DispatcherServlet] ;
- ligne 13 : on demande à ce que la servlet fasse suivre à l'application les commandes HTTP [OPTIONS] ;
- ligne 14 : on rend la servlet ainsi configurée ;
Nous refaisons les tests avec cette nouvelle configuration. On obtient le résultat suivant :
![]() |
- en [1], nous voyons qu'il y a deux requêtes HTTP vers l'URL [http://localhost:8080/getAllMedecins];
- en [2], la requête [OPTIONS] ;
- en [3], les trois entêtes HTTP que nous venons de configurer dans la réponse du serveur ;
Examinons maintenant la seconde requête :
![]() |
- en [1], la requête examinée ;
- en [2], c'est la requête GET. Grâce à la première requête [OPTIONS], le navigateur a reçu les informations qu'il demandait. Il réalise maintenant la requête [GET] demandée initialement ;
- en [3], la réponse du serveur ;
- en [4], le serveur envoie du jSON ;
- en [5], une erreur s'est produite ;
- en [6], le message d'erreur ;
Il est plus difficile d'expliquer ce qui s'est passé ici. La réponse [3] du serveur est normale [HTTP/1.1 200 OK]. On devrait donc avoir le document demandé. Il est possible que le serveur ait bien envoyé le document mais que c'est le navigateur qui empêche son utilisation parce qu'il veut que pour la requête GET également, la réponse comporte l'entête HTTP [Access-Control-Allow-Origin:http://localhost:8081].
Nous modifions le contrôleur [RdvMedecinsController] de la façon suivante :
@Autowired
private RdvMedecinsCorsController rdvMedecinsCorsController;
...
// liste des médecins
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllMedecins(HttpServletResponse httpServletResponse,
@RequestHeader(value = "Origin", required = false) String origin) throws JsonProcessingException {
// la réponse
Response<List<Medecin>> response;
// entêtes CORS
rdvMedecinsCorsController.sendOptions(origin, httpServletResponse);
// état de l'application
...
- lignes 1-2 : le contrôleur [RdvMedecinsCorsController] est injecté ;
- lignes 7-8 : on injecte dans les paramètres de la méthode [getAllMedecins], l'objet HttpServletResponse qui encapsule la réponse qui sera faite au client et l'entête HTTP [Origin] ;
- ligne 12 : on fait appel à la méthode [sendOptions] du contrôleur [RdvMedecinsCorsController], celle-là même qui a été appelée pour traiter la requête HTTP [OPTIONS]. Elle va donc envoyer les mêmes entêtes HTTP que pour cette requête ;
Après cette modification, les résultats sont les suivants :
![]() |
Nous avons bien obtenu la liste des médecins.
8.4.14.3. Les autres URL [GET]
Nous montrons maintenant les autres URL interrogées via un GET. Dans les contrôleurs, le code des actions qui les traitent suit le modèle des actions qui ont traité précédemment l'URL [/getAllMedecins]. Le lecteur peut vérifier le code dans les exemples livrés avec ce document. Voici un exemple :
dans [RdvMedecinsCorsController]
// liste des Rv d'un médecin
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.OPTIONS)
public void getRvMedecinJour(@RequestHeader(value = "Origin", required = false) String origin, HttpServletResponse response) {
sendOptions(origin, response);
}
dans [RdvMedecinsController]
// liste des rendez-vous d'un médecin
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour,
HttpServletResponse httpServletResponse, @RequestHeader(value = "Origin", required = false) String origin)
throws JsonProcessingException {
// la réponse
Response<List<Rv>> response = null;
boolean erreur = false;
// entêtes CORS
rdvMedecinsCorsController.sendOptions(origin, httpServletResponse);
// état de l'application
...
Voici maintenant des copies d'écran d'exécution :
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
8.4.14.4. Les URL [POST]
Examinons le cas suivant :
![]() |
- on fait un POST [1] vers l'URL [2] ;
- en [3], la valeur postée. Il s'agit d'une chaîne jSON ;
- au total, on cherche à supprimer le rendez-vous ayant l'[id] 100 ;
Nous ne modifions pour l'instant aucun code. Le résultat obtenu est alors le suivant :
![]() |
- en [1], comme pour les requêtes [GET], une requête [OPTIONS] est faite par le navigateur ;
- en [2], il demande une autorisation d'accès pour une requête [POST]. Auparavant c'était [GET] ;
- en [3], il demande une autorisation d'envoyer les entêtes HTTP [accept, authorization, content-type]. Auparavant, on avait seulement les deux premiers entêtes ;
Nous modifions la méthode [RdvMedecinsCorsController.sendOptions] de la façon suivante :
public void sendOptions(String origin, HttpServletResponse response) {
// Cors allowed ?
if (!application.isCorsAllowed() || origin==null || !origin.startsWith("http://localhost")) {
return;
}
// on fixe le header CORS
response.addHeader("Access-Control-Allow-Origin", origin);
// on autorise certains headers
response.addHeader("Access-Control-Allow-Headers", "accept, authorization, content-type");
// on autorise le GET
response.addHeader("Access-Control-Allow-Methods", "GET, POST");
}
- ligne 9 : on a ajouté l'entête HTTP [Content-Type] (la casse n'a pas d'importance) ;
- ligne 11 : on a ajouté la méthode HTTP [POST] ;
Ceci fait les méthodes [POST] sont traitées de la même façon que les requêtes [GET]. Voici l'exemple de l'URL [/supprimerRv] :
dans [RdvMedecinsController]
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String supprimerRv(@RequestBody PostSupprimerRv post, HttpServletResponse httpServletResponse,
@RequestHeader(value = "Origin", required = false) String origin) throws JsonProcessingException {
// la réponse
Response<Void> response = null;
boolean erreur = false;
// entêtes CORS
rdvMedecinsCorsController.sendOptions(origin, httpServletResponse);
// état de l'application
if (messages != null) {
...
dans [RdvMedecinsCorsController]
@RequestMapping(value = "/supprimerRv", method = RequestMethod.OPTIONS)
public void supprimerRv(@RequestHeader(value = "Origin", required = false) String origin, HttpServletResponse response) {
sendOptions(origin, response);
}
Le résultat obtenu est le suivant :
![]() |
Pour l'URL [/ajouterRv], on obtient le résultat suivant :
![]() |
8.4.14.5. Conclusion
Notre application supporte désormais les requêtes inter-domaines. Celles-ci peuvent être autorisées ou non par configuration dans la classe [ApplicationModel] :
// données de configuration
private boolean corsAllowed = false;
8.5. Client programmé du service web / jSON
Revenons à l'architecture générale de l'application que nous voulons écrire :
![]() |
La partie haute du schéma a été écrite. C'est le serveur web / jSON. Nous nous attaquons maintenant à la partie basse et d'abord à sa couche [DAO]. Nous allons écrire celle-ci puis la tester avec un client console. L'architecture de test sera la suivante :
![]() |
8.5.1. Le projet du client console
Le projet STS du client console sera le suivant :
![]() |
8.5.2. Configuration Maven
Le fichier [pom.xml] du client console est le suivant :
<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>istia.st.rdvmedecins</groupId>
<artifactId>rdvmedecins-webjson-client-console</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>rdvmedecins-webjson-client-console</name>
<description>Client console du serveur web / jSON</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.6.RELEASE</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<dependencies>
<!-- Spring -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<!-- librairie jSON utilisée par Spring -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- composant utilisé par Spring RestTemplate -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
</dependencies>
</project>
- lignes 15-20 : le projet Spring Boot parent ;
- lignes 24-27 : le client console du serveur web / jSON est basé sur un composant appelé [RestTemplate] fourni par la dépendance [spring-web] ;
- lignes 29-36 : la sérialisation / désérialisation des objets jSON nécessite une bibliothèque jSON. Nous utilisons une variante de la librairie Jackson utilisée par Spring web ;
- lignes 38-41 : au plus bas niveau, le composant [RestTemplate] communique avec le serveur via des sockets TCP/IP. Nous voulons fixer le [timeout] de celles-ci, ç-à-d le temps maximum d'attente d'une réponse du serveur. Le composant [RestTemplate] ne nous permet pas de fixer celui-ci. Pour le faire, nous allons passer au constructeur [RestTemplate] un composant de bas niveau fourni par la dépendance [org.apache.httpcomponents.httpclient]. C'est cette dépendance qui va nous permettre de fixer le [timeout] de la communication ;
8.5.3. Le package [rdvmedecins.client.entities]
![]() |
Le package [rdvmedecins.client.entities] rassemble toutes les entités que le service web / jSON envoie via ses différentes URL. Nous n'allons pas les détailler de nouveau. On se contentera de dire que les entités JPA [Client, Creneau, Medecin, Rv, Personne] ont été débarrassées de toutes leurs annotations JPA ainsi que de leurs annotations jSON. Voici par exemple, la classe [Rv] :
package rdvmedecins.client.entities;
import java.util.Date;
public class Rv extends AbstractEntity {
private static final long serialVersionUID = 1L;
// jour du Rv
private Date jour;
// un rv est lié à un client
private Client client;
// un rv est lié à un créneau
private Creneau creneau;
// clés étrangères
private long idClient;
private long idCreneau;
// constructeur par défaut
public Rv() {
}
// avec paramètres
public Rv(Date jour, Client client, Creneau creneau) {
this.jour = jour;
this.client = client;
this.creneau = creneau;
}
// toString
public String toString() {
return String.format("Rv[%d, %s, %d, %d]", id, jour, client.id, creneau.id);
}
// getters et setters
...
}
8.5.4. Le package [rdvmedecins.client.requests]
![]() |
Le package [rdvmedecins.client.requests] rassemple les deux classes dont la valeur jSON est postée aux URL [/ajouterRv] et [supprimerRv]. Elles sont identiques à ce qu'elles sont côté serveur.
8.5.5. Le package [rdvmedecins.client.responses]
![]() |
[Response] est le type de toutes les réponses du service web / jSON. C'est un type générique :
package rdvmedecins.client.responses;
import java.util.List;
public class Response<T> {
// ----------------- propriétés
// statut de l'opération
private int status;
// les éventuels messages d'erreur
private List<String> messages;
// le corps de la réponse
private T body;
// constructeurs
public Response() {
}
public Response(int status, List<String> messages, T body) {
this.status = status;
this.messages = messages;
this.body = body;
}
// getters et setters
...
}
- ligne 5 : le type [T] varie selon l'URL du service web / jSON ;
8.5.6. Le package [rdvmedecins.client.dao]
![]() |
- [IDao] est l'interface de la couche [DAO] et [Dao] son implémentation. Nous allons revenir sur cette implémentation ;
8.5.7. Le package [rdvmedecins.client.config]
![]() |
La classe [DaoConfig] configure l'application. Son code est le suivant :
package rdvmedecins.client.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
@Configuration
@ComponentScan({ "rdvmedecins.client.dao" })
public class DaoConfig {
@Bean
public RestTemplate restTemplate() {
// création du composant RestTemplate
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
RestTemplate restTemplate = new RestTemplate(factory);
// résultat
return restTemplate;
}
// mappeurs jSON
@Bean
public ObjectMapper jsonMapper(){
return new ObjectMapper();
}
@Bean
public ObjectMapper jsonMapperShortCreneau() {
ObjectMapper jsonMapperShortCreneau = new ObjectMapper();
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperShortCreneau.setFilters(new SimpleFilterProvider().addFilter("creneauFilter", creneauFilter));
return jsonMapperShortCreneau;
}
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter",
creneauFilter));
return jsonMapperLongRv;
}
@Bean
public ObjectMapper jsonMapperShortRv() {
ObjectMapper jsonMapperShortRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("client", "creneau");
jsonMapperShortRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter));
return jsonMapperShortRv;
}
}
- ligne 13 : la classe [DaoConfig] est une classe de configuration Spring ;
- ligne 14 : la package [rdvmedecins.client.dao] sera exploré pour y chercher des composants Spring. On y trouvera le composant [Dao] ;
- lignes 17-24 : définissent un singleton Spring de nom [restTemplate] (le nom de la méthode). Cette méthode rend une instance [RestTemplate] qui est l'outil de base que Spring fournit pour communiquer avec un service web / jSON ;
- ligne 21 : on pourrait écrire [RestTemplate restTemplate = new RestTemplate() ;]. C'est suffisant dans la plupart des cas. Mais ici, nous voulons fixer les [timeout] du client. Pour cela, on injecte dans le composant [RestTemplate], un composant de bas niveau de type [HttpComponentsClientHttpRequestFactory] (ligne 20) qui va nous permettre de fixer ces [timeout]. La dépendance Maven nécessaire a été présentée ;
- lignes 28-57 : définissent des mappeurs jSON. Ce sont les mappeurs jSON utilisés côté serveur (cf paragraphe 8.4.11.3)pour sérialiser le type T de la réponse [Response<T>]. Ces mêmes convertisseurs vont être utilisés maintenant côté client pour désérialiser le type T ;
8.5.8. L'interface [IDao]
Revenons à l'architecture de l'application :
![]() |
La couche [DAO] est un adaptateur entre la couche [console] et les URL exposées par le service web / jSON. Son interface [IDao] sera la suivante :
package rdvmedecins.client.dao;
import java.util.List;
import rdvmedecins.client.entities.AgendaMedecinJour;
import rdvmedecins.client.entities.Client;
import rdvmedecins.client.entities.Creneau;
import rdvmedecins.client.entities.Medecin;
import rdvmedecins.client.entities.Rv;
import rdvmedecins.client.entities.User;
public interface IDao {
// Url du service web
public void setUrlServiceWebJson(String url);
// timeout
public void setTimeout(int timeout);
// authentification
public void authenticate(User user);
// liste des clients
public List<Client> getAllClients(User user);
// liste des Médecins
public List<Medecin> getAllMedecins(User user);
// liste des créneaux horaires d'un médecin
public List<Creneau> getAllCreneaux(User user, long idMedecin);
// trouver un client identifié par son id
public Client getClientById(User user, long id);
// trouver un client identifié par son id
public Medecin getMedecinById(User user, long id);
// trouver un Rv identifié par son id
public Rv getRvById(User user, long id);
// trouver un créneau horaire identifié par son id
public Creneau getCreneauById(User user, long id);
// ajouter un RV
public Rv ajouterRv(User user, String jour, long idCreneau, long idClient);
// supprimer un RV
public void supprimerRv(User user, long idRv);
// liste des Rv d'un médecin, un jour donné
public List<Rv> getRvMedecinJour(User user, long idMedecin, String jour);
// agenda
public AgendaMedecinJour getAgendaMedecinJour(User user, long idMedecin, String jour);
}
- ligne 14 : la méthode permettant de fixer l'URL racine du service web / jSON, par exemple [http://localhost:8080];
- ligne 17 : la méthode qui permet de fixer les [timeout] côté client. On veut contrôler ce paramètre car certains clients HTTP sont parfois très longs à attendre une réponse qui ne viendra pas ;
- ligne 20 : la méthode permettant d'identifier un utilisateur [login, passwd]. Lance une exception si l'utilisateur n'est pas reconnu ;
- lignes 22-53 : à chaque URL exposée par le service web / jSON est associée une méthode de l'interface dont la signature découle de la signature de la méthode côté serveur traitant l'URL exposée. Prenons par exemple, l'URL serveur suivante :
@RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET)
public Response<String> getAgendaMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour, HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
- ligne 1 : on voit que [idMedecin] et [jour] sont les paramètres de l'URL. Ce seront les paramètres d'entrée de la méthode associée à cette URL côté client ;
- ligne 2 : on voit que la méthode serveur rend un type [Response<String>]. Ce type [String] est le type de la valeur jSON d'un type [AgendaMedecinJour]. Le type du résultat de la méthode associée à cette URL côté client sera [AgendaMedecinJour] ;
Côté client, on déclare la méthode suivante :
public AgendaMedecinJour getAgendaMedecinJour(User user, long idMedecin, String jour);
Cette signature convient lorsque le serveur envoie une réponse [int status, List<String> messages, String body] avec [status==0]. Dans ce cas nous avons [messages==null && body!=null]. Elle ne convient pas lorsque [status!=0]. Dans ce cas nous avons [messages!=null && body==null]. Il nous faut d'une façon ou d'une autre signaler qu'il y a eu une erreur. Pour cela nous lancerons une exception de type [RdvMedecinsException] suivant :
package rdvmedecins.client.dao;
import java.util.List;
public class RdvMedecinsException extends RuntimeException {
private static final long serialVersionUID = 1L;
// code d'erreur
private int status;
// liste de messages d'erreur
private List<String> messages;
public RdvMedecinsException() {
}
public RdvMedecinsException(int code, List<String> messages) {
super();
this.status = code;
this.messages = messages;
}
// getters et setters
...
}
- lignes 9 et 11 : l'exception reprendra les valeurs des champs [status, messages] de l'objet [Response<T>] envoyé par le serveur ;
- ligne 5 : la classe [RdvMedecinsException] étend la classe [RuntimeException]. C'est donc une exception non contrôlée, ç-à-d qu'il n'y a pas obligation de la gérer avec un try / catch et de la déclarer dans la signature des méthodes de l'interface ;
Par ailleurs, toutes les méthodes de l'interface [IDao] qui interrogent le service web / jSON ont pour paramètre, le type [User] suivant :
package rdvmedecins.client.entities;
public class User {
// data
private String login;
private String passwd;
// constructeurs
public User() {
}
public User(String login, String passwd) {
this.login = login;
this.passwd = passwd;
}
// getters et setters
...
}
En effet, chaque échange avec le service web / jSON doit être accompagné d'un entête HTTP d'authentification.
8.5.9. Le package [rdvmedecins.clients.console]
Maintenant que nous connaissons l'interface de la couche [DAO], nous pouvons présenter l'application console.
![]() |
La classe [Main] est la suivante :
package rdvmedecins.clients.console;
import java.io.IOException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import rdvmedecins.client.config.DaoConfig;
import rdvmedecins.client.dao.IDao;
import rdvmedecins.client.dao.RdvMedecinsException;
import rdvmedecins.client.entities.Rv;
import rdvmedecins.client.entities.User;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
public class Main {
// sérialiseur jSON
static private ObjectMapper mapper = new ObjectMapper();
// timeout des connexions en millisecondes
static private int TIMEOUT = 1000;
public static void main(String[] args) throws IOException {
// on récupère une référence sur la couche [DAO]
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DaoConfig.class);
IDao dao = context.getBean(IDao.class);
// on fixe l'URL du service web / json
dao.setUrlServiceWebJson("http://localhost:8080");
// on fixe les timeout en millisecondes
dao.setTimeout(TIMEOUT);
// Authentification
String message = "/authenticate [admin,admin]";
try {
dao.authenticate(new User("admin", "admin"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
message = "/authenticate [user,user]";
try {
dao.authenticate(new User("user", "user"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
message = "/authenticate [user,x]";
try {
dao.authenticate(new User("user", "x"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
message = "/authenticate [x,x]";
try {
dao.authenticate(new User("x", "x"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
message = "/authenticate [admin,x]";
try {
dao.authenticate(new User("admin", "x"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// liste des clients
message = "/getAllClients";
try {
showResponse(message, dao.getAllClients(new User("admin", "admin")));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// liste des médecins
message = "/getAllMedecins";
try {
showResponse(message, dao.getAllMedecins(new User("admin", "admin")));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// liste des créneaux du médecin 2
message = "/getAllCreneaux/2";
try {
showResponse(message, dao.getAllCreneaux(new User("admin", "admin"), 2L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// client n° 1
message = "/getClientById/1";
try {
showResponse(message, dao.getClientById(new User("admin", "admin"), 1L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// médecin n° 2
message = "/getMedecinById/2";
try {
showResponse(message, dao.getMedecinById(new User("admin", "admin"), 2L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// créneau n° 3
message = "/getCreneauById/3";
try {
showResponse(message, dao.getCreneauById(new User("admin", "admin"), 3L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// rv n° 4
message = "/getRvById/4";
try {
showResponse(message, dao.getRvById(new User("admin", "admin"), 4L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// ajout d'un rv
message = "/AjouterRv [idClient=4,idCreneau=8,jour=2015-01-08]";
long idRv = 0;
try {
Rv response = dao.ajouterRv(new User("admin", "admin"), "2015-01-08", 8L, 4L);
idRv = response.getId();
showResponse(message, response);
} catch (RdvMedecinsException e) {
showException(message, e);
}
// liste des rv du médecin 1 le 2015-01-08
message = "/getRvMedecinJour/1/2015-01-08";
try {
showResponse(message, dao.getRvMedecinJour(new User("admin", "admin"), 1L, "2015-01-08"));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// agenda du médecin 1 le 2015-01-08
message = "/getAgendaMedecinJour/1/2015-01-08";
try {
showResponse(message, dao.getAgendaMedecinJour(new User("admin", "admin"), 1L, "2015-01-08"));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// suppression du rv ajouté
message = String.format("/supprimerRv [idRv=%s]", idRv);
try {
dao.supprimerRv(new User("admin", "admin"), idRv);
} catch (RdvMedecinsException e) {
showException(message, e);
}
// liste des rv du médecin 1 le 2015-01-08
message = "/getRvMedecinJour/1/2015-01-08";
try {
showResponse(message, dao.getRvMedecinJour(new User("admin", "admin"), 1L, "2015-01-08"));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// fermeture contexte
context.close();
}
private static void showException(String message, RdvMedecinsException e) {
System.out.println(String.format("URL [%s]", message));
System.out.println(String.format("L'erreur n° [%s] s'est produite :", e.getStatus()));
for (String msg : e.getMessages()) {
System.out.println(msg);
}
}
private static <T> void showResponse(String message, T response) throws JsonProcessingException {
System.out.println(String.format("URL [%s]", message));
System.out.println(mapper.writeValueAsString(response));
}
}
- ligne 19 : le sérialiseur jSON qui va nous permettre d'afficher la réponse du serveur, ligne 184 ;
- ligne 25 : le composant [AnnotationConfigApplicationContext] est un composant Spring capable d'exploiter les annotations de configuration d'une application Spring. Nous passons à son constructeur, la classe [AppConfig] qui configure l'application ;
- ligne 26 : on récupère une référence sur la couche [DAO] ;
- lignes 27-30 : on la configure ;
- lignes 32-169 : on teste toutes les méthodes de l'interface [IDao] ;
Les résultats obtenus sont les suivants :
09:20:56.935 [main] INFO o.s.c.a.AnnotationConfigApplicationContext - Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@52feb982: startup date [Wed Oct 14 09:20:56 CEST 2015]; root of context hierarchy
/authenticate [admin,admin] : OK
URL [/authenticate [user,user]]
L'erreur n° [111] s'est produite :
403 Forbidden
URL [/authenticate [user,x]]
L'erreur n° [111] s'est produite :
401 Unauthorized
URL [/authenticate [x,x]]
L'erreur n° [111] s'est produite :
403 Forbidden
URL [/authenticate [admin,x]]
L'erreur n° [111] s'est produite :
401 Unauthorized
URL [/getAllClients]
[{"id":1,"version":1,"titre":"Mr","nom":"MARTIN","prenom":"Jules"},{"id":2,"version":1,"titre":"Mme","nom":"GERMAN","prenom":"Christine"},{"id":3,"version":1,"titre":"Mr","nom":"JACQUARD","prenom":"Jules"},{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"}]
URL [/getAllMedecins]
[{"id":1,"version":1,"titre":"Mme","nom":"PELISSIER","prenom":"Marie"},{"id":2,"version":1,"titre":"Mr","nom":"BROMARD","prenom":"Jacques"},{"id":3,"version":1,"titre":"Mr","nom":"JANDOT","prenom":"Philippe"},{"id":4,"version":1,"titre":"Melle","nom":"JACQUEMOT","prenom":"Justine"}]
URL [/getAllCreneaux/2]
[{"id":25,"version":1,"hdebut":8,"mdebut":0,"hfin":8,"mfin":20,"medecin":null,"idMedecin":2},{"id":26,"version":1,"hdebut":8,"mdebut":20,"hfin":8,"mfin":40,"medecin":null,"idMedecin":2},{"id":27,"version":1,"hdebut":8,"mdebut":40,"hfin":9,"mfin":0,"medecin":null,"idMedecin":2},{"id":28,"version":1,"hdebut":9,"mdebut":0,"hfin":9,"mfin":20,"medecin":null,"idMedecin":2},{"id":29,"version":1,"hdebut":9,"mdebut":20,"hfin":9,"mfin":40,"medecin":null,"idMedecin":2},{"id":30,"version":1,"hdebut":9,"mdebut":40,"hfin":10,"mfin":0,"medecin":null,"idMedecin":2},{"id":31,"version":1,"hdebut":10,"mdebut":0,"hfin":10,"mfin":20,"medecin":null,"idMedecin":2},{"id":32,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":2},{"id":33,"version":1,"hdebut":10,"mdebut":40,"hfin":11,"mfin":0,"medecin":null,"idMedecin":2},{"id":34,"version":1,"hdebut":11,"mdebut":0,"hfin":11,"mfin":20,"medecin":null,"idMedecin":2},{"id":35,"version":1,"hdebut":11,"mdebut":20,"hfin":11,"mfin":40,"medecin":null,"idMedecin":2},{"id":36,"version":1,"hdebut":11,"mdebut":40,"hfin":12,"mfin":0,"medecin":null,"idMedecin":2}]
URL [/getClientById/1]
{"id":1,"version":1,"titre":"Mr","nom":"MARTIN","prenom":"Jules"}
URL [/getMedecinById/2]
{"id":2,"version":1,"titre":"Mr","nom":"BROMARD","prenom":"Jacques"}
URL [/getCreneauById/3]
{"id":3,"version":1,"hdebut":8,"mdebut":40,"hfin":9,"mfin":0,"medecin":null,"idMedecin":1}
URL [/getRvById/4]
L'erreur n° [2] s'est produite :
Le rendez-vous d'id [4] n'existe pas
URL [/ajouterRv [idClient=4,idCreneau=8,jour=2015-01-08]]
{"id":144,"version":0,"jour":1420671600000,"client":{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"},"creneau":{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":1},"idClient":0,"idCreneau":0}
URL [/getRvMedecinJour/1/2015-01-08]
[{"id":144,"version":0,"jour":1420675200000,"client":{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"},"creneau":{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":1},"idClient":4,"idCreneau":8}]
URL [/getAgendaMedecinJour/1/2015-01-08]
{"medecin":{"id":1,"version":1,"titre":"Mme","nom":"PELISSIER","prenom":"Marie"},"jour":1420671600000,"creneauxMedecinJour":[{"creneau":{"id":1,"version":1,"hdebut":8,"mdebut":0,"hfin":8,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":2,"version":1,"hdebut":8,"mdebut":20,"hfin":8,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":3,"version":1,"hdebut":8,"mdebut":40,"hfin":9,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":4,"version":1,"hdebut":9,"mdebut":0,"hfin":9,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":5,"version":1,"hdebut":9,"mdebut":20,"hfin":9,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":6,"version":1,"hdebut":9,"mdebut":40,"hfin":10,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":7,"version":1,"hdebut":10,"mdebut":0,"hfin":10,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":1},"rv":{"id":144,"version":0,"jour":1420675200000,"client":{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"},"creneau":{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":1},"idClient":4,"idCreneau":8}},{"creneau":{"id":9,"version":1,"hdebut":10,"mdebut":40,"hfin":11,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":10,"version":1,"hdebut":11,"mdebut":0,"hfin":11,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":11,"version":1,"hdebut":11,"mdebut":20,"hfin":11,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":12,"version":1,"hdebut":11,"mdebut":40,"hfin":12,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":13,"version":1,"hdebut":14,"mdebut":0,"hfin":14,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":14,"version":1,"hdebut":14,"mdebut":20,"hfin":14,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":15,"version":1,"hdebut":14,"mdebut":40,"hfin":15,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":16,"version":1,"hdebut":15,"mdebut":0,"hfin":15,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":17,"version":1,"hdebut":15,"mdebut":20,"hfin":15,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":18,"version":1,"hdebut":15,"mdebut":40,"hfin":16,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":19,"version":1,"hdebut":16,"mdebut":0,"hfin":16,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":20,"version":1,"hdebut":16,"mdebut":20,"hfin":16,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":21,"version":1,"hdebut":16,"mdebut":40,"hfin":17,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":22,"version":1,"hdebut":17,"mdebut":0,"hfin":17,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":23,"version":1,"hdebut":17,"mdebut":20,"hfin":17,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":24,"version":1,"hdebut":17,"mdebut":40,"hfin":18,"mfin":0,"medecin":null,"idMedecin":1},"rv":null}]}
URL [/getRvMedecinJour/1/2015-01-08]
[]
09:21:00.258 [main] INFO o.s.c.a.AnnotationConfigApplicationContext - Closing org.springframework.context.annotation.AnnotationConfigApplicationContext@52feb982: startup date [Wed Oct 14 09:20:56 CEST 2015]; root of context hierarchy
Nous laissons au lecteur le soin d'associer les résultats au code. Celui-ci montre comment appeler chaque méthode de la couche [DAO]. Notons simplement quelques points :
- lignes 2-14 : montrent que lors d'une erreur d'authentification, le serveur renvoie un status HTTP [403 Forbidden] ou [401 Unauthorized] selon les cas ;
- lignes 30-31 : on ajoute un Rv au médecin n° 1 ;
- lignes 32-33 : on voit ce rendez-vous. C'est le seul dans la journée ;
- lignes 34-35 : on le voit également dans l'agenda du médecin ;
- lignes 36-37 : le rendez-vous a disparu. Le code l'a entre-temps supprimé ;
Les logs de la console sont contrôlés par les fichiers suivants :
![]() |
[application.properties]
logging.level.org.springframework.web=OFF
logging.level.org.hibernate=OFF
spring.main.show-banner=false
logging.level.httpclient.wire=OFF
[logback.xml]
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are by default assigned the type ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- contrôle niveau des logs -->
<root level="info"> <!-- off, info, debug, warn -->
<appender-ref ref="STDOUT" />
</root>
</configuration>
8.5.10. Implémentation de la couche [DAO]
Il nous reste maintenant à présenter le coeur de la couche [DAO], l'implémentation de son interface [IDao]. Nous allons le faire progressivement.
![]() |
L'interface [IDao] est implémentée par la classe abstraite [AbstractDao] et sa classe fille [Dao].
La classe parent [AbstractDao] est la suivante :
package rdvmedecins.client.dao;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.http.RequestEntity.BodyBuilder;
import org.springframework.http.RequestEntity.HeadersBuilder;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import rdvmedecins.client.entities.User;
public abstract class AbstractDao implements IDao {
// data
@Autowired
protected RestTemplate restTemplate;
protected String urlServiceWebJson;
// URL service web / jSON
public void setUrlServiceWebJson(String url) {
this.urlServiceWebJson = url;
}
public void setTimeout(int timeout) {
// on fixe le timeout des requêtes du client web
HttpComponentsClientHttpRequestFactory factory = (HttpComponentsClientHttpRequestFactory) restTemplate
.getRequestFactory();
factory.setConnectTimeout(timeout);
factory.setReadTimeout(timeout);
}
private String getBase64(User user) {
// on encode en base 64 l'utilisateur et son mot de passe - nécessite
// java 8
String chaîne = String.format("%s:%s", user.getLogin(), user.getPasswd());
return String.format("Basic %s", new String(Base64.getEncoder().encode(chaîne.getBytes())));
}
// requête générique
protected String getResponse(User user, String url, String jsonPost) {
...
}
}
- ligne 20 : la classe est abstraite ce qui nous empêche de la désigner comme un composant Spring. Ce sera sa classe fille qui sera désignée comme telle ;
- lignes 23-24 : nous injectons le bean [restTemplate] que nous avons défini dans la classe de configuration [AppConfig] ;
- ligne 25 : l'URL racine du service web / jSON ;
- lignes 32-38 : fixent le timeout du client lorsqu'il attend une réponse du serveur ;
- ligne 34 : nous récupérons le composant [HttpComponentsClientHttpRequestFactory] que nous avions injecté dans le bean [restTemplate] lors de la création de celui-ci (cf [AppConfig]) ;
- ligne 36 : nous fixons le temps maximum d'attente du client lorsqu'il établit une connexion avec le serveur ;
- ligne 37 : nous fixons le temps maximum d'attente du client lorsqu'il attend une réponse à l'une de ses requêtes ;
L'implémentation des méthodes de communication avec le serveur va être factorisée dans la méthode générique suivante :
// requête générique
protected String getResponse(User user, String url, String jsonPost) {
...
}
- ligne 2 : les paramètres de [getResponse] sont les suivants :
- [User user] : l'utilisateur qui fait la connexion ;
- [String url] : l'URL à interroger. Il s'agit de la fin de l'URL, la première partie étant fournie par le champ [urlServiceWebJson] de la classe,
- [String jsonPost] : la chaîne jSON à poster. Si cette valeur est présente alors l'URL sera demandée avec un POST sinon ce sera avec un GET ;
Continuons :
// requête générique
protected String getResponse(User user, String url, String jsonPost) {
// url : URL à contacter
// jsonPost : la valeur jSON à poster
try {
// exécution requête
RequestEntity<?> request;
if (jsonPost == null) {
HeadersBuilder<?> headersBuilder = RequestEntity.get(new URI(String.format("%s%s", urlServiceWebJson, url))).accept(MediaType.APPLICATION_JSON);
if (user != null) {
headersBuilder = headersBuilder.header("Authorization", getBase64(user));
}
request = headersBuilder.build();
} else {
BodyBuilder bodyBuilder = RequestEntity.post(new URI(String.format("%s%s", urlServiceWebJson, url)))
.header("Content-Type", "application/json").accept(MediaType.APPLICATION_JSON);
if (user != null) {
bodyBuilder = bodyBuilder.header("Authorization", getBase64(user));
}
request = bodyBuilder.body(jsonPost);
}
// on exécute la requête
return restTemplate.exchange(request, new ParameterizedTypeReference<String>() {
}).getBody();
} catch (URISyntaxException e) {
throw new RdvMedecinsException(20, getMessagesForException(e));
} catch (RuntimeException e) {
throw new RdvMedecinsException(21, getMessagesForException(e));
}
}
- lignes 23-24 : l'instruction qui fait la requête au serveur et reçoit sa réponse. Le composant [RestTemplate] offre un nombre important de méthodes d'échange avec le serveur. On aurait pu choisir une autre méthode que [exchange]. Le second paramètre de l'appel fixe le type de la réponse attendue, ici une chaîne jSON. Le premier paramètre est la requête de type [RequestEntity] (ligne 7). Le résultat de la méthode [exchange] est de type [ResponseEntity<String>]. Le type [ResponseEntity] encapsule la réponse complète du serveur, entêtes HTTP et document envoyés par celui-ci. De même le type [RequestEntity] encapsule toute la requête du client incluant les entêtes HTTP et l'éventuelle valeur postée ;
- ligne 23 : c'est le corps de l'objet [ResponseEntity<String>] qui est rendue à la méthode appelante, ç-à-d la chaîne jSON envoyée par le serveur ;
- lignes 9-21 : il nous faut construire la requête de type [RequestEntity]. Elle est différente selon que l'on utilise un GET ou un POST pour faire la requête ;
- ligne 9 : la requête pour un GET. La classe [RequestEntity] offre des méthodes statiques pour créer les requêtes GET, POST, HEAD,... La méthode [RequestEntity.get] permet de créer une requête GET en chaînant les différentes méthodes qui construisent celle-ci :
- la méthode [RequestEntity.get] admet pour paramètre l'URL cible sous la forme d'une instance URI,
- la méthode [accept] permet de définir les éléments de l'entête HTTP [Accept]. Ici, nous indiquons que nous acceptons le type [application/json] que va envoyer le serveur ;
- le résultat de ce chaînage de méthodes est un type [HeadersBuilder] ;
- lignes 10-12 : dans le cas où le paramètre [User user] n'est pas null, on inclut l'entête HTTP [Authorization] dans la requête ;
- ligne 13 : la méthode [HeadersBuilder.build] utilise ces différentes informations pour construire le type [RequestEntity] de la requête ;
- ligne 15 : la requête pour un POST. La méthode [RequestEntity.post] permet de créer une requête POST en chaînant les différentes méthodes qui construisent celle-ci :
- la méthode [RequestEntity.post] admet pour paramètre l'URL cible sous la forme d'une instance URI,
- la méthode [header] permet de définir les entêtes HTTP que l'on souhaite utiliser, ici celui de l'autorisation,
- la méthode [header] qui suit inclut dans la requête l'entête [Content-Type: application/json] pour lui indiquer que la valeur postée va lui arriver sous la forme d'une chaîne jSON ;
- la méthode [accept] permet d'indiquer que nous acceptons le type [application/json] que va envoyer le serveur ;
- lignes 17-19 : dans le cas où le paramètre [User user] n'est pas null, on inclut l'entête HTTP [Authorization] dans la requête ;
- ligne 20 : la méthode [BodyBuilder.body] fixe la valeur postée. Celle-ci est le 2ième paramètre de la méthode générique [getResponse] (ligne 2) ;
- lignes 25-28 : s'il se produit une erreur quelconque on lance une exception de type [RdvMedecinsException] ;
La méthode [getMessagesForException] des lignes 26 et 28 est la suivante :
// liste des messages d'erreur d'une exception
protected static List<String> getMessagesForException(Exception exception) {
// on récupère la liste des messages d'erreur de l'exception
Throwable cause = exception;
List<String> erreurs = new ArrayList<String>();
while (cause != null) {
// on récupère le message seulement s'il est !=null et non blanc
String message = cause.getMessage();
if (message != null) {
message = message.trim();
if (message.length() != 0) {
erreurs.add(message);
}
}
// cause suivante
cause = cause.getCause();
}
return erreurs;
}
La méthode privée [getBase64] fournit le code Base64 de la chaîne 'login:passwd' pour l'entête HTTP d'authentification :
private String getBase64(User user) {
// on encode en base 64 l'utilisateur et son mot de passe - nécessite java 8
String chaîne = String.format("%s:%s", user.getLogin(), user.getPasswd());
return String.format("Basic %s", new String(Base64.getEncoder().encode(chaîne.getBytes())));
}
La classe [Dao] étend la classe [AbstractDao] de la façon suivante :
package rdvmedecins.client.dao;
import java.io.IOException;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import rdvmedecins.client.entities.AgendaMedecinJour;
import rdvmedecins.client.entities.Client;
import rdvmedecins.client.entities.Creneau;
import rdvmedecins.client.entities.Medecin;
import rdvmedecins.client.entities.Rv;
import rdvmedecins.client.entities.User;
import rdvmedecins.client.requests.PostAjouterRv;
import rdvmedecins.client.requests.PostSupprimerRv;
import rdvmedecins.client.responses.Response;
@Service
public class Dao extends AbstractDao implements IDao {
// mappeurs jSON
@Autowired
ObjectMapper jsonMapper;
@Autowired
private ObjectMapper jsonMapperShortCreneau;
@Autowired
private ObjectMapper jsonMapperLongRv;
@Autowired
private ObjectMapper jsonMapperShortRv;
public List<Client> getAllClients(User user) {
...
}
public List<Medecin> getAllMedecins(User user) {
...
}
...
}
- ligne 22 : la classe [Dao] est un composant Spring. On a utilisé ici l'annotation [@Service]. On aurait pu continuer à utiliser l'annotation [@Component] utilisé jusqu'à maintenant ;
- lignes 26-36 : injection des quatre mappeurs jSON définis dans la classe de configuration [DaoConfig] ;
Les méthodes de la classe [Dao] suivent toutes le même schéma. Nous allons détailler une opération GET et une opération POST.
Tout d'abord une requête [GET] :
public AgendaMedecinJour getAgendaMedecinJour(User user, long idMedecin, String jour) {
// la réponse
Response<AgendaMedecinJour> response;
// l'agenda
String jsonResponse = getResponse(user, String.format("%s/%s/%s", "/getAgendaMedecinJour", idMedecin, jour), null);
try {
// l'agenda AgendaMedecinJour
response = jsonMapperLongRv.readValue(jsonResponse, new TypeReference<Response<AgendaMedecinJour>>() {
});
} catch (IOException e) {
throw new RdvMedecinsException(401, getMessagesForException(e));
} catch (RuntimeException e) {
throw new RdvMedecinsException(402, getMessagesForException(e));
}
// analyse de la réponse
int status = response.getStatus();
if (status != 0) {
throw new RdvMedecinsException(status, response.getMessages());
} else {
return response.getBody();
}
}
- ligne 5 : on appelle la méthode générique [getResponse]. Les paramètres effectifs utilisés sont les suivants :
- 1 : l'utilisateur ;
- 2 : l'URL cible ;
- 3 : la valeur à poster. Ici il n'y en a pas ;
- ligne 5 : l'appel n'a pas été entouré par un try / catch. La méthode [getResponse] est susceptible de lancer un type [RdvMedecinsException]. Si elle est lancée, cette exception remontera vers la méthode qui a appelé la méthode [getAgendaMedecinJour] ci-dessus ;
- ligne 8 : l'URL [/getAgendaMedecinJour] envoie un type [Response<AgendaMedecinJour>] qui a été sérialisée en jSON côté serveur par le mappeur jSON [jsonMapperLongRv]. On utilise ce même mappeur pour désérialiser la chaîne jSON reçue ;
- lignes 10-13 : si une erreur survient ligne 9, un type [RdvMedecinsException] est lancé ;
- lignes 16-21 : on analyse la réponse envoyée par le serveur ;
- lignes 17-18 : si le serveur a signalé une erreur, alors on lance une exception avec les informations transmises par le serveur ;
- lignes 19-21 : sinon on rend l'agenda du médecin ;
La requête POST examinée sera la suivante :
public Rv ajouterRv(User user, String jour, long idCreneau, long idClient) {
// la réponse
Response<Rv> response;
try {
// le Rv
String jsonResponse = getResponse(user, "/ajouterRv",
jsonMapper.writeValueAsString(new PostAjouterRv(idClient, idCreneau, jour)));
// le Rv Rv
response = jsonMapperLongRv.readValue(jsonResponse, new TypeReference<Response<Rv>>() {
});
} catch (RdvMedecinsException e) {
throw e;
} catch (IOException e) {
throw new RdvMedecinsException(381, getMessagesForException(e));
} catch (RuntimeException e) {
throw new RdvMedecinsException(382, getMessagesForException(e));
}
// analyse de la réponse
int status = response.getStatus();
if (status != 0) {
throw new RdvMedecinsException(status, response.getMessages());
} else {
return response.getBody();
}
}
- ligne 6 : la méthode [getResponse] est appelée avec les paramètres suivants :
- 1 : l'utilisateur ;
- 2 : l'URL cible,
- 3 : la valeur postée : on passe la valeur jSON d'un type [PostAjouter] construit avec les informations reçues en paramètres par la méthode. On utilise un mappeur jSON sans filtres ;
- ligne 9 : côté serveur, c'est le mappeur jSON [jsonMapperLongRv] qui a sérialisé la réponse du serveur. Côté client, on utilise ce même mappeur pour la désérialiser ;
- ligne 6 : l'URL [/ajouterRv] rend la valeur jSON d'un type [Response<Rv>] ;
- lignes 4-11 : ici, la méthode [getResponse] a été mise dans un try / catch parce que la sérialisation de la valeur postée peut lancer une exception. La méthode [getResponse] est susceptible de lancer une exception [RdvMedecinsException]. Dans ce cas, on se contente de la relancer (lignes 11-12) ;
Le code qui suit (lignes 13-24) est analogue à celui qui vient d'être étudié. La seule différence avec une opération GET est donc le second paramètre de la méthode [getResponse] qui doit être la valeur jSON de la valeur à poster.
Les autres méthodes sont construites sur le même modèle.
8.5.11. Anomalie
En faisant divers tests on rencontre une anomalie résumée dans la classe [Anomalie] suivante :
package rdvmedecins.clients.console;
import java.io.IOException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import rdvmedecins.client.config.DaoConfig;
import rdvmedecins.client.dao.IDao;
import rdvmedecins.client.dao.RdvMedecinsException;
import rdvmedecins.client.entities.User;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
public class Anomalie {
// sérialiseur jSON
static private ObjectMapper mapper = new ObjectMapper();
// timeout des connexions en millisecondes
static private int TIMEOUT = 1000;
public static void main(String[] args) throws IOException {
// on récupère une référence sur la couche [DAO]
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DaoConfig.class);
IDao dao = context.getBean(IDao.class);
// on fixe l'URL du service web / json
dao.setUrlServiceWebJson("http://localhost:8080");
// on fixe les timeout en millisecondes
dao.setTimeout(TIMEOUT);
// Authentification
String message = "/authenticate [admin,admin]";
try {
dao.authenticate(new User("admin", "admin"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// Authentification
message = "/authenticate [admin,x]";
try {
dao.authenticate(new User("admin", "x"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// Authentification
message = "/authenticate [user,user]";
try {
dao.authenticate(new User("user", "user"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// fermeture contexte
context.close();
}
private static void showException(String message, RdvMedecinsException e) {
System.out.println(String.format("URL [%s]", message));
System.out.println(String.format("L'erreur n° [%s] s'est produite :", e.getStatus()));
for (String msg : e.getMessages()) {
System.out.println(msg);
}
}
}
- lignes 31-38 : on authentifie l'utilisateur [admin, admin] ;
- lignes 40-47 : on authentifie l'utilisateur [admin, x] qui a donc un mot de passe erroné ;
- lignes 49-56 : on authentifie l'utilisateur [user, user] qui est un utilisateur existant mais non autorisé ;
Voici les résultats :
- ligne 2 : contre toute attente, l'utilisateur [admin, x] a été accepté ;
Si on passe les lignes 33-38 du code en commentaires, on obtient le résultat suivant :
ce qui est le résultat attendu. Tout se passe comme si lorsque l'utilisateur [admin, admin] s'est identifié avec succès une 1ère fois, son mot de passe n'était plus nécessaire pour les fois suivantes. C'est bien le cas. Spring Security utilise par défaut une session qui fait qu'une fois qu'un utilisateur s'est authentifié, il n'a plus besoin de le refaire dans les requêtes suivantes. On peut modifier la configuration de [Spring Security] dans le serveur web / jSON pour que ce ne soit plus le cas :
![]() |
Le fichier [SecurityConfig] doit être modifié de la façon suivante :
@Override
protected void configure(HttpSecurity http) throws Exception {
...
// pas de session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
- la ligne 5 demande à ce qu'il n'y ait pas de session de sécurité ;
Cela a résolu le problème de l'anomalie.
8.6. Ecriture du serveur Spring / Thymeleaf
8.6.1. Introduction
Revenons à l'architecture de l'application client / serveur à construire :
![]() |
- le serveur [Web2] web / jSON a été construit ;
- le couche [DAO] du client [Web1] a été construite ;
La relation entre le serveur [Web1] et les navigateurs clients est une relation client / serveur où le serveur est un serveur web / jSON. En effet, [Web1] va délivrer des flux HTML encapsulés dans une chaîne jSON. L'architecture client / serveur est la suivante :
![]() |
- on a une architecture client [2] / serveur [1] où le client et le serveur communiquent en jSON ;
- en [1], la couche web Spring MVC / Thymeleaf délivre des vues, des fragments de vue, des données dans du jSON. Le serveur est donc un serveur web / jSON comme le serveur [Web1]. Il est lui aussi sans état ;
- en [2] : le code Javascript embarqué dans la vue chargée au démarrage de l'application est structuré en couches :
- la couche [présentation] s'occupe des interactions avec l'utilisateur,
- la couche [DAO] s'occupe de l'accès aux données via le serveur [Web2] ;
- le client [2] mettra certaines vues en cache afin de soulager le serveur ;
Nous allons construire le serveur web / jSON [Web1] implémenté avec Spring MVC / Thymeleaf en plusieurs étapes :
- découverte du framework CSS Bootstrap ;
- écriture des vues ;
- écriture du contrôleur ;
Puis ensuite et à part, nous construirons le client JS du serveur [Web1]. Pour bien montrer que ce client a une certaine indépendance vis à vis du serveur [Web1], nous le construirons avec l'outil [Webstorm] plutôt qu'avec STS.
Dans la suite, certains détails seront ignorés parce qu'ils risqueraient de nous faire oublier l'important qui est l'organisation du code. Le lecteur intéressé pourra trouver le code complet sur le site de ce document.
8.6.2. Le projet STS
![]() |
- en [1], les codes Java ;
- en [2], les vues ;
La configuration Maven dans [pom.xml] est la suivante :
<?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>istia.st.rdvmedecins</groupId>
<artifactId>rdvmedecins-springthymeleaf-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>rdvmedecins-springthymeleaf-server</name>
<description>Gestion de RV Médecins</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.0.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>istia.st.rdvmedecins</groupId>
<artifactId>rdvmedecins-webjson-client-console</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
<properties>
<start-class>rdvmedecins.springthymeleaf.server.boot.Boot</start-class>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.7</java.version>
</properties>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.7</source>
<target>1.7</target>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
...
</project>
- lignes 16-19 : le projet est un projet Thymeleaf ;
- lignes 20-24 : qui s'appuie sur la couche [DAO] que nous venons de construire ;
La configuration Java est assurée par deux fichiers :
![]() |
La couche [web] est configurée par le fichier [WebConfig] suivant :
package rdvmedecins.springthymeleaf.server.config;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.thymeleaf.spring4.SpringTemplateEngine;
import org.thymeleaf.spring4.templateresolver.SpringResourceTemplateResolver;
@EnableAutoConfiguration
public class WebConfig extends WebMvcConfigurerAdapter {
// ----------------- configuration couche [web]
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("i18n/messages");
return messageSource;
}
@Bean
public SpringResourceTemplateResolver templateResolver() {
SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
templateResolver.setPrefix("classpath:/templates/");
templateResolver.setSuffix(".xml");
templateResolver.setTemplateMode("HTML5");
templateResolver.setCacheable(true);
templateResolver.setCharacterEncoding("UTF-8");
return templateResolver;
}
@Bean
SpringTemplateEngine templateEngine(SpringResourceTemplateResolver templateResolver) {
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.setTemplateResolver(templateResolver);
return templateEngine;
}
// configuration dispatcherservlet pour les headers CORS
@Bean
public DispatcherServlet dispatcherServlet() {
DispatcherServlet servlet = new DispatcherServlet();
servlet.setDispatchOptionsRequest(true);
return servlet;
}
}
Nous avons rencontré, à un moment ou à un autre, tous les éléments de cette configuration. Rappelons simplement que les lignes 42-47 sont nécessaires lorsqu'on veut pouvoir interroger le serveur avec des requêtes inter-domaines (CORS). Cela va être le cas ici.
La classe [AppConfig] configure l'ensemble de l'application :
package rdvmedecins.springthymeleaf.server.config;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import rdvmedecins.client.config.DaoConfig;
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins.springthymeleaf.server" })
@Import({ WebConfig.class, DaoConfig.class })
public class AppConfig {
// admin / admin
private final String USER_INIT = "admin";
private final String MDP_USER_INIT = "admin";
// racine service web / json
private final String WEBJSON_ROOT = "http://localhost:8080";
// timeout en millisecondes
private final int TIMEOUT = 5000;
// CORS
private final boolean CORS_ALLOWED=true;
...
}
- lignes 11 : [AppConfig] importe la configuration de la couche [DAO] et de la couche [web] ;
- lignes 15-16 : les identifiants qui vont permettre à l'application de faire un accès au boot de l'application afin de mettre en cache les médecins et les clients ;
- ligne 18 : l'URL du service web / jSON [Web1] ;
- ligne 20 : le timeout des appels HTTP de l'application ;
- ligne 22 : un booléen pour autoriser ou non les appels inter-domaines ;
Enfin dans [application.properties], le serveur Tomcat est configuré pour travailler sur le port 8081 :
![]() |
server.port=8081
8.6.3. Les fonctionnalités de l'application
Elles ont été décrites au paragraphe 8.2. Nous les rappelons maintenant. Avec un navigateur, on demande l'URL [http://localhost:8081/boot.html] :
![]() |
- en [1], la page d'entrée de l'application ;
- en [2] et [3], l'identifiant et le mot de passe de celui qui veut utiliser l'application. Il y a deux utilisateurs : admin/admin (login/password) avec un rôle (ADMIN) et user/user avec un rôle (USER). Seul le rôle ADMIN a le droit d'utiliser l'application. Le rôle USER n'est là que pour montrer ce que répond le serveur dans ce cas d'utilisation ;
- en [4], le bouton qui permet de se connecter au serveur ;
- en [5], la langue de l'application. Il y en a deux : le français par défaut et l'anglais ;
- en [6], l'URL du serveur [rdvmedecins-springthymeleaf-server] ;
![]() |
- en [1], on se connecte ;
![]() |
- une fois connecté, on peut choisir le médecin avec lequel on veut un rendez-vous [2] et le jour de celui-ci [3]. Dès qu'un médecin et un jour ont été renseignés, l'agenda est automatiquement affiché :
![]() |
- une fois obtenu l'agenda du médecin, on peut réserver un créneau [5] ;
![]() |
- en [6], on choisit le patient pour le rendez-vous et on valide ce choix en [7] ;
![]() |
Une fois le rendez-vous validé, on est ramené automatiquement à l'agenda où le nouveau rendez-vous est désormais inscrit. Ce rendez-vous pourra être ultérieurement supprimé [8].
Les principales fonctionnalités ont été décrites. Elles sont simples. Terminons par la gestion de la langue :
![]() |
- en [1], on passe du français à l'anglais ;
![]() |
- en [2], la vue est passée en anglais, y-compris le calendrier ;
8.6.4. Étape 1 : introduction au framework CSS Bootstrap
![]() |
Dans le client web ci-dessus, les pages HTML vont utiliser le framework CSS Bootstrap [http://getbootstrap.com/] que nous présentons maintenant.
8.6.4.1. Le projet des exemples
Le projet des exemples sera le suivant :
![]() |
- en [1] : le projet dans sa globalité ;
- en [2] : les codes Java ;
- en [3] : les scripts Javascript ;
![]() |
- en [4] : les bibliothèques Javascript ;
- en [5] : les vues Thymeleaf ;
- en [6] : les feuilles de style ;
8.6.4.1.1. Configuration Maven
Le fichier [pom.xml] est celui d'un projet Maven Thymeleaf :
<?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>istia.st</groupId>
<artifactId>rdvmedecins-webjson-client-bootstrap</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>rdvmedecins-webjson-client-bootstrap</name>
<description>Démos Bootstrap</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.0.RELEASE</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<start-class>istia.st.rdvmedecins.BootstrapDemo</start-class>
<java.version>1.7</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
8.6.4.1.2. Configuration Java
![]() |
La classe [BootstrapDemo] configure l'application Spring / Thymeleaf :
package istia.st.rdvmedecins;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.thymeleaf.spring4.templateresolver.SpringResourceTemplateResolver;
@EnableAutoConfiguration
@ComponentScan({ "istia.st.rdvmedecins" })
public class BootstrapDemo extends WebMvcConfigurerAdapter {
public static void main(String[] args) {
SpringApplication.run(BootstrapDemo.class, args);
}
@Bean
public SpringResourceTemplateResolver templateResolver() {
SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
templateResolver.setPrefix("classpath:/templates/");
templateResolver.setSuffix(".xml");
templateResolver.setTemplateMode("HTML5");
templateResolver.setCacheable(true);
templateResolver.setCharacterEncoding("UTF-8");
return templateResolver;
}
}
Nous avons déjà rencontré ce type de code.
8.6.4.1.3. Le contrôleur Spring
![]() |
Le contrôleur [BootstrapController] est le suivant :
package istia.st.rdvmedecins;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@Controller
public class BootstrapController {
@RequestMapping(value = "/bs-01", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bso1() {
return "bs-01";
}
@RequestMapping(value = "/bs-02", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs02() {
return "bs-02";
}
@RequestMapping(value = "/bs-03", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs03() {
return "bs-03";
}
@RequestMapping(value = "/bs-04", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs04() {
return "bs-04";
}
@RequestMapping(value = "/bs-05", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs05() {
return "bs-05";
}
@RequestMapping(value = "/bs-06", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs06() {
return "bs-06";
}
@RequestMapping(value = "/bs-07", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs07() {
return "bs-07";
}
@RequestMapping(value = "/bs-08", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs08() {
return "bs-08";
}
}
Les actions ne sont là que pour afficher des vues traitées par Thymeleaf.
8.6.4.1.4. Le fichier [application.properties]
Le fichier [application.properties] configure le serveur Tomcat embarqué :
server.port=8082
8.6.4.2. Exemple n° 1 : le jumbotron
L'action [/bs-01] affiche la vue [bs-01.xml] suivante :
![]() |
La vue [bs-01.xml] est la suivante :
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
</head>
<body id="body">
<div class="container">
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron"></div>
<!-- contenu -->
<div id="content">
<h1>Ici un contenu</h1>
</div>
<!-- erreur -->
<div id="erreur" class="alert alert-danger">
<span>Ici, un texte d'erreur</span>
</div>
</div>
</body>
</html>
- ligne 7 : le fichier CSS du framework Bootstrap ;
- ligne 8 : un fichier CSS local ;
- ligne 13 : affiche [1] ;
- lignes 19-21 : affichent [2] ;
- ligne 11 : la classe CSS [container] définit une zone d'affichage à l'intérieur du navigateur ;
- ligne 19 : la classe CSS [alert] affiche une zone colorée. La classe [alert-danger] utilise une couleur prédéfinie. Il en existe plusieurs [alert-info, alert-warning,...] ;
Le jumbotron [1] est généré par la vue [jumbotron.xml] suivante :
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<!-- Bootstrap Jumbotron -->
<div class="jumbotron">
<div class="row">
<div class="col-md-2">
<img src="resources/images/caduceus.jpg" alt="RvMedecins" />
</div>
<div class="col-md-10">
<h1>
Les Médecins
<br />
associés
</h1>
</div>
</div>
</div>
</section>
- ligne 4 : la zone a la classe CSS [jumbotron] ;
- ligne 5 : la classe [row] définit une ligne à 12 colonnes ;
- ligne 6 : la classe [col-md-2] définit une zone de deux colonnes dans la ligne ;
- ligne 7 : dans ces deux colonnes on met une image ;
- lignes 9-15 : dans les 10 autres colonnes, on met le texte ;
8.6.4.3. Exemple n° 2 : la barre de navigation
L'action [/bs-02] affiche la vue [bs-02.xml] suivante :
![]() |
La nouveauté est la barre de navigation [1] avec son formulaire de saisie et ses boutons :
La vue [bs-02.xml] est la suivante :
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- scripts JS -->
<script src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/js/bs-02.js"></script>
</head>
<body id="body">
<div class="container">
<!-- barre de navigation -->
<div th:include="navbar1"></div>
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron"></div>
<!-- contenu -->
<div id="content">
<h1>Ici un contenu</h1>
</div>
<!-- info -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- ligne 10 : on importe jQuery ;
- ligne 11 : un script JS local ;
- ligne 16 : la barre de navigation ;
La barre de navigation est générée par la vue [navbar1.xml] suivante :
<!DOCTYPE HTML>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="navbar-collapse collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<!-- formulaire d'identification -->
<div class="navbar-form navbar-right" role="form" id="formulaire" method="post">
<div class="form-group">
<input type="text" placeholder="Utilisateur" class="form-control" />
</div>
<div class="form-group">
<input type="password" placeholder="Mot de passe" class="form-control" />
</div>
<button type="button" class="btn btn-success" onclick="javascript:connecter()">Connexion</button>
</div>
</div>
</div>
</div>
</section>
![]() |
- ligne 3 : la classe [navbar] va styler la barre de navigation. La classe [navbar-inverse] lui donne le fond noir. La classe [navbar-fixed-top] va faire en sorte que lorsqu'on 'scrolle' la page affichée par le navigateur, la barre de navigation va rester en haut de l'écran ;
- lignes 5-13 : définissent la zone [1]. C'est typiquement une série de classes que je ne comprends pas. J'utilise le composant tel quel ;
- lignes 14-26 : définissent une zone 'responsive' de la barre de commande. Sur un smartphone, cette zone disparaît dans une zone de menu ;
- ligne 15 : une image actuellement cachée ;
- lignes 17-25 : la classe [navbar-form] habille un formulaire de la barre de commande. La classe [navbar-right] le rejette à droite de celle-ci ;
- lignes 21-23 : les deux zones de saisie du formulaire de la ligne 17 [2]. Elles sont à l'intérieur d'une classe [form-group] qui habille les éléments d'un formulaire et chacune d'elles a la classe [form-control] ;
- ligne 24 : la classe [btn] qui définit un bouton, enrichie de la classe [btn-success] qui lui donne sa couleur verte ;
- ligne 24 : lorsqu'on clique sur le bouton [Connexion], la fonction JS suivante est exécutée :
function connecter() {
showInfo("Connexion demandée...");
}
function showInfo(message) {
$("#info").text(message);
}
Voici un exemple :

8.6.4.4. Exemple n° 3 : le bouton à liste
L'action [/bs-03] affiche la vue [bs-03.xml] suivante :
![]() |
- la nouveauté est le boutron à liste [1] appelé aussi 'dropdown' ;
Le code de la vue [bs-03.xml] est le suivant :
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Bootstrap core JavaScript ================================================== -->
<script src="resources/vendor/jquery-2.1.1.min.js"></script>
<script src="resources/vendor/bootstrap.js"></script>
<!-- script local -->
<script type="text/javascript" src="resources/js/bs-03.js"></script>
</head>
<body id="body">
<div class="container">
<!-- barre de navigation -->
<div th:include="navbar2"></div>
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron"></div>
<!-- contenu -->
<div id="content">
<h1>Ici un contenu</h1>
</div>
<!-- info -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- ligne 11 : le bouton à liste nécessite le fichier JS de Bootstrap ;
- ligne 18 : la nouvelle barre de navigation ;
La vue [navbar2.xml] est la suivante :
<!DOCTYPE HTML>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="navbar-collapse collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<!-- formulaire d'identification -->
<div class="navbar-form navbar-right" role="form" id="formulaire" method="post">
<div class="form-group">
<input type="text" placeholder="Utilisateur" class="form-control" />
</div>
<div class="form-group">
<input type="password" placeholder="Mot de passe" class="form-control" />
</div>
<button type="button" class="btn btn-success" onclick="javascript:connecter()">Connexion</button>
<!-- langues -->
<div class="btn-group">
<button type="button" class="btn btn-danger">Langues</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li>
<a href="javascript:setLang('fr')">Français</a>
</li>
<li>
<a href="javascript:setLang('en')">English</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- init page -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initNavBar2();
/*]]>*/
</script>
</section>
- lignes 25-40 : définissent le bouton à liste ;
- ligne 27 : la classe [btn-danger] lui donne sa couleur rouge ;
- lignes 32-39 : les éléments de la liste. Ce sont des liens associés chacun à une fonction JS ;
- lignes 46-51 : un script JS exécuté après le chargement du document ;
Le script JS [bs-03.js] est le suivant :
function initNavBar2() {
// dropdown des langues
$('.dropdown-toggle').dropdown();
}
function connecter() {
showInfo("Connexion demandée...");
}
function setLang(lang) {
var msg;
switch (lang) {
case 'fr':
msg = "Vous avez choisi la langue française...";
break;
case 'en':
msg = "You have selected english language...";
break;
}
showInfo(msg);
}
function showInfo(message) {
$("#info").text(message);
}
- lignes 1-4 : la fonction qui initialise le [dropdown]. [$('.dropdown-toggle')] localise l'élément qui a la classe [dropdown-toggle]. C'est le bouton à liste (ligne 28 de la vue). On lui applique la fonction JS [dropdown()] qui est définie dans le fichier JS [bootstrap.js]. Ce n'est qu'après cette opération que le bouton se comporte comme un bouton à liste ;
- lignes 10-21 : la fonction exécutée lors du choix d'une langue ;
Voici un exemple :

8.6.4.5. Exemple n° 4 : un menu
L'action [/bs-04] affiche la vue [bs-04.xml] suivante :
![]() |
On a ajouté un menu [1].
La vue [bs-04.xml] est la suivante :
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Bootstrap core JavaScript ================================================== -->
<script src="resources/vendor/jquery-2.1.1.min.js"></script>
<script src="resources/vendor/bootstrap.js"></script>
<!-- script local -->
<script type="text/javascript" src="resources/js/bs-04.js"></script>
</head>
<body id="body">
<div class="container">
<!-- barre de navigation -->
<div th:include="navbar3"></div>
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron"></div>
<!-- contenu -->
<div id="content">
<h1>Ici un contenu</h1>
</div>
<!-- info -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- ligne 18 : on insère une nouvelle barre de navigation ;
La vue [navbar3.xml] est la suivante :
<!DOCTYPE HTML>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="collapse navbar-collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<ul class="nav navbar-nav">
<li class="active" id="lnkAfficherAgenda">
<a href="javascript:afficherAgenda()">Agenda </a>
</li>
<li class="active" id="lnkAccueil">
<a href="javascript:retourAccueil()">Retour Accueil </a>
</li>
<li class="active" id="lnkRetourAgenda">
<a href="javascript:retourAgenda()">Retour Agenda </a>
</li>
<li class="active" id="lnkValiderRv">
<a href="javascript:validerRv()">Valider </a>
</li>
</ul>
<!-- boutons de droite -->
<div class="navbar-form navbar-right" role="form">
<!-- déconnexion -->
<button type="button" class="btn btn-success" onclick="javascript:deconnecter()">Déconnexion</button>
<!-- langues -->
<div class="btn-group">
<button type="button" class="btn btn-danger">Langues</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li>
<a href="javascript:setLang('fr')">Français</a>
</li>
<li>
<a href="javascript:setLang('en')">English</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- init page -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initNavBar3();
/*]]>*/
</script>
</section>
- lignes 16-29 : créent le menu avec quatre options, chacune d'elles étant reliée à un script JS ;
- lignes 55-60 : un script exécuté au chargement de la page ;
Le script JS [bs-04.js] est le suivant :
...
function initNavBar3() {
// dropdown des langues
$('.dropdown-toggle').dropdown();
// l'image animée
loading = $("#loading");
loading.hide();
}
function afficherAgenda() {
showInfo("option [Agenda] cliquée...");
}
function retourAccueil() {
showInfo("option [Retour accueil] cliquée...");
}
function retourAgenda() {
showInfo("option [Retour agenda] cliquée...");
}
function validerRv() {
showInfo("option [Valider] cliquée...");
}
function setMenu(show) {
// les liens du menu
var lnkAfficherAgenda = $("#lnkAfficherAgenda");
var lnkAccueil = $("#lnkAccueil");
var lnkValiderRv = $("#lnkValiderRv");
var lnkRetourAgenda = $("#lnkRetourAgenda");
// on les met dans un dictionnaire
var options = {
"lnkAccueil" : lnkAccueil,
"lnkAfficherAgenda" : lnkAfficherAgenda,
"lnkValiderRv" : lnkValiderRv,
"lnkRetourAgenda" : lnkRetourAgenda
}
// on cache tous les liens
for ( var key in options) {
options[key].hide();
}
// on affiche ceux qui sont demandés
for (var i = 0; i < show.length; i++) {
var option = show[i];
options[option].show();
}
}
- lignes 2-18 : la fonction d'initialisation de la page ;
- ligne 4 : pour avoir le bouton à liste des langues ;
- lignes 6-7 : l'image animée est cachée ;
- lignes 26-48 : une fonction [setMenu] qui permet d'indiquer quelles options doivent être visibles ;
Allons dans la console de développement (Ctrl-Maj-I) et entrons le code suivant [1] :
![]() |
Puis revenons au navigateur. Le menu a changé [2] :
8.6.4.6. Exemple n° 5 : une liste déroulante
L'action [/bs-05] affiche la vue [bs-05.xml] suivante :
![]() |
La nouveauté est en [1]. Nous utilisons ici un composant fourni en-dehors de Bootstrap, [bootstrap-select] [http://silviomoreto.github.io/bootstrap-select/].
Le code de la vue [bs-05.xml] est la suivante :
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-select.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Bootstrap core JavaScript ================================================== -->
<script type="text/javascript" src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-select.js"></script>
<!-- script local -->
<script type="text/javascript" src="resources/js/bs-05.js"></script>
</head>
<body id="body">
<div class="container">
<!-- barre de navigation -->
<div th:include="navbar3"></div>
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron"></div>
<!-- contenu -->
<div id="content" th:include="choixmedecin">
</div>
<!-- info -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- ligne 8 : le CSS nécessaire à la liste déroulante ;
- ligne 13 : le fichier JS nécessaire à la liste déroulante ;
- ligne 24 : la liste déroulante ;
La vue [choixmedecin.xml] est la suivante :
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-info">Veuillez choisir un médecin</div>
<div class="row">
<div class="col-md-3">
<h2>Médecin</h2>
<select id="idMedecin" class="combobox" data-style="btn-primary">
<option value="1">Mme Marie Pélissier</option>
<option value="2">Mr Jean Pardon</option>
<option value="3">Mlle Jeanne Jirou</option>
<option value="4">Mr Paul Macou</option>
</select>
</div>
</div>
<!-- script local -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initChoixMedecin();
/*]]>*/
</script>
</section>
- ligne 7-12 : on a là une balise [select] classique avec cependant une classe particulière [combobox]. L'attribut [data-style="btn-primary"] donne au composant sa couleur bleue ;
- lignes 16-21 : un script exécuté au chargement de la page ;
Le fichier JS [bs-05.js] est le suivant :
...
function afficherAgenda() {
var idMedecin = $('#idMedecin option:selected').val();
showInfo("Vous avez sélectionné le médecin d'id=" + idMedecin);
}
function initChoixMedecin() {
// le select des médecins
$('#idMedecin').selectpicker();
// le menu
setMenu([ "lnkAfficherAgenda" ]);
}
- lignes 7-12 : la fonction exécutée au chargement de la page ;
- ligne 9 : l'instruction qui transforme le [select] de la page en liste déroulante Bootstrap. [$('#idMedecin')] référence le [select] (ligne 7 de la vue [choixmedecin]) et la fonction JS [selectpicker] provient du fichier JS [bootstrap-select.js] ;
- ligne 11 : on n'affiche qu'une des options du menu ;
- lignes 2-5 : la fonction JS exécutée lorsque qu'on clique sur l'option de menu [Agenda] ;
- ligne 3 : on récupère la valeur de l'option sélectionnée dans la liste déroulante : [$('#idMedecin option:selected')] trouve d'abord le composant [id=idMedecin] puis dans ce composant l'option sélectionnée. L'opération [..].val() récupère ensuite la valeur de l'élément trouvé, ç-à-d l'attribut [value] de l'option sélectionnée ;
Voici un exemple de choix d'un médecin :
![]() |
8.6.4.7. Exemple n° 6 : un calendrier
L'action [/bs-06] affiche la vue [bs-06.xml] suivante :

Le choix d'un médecin ou d'une date déclenche une fonction JS qui affiche et le médecin et la date choisies. Voici un exemple :
![]() |
Grâce au bouton liste des langues, on peut passer le calendrier (et seulement le calendrier) en anglais :

C'est l'exemple le plus complexe de la série. Le calendrier est un composant [bootstrap-datepicker] [http://eternicode.github.io/bootstrap-datepicker].
La vue [bs-06.xml] est la suivante :
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-select.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/datepicker3.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Bootstrap core JavaScript ================================================== -->
<script type="text/javascript" src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-select.js"></script>
<script type="text/javascript" src="resources/vendor/moment-with-locales.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.fr.js"></script>
<!-- script local -->
<script type="text/javascript" src="resources/js/bs-06.js"></script>
</head>
<body id="body">
<div class="container">
<!-- barre de navigation -->
<div th:include="navbar3"></div>
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron"></div>
<!-- contenu -->
<div id="content" th:include="choixmedecinjour">
</div>
<!-- info -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- ligne 8 : le fichier CSS du composant [bootstrap-datepicker] ;
- ligne 16 : le fichier JS du composant [bootstrap-datepicker] ;
- ligne 17 : le fichier JS pour gérer un calendrier français. Par défaut, il est en anglais ;
- ligne 15 : le fichier JS d'une bibliothèque appelée [moment] qui donne accès à de très nombreuses fonctions de calcul du temps [http://momentjs.com/];
- ligne 28 : la vue du calendrier ;
La vue [choixmedecinjour.xml] est la suivante :
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-info">Veuillez choisir un médecin et une date</div>
<div class="row">
<div class="col-md-3">
<h2>Médecin</h2>
<select id="idMedecin" class="combobox" data-style="btn-primary">
<option value="1">Mme Marie Pélissier</option>
<option value="2">Mr Jean Pardon</option>
<option value="3">Mlle Jeanne Jirou</option>
<option value="4">Mr Paul Macou</option>
</select>
</div>
<div class="col-md-3">
<h2>Date</h2>
<section id="calendar_container">
<div id="calendar" class="input-group date">
<input id="displayjour" type="text" class="form-control btn-primary" disabled="true">
<span class="input-group-addon">
<i class="glyphicon glyphicon-th"></i>
</span>
</input>
</div>
</section>
</div>
</div>
<!-- script local -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initChoixMedecinJour();
/*]]>*/
</script>
</section>
- lignes 17-23 : le calendrier ;
- ligne 18 : la classe [btn-primary] lui donne sa couleur bleue ;
- ligne 18 : l'attribut [disabled="true"] fait qu'on ne peut pas saisir la date à la main. Il faut forcément passer par le calendrier ;
- ligne 16 : le calendrier a été placé dans une section [id="calendar_container"]. Pour changer la langue du calendrier, on est obligé de supprimer celui-ci puis de le régénérer. On supprimera donc le contenu du composant [id="calendar_container"] puis on y mettra le nouveau calendrier avec la nouvelle langue ;
- lignes 28-33 : le code d'initialisation de la page ;
Le fichier JS [bs-06.js] est le suivant :
...
var calendar_infos = {};
function initChoixMedecinJour() {
// calendrier
var calendar_container = $("#calendar_container");
calendar_infos = {
"container" : calendar_container,
"html" : calendar_container.html(),
"today" : moment().format('YYYY-MM-DD'),
"langue" : "fr"
}
// création calendrier
updateCalendar();
// le select des médecins
$('#idMedecin').selectpicker();
$('#idMedecin').change(function(e) {
afficherAgenda();
})
// le menu
setMenu([]);
}
- ligne 2 : le calendrier est géré par plusieurs fonctions JS. La variable [calendar_infos] va rassembler des informations sur le calendrier. Elle est globale pour être vue par les différentes fonctions ;
- ligne 6 : on repère le conteneur du calendrier ;
- lignes 7-12 : les informations mémorisées pour le calendrier ;
- ligne 8 : une référence sur son conteneur,
- ligne 9 : le code HTML du calendrier. Avec ces deux informations, on est capable de supprimer le calendrier et de le régénérer,
- ligne 10 : la date d'aujourd'hui au format [aaaa-mm-jj],
- ligne 11 : la langue du calendrier ;
- ligne 14 : création du calendrier ;
- ligne 16 : le combo des médecins ;
- lignes 17-19 : à chaque fois que la valeur sélectionnée dans ce combo changera, la méthode [afficherAgenda] sera exécutée ;
- ligne 21 : pas de menu dans la barre de navigation ;
La fonction [updateCalendar] est la suivante :
function updateCalendar(renew) {
if (renew) {
// régénération du calendrier actuel
calendar_infos.container.html(calendar_infos.html);
}
// initialisation du calendrier
var calendar = $("#calendar");
var settings = {
format : "yyyy-mm-dd",
startDate : calendar_infos.today,
language : calendar_infos.langue,
};
calendar.datepicker(settings);
// sélection de la date courante
if (calendar_infos.date) {
calendar.datepicker('setDate', calendar_infos.date)
}
// évts
calendar.datepicker().on('hide', function(e) {
// affichage jour sélectionné
displayJour();
});
calendar.datepicker().on('changeDate', function(e) {
// on note la nouvelle date
calendar_infos.date = moment(calendar.datepicker('getDate')).format("YYYY-MM-DD");
// affichage infos agenda
afficherAgenda();
// affichage jour sélectionné
displayJour();
});
// affichage jour sélectionné
displayJour();
}
- ligne 1 : la fonction [updateCalendar] admet un paramètre qui peut être présent ou non. S'il est présent, alors le calendrier est régénéré (ligne 4) à partir des informations contenues dans [calendar_infos] ;
- ligne 7 : on référence le calendrier ;
- lignes 8-12 : ses paramètres d'initialisation ;
- ligne 9 : le format des dates gérées [aaaa-mm-jj],
- ligne 10 : la 1ère date qui peut être sélectionnée dans le calendrier. Ici, la date d'aujourd'hui. Les dates qui précèdent ne pourront pas être sélectionnées,
- ligne 11 : la langue du calendrier. Il y en aura deux ['en'] et ['fr'] ;
- ligne 13 : le calendrier est configuré ;
- lignes 15-17 : si la date de [calendar_infos] a été initialisée, alors on donne cette date comme date actuelle du calendrier ;
- lignes 19-22 : à chaque fois que le calendrier se refermera, on affichera la date sélectionnée ;
- lignes 23-30 : à chaque fois qu'il y aura un changement de date dans le calendrier :
- ligne 25 : on note la date sélectionnée dans [calendar_infos],
- ligne 27 : on affiche des informations sur l'agenda,
- ligne 29 : on affiche le jour sélectionné ;
- ligne 32 : affichage du jour sélectionné s'il y en a un ;
La méthode [displayJour] qui affiche le jour sélectionné est la suivante :
// affiche le jour sélectionné
function displayJour() {
if (calendar_infos.date) {
var displayjour = $("#displayjour");
moment.locale(calendar_infos.langue);
jour = moment(calendar_infos.date).format('LL');
displayjour.val(jour);
}
}
- ligne 3 : si une date a déjà été sélectionnée (au début le calendrier n'a pas de date sélectionnée) ;
- ligne 4 : on localise le composant où on va écrire la date ;
- ligne 5 : cette date peut être écrite en anglais ou français. On fixe la langue de la bibliothèque [moment] ;
- ligne 6 : on affiche la date sélectionnée dans la langue choisie et au format long ;
- ligne 7 : cette date est affichée ;
Voici deux exemples :
![]() | ![]() |
Lors d'un changement de médecin ou de date, la méthode [afficherAgenda] est exécutée :
function afficherAgenda() {
// on affiche médecin et date
var idMedecin = $('#idMedecin option:selected').val();
if (calendar_infos.date) {
showInfo("Vous avez sélectionné le médecin d'id=" + idMedecin + " et le jour " + calendar_infos.date);
}
}
8.6.4.8. Exemple n° 7 : une table HTML 'responsive'
Note : 'responsive' est un terme anglais indiquant qu'un composant est capable de s'adapter à la taille de l'écran sur lequel il est visualisé. Nous allons en montrer un exemple.
L'action [/bs-07] affiche la vue [bs-07.xml] suivante (plein écran) :
![]() |
La nouveauté est la table HTML [1]. Cette table est gérée par la bibliothèque JS [footable] : [https://github.com/fooplugins/FooTable].
Si on réduit la taille de la fenêtre du navigateur, on obtient la chose suivante :
![]() |
- la table HTML s'est adaptée à la taille de l'écran ;
- en [1], pour voir le lien [Réserver], il faut cliquer sur le signe [+] ;
- en [2], ce qu'on voit lorsqu'on clique sur le signe [+] ;
La vue [bs-07.xml] est la suivante :
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-select.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/datepicker3.css" />
<link rel="stylesheet" type="text/css" href="resources/css/footable.core.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Bootstrap core JavaScript ================================================== -->
<script type="text/javascript" src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-select.js"></script>
<script type="text/javascript" src="resources/vendor/moment-with-locales.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.fr.js"></script>
<script type="text/javascript" src="resources/vendor/footable.js"></script>
<!-- script local -->
<script type="text/javascript" src="resources/js/bs-07.js"></script>
</head>
<body id="body">
<div class="container">
<!-- barre de navigation -->
<div th:include="navbar3" />
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron" />
<!-- contenu -->
<div id="content" th:include="choixmedecinjour" />
<div id="agenda" th:include="agenda" />
<!-- info -->
<div class="alert alert-success">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- ligne 10 : le CSS de la bibliothèque [footable] ;
- ligne 19 : le JS de la bibliothèque [footable] ;
- ligne 31 : la table HTML d'un agenda ;
La vue [agenda.xml] est la suivante :
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div class="row alert alert-danger">
<div class="col-md-6">
<table id="creneaux" class="table">
<thead>
<tr>
<th data-toggle="true">
<span>Créneau horaire</span>
</th>
<th>
<span>Client</span>
</th>
<th data-hide="phone">
<span>Action</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<span class='status-metro status-active'>
9h00-9h20
</span>
</td>
<td>
<span></span>
</td>
<td>
<a href="javascript:reserver(14)" class="status-metro status-active">
Réserver
</a>
</td>
</tr>
<tr>
<td>
<span class='status-metro status-suspended'>
9h20-9h40
</span>
</td>
<td>
<span>Mme Paule MARTIN</span>
</td>
<td>
<a href="javascript:supprimer(17)" class="status-metro status-suspended">
Supprimer
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- init page -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initAgenda();
/*]]>*/
</script>
</body>
</html>
- ligne 4 : installe la table dans une ligne [row] et un encadré coloré [alert alert-danger] ;
- ligne 5 : la table va occuper 6 colonnes [col-md-6] ;
- ligne 6 : la table HTML est formatée par Bootstrap [class='table'] ;
- ligne 9 : l'attribut [data-toggle] indique la colonne qui héberge le symbole [+/-] qui déplie / replie la ligne ;
- ligne 15 : l'attribut [data-hide='phone'] indique que la colonne doit être cachée si l'écran a la taille d'un écran de téléphone. On peut également utiliser la valeur 'tablet' ;
- ligne 31 : on associe une fonction JS au lien [Réserver] ;
- ligne 46 : on associe une fonction JS au lien [Supprimer] ;
- lignes 56-61 : initialisation de la page ;
Un certain nombre de classes CSS utilisées ci-dessus proviennent du fichier CSS [bootstrapDemo.css] :
@CHARSET "UTF-8";
#creneaux th {
text-align: center;
}
#creneaux td {
text-align: center;
font-weight: bold;
}
.status-metro {
display: inline-block;
padding: 2px 5px;
color:#fff;
}
.status-metro.status-active {
background: #43c83c;
}
.status-metro.status-suspended {
background: #fa3031;
}
Les styles [status-*] proviennent d'un exemple d'utilisation de la table [footable] trouvé sur le site de la bibliothèque.
Dans le fichier JS [bs-07.js], la page est initialisée de la façon suivante :
function initAgenda() {
// le tableau des créneaux horaires
$("#creneaux").footable();
}
C'est tout. [$("#creneaux")] référence la table HTML qu'on veut rendre 'responsive'. Par ailleurs, on trouve les fonctions JS liées aux deux liens [Réserver] et [Supprimer] :
function reserver(idCreneau) {
showInfo("Réservation du créneau n° " + idCreneau);
}
function supprimer(idRv) {
showInfo("Suppression du rv n° " + idRv);
}
8.6.4.9. Exemple n° 8 : une boîte modale
L'action [/bs-08] affiche la vue [bs-08.xml] suivante :

Alors que précédemment, cliquer sur le lien [Réserver] affichait une information dans la boîte d'informations, ici on va faire apparaître une boîte modale pour sélectionner un client pour le RV :

Le composant utilisé est le composant [bootstrap-modal] [https://github.com/jschr/bootstrap-modal/].
La vue [bs-08.xml] est la suivante :
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-select.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/datepicker3.css" />
<link rel="stylesheet" type="text/css" href="resources/css/footable.core.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Bootstrap core JavaScript ================================================== -->
<script type="text/javascript" src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-select.js"></script>
<script type="text/javascript" src="resources/vendor/moment-with-locales.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.fr.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-modal.js"></script>
<script type="text/javascript" src="resources/vendor/footable.js"></script>
<!-- script local -->
<script type="text/javascript" src="resources/js/bs-08.js"></script>
</head>
<body id="body">
<div class="container">
<!-- barre de navigation -->
<div th:include="navbar3" />
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron" />
<!-- contenu -->
<div id="content" th:include="choixmedecinjour" />
<div id="agenda" th:include="agenda-modal" />
<div th:include="resa" />
<!-- info -->
<div class="alert alert-success">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- ligne 19 : le fichier JS nécessaire aux boîtes modales ;
- ligne 32 : la vue [agenda-modal] est identique à la vue [agenda] à un détail près : la fonction JS qui gère le lien [Réserver] :
<a href="javascript:showDialogResa(14)" class="status-metro status-active">Réserver</a>
La fonction [showDialogResa] est chargée de faire apparaître la boîte modale de sélection d'un client ;
- ligne 33 : la vue [resa.xml] est la boîte modale de sélection d'un client :
<!DOCTYPE HTML>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div id="resa" class="modal fade">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">
</span>
</button>
<!-- <h4 class="modal-title">Modal title</h4> -->
</div>
<div class="modal-body">
<div class="alert alert-info">
<h3>
<span>Prise de rendez-vous</span>
</h3>
</div>
<div class="row">
<div class="col-md-3">
<h2>Clients</h2>
<select id="idClient" class="combobox" data-style="btn-primary">
<option value="1">Mme Marguerite Planton</option>
<option value="2">Mr Maxime Franck</option>
<option value="3">Mlle Elisabeth Oron</option>
<option value="4">Mr Gaëtan Calot</option>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-warning" onclick="javascript:cancelDialogResa()">Annuler</button>
<button type="button" class="btn btn-primary" onclick="javascript:validateResa()">Valider</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- init page -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initResa();
/*]]>*/
</script>
</section>
- lignes 3-37 : la boîte modale ;
- lignes 13-30 : le conenu de cette boîte (ce qui sera affiché) ;
- lignes 31-34 : les boutons de la boîte de dialogue ;
- ligne 32 : un bouton [Annuler] géré par la fonction JS [cancelDialogResa] ;
- ligne 33 : un bouton [Valider] géré par la fonction JS [validateResa] ;
- lignes 39-44 : le script d'initialisation de la boîte modale ;
Cela donne la vue suivante :
![]() |
A noter que la boîte modale n'est pas affichée par défaut. C'est pourquoi, on ne la voit pas au démarrage de l'application bien que son code HTML soit présent dans le document.
Le fichier JS [bs-08.js] est le suivant :
var idCreneau;
var idClient;
var resa;
function showDialogResa(idCreneau) {
// on mémorise l'id du créneau
this.idCreneau = idCreneau;
// on affiche le dialogue de réservation
var resa = $("#resa");
resa.modal('show');
// log
showInfo("Réservation du créneau n° " + idCreneau);
}
function cancelDialogResa() {
// on cache la boîte de dialogue
resa.modal('hide');
}
// validation résa
function validateResa() {
// on récupère les infos
var idClient = $('#idClient option:selected').val();
// on cache la boîte de dialogue
resa.modal('hide');
// infos
showInfo("Réservation du créneau n° " + idCreneau + " pour le client n° " + idClient)
}
function initResa() {
// le select des clients
$('#idClient').selectpicker();
// boîte modale
resa = $("#resa");
resa.modal({});
}
- lignes 30-36 : la fonction d'initialisation de la boîte modale ;
- ligne 32 : la boîte modale contient une liste déroulante qu'il faut initialiser ;
- lignes 34-35 : initialisation de la boîte modale elle-même ;
- lignes 5-13 : la fonction JS attachée au lien [Réserver] ;
- ligne 7 : on mémorise le paramètre de la fonction dans la variable globale de la ligne 1 ;
- lignes 9-10 : la boîte modale est rendue visible ;
- ligne 12 : on logue une information dans la boîte d'informations ;
- lignes 15-18 : gestion du bouton [Annuler]. On se contente de cacher la boîte modale (ligne 17) ;
- lignes 21-31 : la fonction JS attachée au bouton [Valider] ;
- ligne 23 : on récupère l'attribut [value] du client sélectionné ;
- ligne 25 : on cache la boîte de dialogue ;
- ligne 27 : on logue les deux informations : n° du créneau réservé et pour quel client ;
8.6.5. Étape 2 : écriture des vues
Nous allons maintenant décrire les vues délivrées par le serveur [Web1] ainsi que leurs modèles.
![]() |
8.6.5.1. La vue [navbar-start]
Elle affiche la barre de navigation de la page de boot :

Le code de [navbar-start.xml] est le suivant :
<!DOCTYPE HTML>
<section xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="navbar-collapse collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<!-- formulaire d'identification -->
<div class="navbar-form navbar-right" role="form" id="formulaire">
<div class="form-group">
<input type="text" th:placeholder="#{service.url}" class="form-control" id="urlService" />
</div>
<div class="form-group">
<input type="text" th:placeholder="#{username}" class="form-control" id="login" />
</div>
<div class="form-group">
<input type="password" th:placeholder="#{password}" class="form-control" id="passwd" />
</div>
<button type="button" class="btn btn-success" th:text="#{login}" onclick="javascript:connecter()">Sign in</button>
<!-- langues -->
<div class="btn-group">
<button type="button" class="btn btn-danger" th:text="#{langues}">Action</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li>
<a href="javascript:setLang('fr')" th:text="#{langues.fr}" />
</li>
<li>
<a href="javascript:setLang('en')" th:text="#{langues.en}" />
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- init page -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initNavBarStart();
/*]]>*/
</script>
</section>
Cette vue n'a pas de modèle. Elle a les gestionnaires d'événements suivants :
évt | gestionnaire |
clic sur le bouton de connexion | |
clic sur le lien [Français] | |
clic sur le lien [English] |
8.6.5.2. La vue [jumbotron]
C'est la vue qui est présentée sous la barre de navigation [navbar-start] dans la page de boot :

Son code [jumbotron.xml] est le suivant :
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<!-- Bootstrap Jumbotron -->
<div class="jumbotron">
<div class="row">
<div class="col-md-2">
<img src="resources/images/caduceus.jpg" alt="RvMedecins" />
</div>
<div class="col-md-10">
<h1 th:utext="#{application.header}" />
</div>
</div>
</div>
</section>
La vue [jumbotron] n'a ni modèle ni événements.
8.6.5.3. La vue [login]
C'est la vue qui est présentée sous le jumbotron dans la page de boot :

Son code [login.xml] est le suivant :
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-info" th:text="#{identification}">Identification
</div>
</section>
La vue n'a ni modèle ni événements.
8.6.5.4. La vue [navbar-run]
C'est la barre de navigation présentée lorsque la connexion a réussi :

Son code [navbar-run.xml] est le suivant :
<!DOCTYPE HTML>
<section xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="collapse navbar-collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<!-- boutons de droite -->
<form class="navbar-form navbar-right" role="form">
<!-- déconnexion -->
<button type="button" class="btn btn-success" th:text="#{options.deconnecter}" onclick="javascript:deconnecter()">Déconnexion</button>
<!-- langues -->
<div class="btn-group">
<button type="button" class="btn btn-danger" th:text="#{langues}">Langue</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li>
<a href="javascript:setLang('fr')" th:text="#{langues.fr}" />
</li>
<li>
<a href="javascript:setLang('en')" th:text="#{langues.en}" />
</li>
</ul>
</div>
</form>
</div>
</div>
</div>
<!-- init page -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initNavBarRun();
/*]]>*/
</script>
</section>
Cette vue n'a pas de modèle. Elle a les gestionnaires d'événements suivants :
évt | gestionnaire |
clic sur le bouton de déconnexion | |
clic sur le lien [Français] | |
clic sur le lien [English] |
8.6.5.5. La vue [accueil]
C'est la vue présentée immédiatement sous la barre de navigation [navbar-run] :

Son code [accueil.html] est le suivant :
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-info" th:text="#{choixmedecinjour.title}">Veuillez choisir un médecin et une date</div>
<div class="row">
<div class="col-md-3">
<h2 th:text="#{rv.medecin}">Médecin</h2>
<select name="idMedecin" id="idMedecin" class="combobox" data-style="btn-primary">
<option th:each="medecinItem : ${rdvmedecins.medecinItems}" th:text="${medecinItem.texte}" th:value="${medecinItem.id}"/>
</select>
</div>
<div class="col-md-3">
<h2 th:text="#{rv.jour}">Date</h2>
<section id="calendar_container">
<div id="calendar" class="input-group date">
<input id="displayjour" type="text" class="form-control btn-primary" disabled="true">
<span class="input-group-addon">
<i class="glyphicon glyphicon-th"></i>
</span>
</input>
</div>
</section>
</div>
</div>
<!-- agenda -->
<div id="agenda"></div>
<!-- script local -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initChoixMedecinJour();
/*]]>*/
</script>
</html>
Son modèle est le suivant :
- [rdvmedecins.medecinItems] (ligne 8) : la liste des médecins ;
Dans sa forme actuelle, la vue ne semble pas avoir de gestionnaire d'événements. En réalité ceux-ci sont définis dans la fonction [initChoixMedecinJour]. Cette fonction a été présentée au paragraphe 8.6.4.7, page 466 et plus particulièrement page 469. On y trouve les gestionnaires d'événements suivants :
évt | gestionnaire |
choix d'un médecin | |
choix d'une date |
8.6.5.6. La vue [agenda]
La vue [agenda] présente une journée de l'agenda d'un médecin :

Son code [agenda.xml] est le suivant :
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<h3 class="alert alert-info" th:text="${agenda.titre}">Agenda de Mme Pélissier le 13/10/2014</h3>
<h4 class="alert alert-danger" th:if="${agenda.creneaux.length}==0" th:text="#{agenda.medecinsanscreneaux}">Ce médecin n'a pas encore de créneaux
de consultation</h4>
<th:block th:if="${agenda.creneaux.length}!=0">
<div class="row tab-content alert alert-warning">
<div class="tab-pane active col-md-6">
<table id="creneaux" class="table">
<thead>
<tr>
<th data-toggle="true">
<span th:text="#{agenda.creneauhoraire}">Créneau horaire</span>
</th>
<th>
<span th:text="#{agenda.client}">Client</span>
</th>
<th data-hide="phone">
<span th:text="#{agenda.action}">Action</span>
</th>
</tr>
</thead>
<tbody>
<tr th:each="creneau,iter : ${agenda.creneaux}">
<td>
<span th:if="${creneau.action}==1" class="status-metro status-active" th:text="${creneau.creneauHoraire}">Créneau horaire</span>
<span th:if="${creneau.action}==2" class="status-metro status-suspended" th:text="${creneau.creneauHoraire}">Créneau horaire</span>
</td>
<td>
<span th:text="${creneau.client}">Client</span>
</td>
<td>
<a th:if="${creneau.action}==1" th:href="@{'javascript:reserverCreneau('+${creneau.id}+')'}" th:text="${creneau.commande}"
class="status-metro status-active">Réserver
</a>
<a th:if="${creneau.action}==2" th:href="@{'javascript:supprimerRv('+${creneau.idRv}+')'}" th:text="${creneau.commande}"
class="status-metro status-suspended">Supprimer
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- réservation -->
<section th:include="resa" />
</th:block>
<!-- init page -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initAgenda();
/*]]>*/
</script>
</body>
</html>
Le modèle de cette vue n'a qu'un élément :
- [agenda] (ligne 4) : un modèle un peu complexe spécialement construit pour l'affichage de l'agenda ;
Elle a les gestionnaires d'événements suivants :
évt | gestionnaire |
clic sur le bouton [Supprimer] | |
clic sur le lien [Réserver] |
La vue [resa] de la ligne 47 est la vue qui est affichée lorsque l'utilisateur clique sur un lien [Réserver] :

Son code [resa.xml] est le suivant :
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div id="resa" class="modal fade">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">
</span>
</button>
<!-- <h4 class="modal-title">Modal title</h4> -->
</div>
<div class="modal-body">
<div class="alert alert-info">
<h3>
<span th:text="#{resa.titre}">Prise de rendez-vous</span>
</h3>
</div>
<div class="row">
<div class="col-md-3">
<h2 th:text="#{resa.client}">Client</h2>
<select name="idClient" id="idClient" class="combobox" data-style="btn-primary">
<option th:each="clientItem : ${clientItems}" th:text="${clientItem.texte}" th:value="${clientItem.id}" />
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-warning" onclick="javascript:cancelDialogResa()" th:text="#{resa.annuler}">Annuler</button>
<button type="button" class="btn btn-primary" onclick="javascript:validerRv()" th:text="#{resa.valider}">Valider</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- init page -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initResa();
/*]]>*/
</script>
</body>
</html>
Son modèle n'a qu'un élément :
- [clientItems] (ligne 24) : la liste des clients ;
Elle a les gestionnaires d'événements suivants :
évt | gestionnaire |
clic sur le bouton [Annuler] | |
clic sur le bouton [Valider] |
8.6.5.7. La vue [erreurs]
C'est la vue qui s'affiche si l'action demandée par l'utilisateur n'a pu aboutir :

Le code [erreurs.xml] est le suivant :
<!DOCTYPE HTML>
<section xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-danger">
<h4>
<span th:text="#{erreurs.titre}">Les erreurs suivantes se sont produites :</span>
</h4>
<ul>
<li th:each="message : ${erreurs}" th:text="${message}" />
</ul>
</div>
</section>
Son modèle n'a qu'un élément :
- [erreurs] (ligne 8) : la liste des erreurs à afficher ;
La vue n'a pas de gestionnaire d'événements.
8.6.5.8. Résumé
Le tableau suivant redonne les vues et leurs modèles :
vue | modèle | gestionnaires d'événements |
navbar-start | ||
jumbotron | ||
login | ||
navbar-run | ||
accueil | ||
agenda | ||
resa | ||
erreurs |
8.6.6. Étape 3 : écriture des actions
Revenons à l'architecture du service web [Web1] :
![]() |
Nous allons voir maintenant quelles URL sont exposées par [Web1] et leur implémentation :
8.6.6.1. Les URL exposées par le service [Web1]
Ce sont les suivantes :
- une URL pour chacune des vues précédentes ou une composition de celles-ci ;
- une URL pour ajouter un RV ;
- une URL pour supprimer un RV ;
Elles rendent toutes une réponse du type [Reponse] suivant :
public class Reponse {
// ----------------- propriétés
// statut de l'opération
private int status;
// la barre de navigation
private String navbar;
// le jumbotron
private String jumbotron;
// le corps de la page
private String content;
// l'agenda
private String agenda;
...
}
- ligne 5 : un état de la réponse : 1 (OK), 2 (erreur) ;
- ligne 7 : le flux HTML des vues [navbar-start] ou [navbar-run] selon les cas ;
- ligne 9 : le flux HTML de la vue [jumbotron] ;
- ligne 13 : le flux HTML de la vue [agenda] ;
- ligne 9 : le flux HTML des vues [accueil], [erreurs], [login] selon les cas ;
Les URL exposées sont les suivantes
met la vue [navbar-start] dans [Reponse.navbar] | |
met la vue [navbar-run] dans [Reponse.navbar] | |
met la vue [accueil] dans [Reponse.content] | |
met la vue [jumbotron] dans [Reponse.jumbotron] | |
met la vue [agenda] dans [Reponse.agenda] | |
met la vue [login] dans [Reponse.content] | |
• si connexion réussie, met la vue [navbar-run] dans [Reponse.navbar], la vue [jumbotron] dans [Reponse.jumbotron], la vue [accueil] dans [Reponse.content] • si connexion ratée, met la vue [erreurs] dans [Reponse.content] et [Reponse.status] à 2 | |
met la vue [navbar-run] dans [Reponse.navbar], la vue [jumbotron] dans [Reponse.jumbotron], la vue [accueil] dans [Reponse.content], la vue [agenda] dans [Reponse.agenda] | |
ajoute le rendez-vous sélectionné et met le nouvel agenda dans [Reponse.agenda] | |
supprime le rendez-vous sélectionné et met le nouvel agenda dans [Reponse.agenda] |
8.6.6.2. Le singleton [ApplicationModel]
![]() |
La classe [ApplicationModel] est instanciée en un unique exemplaire et injectée dans le contrôleur de l'application. Son code est le suivant :
package rdvmedecins.springthymeleaf.server.models;
import java.util.ArrayList;
...
@Component
public class ApplicationModel implements IDao {
....
}
- ligne 6 : [ApplicationModel] est un composant Spring ;
- ligne 7 : qui implémente l'interface de la couche [DAO]. Nous faisons cela pour que les actions n'aient pas à connaître la couche [DAO] mais seulement le singleton [ApplicationModel]. L'architecture de [Web1] devient alors la suivante :
![]() |
Revenons sur le code de la classe [ApplicationModel] :
package rdvmedecins.springthymeleaf.server.models;
import java.util.ArrayList;
...
@Component
public class ApplicationModel implements IDao {
// la couche [DAO]
@Autowired
private IDao dao;
// la configuration
@Autowired
private AppConfig appConfig;
// données provenant de la couche [DAO]
private List<ClientItem> clientItems;
private List<MedecinItem> medecinItems;
// données de configuration
private String userInit;
private String mdpUserInit;
private boolean corsAllowed;
// exception
private RdvMedecinsException rdvMedecinsException;
// constructeur
public ApplicationModel() {
}
@PostConstruct
public void init() {
// config
userInit = appConfig.getUSER_INIT();
mdpUserInit = appConfig.getMDP_USER_INIT();
dao.setTimeout(appConfig.getTIMEOUT());
dao.setUrlServiceWebJson(appConfig.getWEBJSON_ROOT());
corsAllowed = appConfig.isCORS_ALLOWED();
// on met en cache les listes déroulantes des médecins et des clients
List<Medecin> medecins = null;
List<Client> clients = null;
try {
medecins = dao.getAllMedecins(new User(userInit, mdpUserInit));
clients = dao.getAllClients(new User(userInit, mdpUserInit));
} catch (RdvMedecinsException ex) {
rdvMedecinsException = ex;
}
if (rdvMedecinsException == null) {
// on crée les éléments des listes déroulantes
medecinItems = new ArrayList<MedecinItem>();
for (Medecin médecin : medecins) {
medecinItems.add(new MedecinItem(médecin));
}
clientItems = new ArrayList<ClientItem>();
for (Client client : clients) {
clientItems.add(new ClientItem(client));
}
}
}
// getters et setters
...
// implémentation interface [IDao]
@Override
public void setUrlServiceWebJson(String url) {
dao.setUrlServiceWebJson(url);
}
@Override
public void setTimeout(int timeout) {
dao.setTimeout(timeout);
}
@Override
public Rv ajouterRv(User user, String jour, long idCreneau, long idClient) {
return dao.ajouterRv(user, jour, idCreneau, idClient);
}
...
}
- ligne 11 : injection de la référence de l'implémentation de la couche [DAO]. C'est ensuite cette référence qui est utilisée pour implémenter l'interface [IDao] (lignes 64-80) ;
- ligne 14 : injection de la configuration de l'application ;
- lignes 33-37 : utilisation de cette configuration pour configurer divers éléments de l'architecture de l'application ;
- lignes 38-46 : on met en cache les informations qui vont alimenter les listes déroulantes des médecins et des clients. Nous faisons donc l'hypothèse que si un médecin ou un client change, l'application doit être rebootée. L'idée ici est de montrer qu'un singleton Spring peut servir de cache à l'application web ;
Les classes [MedecinItem] et [ClientItem] dérivent toutes deux de la classe [PersonneItem] suivante :
package rdvmedecins.springthymeleaf.server.models;
import rdvmedecins.client.entities.Personne;
public class PersonneItem {
// élément d'une liste
private Long id;
private String texte;
// constructeur
public PersonneItem() {
}
public PersonneItem(Personne personne) {
id = personne.getId();
texte = String.format("%s %s %s", personne.getTitre(), personne.getPrenom(), personne.getNom());
}
// getters et setters
...
}
- ligne 8 : le champ [id] sera la valeur de l'attribut [value] d'une option de la liste déroulante ;
- ligne 9 : le champ [texte] sera le texte affiché par une option de la liste déroulante ;
8.6.6.3. La classe [BaseController]
![]() |
La classe [BaseController] est la classe parent des contrôleurs [RdvMedecinsController] et [RdvMedecinsCorsController]. Il n'était pas obligatoire de créer cette classe parent. On y a rassemblé des méthodes utilitaires de la classe [RdvMedecinsController] pas fondamentales sauf une. On peut les classer dans trois ensembles :
- les méthodes utilitaires ;
- les méthodes qui rendent les vues fusionnées avec leurs modèles ;
- la méthode d'initialisation d'une action
| deux méthodes utilitaires qui fournissent une liste de messages d'erreur. Nous les avons déjà rencontrées et utilisées ; |
| rend la vue [accueil] sans modèle |
| rend la vue [agenda] et son modèle |
| rend la vue [login] sans modèle |
| rend la réponse au client lorsque l'action demandée s'est terminée par une erreur |
| la méthode d'initialisation de toutes les actions du contrôleur [RdvMedecinsController] |
Examinons deux de ces méthodes.
La méthode [getPartialViewAgenda] rend la vue la plus complexe à générer, celle de l'agenda. Son code est le suivant :
// flux [agenda]
protected String getPartialViewAgenda(ActionContext actionContext, AgendaMedecinJour agenda, Locale locale) {
// contextes
WebContext thymeleafContext = actionContext.getThymeleafContext();
WebApplicationContext springContext = actionContext.getSpringContext();
// on construit le modèle de la page [agenda]
ViewModelAgenda modelAgenda = setModelforAgenda(agenda, springContext, locale);
// l'agenda avec son modèle
thymeleafContext.setVariable("agenda", modelAgenda);
thymeleafContext.setVariable("clientItems", application.getClientItems());
return engine.process("agenda", thymeleafContext);
}
- lignes 9-10 : les deux éléments du modèle de l'agenda :
- ligne 9 : l'agenda affiché.
- ligne 10 : la liste des clients affichée lorsque l'utilisateur prend un reendez-vous ;
La méthode [setModelforAgenda] de la ligne 7 est la suivante :
// modèle de la page [Agenda]
private ViewModelAgenda setModelforAgenda(AgendaMedecinJour agenda, WebApplicationContext springContext, Locale locale) {
// le titre de la page
String dateFormat = springContext.getMessage("date.format", null, locale);
Medecin médecin = agenda.getMedecin();
String titre = springContext.getMessage("agenda.titre", new String[] { médecin.getTitre(), médecin.getPrenom(),
médecin.getNom(), new SimpleDateFormat(dateFormat).format(agenda.getJour()) }, locale);
// les créneaux de réservation
ViewModelCreneau[] modelCréneaux = new ViewModelCreneau[agenda.getCreneauxMedecinJour().length];
int i = 0;
for (CreneauMedecinJour creneauMedecinJour : agenda.getCreneauxMedecinJour()) {
// créneau du médecin
Creneau créneau = creneauMedecinJour.getCreneau();
ViewModelCreneau modelCréneau = new ViewModelCreneau();
modelCréneaux[i] = modelCréneau;
// id
modelCréneau.setId(créneau.getId());
// créneau horaire
modelCréneau.setCreneauHoraire(String.format("%02dh%02d-%02dh%02d", créneau.getHdebut(), créneau.getMdebut(),
créneau.getHfin(), créneau.getMfin()));
Rv rv = creneauMedecinJour.getRv();
// client et commande
String commande;
if (rv == null) {
modelCréneau.setClient("");
commande = springContext.getMessage("agenda.reserver", null, locale);
modelCréneau.setCommande(commande);
modelCréneau.setAction(ViewModelCreneau.ACTION_RESERVER);
} else {
Client client = rv.getClient();
modelCréneau.setClient(String.format("%s %s %s", client.getTitre(), client.getPrenom(), client.getNom()));
commande = springContext.getMessage("agenda.supprimer", null, locale);
modelCréneau.setCommande(commande);
modelCréneau.setIdRv(rv.getId());
modelCréneau.setAction(ViewModelCreneau.ACTION_SUPPRIMER);
}
// créneau suivant
i++;
}
// on rend le modèle de l'agenda
ViewModelAgenda modelAgenda = new ViewModelAgenda();
modelAgenda.setTitre(titre);
modelAgenda.setCreneaux(modelCréneaux);
return modelAgenda;
}
- ligne 6 : l'agenda a un titre :

ou bien :

On voit que le format de la date dépend de la langue. On va chercher ce format dans les fichiers de messages (ligne 4).
- lignes 11-40 : pour chaque créneau, on doit afficher la vue :

ou bien la vue :

- lignes 19-20 : affichent le créneau horaire ;
- lignes 25-28 : le cas où le créneau est libre. Il faut alors afficher le bouton [Réserver] ;
- lignes 31-36 : le cas où le créneau est occupé. Il faut alors afficher et le client et le bouton [Supprimer] ;
L'autre méthode sur laquelle nous donnons davantage d'explications est la méthode [getActionContext]. Elle est appelée au début de chacune des actions de [RdvMedecinsController]. Sa signature est la suivante :
protected ActionContext getActionContext(String lang, String origin, HttpServletRequest request,HttpServletResponse response, BindingResult result, RdvMedecinsCorsController rdvMedecinsCorsController)
Elle rend le type [ActionContext] suivant :
public class ActionContext {
// data
private WebContext thymeleafContext;
private WebApplicationContext springContext;
private Locale locale;
private List<String> erreurs;
...
}
- ligne 4 : le contexte Thymeleaf de l'action ;
- ligne 5 : le contexte Spring de l'action ;
- ligne 6 : la locale de l'action ;
- ligne 7 : une éventuelle liste de messages d'erreurs ;
Ses paramètres sont les suivants :
- [lang] : la langue demandée pour l'action 'en' ou 'fr' ;
- [origin] : l'entête HTTP [origin] dans le cas d'un appel inter-domaines ;
- [request] : la requête HTTP en cours de traitement, ce qu'on appelle depuis un moment une action ;
- [response] : la réponse qui va être faite à cette requête ;
- [result] : chaque action de [RdvMedecinsController] reçoit une valeur postée dont on teste la validité. [result] est le résultat de ce test ;
- [rdvMedecinsController] : le contrôleur conteneur des actions ;
La méthode [getActionContext] est implémentée de la façon suivante :
// contexte d'une action
protected ActionContext getActionContext(String lang, String origin, HttpServletRequest request,HttpServletResponse response, BindingResult result, RdvMedecinsCorsController rdvMedecinsCorsController) {
// langue ?
if (lang == null) {
lang = "fr";
}
// locale
Locale locale = null;
if (lang.trim().toLowerCase().equals("fr")) {
// français
locale = new Locale("fr", "FR");
} else {
// tout le reste en anglais
locale = new Locale("en", "US");
}
// entêtes CORS
rdvMedecinsCorsController.sendOptions(origin, response);
// ActionContext
ActionContext actionContext = new ActionContext(new WebContext(request, response, request.getServletContext(),locale), WebApplicationContextUtils.getWebApplicationContext(request.getServletContext()), locale, null);
// erreurs d'initialisation
RdvMedecinsException e = application.getRdvMedecinsException();
if (e != null) {
actionContext.setErreurs(e.getMessages());
return actionContext;
}
// erreurs de POST ?
if (result != null && result.hasErrors()) {
actionContext.setErreurs(getErreursForModel(result, locale, actionContext.getSpringContext()));
return actionContext;
}
// pas d'erreurs
return actionContext;
}
- lignes 3-15 : à partir du paramètre [lang], on fixe la locale de l'action ;
- ligne 17 : on envoie les entêtes HTTP nécessaires aux requêtes inter-domaines. Nosu ne détaillons pas. La technique utilisée est celle du paragraphe 8.4.14 ;
- ligne 19 : cosntruction d'un objet [ActionContext] sans erreurs ;
- ligne 21 : nous avons vu au paragraphe 8.6.6.2 que le singleton [ApplicationModel] accédait à la base de données pour récupérer et les clients et les médecins. Cet accès peut échouer. On mémorise alors l'exception qui se produit. Ligne 21, nous récupérons cette exception ;
- lignes 22-25 : s'il y a eu exception au boot de l'application, toute action est impossible. On rend alors pour toute action un objet [ActionContext] avec les messages d'erreur de l'exception ;
- ligne 27-20 : on analyse le paramètre [result] pour savoir si la valeur postée était valide ou non. Si elle était invalide, on rend un objet [ActionContext] avec les messages d'erreur appropriés ;
- ligne 32 : cas sans erreurs ;
Nous examinons maintenant les actions du contrôleur [RdvMedecinsController]
8.6.6.4. L'action [/getNavBarStart]
L'action [/getNavBarStart] rend la vue [navbar-start]. Sa signature est la suivante :
@RequestMapping(value = "/getNavbarStart", method = RequestMethod.POST)
@ResponseBody
public Reponse getNavbarStart(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin)
Elle rend le type [Reponse] suivant :
public class Reponse {
// ----------------- propriétés
// statut de l'opération
private int status;
// la barre de navigation
private String navbar;
// le jumbotron
private String jumbotron;
// le corps de la page
private String content;
// l'agenda
private String agenda;
...
}
et a les paramètres suivants :
- [PostLang postlang] : la valeur postée suivante :
public class PostLang {
// data
@NotNull
private String lang;
...
}
La classe [PostLang] est la classe parent de toutes les valeurs postées. En effet, le client doit toujours préciser la langue avec laquelle doit s'exécuter l'action.
La méthode [getNavbarStart] est implémentée de la façon suivante :
// navbar-start
@RequestMapping(value = "/getNavbarStart", method = RequestMethod.POST)
@ResponseBody
public Reponse getNavbarStart(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// contextes de l'action
ActionContext actionContext = getActionContext(postLang.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// erreurs ?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// on renvoie la vue [navbar-start]
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setNavbar(engine.process("navbar-start", thymeleafContext));
return reponse;
}
- ligne 7 : initialisation de l'action ;
- lignes 10-13 : si la méthode d'initialisation de l'action a signalé des erreurs, on les envoie dans la réponse au client (ligne 12) avec le status 2 :
- lignes 15-18 : on envoie la vue [navbar-start] avec le status 1 :
Dans la suite, nous ne détaillons que les nouveautés.
8.6.6.5. L'action [/getNavbarRun]
L'action [/getNavBarRun] rend la vue [navbar-run] :
// navbar-run
@RequestMapping(value = "/getNavbarRun", method = RequestMethod.POST)
@ResponseBody
public Reponse getNavbarRun(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request,
HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// contextes de l'action
ActionContext actionContext = getActionContext(postLang.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// erreurs ?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// on renvoie la vue [navbar-run]
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setNavbar(engine.process("navbar-run", thymeleafContext));
return reponse;
}
L'action peut rendre deux types de réponse :
- la réponse avec erreur (lignes 10-13) :
- la réponse avec la vue [navbar-run] :
8.6.6.6. L'action [/getJumbotron]
L'action [/getJumbotron] rend la vue [jumbotron] :
// jumbotron
@RequestMapping(value = "/getJumbotron", method = RequestMethod.POST)
@ResponseBody
public Reponse getJumbotron(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request,
HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// contextes de l'action
ActionContext actionContext = getActionContext(postLang.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// erreurs ?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// on renvoie la vue [jumbotron]
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setJumbotron(engine.process("jumbotron", thymeleafContext));
return reponse;
}
L'action peut rendre deux types de réponse :
- la réponse avec erreur (lignes 10-13) :
- la réponse avec la vue [jumbotron] :
8.6.6.7. L'action [/getLogin]
L'action [/getLogin] rend la vue [login] :
@RequestMapping(value = "/getLogin", method = RequestMethod.POST)
@ResponseBody
public Reponse getLogin(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request,
HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// contextes de l'action
ActionContext actionContext = getActionContext(postLang.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// erreurs ?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// on renvoie la vue [login]
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setJumbotron(engine.process("jumbotron", thymeleafContext));
reponse.setNavbar(engine.process("navbar-start", thymeleafContext));
reponse.setContent(getPartialViewLogin(thymeleafContext));
return reponse;
}
L'action peut rendre deux types de réponse :
- la réponse avec erreur (lignes 9-11) :
- la réponse avec la vue [login] :
8.6.6.8. L'action [/getAccueil]
L'action [/getAccueil] rend la vue [accueil]. Sa signature est la suivante :
@RequestMapping(value = "/getAccueil", method = RequestMethod.POST)
@ResponseBody
public Reponse getAccueil(@Valid @RequestBody PostUser postUser, BindingResult result, HttpServletRequest request,HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin)
- ligne 3, la valeur postée est de type [PostUser] suivant :
public class PostUser extends PostLang {
// data
@NotNull
private User user;
...
}
- ligne 1 : la classe [PostUser] étend la classe [PostLang] et donc embarque une langue ;
- ligne 4 : l'utilisateur qui cherche à obtenir la vue ;
Le code d'implémentation est le suivant :
@RequestMapping(value = "/getAccueil", method = RequestMethod.POST)
@ResponseBody
public Reponse getAccueil(@Valid @RequestBody PostUser postUser, BindingResult result, HttpServletRequest request,
HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// contextes de l'action
ActionContext actionContext = getActionContext(postUser.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// erreurs ?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// la vue [accueil] est protégée
try{
// utilisateur
User user = postUser.getUser();
// on vérifie les identifiants [userName, password]
application.authenticate(user);
}catch(RdvMedecinsException e){
// on renvoie une erreur
return getViewErreurs(thymeleafContext, e.getMessages());
}
// on renvoie la vue [accueil]
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setContent(getPartialViewAccueil(thymeleafContext));
return reponse;
}
- lignes 15-22 : on notera que la page [accueil] est protégée et que donc l'utilisateur doit être authentifié ;
L'action peut rendre deux types de réponse :
- la réponse avec erreur (lignes 11 et 21) :
- la réponse avec la vue [accueil] (lignes 24-27) :
8.6.6.9. L'action [/getNavbarRunJumbotronAccueil]
L'action [/getNavbarRunJumbotronAccueil] rend les vues [navbar-run, jumbotron, accueil]. Elle a la signature suivante :
@RequestMapping(value = "/getNavbarRunJumbotronAccueil", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getNavbarRunJumbotronAccueil(@Valid @RequestBody PostUser post, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin)
- ligne 3 : la valeur postée est du type [PostUser] ;
L'implémentation de l'action est la suivante :
// navbar+ jumbotron + accueil
@RequestMapping(value = "/getNavbarRunJumbotronAccueil", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getNavbarRunJumbotronAccueil(@Valid @RequestBody PostUser postUser, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// contextes de l'action
ActionContext actionContext = getActionContext(postUser.getLang(), origin, request, response, result,
rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// erreurs ?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// la vue [accueil] est protégée
try {
// utilisateur
User user = postUser.getUser();
// on vérifie les identifiants [userName, password]
application.authenticate(user);
} catch (RdvMedecinsException e) {
// on renvoie une erreur
return getViewErreurs(thymeleafContext, e.getMessages());
}
// on envoie la réponse
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setNavbar(engine.process("navbar-run", thymeleafContext));
reponse.setJumbotron(engine.process("jumbotron", thymeleafContext));
reponse.setContent(getPartialViewAccueil(thymeleafContext));
return reponse;
}
L'action peut rendre deux types de réponse :
- la réponse avec erreur (lignes 13, 23) :
- la réponse avec les vues [navbar-run, jumbotron, accueil] (lignes 26-31) :
8.6.6.10. L'action [/getAgenda]
L'action [/getAgenda] rend la vue [agenda]. Sa signature est la suivante :
@RequestMapping(value = "/getAgenda", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getAgenda(@RequestBody @Valid PostGetAgenda postGetAgenda, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin)
- ligne 3 : la valeur postée est de type [PostGetAgenda] suivant :
public class PostGetAgenda extends PostUser {
// données
@NotNull
private Long idMedecin;
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date jour;
...
}
- ligne 1 : la classe [PostGetAgenda] étend la classe [PostUser] et donc embarque une langue et un utilisateur ;
- ligne 5 : le n° du médecin duquel on veut l'agenda ;
- ligne 8 : la journée de l'agenda désirée ;
L'implémentation est la suivante :
@RequestMapping(value = "/getAgenda", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getAgenda(@RequestBody @Valid PostGetAgenda postGetAgenda, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// contextes de l'action
ActionContext actionContext = getActionContext(postGetAgenda.getLang(), origin, request, response, result, rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
WebApplicationContext springContext = actionContext.getSpringContext();
Locale locale = actionContext.getLocale();
// erreurs ?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// on vérifie la validité du post
if (result != null) {
new PostGetAgendaValidator().validate(postGetAgenda, result);
if (result.hasErrors()) {
// on retourne la vue [erreurs]
return getViewErreurs(thymeleafContext, getErreursForModel(result, locale, springContext));
}
}
...
}
- jusqu'à la ligne 14, on a un code désormais classique ;
- lignes 16-21 : on fait une vérification supplémentaire sur la valeur postée. La date doit être postérieure ou égale à celle d'aujourd'hui. Pour le vérifier on utilise un validateur :
package rdvmedecins.web.validators;
import java.text.SimpleDateFormat;
import java.util.Date;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
import rdvmedecins.springthymeleaf.server.requests.PostGetAgenda;
import rdvmedecins.springthymeleaf.server.requests.PostValiderRv;
public class PostGetAgendaValidator implements Validator {
public PostGetAgendaValidator() {
}
@Override
public boolean supports(Class<?> classe) {
return PostGetAgenda.class.equals(classe) || PostValiderRv.class.equals(classe);
}
@Override
public void validate(Object post, Errors errors) {
// le jour choisi pour le rdv
Date jour = null;
if (post instanceof PostGetAgenda) {
jour = ((PostGetAgenda) post).getJour();
} else {
if (post instanceof PostValiderRv) {
jour = ((PostValiderRv) post).getJour();
}
}
// on transforme les dates au format yyyy-MM-dd
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
String strJour = sdf.format(jour);
String strToday = sdf.format(new Date());
// le jour choisi ne doit pas précéder la date d'aujourd'hui
if (strJour.compareTo(strToday) < 0) {
errors.rejectValue("jour", "todayandafter.postChoixMedecinJour", null, null);
}
}
}
- ligne 19 : le validateur travaille pour deux classes : [PostGetAgenda] et [PostValiderRv] ;
Revenons au code de l'action [/getAgenda] :
@RequestMapping(value = "/getAgenda", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getAgenda(@RequestBody @Valid PostGetAgenda postGetAgenda, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
...
// action
try {
// agenda du médecin
AgendaMedecinJour agenda = application.getAgendaMedecinJour(postGetAgenda.getUser(), postGetAgenda.getIdMedecin(),
new SimpleDateFormat("yyyy-MM-dd").format(postGetAgenda.getJour()));
// réponse
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setAgenda(getPartialViewAgenda(actionContext, agenda, locale));
return reponse;
} catch (RdvMedecinsException e1) {
// on retourne la vue [erreurs]
return getViewErreurs(thymeleafContext, e1.getMessages());
} catch (Exception e2) {
// on retourne la vue [erreurs]
return getViewErreurs(thymeleafContext, getErreursForException(e2));
}
}
- lignes 9-10 : avec les paramètres postés, on demande l'agenda du médecin ;
- lignes 12-13 : on rend l'agenda :
- lignes 17, 21 : on rend une réponse avec erreurs :
8.6.6.11. L'action [/getNavbarRunJumbotronAccueilAgenda]
L'action [/getNavbarRunJumbotronAccueilAgenda] rend les vues [navbar-run, jumbotron, accueil, agenda]. Son implémentation est la suivante :
@RequestMapping(value = "/getNavbarRunJumbotronAccueilAgenda", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getNavbarRunJumbotronAccueilAgenda(@Valid @RequestBody PostGetAgenda post, BindingResult result,
HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// contextes de l'action
ActionContext actionContext = getActionContext(post.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// erreurs ?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// agenda
Reponse agenda = getAgenda(post, result, request, response, null);
if (agenda.getStatus() != 1) {
return agenda;
}
// on envoie la réponse
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setNavbar(engine.process("navbar-run", thymeleafContext));
reponse.setJumbotron(engine.process("jumbotron", thymeleafContext));
reponse.setContent(getPartialViewAccueil(thymeleafContext));
reponse.setAgenda(agenda.getAgenda());
return reponse;
}
- lignes 15-18 : on profite de l'existence de l'action [/getAgenda] pour l'appeler. Ensuite on regarde le status de la réponse (ligne 16). Si on détecte une erreur, on ne va plus loin et on renvoie la réponse ;
- lignes 20 : on envoie les vues demandées :
8.6.6.12. L'action [/supprimerRv]
L'action [/supprimerRv] permet de supprimer un rendez-vous. Sa signature est la suivante :
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse supprimerRv(@Valid @RequestBody PostSupprimerRv postSupprimerRv, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin)
- ligne 3 : la valeur postée est du type [PostSupprimerRv] suivant :
public class PostSupprimerRv extends PostUser {
// data
@NotNull
private Long idRv;
..
}
- ligne 1 : la classe [PostSupprimerRv] étend la classe [PostUser] et donc embarque une langue et un utilisateur ;
- ligne 5 : le n° du rendez-vous à supprimer ;
L'implémentation de l'action est la suivante :
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse supprimerRv(@Valid @RequestBody PostSupprimerRv postSupprimerRv, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// contextes de l'action
ActionContext actionContext = getActionContext(postSupprimerRv.getLang(), origin, request, response, result,
rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
Locale locale = actionContext.getLocale();
// erreurs ?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// valeurs postées
User user = postSupprimerRv.getUser();
long idRv = postSupprimerRv.getIdRv();
// on supprime le Rdv
AgendaMedecinJour agenda = null;
try {
// on le récupère
Rv rv = application.getRvById(user, idRv);
Creneau creneau = application.getCreneauById(user, rv.getIdCreneau());
long idMedecin = creneau.getIdMedecin();
Date jour = rv.getJour();
// on supprime le rv associé
application.supprimerRv(user, idRv);
// on régénère l'agenda du médecin
agenda = application.getAgendaMedecinJour(user, idMedecin, new SimpleDateFormat("yyyy-MM-dd").format(jour));
// on rend le nouvel agenda
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setAgenda(getPartialViewAgenda(actionContext, agenda, locale));
return reponse;
} catch (RdvMedecinsException ex) {
// on retourne la vue [erreurs]
return getViewErreurs(thymeleafContext, ex.getMessages());
} catch (Exception e2) {
// on retourne la vue [erreurs]
return getViewErreurs(thymeleafContext, getErreursForException(e2));
}
}
- ligne 22 : on récupère le rendez-vous qu'il faut supprimer. S'il n'existe pas, on a une exception ;
- lignes 23-25 : à partir de ce rendez-vous, on trouve le médecin et le jour concerné. Ces informations sont nécessaires pour régénérer l'agenda du médecin ;
- ligne 27 : le rendez-vous est supprimé ;
- ligne 29 : on demande le nouvel agenda du médecin. C'est important. Outre le créneau qui vient d'être libéré, d'autres utilisateurs de l'application ont pu faire des modifications de l'agenda. Il est important de renvoyer à l'utilisateur la version la plus récente de celui-ci ;
- lignes 31-34 : on rend l'agenda :
8.6.6.13. L'action [/validerRv]
L'action [/validerRv] ajoute un rendez-vous dans l'agenda d'un médecin. Sa signature est la suivante :
@RequestMapping(value = "/validerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse validerRv(@RequestBody PostValiderRv postValiderRv, BindingResult result, HttpServletRequest request, HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin)
- ligne 3 : la valeur postée est du type [PostValiderRv] suivant :
public class PostValiderRv extends PostUser {
// data
@NotNull
private Long idCreneau;
@NotNull
private Long idClient;
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date jour;
...
}
- ligne 1 : la classe [PostValiderRv] étend la classe [PostUser] et donc embarque une langue et un utilisateur ;
- ligne 5 : le n° du créneau horaire ;
- ligne 7 : le n° du client pour lequel est faite la réservation ;
- ligne 10 : le jour du rendez-vous ;
L'implémentation de l'action est la suivante :
// validation d'un rendez-vous
@RequestMapping(value = "/validerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse validerRv(@RequestBody PostValiderRv postValiderRv, BindingResult result, HttpServletRequest request, HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// contextes de l'action
ActionContext actionContext = getActionContext(postValiderRv.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebApplicationContext springContext = actionContext.getSpringContext();
WebContext thymeleafContext = actionContext.getThymeleafContext();
Locale locale = actionContext.getLocale();
// erreurs ?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// on vérifie la validité du jour du rendez-vous
if (result != null) {
new PostGetAgendaValidator().validate(postValiderRv, result);
if (result.hasErrors()) {
// on retourne la vue [erreurs]
return getViewErreurs(thymeleafContext, getErreursForModel(result, locale, springContext));
}
}
// valeurs postées
User user = postValiderRv.getUser();
long idClient = postValiderRv.getIdClient();
long idCreneau = postValiderRv.getIdCreneau();
Date jour = postValiderRv.getJour();
// action
try {
// on récupère des infos sur le créneau
Creneau créneau = application.getCreneauById(user, idCreneau);
long idMedecin = créneau.getIdMedecin();
// on ajoute le Rv
application.ajouterRv(postValiderRv.getUser(), new SimpleDateFormat("yyyy-MM-dd").format(jour), idCreneau,idClient);
// on régénère l'agenda
AgendaMedecinJour agenda = application.getAgendaMedecinJour(user, idMedecin,
new SimpleDateFormat("yyyy-MM-dd").format(jour));
// on rend le nouvel agenda
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setAgenda(getPartialViewAgenda(actionContext, agenda, locale));
return reponse;
} catch (RdvMedecinsException ex) {
// on retourne la vue [erreurs]
return getViewErreurs(thymeleafContext, ex.getMessages());
} catch (Exception e2) {
// on retourne la vue [erreurs]
return getViewErreurs(thymeleafContext, getErreursForException(e2));
}
}
}
Le code est analogue à celui de l'action [/supprimerRv].
8.6.7. Étape 4 : tests du serveur Spring/Thymeleaf
Nous allons maintenant tester les différentes actions précédentes avec le plugin Chrome [Advanced Rest Client] (cf paragraphe 9.6).
8.6.7.1. Configuration des tests
Toutes les actions attendent une valeur postée. Nous posterons des variantes de la chaîne jSON suivante :
{"user":{"login":"admin","passwd":"admin"},"lang":"en","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Cette valeur postée comprend des informations superflues pour la plupart des actions. Cependant, celles-ci sont ignorées par les actions qui les reçoivent et ne provoquent pas d'erreur. Cette valeur postée a l'avantage de couvrir les différentes valeurs à poster.
8.6.7.2. L'action [/getNavbarStart]
![]() |
- en [1], l'action testée ;
- en [2], la valeur postée ;
- en [3], la valeur postée est une chaîne jSON ;
- en [4], la vue [navbar-start] est demandée en anglais ;
Le résultat obtenu est le suivant :
![]() |
On a reçu la vue [navbar-start] en anglais (zones en surbrillance).
Maintenant, faisons une erreur. Nous mettons l'attribut [lang] de la valeur postée à null. Nous recevons le résultat suivant :
![]() |
Nous avons reçu une réponse d'erreur (status 2) indiquant que le champ [lang] était obligatoire.
8.6.7.3. L'action [/getNavbarRun]
Nous demandons l'action [getNavbarRun] avec la valeur postée suivante :
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Le résultat obtenu est le suivant :
![]() |
8.6.7.4. L'action [/getJumbotron]
Nous demandons l'action [getJumbotron] avec la valeur postée suivante :
{"user":{"login":"admin","passwd":"admin"},"lang":"en","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Le résultat obtenu est le suivant :
![]() |
8.6.7.5. L'action [/getLogin]
Nous demandons l'action [getLogin] avec la valeur postée suivante :
{"user":{"login":"admin","passwd":"admin"},"lang":"en","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Le résultat obtenu est le suivant :
![]() |
8.6.7.6. L'action [/getAccueil]
Nous demandons l'action [getAccueil] avec la valeur postée suivante :
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Le résultat obtenu est le suivant :
![]() |
Nous recommençons avec un utilisateur inconnu :
{"user":{"login":"x","passwd":"x"},"lang":"fr","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Le résultat obtenu est le suivant :
![]() |
Nous recommençons avec un utilisateur existant mais pas autorisé à utiliser l'application:
{"user":{"login":"user","passwd":"user"},"lang":"en","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Le résultat obtenu est le suivant :
![]() |
8.6.7.7. L'action [/getAgenda]
Nous demandons l'action [getAgenda] avec la valeur postée suivante :
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Le résultat obtenu est le suivant :
![]() |
Nous recommençons avec un jour antérieur à aujourd'hui :
![]() |
Nous recommençons avec un médecin inexistant :
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":11, "idCreneau":2, "idClient":4, "idRv":93}
Le résultat obtenu est le suivant :
![]() |
8.6.7.8. L'action [/getNavbarRunJumbotronAccueil]
Nous demandons l'action [getNavbarRunJumbotronAccueil] avec la valeur postée suivante :
{"user":{"login":"admin","passwd":"admin"},"lang":"en","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Le résultat obtenu est le suivant :
![]() |
Même chose avec un utilisateur inconnu :
![]() |
8.6.7.9. L'action [/getNavbarRunJumbotronAccueilAgenda]
Nous demandons l'action [getNavbarRunJumbotronAccueilAgenda] avec la valeur postée suivante :
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Le résultat obtenu est le suivant :
![]() |
Nous mettons un médecin qui n'existe pas :
![]() |
8.6.7.10. L'action [/supprimerRv]
Nous demandons l'action [supprimerRv] avec la valeur postée suivante :
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Le Rv de n° 93 n'existe pas. Le résultat obtenu est le suivant :
![]() |
Avec un rendez-vous qui existe :
![]() |
On peut vérifier en base que le rendez-vous a bien été supprimé. Le nouvel agenda est renvoyé.
8.6.7.11. L'action [/validerRv]
Nous demandons l'action [validerRv] avec la valeur postée suivante :
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
Le résultat obtenu est le suivant :
![]() |
On peut vérifier en base que le rendez-vous a bien été créé. Le nouvel agenda a été renvoyé.
On fait la même chose avec un numéro de créneau inexistant :
![]() |
On fait la même chose avec un numéro de client inexistant :
![]() |
8.6.8. étape 5 : Écriture du client Javascript
Revenons à l'architecture du serveur [Web1] :
![]() |
Le client [2] du serveur [Web1] est un client Javascript de type APU (Application à Page Unique) :
- le client demande la page de boot à un serveur web (pas forcément [Web1]) ;
- il demande les pages suivantes au serveur [Web1] via des appels Ajax ;
Pour construire ce client, nous allons utiliser l'outil [Webstorm] (cf paragraphe 9.8). Cet outil m'a semblé plus pratique que STS. Son principal avantage est qu'il offre l'auto-complétion dans la frappe du code ainsi que quelques options de refactoring. Cela évite de nombreuses erreurs.
8.6.8.1. Le projet JS
Le projet JS a l'arborescence suivante :
![]() |
- en [1], le client JS dans son ensemble. [boot.html] est la page de démarrage. Ce sera l'unique page chargée par le navigateur ;
- en [2], les feuilles de style des composants Bootstrap ;
- en [3], les quelques images utilisées par l'application ;
![]() |
- en [4], les scripts JS. C'est là que se situe notre travail ;
- en [5], les bibliothèques JS utilisées : jQuery principalement, et celles des composants Bootstrap ;
8.6.8.2. L'architecture du code
Le code a été fractionné en trois couches :
![]() |
- la couche [présentation] rassemble les fonctions d'initialisation de la page [boot.xml] ainsi que celles des divers composants Bootstrap. Elle est implémentée par le fichier [ui.js] ;
- la couche [événements] rassemble toutes les gestionnaires des événements de la couche [présentation]. Elle est implémentée par le fichier [evts.js] ;
- la couche [DAO] fait les requêtes HTTP vers le serveur [Web1]. Elle est implémentée par le fichier [dao.js] ;
8.6.8.3. La couche [présentation]
![]() |
La couche [présentation] est implémentée par le fichier [ui.js] suivant :
//la couche [présentation]
var ui = {
// variables globales;
"agenda": "",
"resa": "",
"langue": "",
"urlService": "http://localhost:8081",
"page": "login",
"jourAgenda": "",
"idMedecin": "",
"user": {},
"login": {},
"exceptionTitle": {},
"calendar_infos": {},
"erreur": "",
"idCreneau": "",
"done": "",
// composants de la vue
"body": "",
"navbar": "",
"jumbotron": "",
"content": "",
"exception": "",
"exception_text": "",
"exception_title": "",
"loading": ""
};
// la couche des evts
var evts = {};
// la couche [dao]
var dao = {};
// ------------ document ready
$(document).ready(function () {
// initialisation document
console.log("document.ready");
// composants de la page
ui.navbar = $("#navbar");
ui.jumbotron = $("#jumbotron");
ui.content = $("#content");
ui.erreur = $("#erreur");
ui.exception = $("#exception");
ui.exception_text = $("#exception-text");
ui.exception_title = $("#exception-title");
// on mémorise la page de login pour pouvoir la restituer
ui.login.lang = ui.langue;
ui.login.navbar = ui.navbar.html();
ui.login.jumbotron = ui.jumbotron.html();
ui.login.content = ui.content.html();
// URL du service
$("#urlService").val(ui.urlService);
});
// ------------------------ fonctions d'initialisation des composants Bootstrap
ui.initNavBarStart = function () {
...
};
ui.initNavBarRun = function () {
...
};
ui.initChoixMedecinJour = function () {
...
};
ui.updateCalendar = function (renew) {
...
};
// affiche le jour sélectionné
ui.displayJour = function () {
...
};
ui.initAgenda = function () {
...
};
ui.initResa = function () {
...
};
- pour isoler les couches entre-elles, il a été décidé de les placer dans trois objets :
- [ui] pour la couche [présentation] (lignes 2-27),
- [evts] pour la couche de gestion des événements (ligne 29),
- [dao] pour la couche [DAO] (ligne 31) ;
Cette séparation des couches dans trois objets permet d'éviter un certain nombre de conflits de noms de variables et fonctions. Chaque couche utilise des variables et fonctions préfixées par l'objet encapsulant la couche.
- lignes 38-44 : on mémorise les zones qui seront toujours présentes quelques soient les vues affichées. Cela évite de faire des recherches jQuery à répétition et inutiles ;
- lignes 46-49 : on mémorise localement la page de boot afin de pouvoir la restituer lorsque l'utilisateur se déconnecte et qu'il n'a pas changé de langue ;
- lignes 54-83 : fonctions d'initialisation des composants Bootstrap. Elles ont toutes été présentées dans l'étude de ceux-ci au paragraphe 8.6.4 ;
8.6.8.4. Les fonctions utilitaires de la couche [événements]
![]() |
Les gestionnaires d'événements ont été placés dans le fichier [evts.js]. Plusieurs fonctions sont utilisées régulièrement par les gestionnaires d'événements. Nous les présentons maintenant :
// début d'attente
evts.beginWaiting = function () {
// début attente
ui.loading = $("#loading");
ui.loading.show();
ui.exception.hide();
ui.erreur.hide();
evts.travailEnCours = true;
};
// fin d'attente
evts.stopWaiting = function () {
// fin attente
evts.travailEnCours = false;
ui.loading = $("#loading");
ui.loading.hide();
};
// affichage résultat
evts.showResult = function (result) {
// on affiche les données reçues
var data = result.data;
// on analyse le status
switch (result.status) {
case 1:
// erreur ?
if (data.status == 2) {
ui.erreur.html(data.content);
ui.erreur.show();
} else {
if (data.navbar) {
ui.navbar.html(data.navbar);
}
if (data.jumbotron) {
ui.jumbotron.html(data.jumbotron);
}
if (data.content) {
ui.content.html(data.content)
}
if (data.agenda) {
ui.agenda = $("#agenda");
ui.resa = $("#resa");
}
}
break;
case 2:
// affichage erreur
evts.showException(data);
break;
}
};
// ------------ fonctions diverses
evts.showException = function (data) {
// affichage erreur
ui.exception.show();
ui.exception_text.html(data);
ui.exception_title.text(ui.exceptionTitle[ui.langue]);
};
- ligne 2 : la fonction [evts.beginwaiting] est appelée avant toute action [DAO] asynchrone ;
- lignes 4-5 : on affiche l'image animée de l'attente ;
- lignes 6-7 : on cache la zone d'affichage des erreurs et des exceptions (ce ne sont pas les mêmes) ;
- ligne 8 : on note qu'un travail asynchrone est en cours ;
- ligne 12 : la fonction [evts.stopwaiting] est appelée après qu'une action [DAO] asynchrone ait rendu son résultat ;
- ligne 14 : on note que le travail asynchrone est terminé ;
- lignes 15 : on cache l'image animée de l'attente ;
- ligne 20 : la fonction [evts.showResult] affiche le résultat [result] d'une action [DAO] asynchrone. Le résultat est un objet JS de la forme suivante {'status':status,'data':data,'sendMeBack':sendMeBack}.
- lignes 47-50 : utilisées si [result.status==2]. Cela arrive lorsque le serveur [Web1] envoie une réponse avec un entête HTTP d'erreur (par exemple 403 forbidden). Dans ce cas [data] est la chaîne jSON envoyée par le serveur pour signaler l'erreur ;
- ligne 25 : cas où on a reçu une réponse valide du serveur [Web1]. Le champ [data] contient alors la réponse du serveur : {'status':status,'navbar':navbar,'jumbotron':jumbotron,'agenda':agenda,'content':content} ;
- ligne 27 : cas où le serveur [Web1] a envoyé une réponse d'erreur {'status':2,'navbar':null,'jumbotron':null,'agenda':null,'content':erreurs} ;
- lignes 28-29 : la vue [erreurs] est affichée ;
- lignes 31-33 : affichage éventuel de la barre de navigation ;
- lignes 34-36 : affichage éventuel du jumbotron ;
- lignes 37-39 : affichage éventuel du champ [data.content]. Représente selon les cas l'une des vues [accueil, agenda] ;
- lignes 40-43 : si l'agenda a été régénéré on récupère certaines références sur ses composants afin de ne pas les rechercher à chaque fois qu'on en aura besoin ;
- ligne 54 : la fonction [evts.showException] a pour fonction d'afficher le texte de l'exception contenue dans son paramètre [data] ;
- lignes 57-58 : le texte de l'exception est affiché ;
- ligne 58 : le titre de l'exception dépend de la langue du moment ;
La fichier [evts.js] contient plus de 300 lignes de code que je ne vais pas commenter toutes. Je vais simplement prendre quelques exemples pour montrer l'esprit de cette couche.
8.6.8.5. Connexion d'un utilisateur

La connexion d'un utilisateur est assurée par la fonction suivante :
// ------------------------ connexion
evts.connecter = function () {
// on récupère les valeurs à poster
var login = $("#login").val().trim();
var passwd = $("#passwd").val().trim();
// on fixe l'URL du serveur
ui.urlService = $("#urlService").val().trim();
dao.setUrlService(ui.urlService);
// paramètres de la requête
var post = {
"user": {
"login": login,
"passwd": passwd
},
"lang": ui.langue
};
var sendMeBack = {
"user": {
"login": login,
"passwd": passwd
},
"caller": evts.connecterDone
};
// on fait la requête
evts.execute([{
"name": "accueil-sans-agenda",
"post": post,
"sendMeBack": sendMeBack
}]);
};
- lignes 4-5 : on récupère le login et le mot de passe de l'utilisateur ;
- lignes 7-8 : on récupère l'URL du service [Web1]. Elle est mémorisée à la fois dans la couche [ui] et la couche [dao] ;
- lignes 10-16 : la valeur à poster : la langue du moment et l'utilisateur qui cherche à se connecter ;
- lignes 17-23 : l'objet [sendMeBack] est un objet qui est passé à la fonction [DAO] qui va être appelée et que celle-ci doit renvoyer à la fonction de la ligne 22. Ici l'objet [sendMeBack] encapsule l'utilisateur qui cherche à se connecter ;
- lignes 25-29 : la fonction [evts.execute] est capable d'exécuter une suite d'actions asynchrones. Ici, on passe une liste constituée d'une seule action. Les champs de celle-ci sont les suivants :
- [name] : le nom de l'action asynchrone à exécuter,
- [post] : la valeur à poster au serveur [Web1],
- [sendMeBack] : la valeur que l'action asynchrone doit renvoyer avec son résultat ;
Avant de détailler la fonction [evts.execute], regardons la fonction [evts.connecterDone] de la ligne 22. C'est la fonction à laquelle la fonction [DAO] asynchrone appelée doit rendre son résultat :
evts.connecterDone = function (result) {
// affichage résultat
evts.showResult(result);
// connexion réussie ?
if (result.status == 1 && result.data.status == 1) {
// page
ui.page = "accueil-sans-agenda";
// on note l'utilisateur
ui.user = result.sendMeBack.user;
}
};
- ligne 3 : le résultat renvoyé par le serveur [Web1] est affiché ;
- ligne 5 : si ce résultat ne contient pas d'erreurs, alors on mémorise la nature de la nouvelle page (ligne 7) ainsi que l'utilisateur authentifié (ligne 9) ;
La fonction [evts.execute] exécute une suite d'actions asynchrones :
// exécution d'une suite d'actions
evts.execute = function (actions) {
// travail en cours ?
if (evts.travailEnCours) {
// on ne fait rien
return;
}
// attente
evts.beginWaiting();
// exécution des actions
dao.doActions(actions, evts.stopWaiting);
};
- ligne 2 : le paramètre [actions] est une liste d'actions asynchrones à exécuter ;
- lignes 4-7 : l'exécution n'est acceptée que s'il n'y en a pas une autre déjà en cours ;
- ligne 9 : on met en route l'attente ;
- ligne 11 : on demande à la couche [DAO] d'exécuter la suite d'actions. Le second paramètre est le nom de la fonction à exécuter lorsque toutes les actions de la suite auront rendu leur résultat ;
Nous n'allons pas détailler maintenant la fonction [dao.doActions]. Nous allons examiner un autre événement.
8.6.8.6. Changement de langue

Le changement de langue est assuré par la fonction suivante :
// ------------------------ changement de langue
evts.setLang = function (lang) {
// chgt de langue ?
if (lang == ui.langue) {
// on ne fait rien
return;
}
// nouvelle langue
ui.langue = lang;
// quelle page faut-il traduire ?
switch (ui.page) {
case "login":
evts.getLogin();
break;
case "accueil-sans-agenda":
evts.getAccueilSansAgenda();
break;
case "accueil-avec-agenda":
evts.getAccueilAvecAgenda(ui);
break;
}
};
- ligne 2 : le paramètre [lang] est la nouvelle langue : 'fr' ou 'en' ;
- lignes 4-7 : si la nouvelle langue est celle du moment, on ne fait rien ;
- ligne 9 : on mémorise la nouvelle langue ;
- lignes 12-20 : dans le cas d'un changement de langue, il faut régénérer la page actuellement affichée par le navigateur. Il y a trois pages possibles :
- celle appelée [login] où la page affichée est celle de l'authentification,
- celle appelée [accueil-sans-agenda] qui est la page affichée juste après une authentification réussie,
- celle appelée [accueil-avec-agenda] qui est la page affichée dès qu'un premier agenda a été affiché. Ensuite, elle reste en permanence jusqu'à la déconnexion de l'utilisateur ;
Nous allons traiter le cas de la page [accueil-avec-agenda]. Il existe trois versions de cette fonction :
![]() |
- la version [ getAccueilAvecAgenda-one] fait exécuter une unique action asynchrone ;
- la version [ getAccueilAvecAgenda-parallel] fait exécuter quatre actions asynchrones en parallèle ;
- la version [ getAccueilAvecAgenda-sequence] fait exécuter quatre actions asynchrones l'une après l'autre ;
8.6.8.7. La fonction [ getAccueilAvecAgenda-one]
C'est la fonction suivante :
// -------------------------- getAccueilAvecAgenda
evts.getAccueilAvecAgenda=function(ui) {
// paramètres requête
var post = {
"user": ui.user,
"lang": ui.langue,
"idMedecin": ui.idMedecin,
"jour": ui.jourAgenda
};
var sendMeBack = {
"caller": evts.getAccueilAvecAgendaDone
};
// requête
evts.execute([{
"name": "accueil-avec-agenda",
"post": post,
"sendMeBack": sendMeBack
}]);
};
- lignes 4-9 : la valeur à poster encapsule l'utilisateur connecté, la langue désirée, le n° du médecin dont on veut l'agenda, la journée de l'agenda désiré ;
- lignes 10-12 : l'objet [sendMeBack] est l'objet qui sera renvoyé à la fonction de la ligne 11. Ici, il n'embarque aucune information ;
- lignes 14-18 : exécution d'une suite d'une action asynchrone, celle nommée [accueil-avec-agenda] (ligne 15) ;
- ligne 11 : la fonction exécutée lorsque l'action asynchrone [accueil-avec-agenda] aura rendu son résultat ;
La fonction [evts.getAccueilAvecAgendaDone] de la ligne 11 affiche le résultat de la fonction asynchrone nommée [accueil-avec-agenda] :
evts.getAccueilAvecAgendaDone = function (result) {
// affichage résultat
evts.showResult(result);
// nouvelle page ?
if (result.status == 1 && result.data.status == 1) {
ui.page = "accueil-avec-agenda";
}
};
- ligne 1 : [result] est le résultat de la fonction asynchrone nommée [accueil-avec-agenda] ;
- ligne 3 : ce résultat est affiché ;
- ligne 5 : si c'est un résultat sans erreur, on note la nouvelle page (ligne 6) ;
8.6.8.8. La fonction [ getAccueilAvecAgenda-parallel]
C'est la fonction suivante :
// -------------------------- getAccueilAvecAgenda
evts.getAccueilAvecAgenda=function(ui) {
// actions [navbar-run, jumbotron, accueil, agenda] en //
// navbar-run
var navbarRun = {
"name": "navbar-run"
};
navbarRun.post = {
"lang": ui.langue
};
navbarRun.sendMeBack = {
"caller": evts.showResult
};
// jumbotron
var jumbotron = {
"name": "jumbotron"
};
jumbotron.post = {
"lang": ui.langue
};
jumbotron.sendMeBack = {
"caller": evts.showResult
};
// accueil
var accueil = {
"name": "accueil"
};
accueil.post = {
"lang": ui.langue,
"user": ui.user
};
accueil.sendMeBack = {
"caller": evts.showResult
};
// agenda
var agenda = {
"name": "agenda"
};
agenda.post = {
"user": ui.user,
"lang": ui.langue,
"idMedecin": ui.idMedecin,
"jour": ui.jourAgenda
};
agenda.sendMeBack = {
'idMedecin': ui.idMedecin,
'jour': ui.jourAgenda,
"caller": evts.getAgendaDone
};
// exécution actions en //
evts.execute([navbarRun, jumbotron, accueil, agenda])
};
- ligne 51 : on exécute cette fois quatre actions asynchrones. Elles vont être exécutées en parallèle ;
- lignes 5-13 : définition de l'action [navbarRun] qui récupère la barre de navigation [navbar-run] ;
- ligne 12 : la fonction à exécuter lorsque l'action asynchrone [navbarRun] aura rendu son résultat ;
- lignes 15-23 : définition de l'action [jumbotron] qui récupère la vue [jumbotron] ;
- ligne 22 : la fonction à exécuter lorsque l'action asynchrone [jumbotron] aura rendu son résultat ;
- lignes 25-34 : définition de l'action [accueil] qui récupère la vue [accueil] ;
- ligne 33 : la fonction à exécuter lorsque l'action asynchrone [accueil] aura rendu son résultat ;
- lignes 36-49 : définition de l'action [agenda] qui récupère la vue [jumbotron] ;
- ligne 48 : la fonction à exécuter lorsque l'action asynchrone [agenda] aura rendu son résultat ;
8.6.8.9. La fonction [ getAccueilAvecAgenda-sequence]
// -------------------------- getAccueilAvecAgenda
evts.getAccueilAvecAgenda=function(ui) {
// actions [navbar-run, jumbotron, accueil, agenda] dans l'ordre
// agenda
var agenda = {
"name" : "agenda"
};
agenda.post = {
"user" : ui.user,
"lang" : ui.langue,
"idMedecin" : ui.idMedecin,
"jour" : ui.jourAgenda
};
agenda.sendMeBack = {
'idMedecin' : ui.idMedecin,
'jour' : ui.jourAgenda,
"caller" : evts.getAgendaDone
};
// accueil
var accueil = {
"name" : "accueil"
};
accueil.post = {
"lang" : ui.langue,
"user" : ui.user
};
accueil.sendMeBack = {
"caller" : evts.showResult,
"next" : agenda
};
// jumbotron
var jumbotron = {
"name" : "jumbotron"
};
jumbotron.post = {
"lang" : ui.langue
};
jumbotron.sendMeBack = {
"caller" : evts.showResult,
"next" : accueil
};
// navbar-run
var navbarRun = {
"name" : "navbar-run"
};
navbarRun.post = {
"lang" : ui.langue
};
navbarRun.sendMeBack = {
"caller" : evts.showResult,
"next" : jumbotron
};
// exécution actions en séquence
evts.execute([ navbarRun ])
};
- ligne 54 : on exécute l'action [navbarRun]. Lorsqu'elle est terminée, on passe à la suivante : [jumbotron], ligne 51. Cette action est alors exécutée à son tour. Lorsqu'elle est terminée, on passe à la suivante : [accueil], ligne 40. Celle-ci est exécutée à son tour. Lorsqu'elle est terminée, on passe à la suivante : [agenda], ligne 29. Celle-ci est exécutée à son tour. Lorsqu'elle est terminée, on s'arrête car l'action [agenda] n'a pas d'action suivante.
8.6.8.10. La couche [DAO]
![]() |
Le fichier [dao.js] rassemble toutes les fonctions de la couche [DAO]. Nous allons présenter celles-ci progressivement :
// URL exposées par le serveur
dao.urls = {
"login": "/getLogin",
"accueil": "/getAccueil",
"jumbotron": "/getJumbotron",
"agenda": "/getAgenda",
"supprimerRv": "/supprimerRv",
"validerRv": "/validerRv",
"navbar-start": "/getNavbarStart",
"navbar-run": "/getNavbarRun",
"accueil-sans-agenda": "/getNavbarRunJumbotronAccueil",
"accueil-avec-agenda": "/getNavbarRunJumbotronAccueilAgenda"
};
// --------------- interface
// url serveur
dao.setUrlService = function (urlService) {
dao.urlService = urlService;
};
- lignes 16-18 : la fonction qui permet de fixer l'URL du service [Web1] ;
- lignes 2-13 : le dictionnaire reliant le nom d'une action asynchrone à l'URL du serveur [Web1] à interroger ;
// ------------------ gestion générique des actions
// exécution d'une suite d'actions asynchrones
dao.doActions = function (actions, done) {
// traitement des actions
dao.actionsCount = actions.length;
dao.actionIndex = 0;
for (var i = 0; i < dao.actionsCount; i++) {
// requête DAO asynchrone
var deferred = $.Deferred();
deferred.done(dao.actionDone);
dao.doAction(deferred, actions[i], done);
}
};
- ligne 3 : la fonction [dao.doActions] exécute une suite d'actions asynchrones [actions]. Le paramètre [done] est la fonction à exécuter lorsque toutes les actions ont rendu leur résultat ;
- lignes 7-12 : les actions asynchrones sont exécutées en parallèle. Cependant, dans le cas où l'une d'elles a une suivante, celle-ci est alors exécutée à la fin de l'action qui la précède ;
- ligne 9 : on objet [Deferred] dans l'état [pending] ;
- ligne 10 : lorsque cet objet passera dans l'état [resolved], la fonction [dao.actionDone] sera exécutée ;
- ligne 11 : l'action n° i de la liste est exécutée de façon asynchrone. Le paramètre [done] de la ligne 3 est passé en paramètre ;
La fonction [dao.actionDone] qui est exécutée à la fin de chaque action asynchrone est la suivante :
// on a reçu un résultat
dao.actionDone = function (result) {
// caller ?
var sendMeBack = result.sendMeBack;
if (sendMeBack && sendMeBack.caller) {
sendMeBack.caller(result);
}
// next ?
if (sendMeBack && sendMeBack.next) {
// requête DAO asynchrone
var deferred = $.Deferred();
deferred.done(dao.actionDone);
dao.doAction(deferred, sendMeBack.next, sendMeBack.done);
}
// fini ?
dao.actionIndex++;
if (dao.actionIndex == dao.actionsCount) {
// done ?
if (sendMeBack && sendMeBack.done) {
sendMeBack.done(result);
}
}
};
- ligne 2 : la fonction [dao.actionDone] reçoit le résultat [result] d'une des actions asynchrones de la liste des actions à exécuter ;
- lignes 4-7 : si l'action asynchrone terminée avait précisé une fonction à laquelle renvoyer le résultat, cette fonction est appelée ;
- lignes 9-14 : si l'action asynchrone terminée a une suivante, alors cette action est à son tour exécutée ;
- lignes 16 : une action est terminée. On augmente le compteur des actions terminées. Une action qui a un nombre indéterminé d'actions suivantes compte pour une action ;
- lignes 19-21 : si initialement, une fonction [done] avait été précisée pour être exécutée lorsque toutes les actions de la suite ont rendu leur résultat, alors cette fonction est maintenant exécutée ;
La méthode [dao.doAction] exécute une action asychrone :
// exécution d'une action
dao.doAction = function (deferred, action, done) {
// fonction done à embarquer dans l'action
if (action.sendMeBack) {
action.sendMeBack.done = done;
} else {
action.sendMeBack = {
"done": done
};
}
// exécution action
dao.executePost(deferred, action.sendMeBack, dao.urls[action.name], action.post)
};
- lignes 4-10 : on vient de le voir, la fonction qui va traiter le résultat de l'action asynchrone qui va être exécutée doit avoir accès à la fonction [done]. Pour cela, on met cette dernière dans l'objet [sendMeBack], objet qui fera partie du résultat de l'opération asynchrone ;
- ligne 12 : on exécute la fonction [dao.executePost] qui fait un appel HTTP au serveur [Web1]. L'URL cible est l'URL associée au nom de l'action à exécuter ;
La fonction [dao.executePost] exécute un appel HTTP :
// requête HTTP
dao.executePost = function (deferred, sendMeBack, url, post) {
// on fait un appel Ajax à la main
$.ajax({
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
url: dao.urlService + url,
type: 'POST',
data: JSON3.stringify(post),
dataType: 'json',
success: function (data) {
// on rend le résultat
deferred.resolve({
"status": 1,
"data": data,
"sendMeBack": sendMeBack
});
},
error: function (jqXHR, textStatus, errorThrown) {
var data;
if (jqXHR.responseText) {
data = jqXHR.responseText;
} else {
data = textStatus;
}
// on rend l'erreur
deferred.resolve({
"status": 2,
"data": data,
"sendMeBack": sendMeBack
});
}
});
};
Nous avons déjà rencontré et commenté cette fonction. On notera simplement ligne 9 que l'URL cible est la concaténation de l'URL du serveur [Web1] avec l'URL associée au nom de l'action.
8.6.8.11. La page de boot
![]() |

La page de boot [boot.html] affiche la vue ci-dessus. C'est l'unique page chargée directement par le navigateur. Les autres sont obtenues avec des appels Ajax. Son code est le suivant :
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width"/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="css/bootstrap-3.1.1-min.css"/>
<link rel="stylesheet" type="text/css" href="css/bootstrap-select.min.css"/>
<link rel="stylesheet" type="text/css" href="css/datepicker3.css"/>
<link rel="stylesheet" type="text/css" href="css/footable.core.min.css"/>
<!-- Custom styles for this template -->
<link rel="stylesheet" type="text/css" href="css/rdvmedecins.css"/>
<!-- Bootstrap core JavaScript ================================================== -->
<script type="text/javascript" src="vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="vendor/bootstrap.js"></script>
<script type="text/javascript" src="vendor/bootstrap-select.js"></script>
<script type="text/javascript" src="vendor/moment-with-locales.js"></script>
<script type="text/javascript" src="vendor/bootstrap-datepicker.js"></script>
<script type="text/javascript" src="vendor/bootstrap-datepicker.fr.js"></script>
<script type="text/javascript" src="vendor/footable.js"></script>
<!-- scripts utilisateurs -->
<script type="text/javascript" src="js/json3.js"></script>
<script type="text/javascript" src="js/ui.js"></script>
<script type="text/javascript" src="js/evts.js"></script>
<script type="text/javascript" src="js/getAccueilAvecAgenda-sequence.js"></script>
<script type="text/javascript" src="js/dao.js"></script>
</head>
<body id="body">
<div id="navbar">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span> <span class="icon-bar"></span> <span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="navbar-collapse collapse">
<img id="loading" src="images/loading.gif" alt="waiting..." style="display: none"/>
<!-- formulaire d'identification -->
<div class="navbar-form navbar-right" role="form" id="formulaire">
<div class="form-group">
<input type="text" placeholder="URL du serveur" class="form-control" id="urlService"/>
</div>
<div class="form-group">
<input type="text" placeholder="Utilisateur" class="form-control" id="login"/>
</div>
<div class="form-group">
<input type="password" placeholder="Mot de passe" class="form-control" id="passwd"/>
</div>
<button type="button" class="btn btn-success" onclick="javascript:evts.connecter()">Connexion</button>
<!-- langues -->
<div class="btn-group">
<button type="button" class="btn btn-danger">Langue</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span> <span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li><a href="javascript:evts.setLang('fr')">Français</a></li>
<li><a href="javascript:evts.setLang('en')">English</a></li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="container">
<!-- Bootstrap Jumbotron -->
<div id="jumbotron">
<div class="jumbotron">
<div class="row">
<div class="col-md-2">
<img src="images/caduceus.jpg" alt="RvMedecins"/>
</div>
<div class="col-md-10">
<h1>
Cabinet médical<br/>Les Médecins associés
</h1>
</div>
</div>
</div>
</div>
<!-- panneaux d'erreur -->
<div id="erreur"></div>
<div id="exception" class="alert alert-danger" style="display: none">
<h3 id="exception-title"></h3>
<span id="exception-text"></span>
</div>
<!-- contenu -->
<div id="content">
<div class="alert alert-info">Authentifiez-vous pour accéder à l'application</div>
</div>
</div>
<!-- init page -->
<script>
// on initialise la page
ui.langue = 'fr';
ui.exceptionTitle['fr'] = "L'erreur suivante s'est produite côté serveur :";
ui.exceptionTitle['en'] = "The following server error was met:";
ui.initNavBarStart();
</script>
</body>
</html>
- nous avons déjà rencontré ce type de page dans le chapitre sur Bootstrap (paragraphe 8.6.4) ;
- lignes 99-105 : initialisation de certains éléments de la couche [présentation] ;
- ligne 27, le script [getAccueilAvecAgenda-sequence.js] est utilisé. En changeant le script de cette ligne on a trois comportements différents pour obtenir la page [accueil-avec-agenda] :
- [getAccueilAvecAgenda-one.js] obtient la page avec un seul appel HTTP,
- [getAccueilAvecAgenda-parallel.js] obtient la page avec quatre appels HTTP simultanés,
- [getAccueilAvecAgenda-sequence.js] obtient la page avec quatre appels HTTP successifs ;
8.6.8.12. Tests
Il y a différentes façons de faire les tests. Nous allons utiliser ici l'outil [Webstorm] :
![]() |
- en [1] on ouvre un projet. On désigne simplement le dossier [2] contenant l'arborescence statique (HTML, CSS, JS) du site à tester ;
![]() |
- en [3], le site statique ;
- en [4-5], on charge la page [boot.html] ;
![]() |
- en [5], on voit qu'un serveur embarqué par [Webstorm] a délivré la page [boot.html] à partir du port [63342]. C'est un point important à comprendre car cela veut dire que les scripts de la page [boot.html] vont faire des appels inter-domaines au serveur [Web1] qui lui travaille sur [localhost:8081]. Le navigateur qui a chargé [boot.html] sait qu'il l'a chargée à partir de [localhost:63342]. Il ne va donc pas accepter que cette page fasse des appels au site [localhost:8081] parce que ce n'est pas le même port. Il va donc mettre en oeuvre les appels inter-domaines décrits au paragraphe 8.4.14. Pour cette raison, il faut que l'application [Web1] soit configuré pour accepter ces appels inter-domaines. C'est dans le fichier [AppConfig] du serveur Spring / Thymeleaf que ça se décide :
![]() |
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins.springthymeleaf.server" })
@Import({ WebConfig.class, DaoConfig.class })
public class AppConfig {
// admin / admin
private final String USER_INIT = "admin";
private final String MDP_USER_INIT = "admin";
// racine service web / json
private final String WEBJSON_ROOT = "http://localhost:8080";
// timeout en millisecondes
private final int TIMEOUT = 5000;
// CORS
private final boolean CORS_ALLOWED=true;
...
Nous laissons le lecteur faire les tests du client JS. Il doit être capable de reproduire les fonctionnalités décrites au paragraphe 8.6.3.
Une fois que le client JS a été déclaré correct, on peut le déployer dans le dossier du serveur [Web1] pour éviter d'avoir à autoriser les requêtes inter-domaines :
![]() |
Ci-dessus, nous avons copié le site testé dans le dossier [src / main / resources / static]. Ensuite on peut demander l'URL [http://localhost:8081/boot.html] :

Maintenant nous n'avons plus besoin des requêtes inter-domaines et nous pouvons écrire dans le fichier de configuration [AppConfig] du serveur [Web1] :
// CORS
private final boolean CORS_ALLOWED=false;
L'application ci-dessus va continuer à fonctionner. Si on revient vers l'application [Webstorm], elle ne marche plus :


Si on va dans la console de développement (Ctrl-Maj-I) on a la cause de l'erreur :

C'est une erreur de requête inter-domaines non autorisée.
8.6.8.13. Conclusion
Nous avons réalisé l'architecture JS suivante :
![]() |
- les couches sont assez clairement séparées ;
- on a une application de type APU (Application à Page Unique). C'est cette caractéristique qui va maintenant nous permettre de générer une application native pour divers mobiles (Android, IoS, Windows Phone) ;
- on a créé un modèle capable d'exécuter des actions asynchrones en parallèle, en séquence ou un mix des deux ;
8.6.9. étape 6 : génération d'une application native pour Android
L'outil [Phonegap] [http://phonegap.com/] permet de produire un exécutable pour mobile (Android, IoS, Windows 8, ...) à partir d'une application HTML / JS / CSS. Il y a différentes façons d'arriver à ce but. Nous utilisons le plus simple : un outil présent en ligne sur le site de Phonegap [http://build.phonegap.com/apps]. Cet outil va 'uploader' le fichier zip du site statique à convertir. La page de boot doit s'appeler [index.html]. Nous renommons donc la page [boot.html] en [index.html] :
![]() |
puis nous zippons le dossier, ici [rdvmedecins-client-js-03]. Ensuite nous allons sur le site de Phonegap [http://build.phonegap.com/apps] :
![]() |
- avant [1], vous aurez peut-être à créer un compte ;
- en [1], on démarre ;
- en [2], on choisit un plan gratuit n'autorisant qu'une application Phonegap ;
![]() |
- en [3], on télécharge l'application zippée [4] ;
![]() |
- en [5], on donne un nom à l'application ;
- en [6], on la construit. Cette opération peut prendre 1 minute. Patientez jusqu'à ce que les icônes des différentes plate-formes mobiles indiquent que la construction est terminée ;
![]() |
- seuls les binaires Android [7] et Windows [8] ont été générés ;
- on clique sur [7] pour télécharger le binaire d'Android ;
![]() |
- en [9] le binaire [apk] téléchargé ;
Lancez un émulateur [GenyMotion] pour une tablette Android (voir paragraphe 9.9) :
![]() |
Ci-dessus, on lance un émulateur de tablette avec l'API 19 d'Android. Une fois l'émulateur lancé,
- déverrouillez-le en tirant le verrou (s'il est présent) sur le côté puis en le lâchant ;
- avec la souris, tirez le fichier [PGBuildApp-debug.apk] que vous avez téléchargé et déposez-le sur l'émulateur. Il va être alors installé et exécuté ;
![]() |
Il faut changer l'URL en [1]. Pour cela, dans une fenêtre de commande, tapez la commande [ipconfig] (ligne 1 ci-dessous) qui va afficher les différentes adresses IP de votre machine :
C:\Users\Serge Tahé>ipconfig
Configuration IP de Windows
Carte réseau sans fil Connexion au réseau local* 15 :
Statut du média. . . . . . . . . . . . : Média déconnecté
Suffixe DNS propre à la connexion. . . :
Carte Ethernet Connexion au réseau local :
Suffixe DNS propre à la connexion. . . : ad.univ-angers.fr
Adresse IPv6 de liaison locale. . . . .: fe80::698b:455a:925:6b13%4
Adresse IPv4. . . . . . . . . . . . . .: 172.19.81.34
Masque de sous-réseau. . . . . . . . . : 255.255.0.0
Passerelle par défaut. . . . . . . . . : 172.19.0.254
Carte réseau sans fil Wi-Fi :
Statut du média. . . . . . . . . . . . : Média déconnecté
Suffixe DNS propre à la connexion. . . :
...
Notez soit l'adresse IP Wifi (lignes 6-9), soit l'adresse IP sur le réseau local (lignes 11-17). Puis utilisez cette adresse IP dans l'URL du serveur web :
![]() |
Ceci fait, connectez-vous au service web :
![]() |
Testez l'application sur l'émulateur. Elle doit fonctionner. Côté serveur, on peut ou non autoriser les entêtes CORS dans la classe [ApplicationModel] :
// CORS
private final boolean CORS_ALLOWED=false;
Cela n'a pas d'importance pour l'application Android. Celle-ci ne s'exécute pas dans un navigateur. Or l'exigence des entêtes CORS vient du navigateur et non pas du serveur.
8.6.10. Conclusion de l'étude de cas
Nous avons développé l'architecture suivante :
![]() |
C'est une architecture 3tier complexe. Elle visait à réutiliser la couche [Web2] qui était la couche serveur de l'application [AngularJS-Spring MVC] du document [Tutoriel AngularJS / Spring 4] à l'URL [http://tahe.developpez.com/angularjs-spring4/]. C'est uniquement pour cette raison qu'on a une architecture 3tier. Là où dans l'application [AngularJS-Spring MVC], le client de [Web2] était un client [AngularJS], ici le client de [Web2] est une architecture 2tier [jQuery] / [Spring MVC / Thymeleaf]. On a augmenté les couches donc on va perdre en performances.
L'application étudiée ici a été développée au cours du temps dans trois documents différents :
- [Introduction aux frameworks JSF2, Primefaces et Primefaces mobile] à l'URL [http://tahe.developpez.com/java/primefaces/]. L'étude de cas avait alors été développée avec les frameworks JSF2 / Primefaces. Primefaces est une bibliothèque de composants ajaxifiés qui évite d'écrire du javascript. L'application développée alors, était moins complexe que celle étudiée ici. Elle avait une version web classique pour l'ordinateur et une version mobile pour les téléphones ;
- [Tutoriel AngularJS / Spring 4] à l'URL [http://tahe.developpez.com/angularjs-spring4/]. L'application développée alors, avait les mêmes caractéristiques que celle étudiée dans ce document. L'application avait également été portée sur Android ;
- le présent document ;
De ce travail, il ressort pour moi les points suivants :
-
l'application [Primefaces] a été de loin la plus simple à écrire et sa version web mobile s'est révélée performante. Elle ne nécessite pas de connaissances Javascript. Il n'est pas possible de la porter nativement sur les OS des différents mobiles mais est-ce nécessaire ? Il semble difficile de changer le style de l'application. On travaille en effet avec les feuilles de style de Primefaces. Ce peut-être un inconvénient ;
-
l'application [AngularJS-Spring MVC] a été complexe à écrire. Le framework [AngularJS] m'a semblé assez difficile à appréhender dès lors qu'on veut le maîtriser. L'architecture [client Angular] / [service web / jSON implémenté par Spring MVC] est particulièrement propre et performante. Cette architecture est reproductible pour toute application web. C'est l'architecture qui me paraît la plus prometteuse car elle met en jeu côté client et côté serveur des compétences différentes (JS+HTML+CSS côté client, Java ou autre chose côté serveur), ce qui permet de développer le client et le serveur en parallèle ;
-
pour l'application développée dans ce document avec une architecture 3tier [client jQuery] / [serveur Web1 / Spring MVC / Thymeleaf] / [serveur Web2 / Spring MVC], il est possible que certains trouvent la technologie [jQuery+Spring MVC+Thymelaf] plus simple à appréhender que celle de [AngularJS]. La couche [DAO] du client Javascript que nous avons écrite est réutilisable dans d'autres applications ;



























































































































































































































































































