Skip to content

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 :

Image

  • 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) :

ALTER TABLE RV ADD CONSTRAINT UNQ1_RV UNIQUE (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 :

select t from T t where t.something=:value

Il faut donc que le type T ait un champ nommé [something]. Ainsi la méthode

List<Customer> findByLastName(String lastName);

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 :

.   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::       (v1.1.10.RELEASE)

2014-12-19 11:13:46.612  INFO 10932 --- [           main] hello.Application                        : Starting Application on Gportpers3 with PID 10932 (started by ST in D:\data\istia-1415\spring mvc\dvp-final\etude-de-cas\gs-accessing-data-jpa-complete)
2014-12-19 11:13:46.658  INFO 10932 --- [           main] s.c.a.AnnotationConfigApplicationContext : Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@279ad2e3: startup date [Fri Dec 19 11:13:46 CET 2014]; root of context hierarchy
2014-12-19 11:13:48.234  INFO 10932 --- [           main] j.LocalContainerEntityManagerFactoryBean : Building JPA container EntityManagerFactory for persistence unit 'default'
2014-12-19 11:13:48.258  INFO 10932 --- [           main] o.hibernate.jpa.internal.util.LogHelper  : HHH000204: Processing PersistenceUnitInfo [
    name: default
    ...]
2014-12-19 11:13:48.337  INFO 10932 --- [           main] org.hibernate.Version                    : HHH000412: Hibernate Core {4.3.7.Final}
2014-12-19 11:13:48.339  INFO 10932 --- [           main] org.hibernate.cfg.Environment            : HHH000206: hibernate.properties not found
2014-12-19 11:13:48.341  INFO 10932 --- [           main] org.hibernate.cfg.Environment            : HHH000021: Bytecode provider name : javassist
2014-12-19 11:13:48.620  INFO 10932 --- [           main] o.hibernate.annotations.common.Version   : HCANN000001: Hibernate Commons Annotations {4.0.5.Final}
2014-12-19 11:13:48.689  INFO 10932 --- [           main] org.hibernate.dialect.Dialect            : HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
2014-12-19 11:13:48.853  INFO 10932 --- [           main] o.h.h.i.ast.ASTQueryTranslatorFactory    : HHH000397: Using ASTQueryTranslatorFactory
2014-12-19 11:13:49.143  INFO 10932 --- [           main] org.hibernate.tool.hbm2ddl.SchemaExport  : HHH000227: Running hbm2ddl schema export
2014-12-19 11:13:49.151  INFO 10932 --- [           main] org.hibernate.tool.hbm2ddl.SchemaExport  : HHH000230: Schema export complete
2014-12-19 11:13:49.692  INFO 10932 --- [           main] o.s.j.e.a.AnnotationMBeanExporter        : Registering beans for JMX exposure on startup
2014-12-19 11:13:49.709  INFO 10932 --- [           main] hello.Application                        : Started Application in 3.461 seconds (JVM running for 4.435)
Customers found with findAll():
-------------------------------
Customer[id=1, firstName='Jack', lastName='Bauer']
Customer[id=2, firstName='Chloe', lastName='O'Brian']
Customer[id=3, firstName='Kim', lastName='Bauer']
Customer[id=4, firstName='David', lastName='Palmer']
Customer[id=5, firstName='Michelle', lastName='Dessler']

Customer found with findOne(1L):
--------------------------------
Customer[id=1, firstName='Jack', lastName='Bauer']

Customer found with findByLastName('Bauer'):
--------------------------------------------
Customer[id=1, firstName='Jack', lastName='Bauer']
Customer[id=3, firstName='Kim', lastName='Bauer']
2014-12-19 11:13:49.931  INFO 10932 --- [           main] s.c.a.AnnotationConfigApplicationContext : Closing org.springframework.context.annotation.AnnotationConfigApplicationContext@279ad2e3: startup date [Fri Dec 19 11:13:46 CET 2014]; root of context hierarchy
2014-12-19 11:13:49.933  INFO 10932 --- [           main] o.s.j.e.a.AnnotationMBeanExporter        : Unregistering JMX-exposed beans on shutdown
2014-12-19 11:13:49.934  INFO 10932 --- [           main] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2014-12-19 11:13:49.935  INFO 10932 --- [           main] org.hibernate.tool.hbm2ddl.SchemaExport  : HHH000227: Running hbm2ddl schema export
2014-12-19 11:13:49.938  INFO 10932 --- [           main] org.hibernate.tool.hbm2ddl.SchemaExport  : HHH000230: Schema export complete
  • 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 :

.....\dist>dir
12/06/2014  09:11        15 104 869 gs-accessing-data-jpa-2.jar

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 :

SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
juin 12, 2014 9:48:38 AM org.hibernate.ejb.HibernatePersistence logDeprecation
WARN: HHH015016: Encountered a deprecated javax.persistence.spi.PersistenceProvider [org.hibernate.ejb.HibernatePersistence]; use [org.hibernate.jpa.HibernatePersistenceProvider] instead.
juin 12, 2014 9:48:38 AM org.hibernate.jpa.internal.util.LogHelper logPersistenceUnitInformation
INFO: HHH000204: Processing PersistenceUnitInfo [
        name: default
        ...]
juin 12, 2014 9:48:38 AM org.hibernate.Version logVersion
INFO: HHH000412: Hibernate Core {4.3.4.Final}
juin 12, 2014 9:48:38 AM org.hibernate.cfg.Environment <clinit>
INFO: HHH000206: hibernate.properties not found
juin 12, 2014 9:48:38 AM org.hibernate.cfg.Environment buildBytecodeProvider
INFO: HHH000021: Bytecode provider name : javassist
juin 12, 2014 9:48:39 AM org.hibernate.annotations.common.reflection.java.JavaReflectionManager <clinit>
INFO: HCANN000001: Hibernate Commons Annotations {4.0.4.Final}
juin 12, 2014 9:48:39 AM org.hibernate.dialect.Dialect <init>
INFO: HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
juin 12, 2014 9:48:39 AM org.hibernate.hql.internal.ast.ASTQueryTranslatorFactory <init>
INFO: HHH000397: Using ASTQueryTranslatorFactory
juin 12, 2014 9:48:40 AM org.hibernate.tool.hbm2ddl.SchemaUpdate execute
INFO: HHH000228: Running hbm2ddl schema update
juin 12, 2014 9:48:40 AM org.hibernate.tool.hbm2ddl.SchemaUpdate execute
INFO: HHH000102: Fetching database metadata
juin 12, 2014 9:48:40 AM org.hibernate.tool.hbm2ddl.SchemaUpdate execute
INFO: HHH000396: Updating schema
juin 12, 2014 9:48:40 AM org.hibernate.tool.hbm2ddl.DatabaseMetadata getTableMetadata
INFO: HHH000262: Table not found: Customer
juin 12, 2014 9:48:40 AM org.hibernate.tool.hbm2ddl.DatabaseMetadata getTableMetadata
INFO: HHH000262: Table not found: Customer
juin 12, 2014 9:48:40 AM org.hibernate.tool.hbm2ddl.DatabaseMetadata getTableMetadata
INFO: HHH000262: Table not found: Customer
juin 12, 2014 9:48:40 AM org.hibernate.tool.hbm2ddl.SchemaUpdate execute
INFO: HHH000232: Schema update complete
Customers found with findAll():
-------------------------------
Customer[id=1, firstName='Jack', lastName='Bauer']
Customer[id=2, firstName='Chloe', lastName='O'Brian']
Customer[id=3, firstName='Kim', lastName='Bauer']
Customer[id=4, firstName='David', lastName='Palmer']
Customer[id=5, firstName='Michelle', lastName='Dessler']

Customer found with findOne(1L):
--------------------------------
Customer[id=1, firstName='Jack', lastName='Bauer']

Customer found with findByLastName('Bauer'):
--------------------------------------------
Customer[id=1, firstName='Jack', lastName='Bauer']
Customer[id=3, firstName='Kim', lastName='Bauer']

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 :

Medecin m=new Medecin("Mr","Paul","Tatou");

Si de plus, on veut lui affecter un identifiant et une version on pourra écrire :

Medecin m=new Medecin("Mr","Paul","Tatou").build(10,1);

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 :
select rv from Rv rv where rv.creneau.medecin.id=?1 and rv.jour=?2

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 :

1
2
3
4
Ajout d'un Rv le [10/06/2014] dans le créneau 1 pour le client 1
Rv ajouté = Rv[113, Tue Jun 10 16:51:01 CEST 2014, 1, 1]
Liste des rendez-vous
Rv[113, 2014-06-10, 1, 1]

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 :

1
2
3
4
5
6
7
14:20:35.634 [main] INFO  o.s.c.a.AnnotationConfigApplicationContext - Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@345965f2: startup date [Wed Oct 14 14:20:35 CEST 2015]; root of context hierarchy
14:20:36.118 [main] INFO  o.s.o.j.LocalContainerEntityManagerFactoryBean - Building JPA container EntityManagerFactory for persistence unit 'default'
Ajout d'un Rv le [14/10/2015] dans le créneau 1 pour le client 1
Rv ajouté = Rv[191, Wed Oct 14 14:20:38 CEST 2015, 1, 1]
Liste des rendez-vous
Rv[191, 2015-10-14, 1, 1]
14:20:38.211 [main] INFO  o.s.c.a.AnnotationConfigApplicationContext - Closing org.springframework.context.annotation.AnnotationConfigApplicationContext@345965f2: startup date [Wed Oct 14 14:20:35 CEST 2015]; root of context hierarchy

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 :

10:33:12.198 [main] INFO  o.s.c.a.AnnotationConfigApplicationContext - Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@5a4aa2f2: startup date [Wed Oct 14 10:33:12 CEST 2015]; root of context hierarchy
10:33:12.681 [main] INFO  o.s.o.j.LocalContainerEntityManagerFactoryBean - Building JPA container EntityManagerFactory for persistence unit 'default'
10:33:12.702 [main] INFO  o.h.jpa.internal.util.LogHelper - HHH000204: Processing PersistenceUnitInfo [
    name: default
    ...]
10:33:12.773 [main] INFO  org.hibernate.Version - HHH000412: Hibernate Core {4.3.11.Final}
10:33:12.775 [main] INFO  org.hibernate.cfg.Environment - HHH000206: hibernate.properties not found
10:33:12.776 [main] INFO  org.hibernate.cfg.Environment - HHH000021: Bytecode provider name : javassist
10:33:13.011 [main] INFO  o.h.annotations.common.Version - HCANN000001: Hibernate Commons Annotations {4.0.5.Final}
10:33:13.434 [main] INFO  org.hibernate.dialect.Dialect - HHH000400: Using dialect: org.hibernate.dialect.MySQLDialect
10:33:13.621 [main] INFO  o.h.h.i.a.ASTQueryTranslatorFactory - HHH000397: Using ASTQueryTranslatorFactory
Ajout d'un Rv le [14/10/2015] dans le créneau 1 pour le client 1
Rv ajouté = Rv[181, Wed Oct 14 10:33:14 CEST 2015, 1, 1]
Liste des rendez-vous
Rv[181, 2015-10-14, 1, 1]
10:33:14.782 [main] INFO  o.s.c.a.AnnotationConfigApplicationContext - Closing org.springframework.context.annotation.AnnotationConfigApplicationContext@5a4aa2f2: startup date [Wed Oct 14 10:33:12 CEST 2015]; root of context hierarchy

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 :
{"jour":"2014-06-12", "idClient":3, "idCreneau":7}

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 :
{"idRv":116}

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] :

Image

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 :

  1. avoir accès à l'objet qui sérialise ;
  2. 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 :

11:12:12,107 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Could NOT find resource [logback.groovy]
11:12:12,108 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Could NOT find resource [logback-test.xml]
11:12:12,108 |-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:12:12,108 |-WARN in ch.qos.logback.classic.LoggerContext[default] - Resource [logback.xml] occurs multiple times on the classpath.
11:12:12,108 |-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:12:12,108 |-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:12:12,172 |-INFO in ch.qos.logback.classic.joran.action.ConfigurationAction - debug attribute not set
11:12:12,174 |-INFO in ch.qos.logback.core.joran.action.AppenderAction - About to instantiate appender of type [ch.qos.logback.core.ConsoleAppender]
11:12:12,186 |-INFO in ch.qos.logback.core.joran.action.AppenderAction - Naming appender as [STDOUT]
11:12:12,205 |-INFO in ch.qos.logback.core.joran.action.NestedComplexPropertyIA - Assuming default type [ch.qos.logback.classic.encoder.PatternLayoutEncoder] for [encoder] property
11:12:12,255 |-INFO in ch.qos.logback.classic.joran.action.RootLoggerAction - Setting level of ROOT logger to INFO
11:12:12,255 |-INFO in ch.qos.logback.core.joran.action.AppenderRefAction - Attaching appender named [STDOUT] to Logger[ROOT]
11:12:12,256 |-INFO in ch.qos.logback.classic.joran.action.ConfigurationAction - End of configuration.
11:12:12,257 |-INFO in ch.qos.logback.classic.joran.JoranConfigurator@56f4468b - Registering current configuration as safe fallback point

11:12:12.567 [main] INFO  rdvmedecins.web.boot.Boot - Starting Boot on Gportpers3 with PID 5856 (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:12:12.602 [main] INFO  o.s.b.c.e.AnnotationConfigEmbeddedWebApplicationContext - Refreshing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@2ea6137: startup date [Wed Oct 14 11:12:12 CEST 2015]; root of context hierarchy
11:12:13.363 [main] INFO  o.s.b.c.e.t.TomcatEmbeddedServletContainer - Tomcat initialized with port(s): 8080 (http)
11:12:13.503 [main] INFO  o.a.catalina.core.StandardService - Starting service Tomcat
11:12:13.503 [main] INFO  o.a.catalina.core.StandardEngine - Starting Servlet Engine: Apache Tomcat/8.0.26
11:12:13.644 [localhost-startStop-1] INFO  o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
11:12:14.044 [localhost-startStop-1] INFO  o.s.o.j.LocalContainerEntityManagerFactoryBean - Building JPA container EntityManagerFactory for persistence unit 'default'
11:12:17.229 [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@141859ba, org.springframework.security.web.context.SecurityContextPersistenceFilter@19925f3b, org.springframework.security.web.header.HeaderWriterFilter@3083c83b, org.springframework.security.web.authentication.logout.LogoutFilter@7c22ac3b, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@126fe543, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@8eecab2, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@91b42ad, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@5e33581f, org.springframework.security.web.session.SessionManagementFilter@10abfbc1, org.springframework.security.web.access.ExceptionTranslationFilter@3e933729, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@3c8f6f86]
11:12:17.259 [localhost-startStop-1] INFO  o.s.b.c.e.ServletRegistrationBean - Mapping servlet: 'dispatcherServlet' to [/*]
11:12:17.259 [localhost-startStop-1] INFO  o.s.b.c.e.FilterRegistrationBean - Mapping filter: 'springSecurityFilterChain' to: [/*]
11:12:17.837 [main] INFO  o.a.coyote.http11.Http11NioProtocol - Initializing ProtocolHandler ["http-nio-8080"]
11:12:17.853 [main] INFO  o.a.coyote.http11.Http11NioProtocol - Starting ProtocolHandler ["http-nio-8080"]
11:12:17.869 [main] INFO  o.a.tomcat.util.net.NioSelectorPool - Using a shared selector for servlet write/read
11:12:17.900 [main] INFO  o.s.b.c.e.t.TomcatEmbeddedServletContainer - Tomcat started on port(s): 8080 (http)
11:12:17.902 [main] INFO  rdvmedecins.web.boot.Boot - Started Boot in 5.545 seconds (JVM running for 6.305)

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 :

11:14:53,862 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Could NOT find resource [logback.groovy]
11:14:53,862 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Could NOT find resource [logback-test.xml]
11:14:53,862 |-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:14:53,862 |-WARN in ch.qos.logback.classic.LoggerContext[default] - Resource [logback.xml] occurs multiple times on the classpath.
11:14:53,862 |-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:14:53,862 |-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:14:53,924 |-INFO in ch.qos.logback.classic.joran.action.ConfigurationAction - debug attribute not set
11:14:53,924 |-INFO in ch.qos.logback.core.joran.action.AppenderAction - About to instantiate appender of type [ch.qos.logback.core.ConsoleAppender]
11:14:53,940 |-INFO in ch.qos.logback.core.joran.action.AppenderAction - Naming appender as [STDOUT]
11:14:53,956 |-INFO in ch.qos.logback.core.joran.action.NestedComplexPropertyIA - Assuming default type [ch.qos.logback.classic.encoder.PatternLayoutEncoder] for [encoder] property
11:14:54,002 |-INFO in ch.qos.logback.classic.joran.action.RootLoggerAction - Setting level of ROOT logger to OFF
11:14:54,002 |-INFO in ch.qos.logback.core.joran.action.AppenderRefAction - Attaching appender named [STDOUT] to Logger[ROOT]
11:14:54,002 |-INFO in ch.qos.logback.classic.joran.action.ConfigurationAction - End of configuration.
11:14:54,002 |-INFO in ch.qos.logback.classic.joran.JoranConfigurator@56f4468b - Registering current configuration as safe fallback point

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

/, /home

/templates/home.html

/hello

/templates/hello.html

/login

/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

/, /home

accès sans être authentifié


http.authorizeRequests().antMatchers("/", "/home").permitAll()
autres URL

accès authentifié uniquement

http.anyRequest().authenticated();
  • 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 :

Click <a href="/hello">here</a> to see a greeting.

L'URL [/hello] va être demandée lorsqu'on va cliquer sur le lien. Celle-ci est protégée :

URL

règle

code

/, /home

accès sans être authentifié


http.authorizeRequests().antMatchers("/", "/home").permitAll()
autres URL

accès authentifié uniquement

http.anyRequest().authenticated();

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 :

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
...
    <form method="post" action="/login">
...
       <input type="hidden" name="_csrf" value="87bea06a-a177-459d-b279-c6068a7ad3eb" />
   </form>
</body>
</html>
  • 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 :

Image

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 :

User[admin,admin,$2a$10$FN1LMKjPU46aPffh9Zaw4exJOLo51JJPWrxqzak/eJrbt3CO9WzVG]
Roles :
Role[ROLE_ADMIN]
User[user,user,$2a$10$SJehR9Mv2VdyRZo9F0rXa.hKAoGLhJg6kSdyfExi40mEJrNOj0BTq]
Roles :
Role[ROLE_USER]
User[guest,guest,$2a$10$ubyWJb/vg2XZnUOAUjspZuz9jpHP3fIbPTbwQU115EtLdeSZ2PB7q]
Roles :
Role[ROLE_GUEST]
User[x,x,$2a$10$kEXA56wpKHFReVqwQTyWguKguK8I4uhA2zb6t3wGxag8Dyv7AhLom]
Roles :
Role[ROLE_GUEST]

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 :
Authorization:Basic code

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 :

Authorization:Basic YWRtaW46YWRtaW4=
  • 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 :

Authorization:Basic code

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 :

YWRtaW46YWRtaW4=

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 :

dXNlcjp1c2Vy
  • 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 :
Origin:http://localhost:8081

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] :
Access-Control-Request-Method: GET
Access-Control-Request-Headers: accept, authorization

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 :

1
2
3
4
5
/authenticate [admin,admin] : OK
/authenticate [admin,x] : OK
URL [/authenticate [user,user]]
L'erreur n° [111] s'est produite :
403 Forbidden
  • 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 :

1
2
3
4
5
6
URL [/authenticate [admin,x]]
L'erreur n° [111] s'est produite :
401 Unauthorized
URL [/authenticate [user,user]]
L'erreur n° [111] s'est produite :
403 Forbidden

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 :

Image

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 :

Image

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 :

Image

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 :

Image

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 :

 

Image

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 :

Image

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 :

Image

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

connecter() - ligne 27

clic sur le lien [Français]

setLang('fr') - ligne 37

clic sur le lien [English]

setLang('en') - ligne 40

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 :

Image

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 :

Image

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 :

Image

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

deconnecter() - ligne 19

clic sur le lien [Français]

setLang('fr') - ligne 29

clic sur le lien [English]

setLang('en') - ligne 32

8.6.5.5. La vue [accueil]

C'est la vue présentée immédiatement sous la barre de navigation [navbar-run] :

Image

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

getAgenda

choix d'une date

getAgenda

8.6.5.6. La vue [agenda]

La vue [agenda] présente une journée de l'agenda d'un médecin :

Image

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]

supprimerRv(idRv) - ligne 37

clic sur le lien [Réserver]

reserverCreneau(idCreneau) - ligne 34

La vue [resa] de la ligne 47 est la vue qui est affichée lorsque l'utilisateur clique sur un lien [Réserver] :

Image

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]

cancelDialogResa() - ligne 30

clic sur le bouton [Valider]

validerRv() - ligne 31

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 :

Image

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


connecter, setLang

jumbotron



login



navbar-run


deconnecter, setLang

accueil

rdvmedecins.medecinItems (liste des médecins)
getAgenda

agenda

agenda (une journée de l'agenda)
supprimerRv, reserverCreneau

resa

clientItems (liste des clients)
cancelDialogResa, validerRv

erreurs

erreurs (liste d'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

/getNavbarStart

met la vue [navbar-start] dans [Reponse.navbar]

/getNavbarRun

met la vue [navbar-run] dans [Reponse.navbar]

/getAccueil

met la vue [accueil] dans [Reponse.content]

/getJumbotron

met la vue [jumbotron] dans [Reponse.jumbotron]

/getAgenda

met la vue [agenda] dans [Reponse.agenda]

/getLogin

met la vue [login] dans [Reponse.content]

/getNavbarRunJumbotronAccueil

• 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

/getNavbarRunJumbotronAccueilAgenda

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]

/ajouterRv

ajoute le rendez-vous sélectionné et met le nouvel agenda dans [Reponse.agenda]

/supprimerRv

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 :

  1. les méthodes utilitaires ;
  2. les méthodes qui rendent les vues fusionnées avec leurs modèles ;
  3. la méthode d'initialisation d'une action

protected List<String>
getErreursForException(Exception exception)

protected List<String>
getErreursForModel(BindingResult result,
Locale locale,
WebApplicationContext ctx)

deux méthodes utilitaires qui fournissent une liste de messages d'erreur. Nous les avons déjà rencontrées et utilisées ;


protected String getPartialViewAccueil(WebContext
thymeleafContext)

rend la vue [accueil] sans modèle


protected String getPartialViewAgenda(ActionContext
actionContext,
AgendaMedecinJour agenda,
Locale locale)

rend la vue [agenda] et son modèle


protected String getPartialViewLogin(WebContext thymeleafContext)

rend la vue [login] sans modèle


protected Reponse getViewErreurs(WebContext thymeleafContext, List<String> erreurs)

rend la réponse au client lorsque l'action demandée s'est terminée par une erreur


protected ActionContext getActionContext
(String lang, String origin,
HttpServletRequest request,
HttpServletResponse response,
BindingResult result,
RdvMedecinsCorsController rdvMedecinsCorsController) 

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 :

Image

ou bien :

Image

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 :

Image

ou bien la vue :

Image

  • 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 :
 {"status":2,"navbar": null, "jumbotron": null, "agenda":null, "content":erreurs}
  • lignes 15-18 : on envoie la vue [navbar-start] avec le status 1 :
 {"status":1,"navbar": navbar-start, "jumbotron": null, "agenda":null, "content":null}

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) :
 {"status":2,"navbar": null, "jumbotron": null, "agenda":null, "content":erreurs}
  • la réponse avec la vue [navbar-run] :
 {"status":1,"navbar": navbar-run, "jumbotron": null, "agenda":null, "content":null}

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) :
 {"status":2,"navbar": null, "jumbotron": null, "agenda":null, "content":erreurs}
  • la réponse avec la vue [jumbotron] :
 {"status":1,"navbar": null, "jumbotron": jumbotron, "agenda":null, "content":null}

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) :
 {"status":2,"navbar": null, "jumbotron": null, "agenda":null, "content":erreurs}
  • la réponse avec la vue [login] :
 {"status":1,"navbar": navbar-start, "jumbotron": jumbotron, "agenda":null, "content":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) :
 {"status":2,"navbar": null, "jumbotron": null, "agenda":null, "content":erreurs}
  • la réponse avec la vue [accueil] (lignes 24-27) :
 {"status":1,"navbar": null, "jumbotron": null, "agenda":null, "content":accueil}

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) :
 {"status":2,"navbar": null, "jumbotron": null, "agenda":null, "content":erreurs}
  • la réponse avec les vues [navbar-run, jumbotron, accueil] (lignes 26-31) :
 {"status":1,"navbar": navbar-run, "jumbotron": jumbotron, "agenda":null, "content":accueil}

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 :
 {"status":1,"navbar": null, "jumbotron": null, "agenda":agenda, "content":null}
  • lignes 17, 21 : on rend une réponse avec erreurs :
 {"status":2,"navbar": null, "jumbotron": null, "agenda":null, "content":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 :
 {"status":1,"navbar": navbar-run, "jumbotron": jumbotron, "agenda":agenda, "content":accueil}

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 :
 {"status":1,"navbar": null, "jumbotron": null, "agenda":agenda, "content":null}

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

Image

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

Image

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]

C'est la fonction suivante :


// -------------------------- 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

  

Image

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] :

Image

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 :

Image

Image

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

Image

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 :

  1. [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 ;
  2. [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 ;
  3. 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 ;